]> git.basschouten.com Git - openhab-addons.git/blob
adebda7958cda5a6b416f44107baa7c65583e647
[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.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 = Set.of(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 Set<HubStatusListener> listeners = ConcurrentHashMap.newKeySet();
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         updateStatus(ThingStatus.UNKNOWN);
188         scheduleRetry(0);
189     }
190
191     @Override
192     public void dispose() {
193         listeners.clear();
194         cancelRetry();
195         disconnectFromHub();
196         factory.removeChannelTypesForThing(getThing().getUID());
197     }
198
199     @Override
200     protected void updateStatus(ThingStatus status, ThingStatusDetail detail, @Nullable String comment) {
201         super.updateStatus(status, detail, comment);
202         logger.debug("Updating listeners with status {}", status);
203         for (HubStatusListener listener : listeners) {
204             listener.hubStatusChanged(status);
205         }
206     }
207
208     @Override
209     public void channelLinked(ChannelUID channelUID) {
210         client.getCurrentActivity().thenAccept((activity) -> {
211             updateState(channelUID, new StringType(activity.getLabel()));
212         });
213     }
214
215     @Override
216     public void hubDisconnected(@Nullable String reason) {
217         if (getThing().getStatus() == ThingStatus.ONLINE) {
218             setOfflineAndReconnect(String.format("Could not connect: %s", reason));
219         }
220     }
221
222     @Override
223     public void hubConnected() {
224         heartBeatJob = scheduler.scheduleWithFixedDelay(() -> {
225             try {
226                 Ping ping = client.sendPing().get();
227                 if (!propertiesUpdated) {
228                     Map<String, String> properties = editProperties();
229                     properties.put(HUB_PROPERTY_ID, ping.getUuid());
230                     updateProperties(properties);
231                     propertiesUpdated = true;
232                 }
233             } catch (Exception e) {
234                 logger.debug("heartbeat failed", e);
235                 setOfflineAndReconnect("Hearbeat failed");
236             }
237         }, 5, heartBeatInterval, TimeUnit.SECONDS);
238         updateStatus(ThingStatus.ONLINE);
239         getConfigFuture().thenAcceptAsync(harmonyConfig -> updateCurrentActivityChannel(harmonyConfig), scheduler)
240                 .exceptionally(e -> {
241                     setOfflineAndReconnect("Getting config failed: " + e.getMessage());
242                     return null;
243                 });
244         client.getCurrentActivity().thenAccept(activity -> {
245             updateState(activity);
246         });
247     }
248
249     @Override
250     public void activityStatusChanged(@Nullable Activity activity, @Nullable Status status) {
251         updateActivityStatus(activity, status);
252     }
253
254     @Override
255     public void activityStarted(@Nullable Activity activity) {
256         updateState(activity);
257     }
258
259     /**
260      * Starts the connection process
261      */
262     private synchronized void connect() {
263         disconnectFromHub();
264
265         heartBeatInterval = Math.min(config.heartBeatInterval > 0 ? config.heartBeatInterval : HEARTBEAT_INTERVAL,
266                 HEARTBEAT_INTERVAL_MAX);
267
268         String host = config.host;
269
270         // earlier versions required a name and used network discovery to find the hub and retrieve the host,
271         // this section is to not break that and also update older configurations to use the host configuration
272         // option instead of name
273         if (host == null || host.isBlank()) {
274             host = getThing().getProperties().get(HUB_PROPERTY_HOST);
275             if (host != null && !host.isBlank()) {
276                 Configuration genericConfig = getConfig();
277                 genericConfig.put(HUB_PROPERTY_HOST, host);
278                 updateConfiguration(genericConfig);
279             } else {
280                 logger.debug("host not configured");
281                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "host not configured");
282                 return;
283             }
284         }
285
286         try {
287             logger.debug("Connecting: host {}", host);
288             client.connect(host);
289         } catch (Exception e) {
290             logger.debug("Could not connect to HarmonyHub at {}", host, e);
291             setOfflineAndReconnect("Could not connect: " + e.getMessage());
292         }
293     }
294
295     private void disconnectFromHub() {
296         ScheduledFuture<?> heartBeatJob = this.heartBeatJob;
297         if (heartBeatJob != null) {
298             heartBeatJob.cancel(true);
299             this.heartBeatJob = null;
300         }
301         client.disconnect();
302     }
303
304     private void setOfflineAndReconnect(String error) {
305         disconnectFromHub();
306         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, error);
307         scheduleRetry(RETRY_TIME);
308     }
309
310     private void cancelRetry() {
311         ScheduledFuture<?> retryJob = this.retryJob;
312         if (retryJob != null) {
313             retryJob.cancel(true);
314             this.retryJob = null;
315         }
316     }
317
318     private synchronized void scheduleRetry(int delaySeconds) {
319         cancelRetry();
320         retryJob = scheduler.schedule(this::connect, delaySeconds, TimeUnit.SECONDS);
321     }
322
323     private void updateState(@Nullable Activity activity) {
324         if (activity != null) {
325             logger.debug("Updating current activity to {}", activity.getLabel());
326             updateState(new ChannelUID(getThing().getUID(), CHANNEL_CURRENT_ACTIVITY),
327                     new StringType(activity.getLabel()));
328         }
329     }
330
331     private void updateActivityStatus(@Nullable Activity activity, @Nullable Status status) {
332         if (activity == null) {
333             logger.debug("Cannot update activity status of {} with activity that is null", getThing().getUID());
334             return;
335         } else if (status == null) {
336             logger.debug("Cannot update activity status of {} with status that is null", getThing().getUID());
337             return;
338         }
339
340         logger.debug("Received {} activity status for {}", status, activity.getLabel());
341         switch (status) {
342             case ACTIVITY_IS_STARTING:
343                 triggerChannel(CHANNEL_ACTIVITY_STARTING_TRIGGER, getEventName(activity));
344                 break;
345             case ACTIVITY_IS_STARTED:
346             case HUB_IS_OFF:
347                 // hub is off is received with power-off activity
348                 triggerChannel(CHANNEL_ACTIVITY_STARTED_TRIGGER, getEventName(activity));
349                 break;
350             case HUB_IS_TURNING_OFF:
351                 // hub is turning off is received for current activity, we will translate it into activity starting
352                 // trigger of power-off activity (with ID=-1)
353                 getConfigFuture().thenAccept(config -> {
354                     if (config != null) {
355                         Activity powerOff = config.getActivityById(-1);
356                         if (powerOff != null) {
357                             triggerChannel(CHANNEL_ACTIVITY_STARTING_TRIGGER, getEventName(powerOff));
358                         }
359                     }
360                 }).exceptionally(e -> {
361                     setOfflineAndReconnect("Getting config failed: " + e.getMessage());
362                     return null;
363                 });
364                 break;
365             default:
366                 break;
367         }
368     }
369
370     private String getEventName(Activity activity) {
371         return activity.getLabel().replaceAll("[^A-Za-z0-9]", "_");
372     }
373
374     /**
375      * Updates the current activity channel with the available activities as option states.
376      */
377     private void updateCurrentActivityChannel(@Nullable HarmonyConfig config) {
378         ChannelTypeUID channelTypeUID = new ChannelTypeUID(getThing().getUID() + ":" + CHANNEL_CURRENT_ACTIVITY);
379
380         if (config == null) {
381             logger.debug("Cannot update {} when HarmonyConfig is null", channelTypeUID);
382             return;
383         }
384
385         logger.debug("Updating {}", channelTypeUID);
386
387         List<Activity> activities = config.getActivities();
388         // sort our activities in order
389         Collections.sort(activities, ACTIVITY_COMPERATOR);
390
391         // add our activities as channel state options
392         List<StateOption> states = new LinkedList<>();
393         for (Activity activity : activities) {
394             states.add(new StateOption(activity.getLabel(), activity.getLabel()));
395         }
396
397         ChannelType channelType = ChannelTypeBuilder.state(channelTypeUID, "Current Activity", "String")
398                 .withDescription("Current activity for " + getThing().getLabel())
399                 .withStateDescriptionFragment(StateDescriptionFragmentBuilder.create().withPattern("%s")
400                         .withReadOnly(false).withOptions(states).build())
401                 .build();
402
403         factory.addChannelType(channelType);
404
405         Channel channel = ChannelBuilder.create(new ChannelUID(getThing().getUID(), CHANNEL_CURRENT_ACTIVITY), "String")
406                 .withType(channelTypeUID).build();
407
408         // replace existing currentActivity with updated one
409         List<Channel> newChannels = new ArrayList<>();
410         for (Channel c : getThing().getChannels()) {
411             if (!c.getUID().equals(channel.getUID())) {
412                 newChannels.add(c);
413             }
414         }
415         newChannels.add(channel);
416
417         BridgeBuilder thingBuilder = editThing();
418         thingBuilder.withChannels(newChannels);
419         updateThing(thingBuilder.build());
420     }
421
422     /**
423      * Sends a button press to a device
424      *
425      * @param device
426      * @param button
427      */
428     public void pressButton(int device, String button) {
429         client.pressButton(device, button);
430     }
431
432     /**
433      * Sends a button press to a device
434      *
435      * @param device
436      * @param button
437      */
438     public void pressButton(String device, String button) {
439         client.pressButton(device, button);
440     }
441
442     public CompletableFuture<@Nullable HarmonyConfig> getConfigFuture() {
443         return client.getConfig();
444     }
445
446     /**
447      * Adds a HubConnectedListener
448      *
449      * @param listener
450      */
451     public void addHubStatusListener(HubStatusListener listener) {
452         listeners.add(listener);
453         listener.hubStatusChanged(getThing().getStatus());
454     }
455
456     /**
457      * Removes a HubConnectedListener
458      *
459      * @param listener
460      */
461     public void removeHubStatusListener(HubStatusListener listener) {
462         listeners.remove(listener);
463     }
464 }