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.heos.internal.handler;
15 import static org.openhab.binding.heos.internal.HeosBindingConstants.*;
16 import static org.openhab.binding.heos.internal.handler.FutureUtil.cancel;
17 import static org.openhab.core.thing.ThingStatus.OFFLINE;
18 import static org.openhab.core.thing.ThingStatus.ONLINE;
20 import java.io.IOException;
21 import java.util.Collection;
22 import java.util.Collections;
23 import java.util.HashMap;
24 import java.util.List;
26 import java.util.Objects;
27 import java.util.Optional;
29 import java.util.concurrent.ConcurrentHashMap;
30 import java.util.concurrent.CopyOnWriteArrayList;
31 import java.util.concurrent.CopyOnWriteArraySet;
32 import java.util.concurrent.Future;
33 import java.util.concurrent.TimeUnit;
34 import java.util.stream.Collectors;
35 import java.util.stream.Stream;
37 import org.eclipse.jdt.annotation.NonNullByDefault;
38 import org.eclipse.jdt.annotation.Nullable;
39 import org.openhab.binding.heos.internal.HeosChannelHandlerFactory;
40 import org.openhab.binding.heos.internal.HeosChannelManager;
41 import org.openhab.binding.heos.internal.action.HeosActions;
42 import org.openhab.binding.heos.internal.api.HeosFacade;
43 import org.openhab.binding.heos.internal.api.HeosSystem;
44 import org.openhab.binding.heos.internal.configuration.BridgeConfiguration;
45 import org.openhab.binding.heos.internal.discovery.HeosPlayerDiscoveryListener;
46 import org.openhab.binding.heos.internal.exception.HeosNotConnectedException;
47 import org.openhab.binding.heos.internal.exception.HeosNotFoundException;
48 import org.openhab.binding.heos.internal.json.dto.HeosError;
49 import org.openhab.binding.heos.internal.json.dto.HeosEvent;
50 import org.openhab.binding.heos.internal.json.dto.HeosEventObject;
51 import org.openhab.binding.heos.internal.json.dto.HeosResponseObject;
52 import org.openhab.binding.heos.internal.json.payload.Group;
53 import org.openhab.binding.heos.internal.json.payload.Media;
54 import org.openhab.binding.heos.internal.json.payload.Player;
55 import org.openhab.binding.heos.internal.resources.HeosEventListener;
56 import org.openhab.binding.heos.internal.resources.HeosMediaEventListener;
57 import org.openhab.binding.heos.internal.resources.Telnet;
58 import org.openhab.binding.heos.internal.resources.Telnet.ReadException;
59 import org.openhab.core.library.types.OnOffType;
60 import org.openhab.core.thing.Bridge;
61 import org.openhab.core.thing.Channel;
62 import org.openhab.core.thing.ChannelUID;
63 import org.openhab.core.thing.Thing;
64 import org.openhab.core.thing.ThingStatus;
65 import org.openhab.core.thing.ThingStatusDetail;
66 import org.openhab.core.thing.binding.BaseBridgeHandler;
67 import org.openhab.core.thing.binding.ThingHandler;
68 import org.openhab.core.thing.binding.ThingHandlerService;
69 import org.openhab.core.thing.binding.builder.ChannelBuilder;
70 import org.openhab.core.thing.binding.builder.ThingBuilder;
71 import org.openhab.core.thing.type.ChannelTypeUID;
72 import org.openhab.core.types.Command;
73 import org.openhab.core.types.RefreshType;
74 import org.slf4j.Logger;
75 import org.slf4j.LoggerFactory;
78 * The {@link HeosBridgeHandler} is responsible for handling commands, which are
79 * sent to one of the channels.
81 * @author Johannes Einig - Initial contribution
82 * @author Martin van Wingerden - change handling of stop/pause depending on playing item type
85 public class HeosBridgeHandler extends BaseBridgeHandler implements HeosEventListener {
86 private final Logger logger = LoggerFactory.getLogger(HeosBridgeHandler.class);
88 private static final int HEOS_PORT = 1255;
90 private final Set<HeosMediaEventListener> heosMediaEventListeners = new CopyOnWriteArraySet<>();
91 private final List<HeosPlayerDiscoveryListener> playerDiscoveryList = new CopyOnWriteArrayList<>();
92 private final HeosChannelManager channelManager = new HeosChannelManager(this);
93 private final HeosChannelHandlerFactory channelHandlerFactory;
95 private final Map<String, HeosGroupHandler> groupHandlerMap = new ConcurrentHashMap<>();
96 private final Map<String, String> hashToGidMap = new ConcurrentHashMap<>();
98 private List<String[]> selectedPlayerList = new CopyOnWriteArrayList<>();
100 private @Nullable Future<?> startupFuture;
101 private final List<Future<?>> childHandlerInitializedFutures = new CopyOnWriteArrayList<>();
103 private final HeosSystem heosSystem;
104 private @Nullable HeosFacade apiConnection;
106 private boolean loggedIn = false;
107 private boolean bridgeHandlerDisposalOngoing = false;
109 private @NonNullByDefault({}) BridgeConfiguration configuration;
111 private int failureCount;
113 public HeosBridgeHandler(Bridge bridge, HeosDynamicStateDescriptionProvider heosDynamicStateDescriptionProvider) {
115 heosSystem = new HeosSystem(scheduler);
116 channelHandlerFactory = new HeosChannelHandlerFactory(this, heosDynamicStateDescriptionProvider);
120 public void handleCommand(ChannelUID channelUID, Command command) {
121 if (command instanceof RefreshType) {
125 Channel channel = this.getThing().getChannel(channelUID.getId());
126 if (channel == null) {
127 logger.debug("No valid channel found");
132 ChannelTypeUID channelTypeUID = channel.getChannelTypeUID();
134 HeosChannelHandler channelHandler = channelHandlerFactory.getChannelHandler(channelUID, this, channelTypeUID);
135 if (channelHandler != null) {
137 channelHandler.handleBridgeCommand(command, thing.getUID());
139 updateStatus(ONLINE);
140 } catch (IOException | ReadException e) {
141 logger.debug("Failed to handle bridge command", e);
144 if (failureCount > FAILURE_COUNT_LIMIT) {
145 updateStatus(OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
146 "Failed to handle command: " + e.getMessage());
153 public synchronized void initialize() {
154 configuration = thing.getConfiguration().as(BridgeConfiguration.class);
155 cancel(startupFuture);
156 startupFuture = scheduler.submit(this::delayedInitialize);
159 private void delayedInitialize() {
161 HeosFacade connection = null;
163 logger.debug("Running scheduledStartUp job");
165 connection = connectBridge();
166 updateStatus(ThingStatus.ONLINE);
167 updateState(CH_ID_REBOOT, OnOffType.OFF);
169 logger.debug("HEOS System heart beat started. Pulse time is {}s", configuration.heartbeat);
170 // gets all available player and groups to ensure that the system knows
171 // about the conjunction between the groupMemberHash and the GID
172 triggerPlayerDiscovery();
174 String username = configuration.username;
176 String password = configuration.password;
177 if (username != null && !"".equals(username) && password != null && !"".equals(password)) {
178 login(connection, username, password);
180 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.CONFIGURATION_ERROR,
181 "Can't log in. Username or password not set.");
184 fetchPlayersAndGroups();
185 } catch (Telnet.ReadException | IOException | RuntimeException e) {
186 logger.debug("Error occurred while connecting", e);
187 if (connection != null) {
188 connection.closeConnection();
190 updateStatus(OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Errors occurred: " + e.getMessage());
191 cancel(startupFuture, false);
192 startupFuture = scheduler.schedule(this::delayedInitialize, 30, TimeUnit.SECONDS);
196 private void fetchPlayersAndGroups() {
199 Player[] onlinePlayers = getApiConnection().getPlayers().payload;
201 Group[] onlineGroups = getApiConnection().getGroups().payload;
203 if (onlinePlayers != null && onlineGroups != null) {
204 updatePlayerStatus(onlinePlayers, onlineGroups);
206 } catch (ReadException | IOException e) {
207 logger.debug("Failed updating online state of groups/players", e);
211 private void updatePlayerStatus(@Nullable Player[] onlinePlayers, @Nullable Group[] onlineGroups) {
212 if (onlinePlayers == null || onlineGroups == null) {
215 Set<String> players = Stream.of(onlinePlayers).map(p -> Objects.toString(p.playerId))
216 .collect(Collectors.toSet());
217 Set<String> groups = Stream.of(onlineGroups).map(p -> p.id).collect(Collectors.toSet());
219 for (Thing thing : getThing().getThings()) {
222 ThingHandler handler = thing.getHandler();
223 if (handler instanceof HeosThingBaseHandler) {
224 Set<String> target = handler instanceof HeosPlayerHandler ? players : groups;
225 HeosThingBaseHandler heosHandler = (HeosThingBaseHandler) handler;
226 String id = heosHandler.getId();
228 if (target.contains(id)) {
229 heosHandler.setStatusOnline();
231 heosHandler.setStatusOffline();
234 } catch (HeosNotFoundException e) {
235 logger.debug("SKipping handler which reported not found", e);
240 private HeosFacade connectBridge() throws IOException, Telnet.ReadException {
243 logger.debug("Initialize Bridge '{}' with IP '{}'", thing.getProperties().get(PROP_NAME),
244 configuration.ipAddress);
245 bridgeHandlerDisposalOngoing = false;
246 HeosFacade connection = heosSystem.establishConnection(configuration.ipAddress, HEOS_PORT,
247 configuration.heartbeat);
248 connection.registerForChangeEvents(this);
250 apiConnection = connection;
255 private void login(HeosFacade connection, String username, String password) throws IOException, ReadException {
256 logger.debug("Logging in to HEOS account.");
257 HeosResponseObject<Void> response = connection.logIn(username, password);
259 if (response.result) {
260 logger.debug("successfully logged-in, event is fired to handle post-login behaviour");
265 HeosError error = response.getError();
266 logger.debug("Failed to login: {}", error);
267 updateStatus(ONLINE, ThingStatusDetail.CONFIGURATION_ERROR,
268 error != null ? error.code.toString() : "Failed to login, no error was returned.");
272 public void dispose() {
273 bridgeHandlerDisposalOngoing = true; // Flag to prevent the handler from being updated during disposal
275 cancel(startupFuture);
276 for (Future<?> future : childHandlerInitializedFutures) {
281 HeosFacade localApiConnection = apiConnection;
282 if (localApiConnection == null) {
283 logger.debug("Not disposing bridge because of missing apiConnection");
287 localApiConnection.unregisterForChangeEvents(this);
288 logger.debug("HEOS bridge removed from change notifications");
290 logger.debug("Dispose bridge '{}'", thing.getProperties().get(PROP_NAME));
291 localApiConnection.closeConnection();
295 * Manages the removal of the player or group channels from the bridge.
298 public synchronized void childHandlerDisposed(ThingHandler childHandler, Thing childThing) {
299 logger.debug("Disposing child handler for: {}.", childThing.getUID().getId());
300 if (bridgeHandlerDisposalOngoing) { // Checks if bridgeHandler is going to disposed (by stopping the binding or
301 // openHAB for example) and prevents it from being updated which stops the
303 } else if (childHandler instanceof HeosPlayerHandler) {
304 String channelIdentifier = "P" + childThing.getUID().getId();
305 updateThingChannels(channelManager.removeSingleChannel(channelIdentifier));
306 } else if (childHandler instanceof HeosGroupHandler) {
307 String channelIdentifier = "G" + childThing.getUID().getId();
308 updateThingChannels(channelManager.removeSingleChannel(channelIdentifier));
309 // removes the handler from the groupMemberMap that handler is no longer called
310 // if group is getting online
311 removeGroupHandlerInformation((HeosGroupHandler) childHandler);
316 public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) {
317 logger.debug("Initialized child handler for: {}.", childThing.getUID().getId());
318 childHandlerInitializedFutures.add(scheduler.submit(() -> addPlayerChannel(childThing, null)));
321 void resetPlayerList(ChannelUID channelUID) {
322 selectedPlayerList.forEach(element -> updateState(element[1], OnOffType.OFF));
323 selectedPlayerList.clear();
324 updateState(channelUID, OnOffType.OFF);
328 * Sets the HEOS Thing offline
330 @SuppressWarnings("null")
331 public void setGroupOffline(String groupMemberHash) {
332 HeosGroupHandler groupHandler = groupHandlerMap.get(groupMemberHash);
333 if (groupHandler != null) {
334 groupHandler.setStatusOffline();
336 hashToGidMap.remove(groupMemberHash);
340 * Sets the HEOS Thing online. Also updates the link between
341 * the groupMemberHash value with the actual gid of this group
343 public void setGroupOnline(String groupMemberHash, String groupId) {
344 hashToGidMap.put(groupMemberHash, groupId);
345 Optional.ofNullable(groupHandlerMap.get(groupMemberHash)).ifPresent(handler -> {
346 handler.setStatusOnline();
347 addPlayerChannel(handler.getThing(), groupId);
352 * Create a channel for the childThing. Depending if it is a HEOS Group
353 * or a player an identification prefix is added
355 * @param childThing the thing the channel is created for
358 private void addPlayerChannel(Thing childThing, @Nullable String groupId) {
360 String channelIdentifier = "";
363 ThingHandler handler = childThing.getHandler();
364 if (handler instanceof HeosPlayerHandler) {
365 channelIdentifier = "P" + childThing.getUID().getId();
366 pid = ((HeosPlayerHandler) handler).getId();
367 } else if (handler instanceof HeosGroupHandler) {
368 channelIdentifier = "G" + childThing.getUID().getId();
369 if (groupId == null) {
370 pid = ((HeosGroupHandler) handler).getId();
375 Map<String, String> properties = new HashMap<>();
377 String playerName = childThing.getLabel();
378 playerName = playerName == null ? pid : playerName;
379 ChannelUID channelUID = new ChannelUID(getThing().getUID(), channelIdentifier);
380 properties.put(PROP_NAME, playerName);
381 properties.put(PID, pid);
383 Channel channel = ChannelBuilder.create(channelUID, "Switch").withLabel(playerName).withType(CH_TYPE_PLAYER)
384 .withProperties(properties).build();
385 updateThingChannels(channelManager.addSingleChannel(channel));
386 } catch (HeosNotFoundException e) {
387 logger.debug("Group is not yet initialized fully");
391 public void addGroupHandlerInformation(HeosGroupHandler handler) {
392 groupHandlerMap.put(handler.getGroupMemberHash(), handler);
395 private void removeGroupHandlerInformation(HeosGroupHandler handler) {
396 groupHandlerMap.remove(handler.getGroupMemberHash());
399 public @Nullable String getActualGID(String groupHash) {
400 return hashToGidMap.get(groupHash);
404 public void playerStateChangeEvent(HeosEventObject eventObject) {
409 public void playerStateChangeEvent(HeosResponseObject<?> responseObject) {
414 public void playerMediaChangeEvent(String pid, Media media) {
415 heosMediaEventListeners.forEach(element -> element.playerMediaChangeEvent(pid, media));
419 public void bridgeChangeEvent(String event, boolean success, Object command) {
420 if (EVENT_TYPE_EVENT.equals(event)) {
421 if (HeosEvent.PLAYERS_CHANGED.equals(command) || HeosEvent.GROUPS_CHANGED.equals(command)) {
422 fetchPlayersAndGroups();
423 triggerPlayerDiscovery();
424 } else if (EVENT_STREAM_TIMEOUT.equals(command)) {
425 logger.debug("HEOS Bridge events timed-out might be nothing, trying to reconnect");
426 } else if (CONNECTION_LOST.equals(command)) {
427 updateStatus(OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
428 logger.debug("HEOS Bridge OFFLINE");
429 } else if (CONNECTION_RESTORED.equals(command)) {
433 if (EVENT_TYPE_SYSTEM.equals(event) && HeosEvent.USER_CHANGED == command) {
434 if (success && !loggedIn) {
440 private synchronized void updateThingChannels(List<Channel> channelList) {
441 ThingBuilder thingBuilder = editThing();
442 thingBuilder.withChannels(channelList);
443 updateThing(thingBuilder.build());
446 public Player[] getPlayers() throws IOException, ReadException {
447 HeosResponseObject<Player[]> response = getApiConnection().getPlayers();
449 Player[] players = response.payload;
450 if (players == null) {
451 throw new IOException("Received no valid payload");
456 public Group[] getGroups() throws IOException, ReadException {
457 HeosResponseObject<Group[]> response = getApiConnection().getGroups();
459 Group[] groups = response.payload;
460 if (groups == null) {
461 throw new IOException("Received no valid payload");
467 * The list with the currently selected player
469 * @return a HashMap which the currently selected player
471 public Map<String, String> getSelectedPlayer() {
472 return selectedPlayerList.stream().collect(Collectors.toMap(a -> a[0], a -> a[1], (a, b) -> a));
475 public List<String[]> getSelectedPlayerList() {
476 return selectedPlayerList;
479 public void setSelectedPlayerList(List<String[]> selectedPlayerList) {
480 this.selectedPlayerList = selectedPlayerList;
483 public HeosChannelHandlerFactory getChannelHandlerFactory() {
484 return channelHandlerFactory;
488 * Register an {@link HeosPlayerDiscoveryListener} to get informed
489 * if the amount of groups or players have changed
491 * @param listener the implementing class
493 public void registerPlayerDiscoverListener(HeosPlayerDiscoveryListener listener) {
494 playerDiscoveryList.add(listener);
497 private void triggerPlayerDiscovery() {
498 playerDiscoveryList.forEach(HeosPlayerDiscoveryListener::playerChanged);
501 public boolean isLoggedIn() {
505 public boolean isBridgeConnected() {
507 HeosFacade connection = apiConnection;
508 return connection != null && connection.isConnected();
511 public HeosFacade getApiConnection() throws HeosNotConnectedException {
513 HeosFacade localApiConnection = apiConnection;
514 if (localApiConnection != null) {
515 return localApiConnection;
517 throw new HeosNotConnectedException();
522 public Collection<Class<? extends ThingHandlerService>> getServices() {
523 return Collections.singletonList(HeosActions.class);
526 public void registerMediaEventListener(HeosMediaEventListener heosMediaEventListener) {
527 heosMediaEventListeners.add(heosMediaEventListener);