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