]> git.basschouten.com Git - openhab-addons.git/blob
10936ed60f2072e15ebe03a6e01e986cd1edf665
[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.hdpowerview.internal.handler;
14
15 import java.time.DayOfWeek;
16 import java.time.LocalTime;
17 import java.time.format.TextStyle;
18 import java.util.ArrayList;
19 import java.util.EnumSet;
20 import java.util.HashMap;
21 import java.util.List;
22 import java.util.Map;
23 import java.util.Map.Entry;
24 import java.util.StringJoiner;
25 import java.util.concurrent.CopyOnWriteArrayList;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
28
29 import javax.ws.rs.ProcessingException;
30
31 import org.eclipse.jdt.annotation.NonNullByDefault;
32 import org.eclipse.jdt.annotation.Nullable;
33 import org.eclipse.jetty.client.HttpClient;
34 import org.openhab.binding.hdpowerview.internal.HDPowerViewBindingConstants;
35 import org.openhab.binding.hdpowerview.internal.HDPowerViewTranslationProvider;
36 import org.openhab.binding.hdpowerview.internal.HDPowerViewWebTargets;
37 import org.openhab.binding.hdpowerview.internal.HubMaintenanceException;
38 import org.openhab.binding.hdpowerview.internal.HubProcessingException;
39 import org.openhab.binding.hdpowerview.internal.api.responses.SceneCollections;
40 import org.openhab.binding.hdpowerview.internal.api.responses.SceneCollections.SceneCollection;
41 import org.openhab.binding.hdpowerview.internal.api.responses.Scenes;
42 import org.openhab.binding.hdpowerview.internal.api.responses.Scenes.Scene;
43 import org.openhab.binding.hdpowerview.internal.api.responses.ScheduledEvents;
44 import org.openhab.binding.hdpowerview.internal.api.responses.ScheduledEvents.ScheduledEvent;
45 import org.openhab.binding.hdpowerview.internal.api.responses.Shades;
46 import org.openhab.binding.hdpowerview.internal.api.responses.Shades.ShadeData;
47 import org.openhab.binding.hdpowerview.internal.config.HDPowerViewHubConfiguration;
48 import org.openhab.binding.hdpowerview.internal.config.HDPowerViewShadeConfiguration;
49 import org.openhab.core.library.CoreItemFactory;
50 import org.openhab.core.library.types.OnOffType;
51 import org.openhab.core.thing.Bridge;
52 import org.openhab.core.thing.Channel;
53 import org.openhab.core.thing.ChannelGroupUID;
54 import org.openhab.core.thing.ChannelUID;
55 import org.openhab.core.thing.Thing;
56 import org.openhab.core.thing.ThingStatus;
57 import org.openhab.core.thing.ThingStatusDetail;
58 import org.openhab.core.thing.binding.BaseBridgeHandler;
59 import org.openhab.core.thing.binding.ThingHandler;
60 import org.openhab.core.thing.binding.builder.ChannelBuilder;
61 import org.openhab.core.thing.type.ChannelTypeUID;
62 import org.openhab.core.types.Command;
63 import org.openhab.core.types.RefreshType;
64 import org.slf4j.Logger;
65 import org.slf4j.LoggerFactory;
66
67 import com.google.gson.JsonParseException;
68
69 /**
70  * The {@link HDPowerViewHubHandler} is responsible for handling commands, which
71  * are sent to one of the channels.
72  *
73  * @author Andy Lintner - Initial contribution
74  * @author Andrew Fiddian-Green - Added support for secondary rail positions
75  * @author Jacob Laursen - Added support for scene groups and automations
76  */
77 @NonNullByDefault
78 public class HDPowerViewHubHandler extends BaseBridgeHandler {
79
80     private static final long INITIAL_SOFT_POLL_DELAY_MS = 5_000;
81
82     private final Logger logger = LoggerFactory.getLogger(HDPowerViewHubHandler.class);
83     private final HttpClient httpClient;
84     private final HDPowerViewTranslationProvider translationProvider;
85
86     private long refreshInterval;
87     private long hardRefreshPositionInterval;
88     private long hardRefreshBatteryLevelInterval;
89
90     private @Nullable HDPowerViewWebTargets webTargets;
91     private @Nullable ScheduledFuture<?> pollFuture;
92     private @Nullable ScheduledFuture<?> hardRefreshPositionFuture;
93     private @Nullable ScheduledFuture<?> hardRefreshBatteryLevelFuture;
94
95     private List<Scene> sceneCache = new CopyOnWriteArrayList<>();
96     private List<SceneCollection> sceneCollectionCache = new CopyOnWriteArrayList<>();
97     private List<ScheduledEvent> scheduledEventCache = new CopyOnWriteArrayList<>();
98     private Boolean deprecatedChannelsCreated = false;
99
100     private final ChannelTypeUID sceneChannelTypeUID = new ChannelTypeUID(HDPowerViewBindingConstants.BINDING_ID,
101             HDPowerViewBindingConstants.CHANNELTYPE_SCENE_ACTIVATE);
102
103     private final ChannelTypeUID sceneGroupChannelTypeUID = new ChannelTypeUID(HDPowerViewBindingConstants.BINDING_ID,
104             HDPowerViewBindingConstants.CHANNELTYPE_SCENE_GROUP_ACTIVATE);
105
106     private final ChannelTypeUID automationChannelTypeUID = new ChannelTypeUID(HDPowerViewBindingConstants.BINDING_ID,
107             HDPowerViewBindingConstants.CHANNELTYPE_AUTOMATION_ENABLED);
108
109     public HDPowerViewHubHandler(Bridge bridge, HttpClient httpClient,
110             HDPowerViewTranslationProvider translationProvider) {
111         super(bridge);
112         this.httpClient = httpClient;
113         this.translationProvider = translationProvider;
114     }
115
116     @Override
117     public void handleCommand(ChannelUID channelUID, Command command) {
118         if (RefreshType.REFRESH == command) {
119             requestRefreshShadePositions();
120             return;
121         }
122
123         Channel channel = getThing().getChannel(channelUID.getId());
124         if (channel == null) {
125             return;
126         }
127
128         try {
129             HDPowerViewWebTargets webTargets = this.webTargets;
130             if (webTargets == null) {
131                 throw new ProcessingException("Web targets not initialized");
132             }
133             int id = Integer.parseInt(channelUID.getIdWithoutGroup());
134             if (sceneChannelTypeUID.equals(channel.getChannelTypeUID()) && OnOffType.ON == command) {
135                 webTargets.activateScene(id);
136                 // Reschedule soft poll for immediate shade position update.
137                 scheduleSoftPoll(0);
138             } else if (sceneGroupChannelTypeUID.equals(channel.getChannelTypeUID()) && OnOffType.ON == command) {
139                 webTargets.activateSceneCollection(id);
140                 // Reschedule soft poll for immediate shade position update.
141                 scheduleSoftPoll(0);
142             } else if (automationChannelTypeUID.equals(channel.getChannelTypeUID())) {
143                 webTargets.enableScheduledEvent(id, OnOffType.ON == command);
144             }
145         } catch (HubMaintenanceException e) {
146             // exceptions are logged in HDPowerViewWebTargets
147         } catch (NumberFormatException | HubProcessingException e) {
148             logger.debug("Unexpected error {}", e.getMessage());
149         }
150     }
151
152     @Override
153     public void initialize() {
154         logger.debug("Initializing hub");
155         HDPowerViewHubConfiguration config = getConfigAs(HDPowerViewHubConfiguration.class);
156         String host = config.host;
157
158         if (host == null || host.isEmpty()) {
159             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
160                     "@text/offline.conf-error.no-host-address");
161             return;
162         }
163
164         webTargets = new HDPowerViewWebTargets(httpClient, host);
165         refreshInterval = config.refresh;
166         hardRefreshPositionInterval = config.hardRefresh;
167         hardRefreshBatteryLevelInterval = config.hardRefreshBatteryLevel;
168         initializeChannels();
169         schedulePoll();
170     }
171
172     private void initializeChannels() {
173         // Rebuild dynamic channels and synchronize with cache.
174         updateThing(editThing().withChannels(new ArrayList<Channel>()).build());
175         sceneCache.clear();
176         sceneCollectionCache.clear();
177         scheduledEventCache.clear();
178         deprecatedChannelsCreated = false;
179     }
180
181     public @Nullable HDPowerViewWebTargets getWebTargets() {
182         return webTargets;
183     }
184
185     @Override
186     public void handleRemoval() {
187         super.handleRemoval();
188         stopPoll();
189     }
190
191     @Override
192     public void dispose() {
193         super.dispose();
194         stopPoll();
195     }
196
197     private void schedulePoll() {
198         scheduleSoftPoll(INITIAL_SOFT_POLL_DELAY_MS);
199         scheduleHardPoll();
200     }
201
202     private void scheduleSoftPoll(long initialDelay) {
203         ScheduledFuture<?> future = this.pollFuture;
204         if (future != null) {
205             future.cancel(false);
206         }
207         logger.debug("Scheduling poll for {} ms out, then every {} ms", initialDelay, refreshInterval);
208         this.pollFuture = scheduler.scheduleWithFixedDelay(this::poll, initialDelay, refreshInterval,
209                 TimeUnit.MILLISECONDS);
210     }
211
212     private void scheduleHardPoll() {
213         ScheduledFuture<?> future = this.hardRefreshPositionFuture;
214         if (future != null) {
215             future.cancel(false);
216         }
217         if (hardRefreshPositionInterval > 0) {
218             logger.debug("Scheduling hard position refresh every {} minutes", hardRefreshPositionInterval);
219             this.hardRefreshPositionFuture = scheduler.scheduleWithFixedDelay(this::requestRefreshShadePositions, 1,
220                     hardRefreshPositionInterval, TimeUnit.MINUTES);
221         }
222
223         future = this.hardRefreshBatteryLevelFuture;
224         if (future != null) {
225             future.cancel(false);
226         }
227         if (hardRefreshBatteryLevelInterval > 0) {
228             logger.debug("Scheduling hard battery level refresh every {} hours", hardRefreshBatteryLevelInterval);
229             this.hardRefreshBatteryLevelFuture = scheduler.scheduleWithFixedDelay(
230                     this::requestRefreshShadeBatteryLevels, 1, hardRefreshBatteryLevelInterval, TimeUnit.HOURS);
231         }
232     }
233
234     private synchronized void stopPoll() {
235         ScheduledFuture<?> future = this.pollFuture;
236         if (future != null) {
237             future.cancel(true);
238         }
239         this.pollFuture = null;
240
241         future = this.hardRefreshPositionFuture;
242         if (future != null) {
243             future.cancel(true);
244         }
245         this.hardRefreshPositionFuture = null;
246
247         future = this.hardRefreshBatteryLevelFuture;
248         if (future != null) {
249             future.cancel(true);
250         }
251         this.hardRefreshBatteryLevelFuture = null;
252     }
253
254     private synchronized void poll() {
255         try {
256             logger.debug("Polling for state");
257             pollShades();
258
259             List<Scene> scenes = updateSceneChannels();
260             List<SceneCollection> sceneCollections = updateSceneCollectionChannels();
261             List<ScheduledEvent> scheduledEvents = updateScheduledEventChannels(scenes, sceneCollections);
262
263             // Scheduled events should also have their current state updated if event has been
264             // enabled or disabled through app or other integration.
265             updateScheduledEventStates(scheduledEvents);
266         } catch (JsonParseException e) {
267             logger.warn("Bridge returned a bad JSON response: {}", e.getMessage());
268         } catch (HubProcessingException e) {
269             logger.warn("Error connecting to bridge: {}", e.getMessage());
270             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, e.getMessage());
271         } catch (HubMaintenanceException e) {
272             // exceptions are logged in HDPowerViewWebTargets
273         }
274     }
275
276     private void pollShades() throws JsonParseException, HubProcessingException, HubMaintenanceException {
277         HDPowerViewWebTargets webTargets = this.webTargets;
278         if (webTargets == null) {
279             throw new ProcessingException("Web targets not initialized");
280         }
281
282         Shades shades = webTargets.getShades();
283         if (shades == null) {
284             throw new JsonParseException("Missing 'shades' element");
285         }
286
287         List<ShadeData> shadesData = shades.shadeData;
288         if (shadesData == null) {
289             throw new JsonParseException("Missing 'shades.shadeData' element");
290         }
291
292         updateStatus(ThingStatus.ONLINE);
293         logger.debug("Received data for {} shades", shadesData.size());
294
295         Map<String, ShadeData> idShadeDataMap = getIdShadeDataMap(shadesData);
296         Map<Thing, String> thingIdMap = getThingIdMap();
297         for (Entry<Thing, String> item : thingIdMap.entrySet()) {
298             Thing thing = item.getKey();
299             String shadeId = item.getValue();
300             ShadeData shadeData = idShadeDataMap.get(shadeId);
301             updateShadeThing(shadeId, thing, shadeData);
302         }
303     }
304
305     private void updateShadeThing(String shadeId, Thing thing, @Nullable ShadeData shadeData) {
306         HDPowerViewShadeHandler thingHandler = ((HDPowerViewShadeHandler) thing.getHandler());
307         if (thingHandler == null) {
308             logger.debug("Shade '{}' handler not initialized", shadeId);
309             return;
310         }
311         if (shadeData == null) {
312             logger.debug("Shade '{}' has no data in hub", shadeId);
313         } else {
314             logger.debug("Updating shade '{}'", shadeId);
315         }
316         thingHandler.onReceiveUpdate(shadeData);
317     }
318
319     private List<Scene> fetchScenes() throws JsonParseException, HubProcessingException, HubMaintenanceException {
320         HDPowerViewWebTargets webTargets = this.webTargets;
321         if (webTargets == null) {
322             throw new ProcessingException("Web targets not initialized");
323         }
324
325         Scenes scenes = webTargets.getScenes();
326         if (scenes == null) {
327             throw new JsonParseException("Missing 'scenes' element");
328         }
329
330         List<Scene> sceneData = scenes.sceneData;
331         if (sceneData == null) {
332             throw new JsonParseException("Missing 'scenes.sceneData' element");
333         }
334         logger.debug("Received data for {} scenes", sceneData.size());
335
336         return sceneData;
337     }
338
339     private List<Scene> updateSceneChannels()
340             throws JsonParseException, HubProcessingException, HubMaintenanceException {
341         List<Scene> scenes = fetchScenes();
342
343         if (scenes.size() == sceneCache.size() && sceneCache.containsAll(scenes)) {
344             // Duplicates are not allowed. Reordering is not supported.
345             logger.debug("Preserving scene channels, no changes detected");
346             return scenes;
347         }
348
349         logger.debug("Updating all scene channels, changes detected");
350         sceneCache = new CopyOnWriteArrayList<Scene>(scenes);
351
352         List<Channel> allChannels = new ArrayList<>(getThing().getChannels());
353         allChannels.removeIf(c -> HDPowerViewBindingConstants.CHANNEL_GROUP_SCENES.equals(c.getUID().getGroupId()));
354         scenes.stream().sorted().forEach(scene -> allChannels.add(createSceneChannel(scene)));
355         updateThing(editThing().withChannels(allChannels).build());
356
357         createDeprecatedSceneChannels(scenes);
358
359         return scenes;
360     }
361
362     private Channel createSceneChannel(Scene scene) {
363         ChannelGroupUID channelGroupUid = new ChannelGroupUID(thing.getUID(),
364                 HDPowerViewBindingConstants.CHANNEL_GROUP_SCENES);
365         ChannelUID channelUid = new ChannelUID(channelGroupUid, Integer.toString(scene.id));
366         String description = translationProvider.getText("dynamic-channel.scene-activate.description", scene.getName());
367         Channel channel = ChannelBuilder.create(channelUid, CoreItemFactory.SWITCH).withType(sceneChannelTypeUID)
368                 .withLabel(scene.getName()).withDescription(description).build();
369
370         return channel;
371     }
372
373     /**
374      * Create backwards compatible scene channels if any items configured before release 3.2
375      * are still linked. Users should have a reasonable amount of time to migrate to the new
376      * scene channels that are connected to a channel group.
377      */
378     private void createDeprecatedSceneChannels(List<Scene> scenes) {
379         if (deprecatedChannelsCreated) {
380             // Only do this once.
381             return;
382         }
383         ChannelGroupUID channelGroupUid = new ChannelGroupUID(thing.getUID(),
384                 HDPowerViewBindingConstants.CHANNEL_GROUP_SCENES);
385         for (Scene scene : scenes) {
386             String channelId = Integer.toString(scene.id);
387             ChannelUID newChannelUid = new ChannelUID(channelGroupUid, channelId);
388             ChannelUID deprecatedChannelUid = new ChannelUID(getThing().getUID(), channelId);
389             String description = translationProvider.getText("dynamic-channel.scene-activate.deprecated.description",
390                     scene.getName());
391             Channel channel = ChannelBuilder.create(deprecatedChannelUid, CoreItemFactory.SWITCH)
392                     .withType(sceneChannelTypeUID).withLabel(scene.getName()).withDescription(description).build();
393             logger.debug("Creating deprecated channel '{}' ('{}') to probe for linked items", deprecatedChannelUid,
394                     scene.getName());
395             updateThing(editThing().withChannel(channel).build());
396             if (this.isLinked(deprecatedChannelUid) && !this.isLinked(newChannelUid)) {
397                 logger.warn("Created deprecated channel '{}' ('{}'), please link items to '{}' instead",
398                         deprecatedChannelUid, scene.getName(), newChannelUid);
399             } else {
400                 if (this.isLinked(newChannelUid)) {
401                     logger.debug("Removing deprecated channel '{}' ('{}') since new channel '{}' is linked",
402                             deprecatedChannelUid, scene.getName(), newChannelUid);
403
404                 } else {
405                     logger.debug("Removing deprecated channel '{}' ('{}') since it has no linked items",
406                             deprecatedChannelUid, scene.getName());
407                 }
408                 updateThing(editThing().withoutChannel(deprecatedChannelUid).build());
409             }
410         }
411         deprecatedChannelsCreated = true;
412     }
413
414     private List<SceneCollection> fetchSceneCollections()
415             throws JsonParseException, HubProcessingException, HubMaintenanceException {
416         HDPowerViewWebTargets webTargets = this.webTargets;
417         if (webTargets == null) {
418             throw new ProcessingException("Web targets not initialized");
419         }
420
421         SceneCollections sceneCollections = webTargets.getSceneCollections();
422         if (sceneCollections == null) {
423             throw new JsonParseException("Missing 'sceneCollections' element");
424         }
425
426         List<SceneCollection> sceneCollectionData = sceneCollections.sceneCollectionData;
427         if (sceneCollectionData == null) {
428             throw new JsonParseException("Missing 'sceneCollections.sceneCollectionData' element");
429         }
430         logger.debug("Received data for {} sceneCollections", sceneCollectionData.size());
431
432         return sceneCollectionData;
433     }
434
435     private List<SceneCollection> updateSceneCollectionChannels()
436             throws JsonParseException, HubProcessingException, HubMaintenanceException {
437         List<SceneCollection> sceneCollections = fetchSceneCollections();
438
439         if (sceneCollections.size() == sceneCollectionCache.size()
440                 && sceneCollectionCache.containsAll(sceneCollections)) {
441             // Duplicates are not allowed. Reordering is not supported.
442             logger.debug("Preserving scene collection channels, no changes detected");
443             return sceneCollections;
444         }
445
446         logger.debug("Updating all scene collection channels, changes detected");
447         sceneCollectionCache = new CopyOnWriteArrayList<SceneCollection>(sceneCollections);
448
449         List<Channel> allChannels = new ArrayList<>(getThing().getChannels());
450         allChannels
451                 .removeIf(c -> HDPowerViewBindingConstants.CHANNEL_GROUP_SCENE_GROUPS.equals(c.getUID().getGroupId()));
452         sceneCollections.stream().sorted()
453                 .forEach(sceneCollection -> allChannels.add(createSceneCollectionChannel(sceneCollection)));
454         updateThing(editThing().withChannels(allChannels).build());
455
456         return sceneCollections;
457     }
458
459     private Channel createSceneCollectionChannel(SceneCollection sceneCollection) {
460         ChannelGroupUID channelGroupUid = new ChannelGroupUID(thing.getUID(),
461                 HDPowerViewBindingConstants.CHANNEL_GROUP_SCENE_GROUPS);
462         ChannelUID channelUid = new ChannelUID(channelGroupUid, Integer.toString(sceneCollection.id));
463         String description = translationProvider.getText("dynamic-channel.scene-group-activate.description",
464                 sceneCollection.getName());
465         Channel channel = ChannelBuilder.create(channelUid, CoreItemFactory.SWITCH).withType(sceneGroupChannelTypeUID)
466                 .withLabel(sceneCollection.getName()).withDescription(description).build();
467
468         return channel;
469     }
470
471     private List<ScheduledEvent> fetchScheduledEvents()
472             throws JsonParseException, HubProcessingException, HubMaintenanceException {
473         HDPowerViewWebTargets webTargets = this.webTargets;
474         if (webTargets == null) {
475             throw new ProcessingException("Web targets not initialized");
476         }
477
478         ScheduledEvents scheduledEvents = webTargets.getScheduledEvents();
479         if (scheduledEvents == null) {
480             throw new JsonParseException("Missing 'scheduledEvents' element");
481         }
482
483         List<ScheduledEvent> scheduledEventData = scheduledEvents.scheduledEventData;
484         if (scheduledEventData == null) {
485             throw new JsonParseException("Missing 'scheduledEvents.scheduledEventData' element");
486         }
487         logger.debug("Received data for {} scheduledEvents", scheduledEventData.size());
488
489         return scheduledEventData;
490     }
491
492     private List<ScheduledEvent> updateScheduledEventChannels(List<Scene> scenes,
493             List<SceneCollection> sceneCollections)
494             throws JsonParseException, HubProcessingException, HubMaintenanceException {
495         List<ScheduledEvent> scheduledEvents = fetchScheduledEvents();
496
497         if (scheduledEvents.size() == scheduledEventCache.size() && scheduledEventCache.containsAll(scheduledEvents)) {
498             // Duplicates are not allowed. Reordering is not supported.
499             logger.debug("Preserving scheduled event channels, no changes detected");
500             return scheduledEvents;
501         }
502
503         logger.debug("Updating all scheduled event channels, changes detected");
504         scheduledEventCache = new CopyOnWriteArrayList<ScheduledEvent>(scheduledEvents);
505
506         List<Channel> allChannels = new ArrayList<>(getThing().getChannels());
507         allChannels
508                 .removeIf(c -> HDPowerViewBindingConstants.CHANNEL_GROUP_AUTOMATIONS.equals(c.getUID().getGroupId()));
509         scheduledEvents.stream().forEach(scheduledEvent -> {
510             Channel channel = createScheduledEventChannel(scheduledEvent, scenes, sceneCollections);
511             if (channel != null) {
512                 allChannels.add(channel);
513             }
514         });
515         updateThing(editThing().withChannels(allChannels).build());
516
517         return scheduledEvents;
518     }
519
520     private @Nullable Channel createScheduledEventChannel(ScheduledEvent scheduledEvent, List<Scene> scenes,
521             List<SceneCollection> sceneCollections) {
522         String referencedName = getReferencedSceneOrSceneCollectionName(scheduledEvent, scenes, sceneCollections);
523         if (referencedName == null) {
524             return null;
525         }
526         ChannelGroupUID channelGroupUid = new ChannelGroupUID(thing.getUID(),
527                 HDPowerViewBindingConstants.CHANNEL_GROUP_AUTOMATIONS);
528         ChannelUID channelUid = new ChannelUID(channelGroupUid, Integer.toString(scheduledEvent.id));
529         String label = getScheduledEventName(referencedName, scheduledEvent);
530         String description = translationProvider.getText("dynamic-channel.automation-enabled.description",
531                 referencedName);
532         Channel channel = ChannelBuilder.create(channelUid, CoreItemFactory.SWITCH).withType(automationChannelTypeUID)
533                 .withLabel(label).withDescription(description).build();
534
535         return channel;
536     }
537
538     private @Nullable String getReferencedSceneOrSceneCollectionName(ScheduledEvent scheduledEvent, List<Scene> scenes,
539             List<SceneCollection> sceneCollections) {
540         if (scheduledEvent.sceneId > 0) {
541             for (Scene scene : scenes) {
542                 if (scene.id == scheduledEvent.sceneId) {
543                     return scene.getName();
544                 }
545             }
546             logger.error("Scene '{}' was not found for scheduled event '{}'", scheduledEvent.sceneId,
547                     scheduledEvent.id);
548             return null;
549         } else if (scheduledEvent.sceneCollectionId > 0) {
550             for (SceneCollection sceneCollection : sceneCollections) {
551                 if (sceneCollection.id == scheduledEvent.sceneCollectionId) {
552                     return sceneCollection.getName();
553                 }
554             }
555             logger.error("Scene collection '{}' was not found for scheduled event '{}'",
556                     scheduledEvent.sceneCollectionId, scheduledEvent.id);
557             return null;
558         } else {
559             logger.error("Scheduled event '{}'' not related to any scene or scene collection", scheduledEvent.id);
560             return null;
561         }
562     }
563
564     private String getScheduledEventName(String sceneName, ScheduledEvent scheduledEvent) {
565         String timeString, daysString;
566
567         switch (scheduledEvent.eventType) {
568             case ScheduledEvents.SCHEDULED_EVENT_TYPE_TIME:
569                 timeString = LocalTime.of(scheduledEvent.hour, scheduledEvent.minute).toString();
570                 break;
571             case ScheduledEvents.SCHEDULED_EVENT_TYPE_SUNRISE:
572                 if (scheduledEvent.minute == 0) {
573                     timeString = translationProvider.getText("dynamic-channel.automation.at_sunrise");
574                 } else if (scheduledEvent.minute < 0) {
575                     timeString = translationProvider.getText("dynamic-channel.automation.before_sunrise",
576                             getFormattedTimeOffset(-scheduledEvent.minute));
577                 } else {
578                     timeString = translationProvider.getText("dynamic-channel.automation.after_sunrise",
579                             getFormattedTimeOffset(scheduledEvent.minute));
580                 }
581                 break;
582             case ScheduledEvents.SCHEDULED_EVENT_TYPE_SUNSET:
583                 if (scheduledEvent.minute == 0) {
584                     timeString = translationProvider.getText("dynamic-channel.automation.at_sunset");
585                 } else if (scheduledEvent.minute < 0) {
586                     timeString = translationProvider.getText("dynamic-channel.automation.before_sunset",
587                             getFormattedTimeOffset(-scheduledEvent.minute));
588                 } else {
589                     timeString = translationProvider.getText("dynamic-channel.automation.after_sunset",
590                             getFormattedTimeOffset(scheduledEvent.minute));
591                 }
592                 break;
593             default:
594                 return sceneName;
595         }
596
597         EnumSet<DayOfWeek> days = scheduledEvent.getDays();
598         if (EnumSet.allOf(DayOfWeek.class).equals(days)) {
599             daysString = translationProvider.getText("dynamic-channel.automation.all-days");
600         } else if (ScheduledEvents.WEEKDAYS.equals(days)) {
601             daysString = translationProvider.getText("dynamic-channel.automation.weekdays");
602         } else if (ScheduledEvents.WEEKENDS.equals(days)) {
603             daysString = translationProvider.getText("dynamic-channel.automation.weekends");
604         } else {
605             StringJoiner joiner = new StringJoiner(", ");
606             days.forEach(day -> joiner.add(day.getDisplayName(TextStyle.SHORT, translationProvider.getLocale())));
607             daysString = joiner.toString();
608         }
609
610         return translationProvider.getText("dynamic-channel.automation-enabled.label", sceneName, timeString,
611                 daysString);
612     }
613
614     private String getFormattedTimeOffset(int minutes) {
615         if (minutes >= 60) {
616             int remainder = minutes % 60;
617             if (remainder == 0) {
618                 return translationProvider.getText("dynamic-channel.automation.hour", minutes / 60);
619             }
620             return translationProvider.getText("dynamic-channel.automation.hour-minute", minutes / 60, remainder);
621         }
622         return translationProvider.getText("dynamic-channel.automation.minute", minutes);
623     }
624
625     private void updateScheduledEventStates(List<ScheduledEvent> scheduledEvents) {
626         ChannelGroupUID channelGroupUid = new ChannelGroupUID(thing.getUID(),
627                 HDPowerViewBindingConstants.CHANNEL_GROUP_AUTOMATIONS);
628         for (ScheduledEvent scheduledEvent : scheduledEvents) {
629             String scheduledEventId = Integer.toString(scheduledEvent.id);
630             ChannelUID channelUid = new ChannelUID(channelGroupUid, scheduledEventId);
631             updateState(channelUid, scheduledEvent.enabled ? OnOffType.ON : OnOffType.OFF);
632         }
633     }
634
635     private Map<Thing, String> getThingIdMap() {
636         Map<Thing, String> ret = new HashMap<>();
637         for (Thing thing : getThing().getThings()) {
638             String id = thing.getConfiguration().as(HDPowerViewShadeConfiguration.class).id;
639             if (id != null && !id.isEmpty()) {
640                 ret.put(thing, id);
641             }
642         }
643         return ret;
644     }
645
646     private Map<String, ShadeData> getIdShadeDataMap(List<ShadeData> shadeData) {
647         Map<String, ShadeData> ret = new HashMap<>();
648         for (ShadeData shade : shadeData) {
649             if (shade.id != 0) {
650                 ret.put(Integer.toString(shade.id), shade);
651             }
652         }
653         return ret;
654     }
655
656     private void requestRefreshShadePositions() {
657         Map<Thing, String> thingIdMap = getThingIdMap();
658         for (Entry<Thing, String> item : thingIdMap.entrySet()) {
659             Thing thing = item.getKey();
660             ThingHandler handler = thing.getHandler();
661             if (handler instanceof HDPowerViewShadeHandler) {
662                 ((HDPowerViewShadeHandler) handler).requestRefreshShadePosition();
663             } else {
664                 String shadeId = item.getValue();
665                 logger.debug("Shade '{}' handler not initialized", shadeId);
666             }
667         }
668     }
669
670     private void requestRefreshShadeBatteryLevels() {
671         Map<Thing, String> thingIdMap = getThingIdMap();
672         for (Entry<Thing, String> item : thingIdMap.entrySet()) {
673             Thing thing = item.getKey();
674             ThingHandler handler = thing.getHandler();
675             if (handler instanceof HDPowerViewShadeHandler) {
676                 ((HDPowerViewShadeHandler) handler).requestRefreshShadeBatteryLevel();
677             } else {
678                 String shadeId = item.getValue();
679                 logger.debug("Shade '{}' handler not initialized", shadeId);
680             }
681         }
682     }
683 }