]> git.basschouten.com Git - openhab-addons.git/blob
e2b0c64b977b607c14384c4e01e4780a98521a2c
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.icalendar.internal.handler;
14
15 import static org.openhab.binding.icalendar.internal.ICalendarBindingConstants.*;
16
17 import java.io.File;
18 import java.io.FileInputStream;
19 import java.io.IOException;
20 import java.math.BigDecimal;
21 import java.net.URI;
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;
27
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.builder.ChannelBuilder;
53 import org.openhab.core.thing.binding.builder.ThingBuilder;
54 import org.openhab.core.types.Command;
55 import org.openhab.core.types.RefreshType;
56 import org.openhab.core.types.UnDefType;
57 import org.slf4j.Logger;
58 import org.slf4j.LoggerFactory;
59
60 /**
61  * The {@link ICalendarHandler} is responsible for handling commands, which are
62  * sent to one of the channels.
63  *
64  * @author Michael Wodniok - Initial contribution
65  * @author Andrew Fiddian-Green - Support for Command Tags embedded in the Event description
66  * @author Michael Wodniok - Added last_update-channel and additional needed handling of it
67  */
68 @NonNullByDefault
69 public class ICalendarHandler extends BaseBridgeHandler implements CalendarUpdateListener {
70
71     private final File calendarFile;
72     private @Nullable ICalendarConfiguration configuration;
73     private final EventPublisher eventPublisherCallback;
74     private final HttpClient httpClient;
75     private final Logger logger = LoggerFactory.getLogger(ICalendarHandler.class);
76     private final TimeZoneProvider tzProvider;
77     private @Nullable ScheduledFuture<?> pullJobFuture;
78     private @Nullable AbstractPresentableCalendar runtimeCalendar;
79     private @Nullable ScheduledFuture<?> updateJobFuture;
80     private Instant updateStatesLastCalledTime;
81     private @Nullable Instant calendarDownloadedTime;
82
83     public ICalendarHandler(Bridge bridge, HttpClient httpClient, EventPublisher eventPublisher,
84             TimeZoneProvider tzProvider) {
85         super(bridge);
86         this.httpClient = httpClient;
87         calendarFile = new File(OpenHAB.getUserDataFolder() + File.separator
88                 + getThing().getUID().getAsString().replaceAll("[<>:\"/\\\\|?*]", "_") + ".ical");
89         eventPublisherCallback = eventPublisher;
90         updateStatesLastCalledTime = Instant.now();
91         this.tzProvider = tzProvider;
92     }
93
94     @Override
95     public void dispose() {
96         final ScheduledFuture<?> currentUpdateJobFuture = updateJobFuture;
97         if (currentUpdateJobFuture != null) {
98             currentUpdateJobFuture.cancel(true);
99         }
100         final ScheduledFuture<?> currentPullJobFuture = pullJobFuture;
101         if (currentPullJobFuture != null) {
102             currentPullJobFuture.cancel(true);
103         }
104     }
105
106     @Override
107     public void handleCommand(ChannelUID channelUID, Command command) {
108         switch (channelUID.getId()) {
109             case CHANNEL_CURRENT_EVENT_PRESENT:
110             case CHANNEL_CURRENT_EVENT_TITLE:
111             case CHANNEL_CURRENT_EVENT_START:
112             case CHANNEL_CURRENT_EVENT_END:
113             case CHANNEL_NEXT_EVENT_TITLE:
114             case CHANNEL_NEXT_EVENT_START:
115             case CHANNEL_NEXT_EVENT_END:
116             case CHANNEL_LAST_UPDATE:
117                 if (command instanceof RefreshType) {
118                     updateStates();
119                 }
120                 break;
121             default:
122                 logger.warn("Framework sent command to unknown channel with id '{}'", channelUID.getId());
123         }
124     }
125
126     @Override
127     public void initialize() {
128         migrateLastUpdateChannel();
129
130         final ICalendarConfiguration currentConfiguration = getConfigAs(ICalendarConfiguration.class);
131         configuration = currentConfiguration;
132
133         try {
134             if ((currentConfiguration.username == null && currentConfiguration.password != null)
135                     || (currentConfiguration.username != null && currentConfiguration.password == null)) {
136                 throw new ConfigBrokenException("Only one of username and password was set. This is invalid.");
137             }
138
139             PullJob regularPull;
140             final BigDecimal maxSizeBD = currentConfiguration.maxSize;
141             if (maxSizeBD == null || maxSizeBD.intValue() < 1) {
142                 throw new ConfigBrokenException(
143                         "maxSize is either not set or less than 1 (mebibyte), which is not allowed.");
144             }
145             final int maxSize = maxSizeBD.intValue();
146             try {
147                 regularPull = new PullJob(httpClient, new URI(currentConfiguration.url), currentConfiguration.username,
148                         currentConfiguration.password, calendarFile, maxSize * 1048576, this);
149             } catch (URISyntaxException e) {
150                 throw new ConfigBrokenException(String.format(
151                         "The URI '%s' for downloading the calendar contains syntax errors.", currentConfiguration.url));
152
153             }
154
155             final BigDecimal refreshTimeBD = currentConfiguration.refreshTime;
156             if (refreshTimeBD == null || refreshTimeBD.longValue() < 1) {
157                 throw new ConfigBrokenException(
158                         "refreshTime is either not set or less than 1 (minute), which is not allowed.");
159             }
160             final long refreshTime = refreshTimeBD.longValue();
161             if (calendarFile.isFile()) {
162                 updateStatus(ThingStatus.ONLINE);
163
164                 scheduler.submit(() -> {
165                     // reload calendar file asynchronously
166                     if (reloadCalendar()) {
167                         updateStates();
168                         updateChildren();
169                     } else {
170                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
171                                 "The calendar seems to be configured correctly, but the local copy of calendar could not be loaded.");
172                     }
173                 });
174                 pullJobFuture = scheduler.scheduleWithFixedDelay(regularPull, refreshTime, refreshTime,
175                         TimeUnit.MINUTES);
176             } else {
177                 updateStatus(ThingStatus.OFFLINE);
178                 logger.debug(
179                         "The calendar is currently offline as no local copy exists. It will go online as soon as a valid valid calendar is retrieved.");
180                 pullJobFuture = scheduler.scheduleWithFixedDelay(regularPull, 0, refreshTime, TimeUnit.MINUTES);
181             }
182         } catch (ConfigBrokenException e) {
183             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
184         }
185     }
186
187     @Override
188     public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) {
189         final AbstractPresentableCalendar calendar = runtimeCalendar;
190         if (calendar != null) {
191             updateChild(childHandler);
192         }
193     }
194
195     @Override
196     public void onCalendarUpdated() {
197         if (reloadCalendar()) {
198             updateStates();
199             updateChildren();
200         } else {
201             logger.trace("Calendar was updated, but loading failed.");
202         }
203     }
204
205     /**
206      * @return the calendar that is used for all operations
207      */
208     @Nullable
209     public AbstractPresentableCalendar getRuntimeCalendar() {
210         return runtimeCalendar;
211     }
212
213     private void executeEventCommands(List<Event> events, CommandTagType execTime) {
214         // no begun or ended events => exit quietly as there is nothing to do
215         if (events.isEmpty()) {
216             return;
217         }
218
219         final ICalendarConfiguration syncConfiguration = configuration;
220         if (syncConfiguration == null) {
221             logger.debug("Configuration not instantiated!");
222             return;
223         }
224         // loop through all events in the list
225         for (Event event : events) {
226
227             // loop through all command tags in the event
228             for (CommandTag cmdTag : event.commandTags) {
229
230                 // only process the BEGIN resp. END tags
231                 if (cmdTag.getTagType() != execTime) {
232                     continue;
233                 }
234                 if (!cmdTag.isAuthorized(syncConfiguration.authorizationCode)) {
235                     logger.warn("Event: {}, Command Tag: {} => Command not authorized!", event.title,
236                             cmdTag.getFullTag());
237                     continue;
238                 }
239
240                 final Command cmdState = cmdTag.getCommand();
241                 if (cmdState == null) {
242                     logger.warn("Event: {}, Command Tag: {} => Error creating Command State!", event.title,
243                             cmdTag.getFullTag());
244                     continue;
245                 }
246
247                 // (try to) execute the command
248                 try {
249                     eventPublisherCallback.post(ItemEventFactory.createCommandEvent(cmdTag.getItemName(), cmdState));
250                     if (logger.isDebugEnabled()) {
251                         String cmdType = cmdState.getClass().toString();
252                         int index = cmdType.lastIndexOf(".") + 1;
253                         if ((index > 0) && (index < cmdType.length())) {
254                             cmdType = cmdType.substring(index);
255                         }
256                         logger.debug("Event: {}, Command Tag: {} => {}.postUpdate({}: {})", event.title,
257                                 cmdTag.getFullTag(), cmdTag.getItemName(), cmdType, cmdState);
258                     }
259                 } catch (IllegalArgumentException | IllegalStateException e) {
260                     logger.warn("Event: {}, Command Tag: {} => Unable to push command to target item!", event.title,
261                             cmdTag.getFullTag());
262                     logger.debug("Exception occured while pushing to item!", e);
263                 }
264             }
265         }
266     }
267
268     /**
269      * Migration for last_update-channel as this change is compatible to previous instances.
270      */
271     private void migrateLastUpdateChannel() {
272         Thing thing = getThing();
273         if (thing.getChannel(CHANNEL_LAST_UPDATE) == null) {
274             logger.debug("last_update channel is missing in this Thing. Adding it.");
275             ThingBuilder thingBuilder = editThing();
276             ChannelBuilder channelBuilder = ChannelBuilder.create(new ChannelUID(thing.getUID(), CHANNEL_LAST_UPDATE));
277             thingBuilder.withChannel(channelBuilder.withType(LAST_UPDATE_TYPE_UID).build());
278             updateThing(thingBuilder.build());
279         }
280     }
281
282     /**
283      * Reloads the calendar from local ical-file. Replaces the class internal calendar - if loading succeeds. Else
284      * logging details at warn-level logger.
285      *
286      * @return Whether the calendar was loaded successfully.
287      */
288     private boolean reloadCalendar() {
289         logger.trace("reloading calendar of {}", getThing().getUID());
290         if (!calendarFile.isFile()) {
291             logger.info("Local file for reloading calendar is missing.");
292             return false;
293         }
294         final ICalendarConfiguration config = configuration;
295         if (config == null) {
296             logger.warn("Can't reload calendar when configuration is missing.");
297             return false;
298         }
299         try (final FileInputStream fileStream = new FileInputStream(calendarFile)) {
300             final AbstractPresentableCalendar calendar = AbstractPresentableCalendar.create(fileStream);
301             runtimeCalendar = calendar;
302             rescheduleCalendarStateUpdate();
303             calendarDownloadedTime = Instant.ofEpochMilli(calendarFile.lastModified());
304         } catch (IOException | CalendarException e) {
305             logger.warn("Loading calendar failed: {}", e.getMessage());
306             return false;
307         }
308         return true;
309     }
310
311     /**
312      * Reschedules the next update of the states.
313      */
314     private void rescheduleCalendarStateUpdate() {
315         final ScheduledFuture<?> currentUpdateJobFuture = updateJobFuture;
316         if (currentUpdateJobFuture != null) {
317             if (!(currentUpdateJobFuture.isCancelled() || currentUpdateJobFuture.isDone())) {
318                 currentUpdateJobFuture.cancel(true);
319             }
320             updateJobFuture = null;
321         }
322         final AbstractPresentableCalendar currentCalendar = runtimeCalendar;
323         if (currentCalendar == null) {
324             return;
325         }
326         final Instant now = Instant.now();
327         if (currentCalendar.isEventPresent(now)) {
328             final Event currentEvent = currentCalendar.getCurrentEvent(now);
329             if (currentEvent == null) {
330                 logger.debug(
331                         "Could not schedule next update of states, due to unexpected behaviour of calendar implementation.");
332                 return;
333             }
334             updateJobFuture = scheduler.schedule(() -> {
335                 ICalendarHandler.this.updateStates();
336                 ICalendarHandler.this.rescheduleCalendarStateUpdate();
337             }, currentEvent.end.getEpochSecond() - now.getEpochSecond(), TimeUnit.SECONDS);
338             logger.debug("Scheduled update in {} seconds", currentEvent.end.getEpochSecond() - now.getEpochSecond());
339         } else {
340             final Event nextEvent = currentCalendar.getNextEvent(now);
341             final ICalendarConfiguration currentConfig = this.configuration;
342             if (currentConfig == null) {
343                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
344                         "Something is broken, the configuration is not available.");
345                 return;
346             }
347             if (nextEvent == null) {
348                 updateJobFuture = scheduler.schedule(() -> {
349                     ICalendarHandler.this.rescheduleCalendarStateUpdate();
350                 }, 1L, TimeUnit.DAYS);
351                 logger.debug("Scheduled reschedule in 1 day");
352             } else {
353                 updateJobFuture = scheduler.schedule(() -> {
354                     ICalendarHandler.this.updateStates();
355                     ICalendarHandler.this.rescheduleCalendarStateUpdate();
356                 }, nextEvent.start.getEpochSecond() - now.getEpochSecond(), TimeUnit.SECONDS);
357                 logger.debug("Scheduled update in {} seconds", nextEvent.start.getEpochSecond() - now.getEpochSecond());
358
359             }
360         }
361     }
362
363     /**
364      * Updates the states of the Thing and its channels.
365      */
366     private void updateStates() {
367         logger.trace("updating states of {}", getThing().getUID());
368         final AbstractPresentableCalendar calendar = runtimeCalendar;
369         if (calendar == null) {
370             updateStatus(ThingStatus.OFFLINE);
371         } else {
372             updateStatus(ThingStatus.ONLINE);
373
374             final Instant now = Instant.now();
375             if (calendar.isEventPresent(now)) {
376                 updateState(CHANNEL_CURRENT_EVENT_PRESENT, OnOffType.ON);
377                 final Event currentEvent = calendar.getCurrentEvent(now);
378                 if (currentEvent == null) {
379                     logger.warn("Unexpected inconsistency of internal API. Not Updating event details.");
380                 } else {
381                     updateState(CHANNEL_CURRENT_EVENT_TITLE, new StringType(currentEvent.title));
382                     updateState(CHANNEL_CURRENT_EVENT_START,
383                             new DateTimeType(currentEvent.start.atZone(tzProvider.getTimeZone())));
384                     updateState(CHANNEL_CURRENT_EVENT_END,
385                             new DateTimeType(currentEvent.end.atZone(tzProvider.getTimeZone())));
386                 }
387             } else {
388                 updateState(CHANNEL_CURRENT_EVENT_PRESENT, OnOffType.OFF);
389                 updateState(CHANNEL_CURRENT_EVENT_TITLE, UnDefType.UNDEF);
390                 updateState(CHANNEL_CURRENT_EVENT_START, UnDefType.UNDEF);
391                 updateState(CHANNEL_CURRENT_EVENT_END, UnDefType.UNDEF);
392             }
393
394             final Event nextEvent = calendar.getNextEvent(now);
395             if (nextEvent != null) {
396                 updateState(CHANNEL_NEXT_EVENT_TITLE, new StringType(nextEvent.title));
397                 updateState(CHANNEL_NEXT_EVENT_START,
398                         new DateTimeType(nextEvent.start.atZone(tzProvider.getTimeZone())));
399                 updateState(CHANNEL_NEXT_EVENT_END, new DateTimeType(nextEvent.end.atZone(tzProvider.getTimeZone())));
400             } else {
401                 updateState(CHANNEL_NEXT_EVENT_TITLE, UnDefType.UNDEF);
402                 updateState(CHANNEL_NEXT_EVENT_START, UnDefType.UNDEF);
403                 updateState(CHANNEL_NEXT_EVENT_END, UnDefType.UNDEF);
404             }
405
406             final Instant lastUpdate = calendarDownloadedTime;
407             updateState(CHANNEL_LAST_UPDATE,
408                     (lastUpdate != null ? new DateTimeType(lastUpdate.atZone(tzProvider.getTimeZone()))
409                             : UnDefType.UNDEF));
410
411             // process all Command Tags in all Calendar Events which ENDED since updateStates was last called
412             // the END Event tags must be processed before the BEGIN ones
413             executeEventCommands(calendar.getJustEndedEvents(updateStatesLastCalledTime, now), CommandTagType.END);
414
415             // process all Command Tags in all Calendar Events which BEGAN since updateStates was last called
416             // the END Event tags must be processed before the BEGIN ones
417             executeEventCommands(calendar.getJustBegunEvents(updateStatesLastCalledTime, now), CommandTagType.BEGIN);
418
419             // save time when updateStates was previously called
420             // the purpose is to prevent repeat command execution of events that have already been executed
421             updateStatesLastCalledTime = now;
422         }
423     }
424
425     /**
426      * Updates all children of this handler.
427      */
428     private void updateChildren() {
429         getThing().getThings().forEach(childThing -> updateChild(childThing.getHandler()));
430     }
431
432     /**
433      * Updates a specific child handler.
434      *
435      * @param childHandler the handler to be updated
436      */
437     private void updateChild(@Nullable ThingHandler childHandler) {
438         if (childHandler instanceof CalendarUpdateListener) {
439             logger.trace("Notifying {} about fresh calendar.", childHandler.getThing().getUID());
440             try {
441                 ((CalendarUpdateListener) childHandler).onCalendarUpdated();
442             } catch (Exception e) {
443                 logger.trace("The update of a child handler failed. Ignoring.", e);
444             }
445         }
446     }
447 }