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