2 * Copyright (c) 2010-2021 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.icalendar.internal.handler;
15 import static org.openhab.binding.icalendar.internal.ICalendarBindingConstants.*;
18 import java.io.FileInputStream;
19 import java.io.IOException;
20 import java.math.BigDecimal;
22 import java.net.URISyntaxException;
23 import java.time.Instant;
24 import java.util.List;
25 import java.util.concurrent.ScheduledFuture;
26 import java.util.concurrent.TimeUnit;
28 import org.eclipse.jdt.annotation.NonNullByDefault;
29 import org.eclipse.jdt.annotation.Nullable;
30 import org.eclipse.jetty.client.HttpClient;
31 import org.openhab.binding.icalendar.internal.config.ICalendarConfiguration;
32 import org.openhab.binding.icalendar.internal.handler.PullJob.CalendarUpdateListener;
33 import org.openhab.binding.icalendar.internal.logic.AbstractPresentableCalendar;
34 import org.openhab.binding.icalendar.internal.logic.CalendarException;
35 import org.openhab.binding.icalendar.internal.logic.CommandTag;
36 import org.openhab.binding.icalendar.internal.logic.CommandTagType;
37 import org.openhab.binding.icalendar.internal.logic.Event;
38 import org.openhab.core.OpenHAB;
39 import org.openhab.core.events.EventPublisher;
40 import org.openhab.core.i18n.TimeZoneProvider;
41 import org.openhab.core.items.events.ItemEventFactory;
42 import org.openhab.core.library.types.DateTimeType;
43 import org.openhab.core.library.types.OnOffType;
44 import org.openhab.core.library.types.StringType;
45 import org.openhab.core.thing.Bridge;
46 import org.openhab.core.thing.ChannelUID;
47 import org.openhab.core.thing.Thing;
48 import org.openhab.core.thing.ThingStatus;
49 import org.openhab.core.thing.ThingStatusDetail;
50 import org.openhab.core.thing.binding.BaseBridgeHandler;
51 import org.openhab.core.thing.binding.ThingHandler;
52 import org.openhab.core.thing.binding.ThingHandlerCallback;
53 import org.openhab.core.thing.binding.builder.ChannelBuilder;
54 import org.openhab.core.thing.binding.builder.ThingBuilder;
55 import org.openhab.core.types.Command;
56 import org.openhab.core.types.RefreshType;
57 import org.openhab.core.types.UnDefType;
58 import org.slf4j.Logger;
59 import org.slf4j.LoggerFactory;
62 * The {@link ICalendarHandler} is responsible for handling commands, which are
63 * sent to one of the channels.
65 * @author Michael Wodniok - Initial contribution
66 * @author Andrew Fiddian-Green - Support for Command Tags embedded in the Event description
67 * @author Michael Wodniok - Added last_update-channel and additional needed handling of it
70 public class ICalendarHandler extends BaseBridgeHandler implements CalendarUpdateListener {
72 private final File calendarFile;
73 private @Nullable ICalendarConfiguration configuration;
74 private final EventPublisher eventPublisherCallback;
75 private final HttpClient httpClient;
76 private final Logger logger = LoggerFactory.getLogger(ICalendarHandler.class);
77 private final TimeZoneProvider tzProvider;
78 private @Nullable ScheduledFuture<?> pullJobFuture;
79 private @Nullable AbstractPresentableCalendar runtimeCalendar;
80 private @Nullable ScheduledFuture<?> updateJobFuture;
81 private Instant updateStatesLastCalledTime;
82 private @Nullable Instant calendarDownloadedTime;
84 public ICalendarHandler(Bridge bridge, HttpClient httpClient, EventPublisher eventPublisher,
85 TimeZoneProvider tzProvider) {
87 this.httpClient = httpClient;
88 final File cacheFolder = new File(new File(OpenHAB.getUserDataFolder(), "cache"),
89 "org.openhab.binding.icalendar");
90 if (!cacheFolder.exists()) {
91 logger.debug("Creating cache folder '{}'", cacheFolder.getAbsolutePath());
94 calendarFile = new File(cacheFolder,
95 getThing().getUID().getAsString().replaceAll("[<>:\"/\\\\|?*]", "_") + ".ical");
96 eventPublisherCallback = eventPublisher;
97 updateStatesLastCalledTime = Instant.now();
98 this.tzProvider = tzProvider;
102 public void dispose() {
103 final ScheduledFuture<?> currentUpdateJobFuture = updateJobFuture;
104 if (currentUpdateJobFuture != null) {
105 currentUpdateJobFuture.cancel(true);
107 final ScheduledFuture<?> currentPullJobFuture = pullJobFuture;
108 if (currentPullJobFuture != null) {
109 currentPullJobFuture.cancel(true);
114 public void handleCommand(ChannelUID channelUID, Command command) {
115 switch (channelUID.getId()) {
116 case CHANNEL_CURRENT_EVENT_PRESENT:
117 case CHANNEL_CURRENT_EVENT_TITLE:
118 case CHANNEL_CURRENT_EVENT_START:
119 case CHANNEL_CURRENT_EVENT_END:
120 case CHANNEL_NEXT_EVENT_TITLE:
121 case CHANNEL_NEXT_EVENT_START:
122 case CHANNEL_NEXT_EVENT_END:
123 case CHANNEL_LAST_UPDATE:
124 if (command instanceof RefreshType) {
129 logger.warn("Framework sent command to unknown channel with id '{}'", channelUID.getId());
134 public void initialize() {
135 migrateLastUpdateChannel();
137 final ICalendarConfiguration currentConfiguration = getConfigAs(ICalendarConfiguration.class);
138 configuration = currentConfiguration;
141 if ((currentConfiguration.username == null && currentConfiguration.password != null)
142 || (currentConfiguration.username != null && currentConfiguration.password == null)) {
143 throw new ConfigBrokenException("Only one of username and password was set. This is invalid.");
147 final BigDecimal maxSizeBD = currentConfiguration.maxSize;
148 if (maxSizeBD == null || maxSizeBD.intValue() < 1) {
149 throw new ConfigBrokenException(
150 "maxSize is either not set or less than 1 (mebibyte), which is not allowed.");
152 final int maxSize = maxSizeBD.intValue();
154 regularPull = new PullJob(httpClient, new URI(currentConfiguration.url), currentConfiguration.username,
155 currentConfiguration.password, calendarFile, maxSize * 1048576, this);
156 } catch (URISyntaxException e) {
157 throw new ConfigBrokenException(String.format(
158 "The URI '%s' for downloading the calendar contains syntax errors.", currentConfiguration.url));
162 final BigDecimal refreshTimeBD = currentConfiguration.refreshTime;
163 if (refreshTimeBD == null || refreshTimeBD.longValue() < 1) {
164 throw new ConfigBrokenException(
165 "refreshTime is either not set or less than 1 (minute), which is not allowed.");
167 final long refreshTime = refreshTimeBD.longValue();
168 if (calendarFile.isFile()) {
169 updateStatus(ThingStatus.ONLINE);
171 scheduler.submit(() -> {
172 // reload calendar file asynchronously
173 if (reloadCalendar()) {
177 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
178 "The calendar seems to be configured correctly, but the local copy of calendar could not be loaded.");
181 pullJobFuture = scheduler.scheduleWithFixedDelay(regularPull, refreshTime, refreshTime,
184 updateStatus(ThingStatus.OFFLINE);
186 "The calendar is currently offline as no local copy exists. It will go online as soon as a valid valid calendar is retrieved.");
187 pullJobFuture = scheduler.scheduleWithFixedDelay(regularPull, 0, refreshTime, TimeUnit.MINUTES);
189 } catch (ConfigBrokenException e) {
190 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
195 public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) {
196 final AbstractPresentableCalendar calendar = runtimeCalendar;
197 if (calendar != null) {
198 updateChild(childHandler);
203 public void onCalendarUpdated() {
204 if (reloadCalendar()) {
208 logger.trace("Calendar was updated, but loading failed.");
213 * @return the calendar that is used for all operations
216 public AbstractPresentableCalendar getRuntimeCalendar() {
217 return runtimeCalendar;
220 private void executeEventCommands(List<Event> events, CommandTagType execTime) {
221 // no begun or ended events => exit quietly as there is nothing to do
222 if (events.isEmpty()) {
226 final ICalendarConfiguration syncConfiguration = configuration;
227 if (syncConfiguration == null) {
228 logger.debug("Configuration not instantiated!");
231 // loop through all events in the list
232 for (Event event : events) {
234 // loop through all command tags in the event
235 for (CommandTag cmdTag : event.commandTags) {
237 // only process the BEGIN resp. END tags
238 if (cmdTag.getTagType() != execTime) {
241 if (!cmdTag.isAuthorized(syncConfiguration.authorizationCode)) {
242 logger.warn("Event: {}, Command Tag: {} => Command not authorized!", event.title,
243 cmdTag.getFullTag());
247 final Command cmdState = cmdTag.getCommand();
248 if (cmdState == null) {
249 logger.warn("Event: {}, Command Tag: {} => Error creating Command State!", event.title,
250 cmdTag.getFullTag());
254 // (try to) execute the command
256 eventPublisherCallback.post(ItemEventFactory.createCommandEvent(cmdTag.getItemName(), cmdState));
257 if (logger.isDebugEnabled()) {
258 String cmdType = cmdState.getClass().toString();
259 int index = cmdType.lastIndexOf(".") + 1;
260 if ((index > 0) && (index < cmdType.length())) {
261 cmdType = cmdType.substring(index);
263 logger.debug("Event: {}, Command Tag: {} => {}.postUpdate({}: {})", event.title,
264 cmdTag.getFullTag(), cmdTag.getItemName(), cmdType, cmdState);
266 } catch (IllegalArgumentException | IllegalStateException e) {
267 logger.warn("Event: {}, Command Tag: {} => Unable to push command to target item!", event.title,
268 cmdTag.getFullTag());
269 logger.debug("Exception occured while pushing to item!", e);
276 * Migration for last_update-channel as this change is compatible to previous instances.
278 private void migrateLastUpdateChannel() {
279 final Thing thing = getThing();
280 if (thing.getChannel(CHANNEL_LAST_UPDATE) == null) {
281 logger.trace("last_update channel is missing in this Thing. Adding it.");
282 final ThingHandlerCallback callback = getCallback();
283 if (callback == null) {
284 logger.debug("ThingHandlerCallback is null. Skipping migration of last_update channel.");
287 final ChannelBuilder channelBuilder = callback
288 .createChannelBuilder(new ChannelUID(thing.getUID(), CHANNEL_LAST_UPDATE), LAST_UPDATE_TYPE_UID);
289 final ThingBuilder thingBuilder = editThing();
290 thingBuilder.withChannel(channelBuilder.build());
291 updateThing(thingBuilder.build());
296 * Reloads the calendar from local ical-file. Replaces the class internal calendar - if loading succeeds. Else
297 * logging details at warn-level logger.
299 * @return Whether the calendar was loaded successfully.
301 private boolean reloadCalendar() {
302 logger.trace("reloading calendar of {}", getThing().getUID());
303 if (!calendarFile.isFile()) {
304 logger.info("Local file for reloading calendar is missing.");
307 final ICalendarConfiguration config = configuration;
308 if (config == null) {
309 logger.warn("Can't reload calendar when configuration is missing.");
312 try (final FileInputStream fileStream = new FileInputStream(calendarFile)) {
313 final AbstractPresentableCalendar calendar = AbstractPresentableCalendar.create(fileStream);
314 runtimeCalendar = calendar;
315 rescheduleCalendarStateUpdate();
316 calendarDownloadedTime = Instant.ofEpochMilli(calendarFile.lastModified());
317 } catch (IOException | CalendarException e) {
318 logger.warn("Loading calendar failed: {}", e.getMessage());
325 * Reschedules the next update of the states.
327 private void rescheduleCalendarStateUpdate() {
328 final ScheduledFuture<?> currentUpdateJobFuture = updateJobFuture;
329 if (currentUpdateJobFuture != null) {
330 if (!(currentUpdateJobFuture.isCancelled() || currentUpdateJobFuture.isDone())) {
331 currentUpdateJobFuture.cancel(true);
333 updateJobFuture = null;
335 final AbstractPresentableCalendar currentCalendar = runtimeCalendar;
336 if (currentCalendar == null) {
339 final Instant now = Instant.now();
340 if (currentCalendar.isEventPresent(now)) {
341 final Event currentEvent = currentCalendar.getCurrentEvent(now);
342 if (currentEvent == null) {
344 "Could not schedule next update of states, due to unexpected behaviour of calendar implementation.");
347 updateJobFuture = scheduler.schedule(() -> {
348 ICalendarHandler.this.updateStates();
349 ICalendarHandler.this.rescheduleCalendarStateUpdate();
350 }, currentEvent.end.getEpochSecond() - now.getEpochSecond(), TimeUnit.SECONDS);
351 logger.debug("Scheduled update in {} seconds", currentEvent.end.getEpochSecond() - now.getEpochSecond());
353 final Event nextEvent = currentCalendar.getNextEvent(now);
354 final ICalendarConfiguration currentConfig = this.configuration;
355 if (currentConfig == null) {
356 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
357 "Something is broken, the configuration is not available.");
360 if (nextEvent == null) {
361 updateJobFuture = scheduler.schedule(() -> {
362 ICalendarHandler.this.rescheduleCalendarStateUpdate();
363 }, 1L, TimeUnit.DAYS);
364 logger.debug("Scheduled reschedule in 1 day");
366 updateJobFuture = scheduler.schedule(() -> {
367 ICalendarHandler.this.updateStates();
368 ICalendarHandler.this.rescheduleCalendarStateUpdate();
369 }, nextEvent.start.getEpochSecond() - now.getEpochSecond(), TimeUnit.SECONDS);
370 logger.debug("Scheduled update in {} seconds", nextEvent.start.getEpochSecond() - now.getEpochSecond());
377 * Updates the states of the Thing and its channels.
379 private void updateStates() {
380 logger.trace("updating states of {}", getThing().getUID());
381 final AbstractPresentableCalendar calendar = runtimeCalendar;
382 if (calendar == null) {
383 updateStatus(ThingStatus.OFFLINE);
385 updateStatus(ThingStatus.ONLINE);
387 final Instant now = Instant.now();
388 if (calendar.isEventPresent(now)) {
389 updateState(CHANNEL_CURRENT_EVENT_PRESENT, OnOffType.ON);
390 final Event currentEvent = calendar.getCurrentEvent(now);
391 if (currentEvent == null) {
392 logger.warn("Unexpected inconsistency of internal API. Not Updating event details.");
394 updateState(CHANNEL_CURRENT_EVENT_TITLE, new StringType(currentEvent.title));
395 updateState(CHANNEL_CURRENT_EVENT_START,
396 new DateTimeType(currentEvent.start.atZone(tzProvider.getTimeZone())));
397 updateState(CHANNEL_CURRENT_EVENT_END,
398 new DateTimeType(currentEvent.end.atZone(tzProvider.getTimeZone())));
401 updateState(CHANNEL_CURRENT_EVENT_PRESENT, OnOffType.OFF);
402 updateState(CHANNEL_CURRENT_EVENT_TITLE, UnDefType.UNDEF);
403 updateState(CHANNEL_CURRENT_EVENT_START, UnDefType.UNDEF);
404 updateState(CHANNEL_CURRENT_EVENT_END, UnDefType.UNDEF);
407 final Event nextEvent = calendar.getNextEvent(now);
408 if (nextEvent != null) {
409 updateState(CHANNEL_NEXT_EVENT_TITLE, new StringType(nextEvent.title));
410 updateState(CHANNEL_NEXT_EVENT_START,
411 new DateTimeType(nextEvent.start.atZone(tzProvider.getTimeZone())));
412 updateState(CHANNEL_NEXT_EVENT_END, new DateTimeType(nextEvent.end.atZone(tzProvider.getTimeZone())));
414 updateState(CHANNEL_NEXT_EVENT_TITLE, UnDefType.UNDEF);
415 updateState(CHANNEL_NEXT_EVENT_START, UnDefType.UNDEF);
416 updateState(CHANNEL_NEXT_EVENT_END, UnDefType.UNDEF);
419 final Instant lastUpdate = calendarDownloadedTime;
420 updateState(CHANNEL_LAST_UPDATE,
421 (lastUpdate != null ? new DateTimeType(lastUpdate.atZone(tzProvider.getTimeZone()))
424 // process all Command Tags in all Calendar Events which ENDED since updateStates was last called
425 // the END Event tags must be processed before the BEGIN ones
426 executeEventCommands(calendar.getJustEndedEvents(updateStatesLastCalledTime, now), CommandTagType.END);
428 // process all Command Tags in all Calendar Events which BEGAN since updateStates was last called
429 // the END Event tags must be processed before the BEGIN ones
430 executeEventCommands(calendar.getJustBegunEvents(updateStatesLastCalledTime, now), CommandTagType.BEGIN);
432 // save time when updateStates was previously called
433 // the purpose is to prevent repeat command execution of events that have already been executed
434 updateStatesLastCalledTime = now;
439 * Updates all children of this handler.
441 private void updateChildren() {
442 getThing().getThings().forEach(childThing -> updateChild(childThing.getHandler()));
446 * Updates a specific child handler.
448 * @param childHandler the handler to be updated
450 private void updateChild(@Nullable ThingHandler childHandler) {
451 if (childHandler instanceof CalendarUpdateListener) {
452 logger.trace("Notifying {} about fresh calendar.", childHandler.getThing().getUID());
454 ((CalendarUpdateListener) childHandler).onCalendarUpdated();
455 } catch (Exception e) {
456 logger.trace("The update of a child handler failed. Ignoring.", e);