]> git.basschouten.com Git - openhab-addons.git/blob
4974583eda5b5331a72d7b3ad2d9b7e01c6a15ff
[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.harmonyhub.internal.handler;
14
15 import static org.openhab.binding.harmonyhub.internal.HarmonyHubBindingConstants.*;
16
17 import java.util.ArrayList;
18 import java.util.Collections;
19 import java.util.Comparator;
20 import java.util.LinkedList;
21 import java.util.List;
22 import java.util.Map;
23 import java.util.Set;
24 import java.util.concurrent.CompletableFuture;
25 import java.util.concurrent.CopyOnWriteArrayList;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
28
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.openhab.binding.harmonyhub.internal.HarmonyHubHandlerFactory;
32 import org.openhab.binding.harmonyhub.internal.config.HarmonyHubConfig;
33 import org.openhab.core.config.core.Configuration;
34 import org.openhab.core.library.types.DecimalType;
35 import org.openhab.core.library.types.NextPreviousType;
36 import org.openhab.core.library.types.PlayPauseType;
37 import org.openhab.core.library.types.RewindFastforwardType;
38 import org.openhab.core.library.types.StringType;
39 import org.openhab.core.thing.Bridge;
40 import org.openhab.core.thing.Channel;
41 import org.openhab.core.thing.ChannelUID;
42 import org.openhab.core.thing.ThingStatus;
43 import org.openhab.core.thing.ThingStatusDetail;
44 import org.openhab.core.thing.ThingTypeUID;
45 import org.openhab.core.thing.binding.BaseBridgeHandler;
46 import org.openhab.core.thing.binding.builder.BridgeBuilder;
47 import org.openhab.core.thing.binding.builder.ChannelBuilder;
48 import org.openhab.core.thing.type.ChannelType;
49 import org.openhab.core.thing.type.ChannelTypeBuilder;
50 import org.openhab.core.thing.type.ChannelTypeUID;
51 import org.openhab.core.types.Command;
52 import org.openhab.core.types.RefreshType;
53 import org.openhab.core.types.StateDescriptionFragmentBuilder;
54 import org.openhab.core.types.StateOption;
55 import org.slf4j.Logger;
56 import org.slf4j.LoggerFactory;
57
58 import com.digitaldan.harmony.HarmonyClient;
59 import com.digitaldan.harmony.HarmonyClientListener;
60 import com.digitaldan.harmony.config.Activity;
61 import com.digitaldan.harmony.config.Activity.Status;
62 import com.digitaldan.harmony.config.HarmonyConfig;
63 import com.digitaldan.harmony.config.Ping;
64
65 /**
66  * The {@link HarmonyHubHandler} is responsible for handling commands for Harmony Hubs, which are
67  * sent to one of the channels.
68  *
69  * @author Dan Cunningham - Initial contribution
70  * @author Pawel Pieczul - added support for hub status changes
71  * @author Wouter Born - Add null annotations
72  */
73 @NonNullByDefault
74 public class HarmonyHubHandler extends BaseBridgeHandler implements HarmonyClientListener {
75
76     private final Logger logger = LoggerFactory.getLogger(HarmonyHubHandler.class);
77
78     public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections.singleton(HARMONY_HUB_THING_TYPE);
79
80     private static final Comparator<Activity> ACTIVITY_COMPERATOR = Comparator.comparing(Activity::getActivityOrder,
81             Comparator.nullsFirst(Integer::compareTo));
82
83     private static final int RETRY_TIME = 60;
84     private static final int HEARTBEAT_INTERVAL = 30;
85     // Websocket will timeout after 60 seconds, pick a sensible max under this,
86     private static final int HEARTBEAT_INTERVAL_MAX = 50;
87     private List<HubStatusListener> listeners = new CopyOnWriteArrayList<>();
88     private final HarmonyHubHandlerFactory factory;
89     private @NonNullByDefault({}) HarmonyHubConfig config;
90     private final HarmonyClient client;
91     private @Nullable ScheduledFuture<?> retryJob;
92     private @Nullable ScheduledFuture<?> heartBeatJob;
93     private boolean propertiesUpdated;
94
95     private int heartBeatInterval;
96
97     public HarmonyHubHandler(Bridge bridge, HarmonyHubHandlerFactory factory) {
98         super(bridge);
99         this.factory = factory;
100         client = new HarmonyClient(factory.getHttpClient());
101         client.addListener(this);
102     }
103
104     @Override
105     public void handleCommand(ChannelUID channelUID, Command command) {
106         logger.trace("Handling command '{}' for {}", command, channelUID);
107
108         if (getThing().getStatus() != ThingStatus.ONLINE) {
109             logger.debug("Hub is offline, ignoring command {} for channel {}", command, channelUID);
110             return;
111         }
112
113         if (command instanceof RefreshType) {
114             client.getCurrentActivity().thenAccept(activity -> {
115                 updateState(activity);
116             });
117             return;
118         }
119
120         Channel channel = getThing().getChannel(channelUID.getId());
121         if (channel == null) {
122             logger.warn("No such channel for UID {}", channelUID);
123             return;
124         }
125
126         switch (channel.getUID().getId()) {
127             case CHANNEL_CURRENT_ACTIVITY:
128                 if (command instanceof DecimalType) {
129                     try {
130                         client.startActivity(((DecimalType) command).intValue());
131                     } catch (Exception e) {
132                         logger.warn("Could not start activity", e);
133                     }
134                 } else {
135                     try {
136                         try {
137                             int actId = Integer.parseInt(command.toString());
138                             client.startActivity(actId);
139                         } catch (NumberFormatException ignored) {
140                             client.startActivityByName(command.toString());
141                         }
142                     } catch (IllegalArgumentException e) {
143                         logger.warn("Activity '{}' is not known by the hub, ignoring it.", command);
144                     } catch (Exception e) {
145                         logger.warn("Could not start activity", e);
146                     }
147                 }
148                 break;
149             case CHANNEL_BUTTON_PRESS:
150                 client.pressButtonCurrentActivity(command.toString());
151                 break;
152             case CHANNEL_PLAYER:
153                 String cmd = null;
154                 if (command instanceof PlayPauseType) {
155                     if (command == PlayPauseType.PLAY) {
156                         cmd = "Play";
157                     } else if (command == PlayPauseType.PAUSE) {
158                         cmd = "Pause";
159                     }
160                 } else if (command instanceof NextPreviousType) {
161                     if (command == NextPreviousType.NEXT) {
162                         cmd = "SkipForward";
163                     } else if (command == NextPreviousType.PREVIOUS) {
164                         cmd = "SkipBackward";
165                     }
166                 } else if (command instanceof RewindFastforwardType) {
167                     if (command == RewindFastforwardType.FASTFORWARD) {
168                         cmd = "FastForward";
169                     } else if (command == RewindFastforwardType.REWIND) {
170                         cmd = "Rewind";
171                     }
172                 }
173                 if (cmd != null) {
174                     client.pressButtonCurrentActivity(cmd);
175                 } else {
176                     logger.warn("Unknown player type {}", command);
177                 }
178                 break;
179             default:
180                 logger.warn("Unknown channel id {}", channel.getUID().getId());
181         }
182     }
183
184     @Override
185     public void initialize() {
186         config = getConfigAs(HarmonyHubConfig.class);
187         cancelRetry();
188         updateStatus(ThingStatus.UNKNOWN);
189         scheduleRetry(0);
190     }
191
192     @Override
193     public void dispose() {
194         listeners.clear();
195         cancelRetry();
196         disconnectFromHub();
197         factory.removeChannelTypesForThing(getThing().getUID());
198     }
199
200     @Override
201     protected void updateStatus(ThingStatus status, ThingStatusDetail detail, @Nullable String comment) {
202         super.updateStatus(status, detail, comment);
203         logger.debug("Updating listeners with status {}", status);
204         for (HubStatusListener listener : listeners) {
205             listener.hubStatusChanged(status);
206         }
207     }
208
209     @Override
210     public void channelLinked(ChannelUID channelUID) {
211         client.getCurrentActivity().thenAccept((activity) -> {
212             updateState(channelUID, new StringType(activity.getLabel()));
213         });
214     }
215
216     @Override
217     public void hubDisconnected(@Nullable String reason) {
218         if (getThing().getStatus() == ThingStatus.ONLINE) {
219             setOfflineAndReconnect(String.format("Could not connect: %s", reason));
220         }
221     }
222
223     @Override
224     public void hubConnected() {
225         heartBeatJob = scheduler.scheduleWithFixedDelay(() -> {
226             try {
227                 Ping ping = client.sendPing().get();
228                 if (!propertiesUpdated) {
229                     Map<String, String> properties = editProperties();
230                     properties.put(HUB_PROPERTY_ID, ping.getUuid());
231                     updateProperties(properties);
232                     propertiesUpdated = true;
233                 }
234             } catch (Exception e) {
235                 logger.debug("heartbeat failed", e);
236                 setOfflineAndReconnect("Hearbeat failed");
237             }
238         }, 5, heartBeatInterval, TimeUnit.SECONDS);
239         updateStatus(ThingStatus.ONLINE);
240         getConfigFuture().thenAcceptAsync(harmonyConfig -> updateCurrentActivityChannel(harmonyConfig), scheduler)
241                 .exceptionally(e -> {
242                     setOfflineAndReconnect("Getting config failed: " + e.getMessage());
243                     return null;
244                 });
245         client.getCurrentActivity().thenAccept(activity -> {
246             updateState(activity);
247         });
248     }
249
250     @Override
251     public void activityStatusChanged(@Nullable Activity activity, @Nullable Status status) {
252         updateActivityStatus(activity, status);
253     }
254
255     @Override
256     public void activityStarted(@Nullable Activity activity) {
257         updateState(activity);
258     }
259
260     /**
261      * Starts the connection process
262      */
263     private synchronized void connect() {
264         disconnectFromHub();
265
266         heartBeatInterval = Math.min(config.heartBeatInterval > 0 ? config.heartBeatInterval : HEARTBEAT_INTERVAL,
267                 HEARTBEAT_INTERVAL_MAX);
268
269         String host = config.host;
270
271         // earlier versions required a name and used network discovery to find the hub and retrieve the host,
272         // this section is to not break that and also update older configurations to use the host configuration
273         // option instead of name
274         if (host == null || host.isBlank()) {
275             host = getThing().getProperties().get(HUB_PROPERTY_HOST);
276             if (host != null && !host.isBlank()) {
277                 Configuration genericConfig = getConfig();
278                 genericConfig.put(HUB_PROPERTY_HOST, host);
279                 updateConfiguration(genericConfig);
280             } else {
281                 logger.debug("host not configured");
282                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "host not configured");
283                 return;
284             }
285         }
286
287         try {
288             logger.debug("Connecting: host {}", host);
289             client.connect(host);
290         } catch (Exception e) {
291             logger.debug("Could not connect to HarmonyHub at {}", host, e);
292             setOfflineAndReconnect("Could not connect: " + e.getMessage());
293         }
294     }
295
296     private void disconnectFromHub() {
297         ScheduledFuture<?> localHeartBeatJob = heartBeatJob;
298         if (localHeartBeatJob != null && !localHeartBeatJob.isDone()) {
299             localHeartBeatJob.cancel(false);
300         }
301         client.disconnect();
302     }
303
304     private void setOfflineAndReconnect(String error) {
305         disconnectFromHub();
306         scheduleRetry(RETRY_TIME);
307         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, error);
308     }
309
310     private void cancelRetry() {
311         ScheduledFuture<?> localRetryJob = retryJob;
312         if (localRetryJob != null && !localRetryJob.isDone()) {
313             localRetryJob.cancel(false);
314         }
315     }
316
317     private synchronized void scheduleRetry(int retrySeconds) {
318         cancelRetry();
319         retryJob = scheduler.schedule(this::connect, retrySeconds, TimeUnit.SECONDS);
320     }
321
322     private void updateState(@Nullable Activity activity) {
323         if (activity != null) {
324             logger.debug("Updating current activity to {}", activity.getLabel());
325             updateState(new ChannelUID(getThing().getUID(), CHANNEL_CURRENT_ACTIVITY),
326                     new StringType(activity.getLabel()));
327         }
328     }
329
330     private void updateActivityStatus(@Nullable Activity activity, @Nullable Status status) {
331         if (activity == null) {
332             logger.debug("Cannot update activity status of {} with activity that is null", getThing().getUID());
333             return;
334         } else if (status == null) {
335             logger.debug("Cannot update activity status of {} with status that is null", getThing().getUID());
336             return;
337         }
338
339         logger.debug("Received {} activity status for {}", status, activity.getLabel());
340         switch (status) {
341             case ACTIVITY_IS_STARTING:
342                 triggerChannel(CHANNEL_ACTIVITY_STARTING_TRIGGER, getEventName(activity));
343                 break;
344             case ACTIVITY_IS_STARTED:
345             case HUB_IS_OFF:
346                 // hub is off is received with power-off activity
347                 triggerChannel(CHANNEL_ACTIVITY_STARTED_TRIGGER, getEventName(activity));
348                 break;
349             case HUB_IS_TURNING_OFF:
350                 // hub is turning off is received for current activity, we will translate it into activity starting
351                 // trigger of power-off activity (with ID=-1)
352                 getConfigFuture().thenAccept(config -> {
353                     if (config != null) {
354                         Activity powerOff = config.getActivityById(-1);
355                         if (powerOff != null) {
356                             triggerChannel(CHANNEL_ACTIVITY_STARTING_TRIGGER, getEventName(powerOff));
357                         }
358                     }
359                 }).exceptionally(e -> {
360                     setOfflineAndReconnect("Getting config failed: " + e.getMessage());
361                     return null;
362                 });
363                 break;
364             default:
365                 break;
366         }
367     }
368
369     private String getEventName(Activity activity) {
370         return activity.getLabel().replaceAll("[^A-Za-z0-9]", "_");
371     }
372
373     /**
374      * Updates the current activity channel with the available activities as option states.
375      */
376     private void updateCurrentActivityChannel(@Nullable HarmonyConfig config) {
377         ChannelTypeUID channelTypeUID = new ChannelTypeUID(getThing().getUID() + ":" + CHANNEL_CURRENT_ACTIVITY);
378
379         if (config == null) {
380             logger.debug("Cannot update {} when HarmonyConfig is null", channelTypeUID);
381             return;
382         }
383
384         logger.debug("Updating {}", channelTypeUID);
385
386         List<Activity> activities = config.getActivities();
387         // sort our activities in order
388         Collections.sort(activities, ACTIVITY_COMPERATOR);
389
390         // add our activities as channel state options
391         List<StateOption> states = new LinkedList<>();
392         for (Activity activity : activities) {
393             states.add(new StateOption(activity.getLabel(), activity.getLabel()));
394         }
395
396         ChannelType channelType = ChannelTypeBuilder.state(channelTypeUID, "Current Activity", "String")
397                 .withDescription("Current activity for " + getThing().getLabel())
398                 .withStateDescriptionFragment(StateDescriptionFragmentBuilder.create().withPattern("%s")
399                         .withReadOnly(false).withOptions(states).build())
400                 .build();
401
402         factory.addChannelType(channelType);
403
404         Channel channel = ChannelBuilder.create(new ChannelUID(getThing().getUID(), CHANNEL_CURRENT_ACTIVITY), "String")
405                 .withType(channelTypeUID).build();
406
407         // replace existing currentActivity with updated one
408         List<Channel> newChannels = new ArrayList<>();
409         for (Channel c : getThing().getChannels()) {
410             if (!c.getUID().equals(channel.getUID())) {
411                 newChannels.add(c);
412             }
413         }
414         newChannels.add(channel);
415
416         BridgeBuilder thingBuilder = editThing();
417         thingBuilder.withChannels(newChannels);
418         updateThing(thingBuilder.build());
419     }
420
421     /**
422      * Sends a button press to a device
423      *
424      * @param device
425      * @param button
426      */
427     public void pressButton(int device, String button) {
428         client.pressButton(device, button);
429     }
430
431     /**
432      * Sends a button press to a device
433      *
434      * @param device
435      * @param button
436      */
437     public void pressButton(String device, String button) {
438         client.pressButton(device, button);
439     }
440
441     public CompletableFuture<@Nullable HarmonyConfig> getConfigFuture() {
442         return client.getConfig();
443     }
444
445     /**
446      * Adds a HubConnectedListener
447      *
448      * @param listener
449      */
450     public void addHubStatusListener(HubStatusListener listener) {
451         listeners.add(listener);
452         listener.hubStatusChanged(getThing().getStatus());
453     }
454
455     /**
456      * Removes a HubConnectedListener
457      *
458      * @param listener
459      */
460     public void removeHubStatusListener(HubStatusListener listener) {
461         listeners.remove(listener);
462     }
463 }