2 * Copyright (c) 2010-2022 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.CopyOnWriteArrayList;
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.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;
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;
66 * The {@link HarmonyHubHandler} is responsible for handling commands for Harmony Hubs, which are
67 * sent to one of the channels.
69 * @author Dan Cunningham - Initial contribution
70 * @author Pawel Pieczul - added support for hub status changes
71 * @author Wouter Born - Add null annotations
74 public class HarmonyHubHandler extends BaseBridgeHandler implements HarmonyClientListener {
76 private final Logger logger = LoggerFactory.getLogger(HarmonyHubHandler.class);
78 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections.singleton(HARMONY_HUB_THING_TYPE);
80 private static final Comparator<Activity> ACTIVITY_COMPERATOR = Comparator.comparing(Activity::getActivityOrder,
81 Comparator.nullsFirst(Integer::compareTo));
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 List<HubStatusListener> listeners = new CopyOnWriteArrayList<>();
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;
95 private int heartBeatInterval;
97 public HarmonyHubHandler(Bridge bridge, HarmonyHubHandlerFactory factory) {
99 this.factory = factory;
100 client = new HarmonyClient(factory.getHttpClient());
101 client.addListener(this);
105 public void handleCommand(ChannelUID channelUID, Command command) {
106 logger.trace("Handling command '{}' for {}", command, channelUID);
108 if (getThing().getStatus() != ThingStatus.ONLINE) {
109 logger.debug("Hub is offline, ignoring command {} for channel {}", command, channelUID);
113 if (command instanceof RefreshType) {
114 client.getCurrentActivity().thenAccept(activity -> {
115 updateState(activity);
120 Channel channel = getThing().getChannel(channelUID.getId());
121 if (channel == null) {
122 logger.warn("No such channel for UID {}", channelUID);
126 switch (channel.getUID().getId()) {
127 case CHANNEL_CURRENT_ACTIVITY:
128 if (command instanceof DecimalType) {
130 client.startActivity(((DecimalType) command).intValue());
131 } catch (Exception e) {
132 logger.warn("Could not start activity", e);
137 int actId = Integer.parseInt(command.toString());
138 client.startActivity(actId);
139 } catch (NumberFormatException ignored) {
140 client.startActivityByName(command.toString());
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);
149 case CHANNEL_BUTTON_PRESS:
150 client.pressButtonCurrentActivity(command.toString());
154 if (command instanceof PlayPauseType) {
155 if (command == PlayPauseType.PLAY) {
157 } else if (command == PlayPauseType.PAUSE) {
160 } else if (command instanceof NextPreviousType) {
161 if (command == NextPreviousType.NEXT) {
163 } else if (command == NextPreviousType.PREVIOUS) {
164 cmd = "SkipBackward";
166 } else if (command instanceof RewindFastforwardType) {
167 if (command == RewindFastforwardType.FASTFORWARD) {
169 } else if (command == RewindFastforwardType.REWIND) {
174 client.pressButtonCurrentActivity(cmd);
176 logger.warn("Unknown player type {}", command);
180 logger.warn("Unknown channel id {}", channel.getUID().getId());
185 public void initialize() {
186 config = getConfigAs(HarmonyHubConfig.class);
188 updateStatus(ThingStatus.UNKNOWN);
193 public void dispose() {
197 factory.removeChannelTypesForThing(getThing().getUID());
201 protected void updateStatus(ThingStatus status, ThingStatusDetail detail, @Nullable String comment) {
202 super.updateStatus(status, detail, comment);
203 logger.debug("Updating listeners with status {}", status);
204 for (HubStatusListener listener : listeners) {
205 listener.hubStatusChanged(status);
210 public void channelLinked(ChannelUID channelUID) {
211 client.getCurrentActivity().thenAccept((activity) -> {
212 updateState(channelUID, new StringType(activity.getLabel()));
217 public void hubDisconnected(@Nullable String reason) {
218 if (getThing().getStatus() == ThingStatus.ONLINE) {
219 setOfflineAndReconnect(String.format("Could not connect: %s", reason));
224 public void hubConnected() {
225 heartBeatJob = scheduler.scheduleWithFixedDelay(() -> {
227 Ping ping = client.sendPing().get();
228 if (!propertiesUpdated) {
229 Map<String, String> properties = editProperties();
230 properties.put(HUB_PROPERTY_ID, ping.getUuid());
231 updateProperties(properties);
232 propertiesUpdated = true;
234 } catch (Exception e) {
235 logger.debug("heartbeat failed", e);
236 setOfflineAndReconnect("Hearbeat failed");
238 }, 5, heartBeatInterval, TimeUnit.SECONDS);
239 updateStatus(ThingStatus.ONLINE);
240 getConfigFuture().thenAcceptAsync(harmonyConfig -> updateCurrentActivityChannel(harmonyConfig), scheduler)
241 .exceptionally(e -> {
242 setOfflineAndReconnect("Getting config failed: " + e.getMessage());
245 client.getCurrentActivity().thenAccept(activity -> {
246 updateState(activity);
251 public void activityStatusChanged(@Nullable Activity activity, @Nullable Status status) {
252 updateActivityStatus(activity, status);
256 public void activityStarted(@Nullable Activity activity) {
257 updateState(activity);
261 * Starts the connection process
263 private synchronized void connect() {
266 heartBeatInterval = Math.min(config.heartBeatInterval > 0 ? config.heartBeatInterval : HEARTBEAT_INTERVAL,
267 HEARTBEAT_INTERVAL_MAX);
269 String host = config.host;
271 // earlier versions required a name and used network discovery to find the hub and retrieve the host,
272 // this section is to not break that and also update older configurations to use the host configuration
273 // option instead of name
274 if (host == null || host.isBlank()) {
275 host = getThing().getProperties().get(HUB_PROPERTY_HOST);
276 if (host != null && !host.isBlank()) {
277 Configuration genericConfig = getConfig();
278 genericConfig.put(HUB_PROPERTY_HOST, host);
279 updateConfiguration(genericConfig);
281 logger.debug("host not configured");
282 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "host not configured");
288 logger.debug("Connecting: host {}", host);
289 client.connect(host);
290 } catch (Exception e) {
291 logger.debug("Could not connect to HarmonyHub at {}", host, e);
292 setOfflineAndReconnect("Could not connect: " + e.getMessage());
296 private void disconnectFromHub() {
297 ScheduledFuture<?> localHeartBeatJob = heartBeatJob;
298 if (localHeartBeatJob != null && !localHeartBeatJob.isDone()) {
299 localHeartBeatJob.cancel(false);
304 private void setOfflineAndReconnect(String error) {
306 scheduleRetry(RETRY_TIME);
307 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, error);
310 private void cancelRetry() {
311 ScheduledFuture<?> localRetryJob = retryJob;
312 if (localRetryJob != null && !localRetryJob.isDone()) {
313 localRetryJob.cancel(false);
317 private synchronized void scheduleRetry(int retrySeconds) {
319 retryJob = scheduler.schedule(this::connect, retrySeconds, TimeUnit.SECONDS);
322 private void updateState(@Nullable Activity activity) {
323 if (activity != null) {
324 logger.debug("Updating current activity to {}", activity.getLabel());
325 updateState(new ChannelUID(getThing().getUID(), CHANNEL_CURRENT_ACTIVITY),
326 new StringType(activity.getLabel()));
330 private void updateActivityStatus(@Nullable Activity activity, @Nullable Status status) {
331 if (activity == null) {
332 logger.debug("Cannot update activity status of {} with activity that is null", getThing().getUID());
334 } else if (status == null) {
335 logger.debug("Cannot update activity status of {} with status that is null", getThing().getUID());
339 logger.debug("Received {} activity status for {}", status, activity.getLabel());
341 case ACTIVITY_IS_STARTING:
342 triggerChannel(CHANNEL_ACTIVITY_STARTING_TRIGGER, getEventName(activity));
344 case ACTIVITY_IS_STARTED:
346 // hub is off is received with power-off activity
347 triggerChannel(CHANNEL_ACTIVITY_STARTED_TRIGGER, getEventName(activity));
349 case HUB_IS_TURNING_OFF:
350 // hub is turning off is received for current activity, we will translate it into activity starting
351 // trigger of power-off activity (with ID=-1)
352 getConfigFuture().thenAccept(config -> {
353 if (config != null) {
354 Activity powerOff = config.getActivityById(-1);
355 if (powerOff != null) {
356 triggerChannel(CHANNEL_ACTIVITY_STARTING_TRIGGER, getEventName(powerOff));
359 }).exceptionally(e -> {
360 setOfflineAndReconnect("Getting config failed: " + e.getMessage());
369 private String getEventName(Activity activity) {
370 return activity.getLabel().replaceAll("[^A-Za-z0-9]", "_");
374 * Updates the current activity channel with the available activities as option states.
376 private void updateCurrentActivityChannel(@Nullable HarmonyConfig config) {
377 ChannelTypeUID channelTypeUID = new ChannelTypeUID(getThing().getUID() + ":" + CHANNEL_CURRENT_ACTIVITY);
379 if (config == null) {
380 logger.debug("Cannot update {} when HarmonyConfig is null", channelTypeUID);
384 logger.debug("Updating {}", channelTypeUID);
386 List<Activity> activities = config.getActivities();
387 // sort our activities in order
388 Collections.sort(activities, ACTIVITY_COMPERATOR);
390 // add our activities as channel state options
391 List<StateOption> states = new LinkedList<>();
392 for (Activity activity : activities) {
393 states.add(new StateOption(activity.getLabel(), activity.getLabel()));
396 ChannelType channelType = ChannelTypeBuilder.state(channelTypeUID, "Current Activity", "String")
397 .withDescription("Current activity for " + getThing().getLabel())
398 .withStateDescriptionFragment(StateDescriptionFragmentBuilder.create().withPattern("%s")
399 .withReadOnly(false).withOptions(states).build())
402 factory.addChannelType(channelType);
404 Channel channel = ChannelBuilder.create(new ChannelUID(getThing().getUID(), CHANNEL_CURRENT_ACTIVITY), "String")
405 .withType(channelTypeUID).build();
407 // replace existing currentActivity with updated one
408 List<Channel> newChannels = new ArrayList<>();
409 for (Channel c : getThing().getChannels()) {
410 if (!c.getUID().equals(channel.getUID())) {
414 newChannels.add(channel);
416 BridgeBuilder thingBuilder = editThing();
417 thingBuilder.withChannels(newChannels);
418 updateThing(thingBuilder.build());
422 * Sends a button press to a device
427 public void pressButton(int device, String button) {
428 client.pressButton(device, button);
432 * Sends a button press to a device
437 public void pressButton(String device, String button) {
438 client.pressButton(device, button);
441 public CompletableFuture<@Nullable HarmonyConfig> getConfigFuture() {
442 return client.getConfig();
446 * Adds a HubConnectedListener
450 public void addHubStatusListener(HubStatusListener listener) {
451 listeners.add(listener);
452 listener.hubStatusChanged(getThing().getStatus());
456 * Removes a HubConnectedListener
460 public void removeHubStatusListener(HubStatusListener listener) {
461 listeners.remove(listener);