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