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