2 * Copyright (c) 2010-2020 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.types.Command;
53 import org.openhab.core.types.RefreshType;
54 import org.openhab.core.types.UnDefType;
55 import org.slf4j.Logger;
56 import org.slf4j.LoggerFactory;
59 * The {@link ICalendarHandler} is responsible for handling commands, which are
60 * sent to one of the channels.
62 * @author Michael Wodniok - Initial contribution
63 * @author Andrew Fiddian-Green - Support for Command Tags embedded in the Event description
66 public class ICalendarHandler extends BaseBridgeHandler implements CalendarUpdateListener {
68 private final File calendarFile;
69 private @Nullable ICalendarConfiguration configuration;
70 private final EventPublisher eventPublisherCallback;
71 private final HttpClient httpClient;
72 private final Logger logger = LoggerFactory.getLogger(ICalendarHandler.class);
73 private final TimeZoneProvider tzProvider;
74 private @Nullable ScheduledFuture<?> pullJobFuture;
75 private @Nullable AbstractPresentableCalendar runtimeCalendar;
76 private @Nullable ScheduledFuture<?> updateJobFuture;
77 private Instant updateStatesLastCalledTime;
79 public ICalendarHandler(Bridge bridge, HttpClient httpClient, EventPublisher eventPublisher,
80 TimeZoneProvider tzProvider) {
82 this.httpClient = httpClient;
83 calendarFile = new File(OpenHAB.getUserDataFolder() + File.separator
84 + getThing().getUID().getAsString().replaceAll("[<>:\"/\\\\|?*]", "_") + ".ical");
85 eventPublisherCallback = eventPublisher;
86 updateStatesLastCalledTime = Instant.now();
87 this.tzProvider = tzProvider;
91 public void dispose() {
92 final ScheduledFuture<?> currentUpdateJobFuture = updateJobFuture;
93 if (currentUpdateJobFuture != null) {
94 currentUpdateJobFuture.cancel(true);
96 final ScheduledFuture<?> currentPullJobFuture = pullJobFuture;
97 if (currentPullJobFuture != null) {
98 currentPullJobFuture.cancel(true);
103 public void handleCommand(ChannelUID channelUID, Command command) {
104 switch (channelUID.getId()) {
105 case CHANNEL_CURRENT_EVENT_PRESENT:
106 case CHANNEL_CURRENT_EVENT_TITLE:
107 case CHANNEL_CURRENT_EVENT_START:
108 case CHANNEL_CURRENT_EVENT_END:
109 case CHANNEL_NEXT_EVENT_TITLE:
110 case CHANNEL_NEXT_EVENT_START:
111 case CHANNEL_NEXT_EVENT_END:
112 if (command instanceof RefreshType) {
117 logger.warn("Framework sent command to unknown channel with id '{}'", channelUID.getId());
122 public void initialize() {
123 final ICalendarConfiguration currentConfiguration = getConfigAs(ICalendarConfiguration.class);
124 configuration = currentConfiguration;
127 if ((currentConfiguration.username == null && currentConfiguration.password != null)
128 || (currentConfiguration.username != null && currentConfiguration.password == null)) {
129 throw new ConfigBrokenException("Only one of username and password was set. This is invalid.");
133 final BigDecimal maxSizeBD = currentConfiguration.maxSize;
134 if (maxSizeBD == null || maxSizeBD.intValue() < 1) {
135 throw new ConfigBrokenException(
136 "maxSize is either not set or less than 1 (mebibyte), which is not allowed.");
138 final int maxSize = maxSizeBD.intValue();
140 regularPull = new PullJob(httpClient, new URI(currentConfiguration.url), currentConfiguration.username,
141 currentConfiguration.password, calendarFile, maxSize * 1048576, this);
142 } catch (URISyntaxException e) {
143 throw new ConfigBrokenException(String.format(
144 "The URI '%s' for downloading the calendar contains syntax errors.", currentConfiguration.url));
148 final BigDecimal refreshTimeBD = currentConfiguration.refreshTime;
149 if (refreshTimeBD == null || refreshTimeBD.longValue() < 1) {
150 throw new ConfigBrokenException(
151 "refreshTime is either not set or less than 1 (minute), which is not allowed.");
153 final long refreshTime = refreshTimeBD.longValue();
154 if (calendarFile.isFile()) {
155 updateStatus(ThingStatus.ONLINE);
157 scheduler.submit(() -> {
158 // reload calendar file asynchronously
159 if (reloadCalendar()) {
162 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
163 "The calendar seems to be configured correctly, but the local copy of calendar could not be loaded.");
166 pullJobFuture = scheduler.scheduleWithFixedDelay(regularPull, refreshTime, refreshTime,
169 updateStatus(ThingStatus.OFFLINE);
171 "The calendar is currently offline as no local copy exists. It will go online as soon as a valid valid calendar is retrieved.");
172 pullJobFuture = scheduler.scheduleWithFixedDelay(regularPull, 0, refreshTime, TimeUnit.MINUTES);
174 } catch (ConfigBrokenException e) {
175 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
180 public void onCalendarUpdated() {
181 if (reloadCalendar()) {
183 for (Thing childThing : getThing().getThings()) {
184 ThingHandler handler = childThing.getHandler();
185 if (handler instanceof CalendarUpdateListener) {
187 logger.trace("Notifying {} about fresh calendar.", handler.getThing().getUID());
188 ((CalendarUpdateListener) handler).onCalendarUpdated();
189 } catch (Exception e) {
190 logger.trace("The update of a child handler failed. Ignoring.", e);
195 logger.trace("Calendar was updated, but loading failed.");
200 * @return the calendar that is used for all operations
203 public AbstractPresentableCalendar getRuntimeCalendar() {
204 return runtimeCalendar;
207 private void executeEventCommands(List<Event> events, CommandTagType execTime) {
208 // no begun or ended events => exit quietly as there is nothing to do
209 if (events.isEmpty()) {
213 final ICalendarConfiguration syncConfiguration = configuration;
214 if (syncConfiguration == null) {
215 logger.debug("Configuration not instantiated!");
218 // loop through all events in the list
219 for (Event event : events) {
221 // loop through all command tags in the event
222 for (CommandTag cmdTag : event.commandTags) {
224 // only process the BEGIN resp. END tags
225 if (cmdTag.getTagType() != execTime) {
228 if (!cmdTag.isAuthorized(syncConfiguration.authorizationCode)) {
229 logger.warn("Event: {}, Command Tag: {} => Command not authorized!", event.title,
230 cmdTag.getFullTag());
234 final Command cmdState = cmdTag.getCommand();
235 if (cmdState == null) {
236 logger.warn("Event: {}, Command Tag: {} => Error creating Command State!", event.title,
237 cmdTag.getFullTag());
241 // (try to) execute the command
243 eventPublisherCallback.post(ItemEventFactory.createCommandEvent(cmdTag.getItemName(), cmdState));
244 if (logger.isDebugEnabled()) {
245 String cmdType = cmdState.getClass().toString();
246 int index = cmdType.lastIndexOf(".") + 1;
247 if ((index > 0) && (index < cmdType.length())) {
248 cmdType = cmdType.substring(index);
250 logger.debug("Event: {}, Command Tag: {} => {}.postUpdate({}: {})", event.title,
251 cmdTag.getFullTag(), cmdTag.getItemName(), cmdType, cmdState);
253 } catch (IllegalArgumentException | IllegalStateException e) {
254 logger.warn("Event: {}, Command Tag: {} => Unable to push command to target item!", event.title,
255 cmdTag.getFullTag());
256 logger.debug("Exception occured while pushing to item!", e);
263 * Reloads the calendar from local ical-file. Replaces the class internal calendar - if loading succeeds. Else
264 * logging details at warn-level logger.
266 * @return Whether the calendar was loaded successfully.
268 private boolean reloadCalendar() {
269 if (!calendarFile.isFile()) {
270 logger.info("Local file for reloading calendar is missing.");
273 final ICalendarConfiguration config = configuration;
274 if (config == null) {
275 logger.warn("Can't reload calendar when configuration is missing.");
278 try (final FileInputStream fileStream = new FileInputStream(calendarFile)) {
279 final AbstractPresentableCalendar calendar = AbstractPresentableCalendar.create(fileStream);
280 runtimeCalendar = calendar;
281 rescheduleCalendarStateUpdate();
282 } catch (IOException | CalendarException e) {
283 logger.warn("Loading calendar failed: {}", e.getMessage());
290 * Reschedules the next update of the states.
292 private void rescheduleCalendarStateUpdate() {
293 final ScheduledFuture<?> currentUpdateJobFuture = updateJobFuture;
294 if (currentUpdateJobFuture != null) {
295 if (!(currentUpdateJobFuture.isCancelled() || currentUpdateJobFuture.isDone())) {
296 currentUpdateJobFuture.cancel(true);
298 updateJobFuture = null;
300 final AbstractPresentableCalendar currentCalendar = runtimeCalendar;
301 if (currentCalendar == null) {
304 final Instant now = Instant.now();
305 if (currentCalendar.isEventPresent(now)) {
306 final Event currentEvent = currentCalendar.getCurrentEvent(now);
307 if (currentEvent == null) {
309 "Could not schedule next update of states, due to unexpected behaviour of calendar implementation.");
312 updateJobFuture = scheduler.schedule(() -> {
313 ICalendarHandler.this.updateStates();
314 ICalendarHandler.this.rescheduleCalendarStateUpdate();
315 }, currentEvent.end.getEpochSecond() - now.getEpochSecond(), TimeUnit.SECONDS);
317 final Event nextEvent = currentCalendar.getNextEvent(now);
318 final ICalendarConfiguration currentConfig = this.configuration;
319 if (currentConfig == null) {
320 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
321 "Something is broken, the configuration is not available.");
324 if (nextEvent == null) {
325 updateJobFuture = scheduler.schedule(() -> {
326 ICalendarHandler.this.rescheduleCalendarStateUpdate();
327 }, 1L, TimeUnit.DAYS);
329 updateJobFuture = scheduler.schedule(() -> {
330 ICalendarHandler.this.updateStates();
331 ICalendarHandler.this.rescheduleCalendarStateUpdate();
332 }, nextEvent.start.getEpochSecond() - now.getEpochSecond(), TimeUnit.SECONDS);
338 * Updates the states of the Thing and its channels.
340 private void updateStates() {
341 final AbstractPresentableCalendar calendar = runtimeCalendar;
342 if (calendar == null) {
343 updateStatus(ThingStatus.OFFLINE);
345 updateStatus(ThingStatus.ONLINE);
347 final Instant now = Instant.now();
348 if (calendar.isEventPresent(now)) {
349 updateState(CHANNEL_CURRENT_EVENT_PRESENT, OnOffType.ON);
350 final Event currentEvent = calendar.getCurrentEvent(now);
351 if (currentEvent == null) {
352 logger.warn("Unexpected inconsistency of internal API. Not Updating event details.");
354 updateState(CHANNEL_CURRENT_EVENT_TITLE, new StringType(currentEvent.title));
355 updateState(CHANNEL_CURRENT_EVENT_START,
356 new DateTimeType(currentEvent.start.atZone(tzProvider.getTimeZone())));
357 updateState(CHANNEL_CURRENT_EVENT_END,
358 new DateTimeType(currentEvent.end.atZone(tzProvider.getTimeZone())));
361 updateState(CHANNEL_CURRENT_EVENT_PRESENT, OnOffType.OFF);
362 updateState(CHANNEL_CURRENT_EVENT_TITLE, UnDefType.UNDEF);
363 updateState(CHANNEL_CURRENT_EVENT_START, UnDefType.UNDEF);
364 updateState(CHANNEL_CURRENT_EVENT_END, UnDefType.UNDEF);
367 final Event nextEvent = calendar.getNextEvent(now);
368 if (nextEvent != null) {
369 updateState(CHANNEL_NEXT_EVENT_TITLE, new StringType(nextEvent.title));
370 updateState(CHANNEL_NEXT_EVENT_START,
371 new DateTimeType(nextEvent.start.atZone(tzProvider.getTimeZone())));
372 updateState(CHANNEL_NEXT_EVENT_END, new DateTimeType(nextEvent.end.atZone(tzProvider.getTimeZone())));
374 updateState(CHANNEL_NEXT_EVENT_TITLE, UnDefType.UNDEF);
375 updateState(CHANNEL_NEXT_EVENT_START, UnDefType.UNDEF);
376 updateState(CHANNEL_NEXT_EVENT_END, UnDefType.UNDEF);
379 // process all Command Tags in all Calendar Events which ENDED since updateStates was last called
380 // the END Event tags must be processed before the BEGIN ones
381 executeEventCommands(calendar.getJustEndedEvents(updateStatesLastCalledTime, now), CommandTagType.END);
383 // process all Command Tags in all Calendar Events which BEGAN since updateStates was last called
384 // the END Event tags must be processed before the BEGIN ones
385 executeEventCommands(calendar.getJustBegunEvents(updateStatesLastCalledTime, now), CommandTagType.BEGIN);
387 // save time when updateStates was previously called
388 // the purpose is to prevent repeat command execution of events that have already been executed
389 updateStatesLastCalledTime = now;