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