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