2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.harmonyhub.internal.handler;
15 import static org.openhab.binding.harmonyhub.internal.HarmonyHubBindingConstants.*;
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;
24 import java.util.concurrent.CompletableFuture;
25 import java.util.concurrent.ConcurrentHashMap;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
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;
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;
67 * The {@link HarmonyHubHandler} is responsible for handling commands for Harmony Hubs, which are
68 * sent to one of the channels.
70 * @author Dan Cunningham - Initial contribution
71 * @author Pawel Pieczul - added support for hub status changes
72 * @author Wouter Born - Add null annotations
75 public class HarmonyHubHandler extends BaseBridgeHandler implements HarmonyClientListener {
77 private final Logger logger = LoggerFactory.getLogger(HarmonyHubHandler.class);
79 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(HARMONY_HUB_THING_TYPE);
81 private static final Comparator<Activity> ACTIVITY_COMPERATOR = Comparator.comparing(Activity::getActivityOrder,
82 Comparator.nullsFirst(Integer::compareTo));
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;
96 private int heartBeatInterval;
98 public HarmonyHubHandler(Bridge bridge, HarmonyHubDynamicTypeProvider typeProvider, HttpClient httpClient) {
100 this.typeProvider = typeProvider;
101 client = new HarmonyClient(httpClient);
102 client.addListener(this);
106 public void handleCommand(ChannelUID channelUID, Command command) {
107 logger.trace("Handling command '{}' for {}", command, channelUID);
109 if (getThing().getStatus() != ThingStatus.ONLINE) {
110 logger.debug("Hub is offline, ignoring command {} for channel {}", command, channelUID);
114 if (command instanceof RefreshType) {
115 client.getCurrentActivity().thenAccept(activity -> {
116 updateState(activity);
121 Channel channel = getThing().getChannel(channelUID.getId());
122 if (channel == null) {
123 logger.warn("No such channel for UID {}", channelUID);
127 switch (channel.getUID().getId()) {
128 case CHANNEL_CURRENT_ACTIVITY:
129 if (command instanceof DecimalType) {
131 client.startActivity(((DecimalType) command).intValue());
132 } catch (Exception e) {
133 logger.warn("Could not start activity", e);
138 int actId = Integer.parseInt(command.toString());
139 client.startActivity(actId);
140 } catch (NumberFormatException ignored) {
141 client.startActivityByName(command.toString());
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);
150 case CHANNEL_BUTTON_PRESS:
151 client.pressButtonCurrentActivity(command.toString());
155 if (command instanceof PlayPauseType) {
156 if (command == PlayPauseType.PLAY) {
158 } else if (command == PlayPauseType.PAUSE) {
161 } else if (command instanceof NextPreviousType) {
162 if (command == NextPreviousType.NEXT) {
164 } else if (command == NextPreviousType.PREVIOUS) {
165 cmd = "SkipBackward";
167 } else if (command instanceof RewindFastforwardType) {
168 if (command == RewindFastforwardType.FASTFORWARD) {
170 } else if (command == RewindFastforwardType.REWIND) {
175 client.pressButtonCurrentActivity(cmd);
177 logger.warn("Unknown player type {}", command);
181 logger.warn("Unknown channel id {}", channel.getUID().getId());
186 public void initialize() {
187 config = getConfigAs(HarmonyHubConfig.class);
188 updateStatus(ThingStatus.UNKNOWN);
193 public void dispose() {
200 public void handleRemoval() {
201 typeProvider.removeChannelTypesForThing(getThing().getUID());
202 super.handleRemoval();
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);
215 public void channelLinked(ChannelUID channelUID) {
216 client.getCurrentActivity().thenAccept((activity) -> {
217 updateState(channelUID, new StringType(activity.getLabel()));
222 public void hubDisconnected(@Nullable String reason) {
223 if (getThing().getStatus() == ThingStatus.ONLINE) {
224 setOfflineAndReconnect(String.format("Could not connect: %s", reason));
229 public void hubConnected() {
230 heartBeatJob = scheduler.scheduleWithFixedDelay(() -> {
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;
239 } catch (Exception e) {
240 logger.debug("heartbeat failed", e);
241 setOfflineAndReconnect("Hearbeat failed");
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());
250 client.getCurrentActivity().thenAccept(activity -> {
251 updateState(activity);
256 public void activityStatusChanged(@Nullable Activity activity, @Nullable Status status) {
257 updateActivityStatus(activity, status);
261 public void activityStarted(@Nullable Activity activity) {
262 updateState(activity);
266 * Starts the connection process
268 private synchronized void connect() {
271 heartBeatInterval = Math.min(config.heartBeatInterval > 0 ? config.heartBeatInterval : HEARTBEAT_INTERVAL,
272 HEARTBEAT_INTERVAL_MAX);
274 String host = config.host;
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);
286 logger.debug("host not configured");
287 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "host not configured");
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());
301 private void disconnectFromHub() {
302 ScheduledFuture<?> heartBeatJob = this.heartBeatJob;
303 if (heartBeatJob != null) {
304 heartBeatJob.cancel(true);
305 this.heartBeatJob = null;
310 private void setOfflineAndReconnect(String error) {
312 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, error);
313 scheduleRetry(RETRY_TIME);
316 private void cancelRetry() {
317 ScheduledFuture<?> retryJob = this.retryJob;
318 if (retryJob != null) {
319 retryJob.cancel(true);
320 this.retryJob = null;
324 private synchronized void scheduleRetry(int delaySeconds) {
326 retryJob = scheduler.schedule(this::connect, delaySeconds, TimeUnit.SECONDS);
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()));
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());
341 } else if (status == null) {
342 logger.debug("Cannot update activity status of {} with status that is null", getThing().getUID());
346 logger.debug("Received {} activity status for {}", status, activity.getLabel());
348 case ACTIVITY_IS_STARTING:
349 triggerChannel(CHANNEL_ACTIVITY_STARTING_TRIGGER, getEventName(activity));
351 case ACTIVITY_IS_STARTED:
353 // hub is off is received with power-off activity
354 triggerChannel(CHANNEL_ACTIVITY_STARTED_TRIGGER, getEventName(activity));
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));
366 }).exceptionally(e -> {
367 setOfflineAndReconnect("Getting config failed: " + e.getMessage());
376 private String getEventName(Activity activity) {
377 return activity.getLabel().replaceAll("[^A-Za-z0-9]", "_");
381 * Updates the current activity channel with the available activities as option states.
383 private void updateCurrentActivityChannel(@Nullable HarmonyConfig config) {
384 ChannelTypeUID channelTypeUID = new ChannelTypeUID(getThing().getUID() + ":" + CHANNEL_CURRENT_ACTIVITY);
386 if (config == null) {
387 logger.debug("Cannot update {} when HarmonyConfig is null", channelTypeUID);
391 logger.debug("Updating {}", channelTypeUID);
393 List<Activity> activities = config.getActivities();
394 // sort our activities in order
395 Collections.sort(activities, ACTIVITY_COMPERATOR);
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()));
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())
409 typeProvider.putChannelType(channelType);
411 Channel channel = ChannelBuilder.create(new ChannelUID(getThing().getUID(), CHANNEL_CURRENT_ACTIVITY), "String")
412 .withType(channelTypeUID).build();
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())) {
421 newChannels.add(channel);
423 BridgeBuilder thingBuilder = editThing();
424 thingBuilder.withChannels(newChannels);
425 updateThing(thingBuilder.build());
429 * Sends a button press to a device
434 public void pressButton(int device, String button) {
435 client.pressButton(device, button);
439 * Sends a button press to a device
444 public void pressButton(String device, String button) {
445 client.pressButton(device, button);
448 public CompletableFuture<@Nullable HarmonyConfig> getConfigFuture() {
449 return client.getConfig();
453 * Adds a HubConnectedListener
457 public void addHubStatusListener(HubStatusListener listener) {
458 listeners.add(listener);
459 listener.hubStatusChanged(getThing().getStatus());
463 * Removes a HubConnectedListener
467 public void removeHubStatusListener(HubStatusListener listener) {
468 listeners.remove(listener);