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