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