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.HashMap;
23 import java.util.List;
25 import java.util.Objects;
26 import java.util.Optional;
28 import java.util.concurrent.ConcurrentHashMap;
29 import java.util.concurrent.CopyOnWriteArrayList;
30 import java.util.concurrent.CopyOnWriteArraySet;
31 import java.util.concurrent.Future;
32 import java.util.concurrent.TimeUnit;
33 import java.util.stream.Collectors;
34 import java.util.stream.Stream;
36 import org.eclipse.jdt.annotation.NonNullByDefault;
37 import org.eclipse.jdt.annotation.Nullable;
38 import org.openhab.binding.heos.internal.HeosChannelHandlerFactory;
39 import org.openhab.binding.heos.internal.HeosChannelManager;
40 import org.openhab.binding.heos.internal.action.HeosActions;
41 import org.openhab.binding.heos.internal.api.HeosFacade;
42 import org.openhab.binding.heos.internal.api.HeosSystem;
43 import org.openhab.binding.heos.internal.configuration.BridgeConfiguration;
44 import org.openhab.binding.heos.internal.discovery.HeosPlayerDiscoveryListener;
45 import org.openhab.binding.heos.internal.exception.HeosNotConnectedException;
46 import org.openhab.binding.heos.internal.exception.HeosNotFoundException;
47 import org.openhab.binding.heos.internal.json.dto.HeosError;
48 import org.openhab.binding.heos.internal.json.dto.HeosEvent;
49 import org.openhab.binding.heos.internal.json.dto.HeosEventObject;
50 import org.openhab.binding.heos.internal.json.dto.HeosResponseObject;
51 import org.openhab.binding.heos.internal.json.payload.Group;
52 import org.openhab.binding.heos.internal.json.payload.Media;
53 import org.openhab.binding.heos.internal.json.payload.Player;
54 import org.openhab.binding.heos.internal.resources.HeosEventListener;
55 import org.openhab.binding.heos.internal.resources.HeosMediaEventListener;
56 import org.openhab.binding.heos.internal.resources.Telnet;
57 import org.openhab.binding.heos.internal.resources.Telnet.ReadException;
58 import org.openhab.core.library.types.OnOffType;
59 import org.openhab.core.thing.Bridge;
60 import org.openhab.core.thing.Channel;
61 import org.openhab.core.thing.ChannelUID;
62 import org.openhab.core.thing.Thing;
63 import org.openhab.core.thing.ThingStatus;
64 import org.openhab.core.thing.ThingStatusDetail;
65 import org.openhab.core.thing.binding.BaseBridgeHandler;
66 import org.openhab.core.thing.binding.ThingHandler;
67 import org.openhab.core.thing.binding.ThingHandlerService;
68 import org.openhab.core.thing.binding.builder.ChannelBuilder;
69 import org.openhab.core.thing.binding.builder.ThingBuilder;
70 import org.openhab.core.thing.type.ChannelTypeUID;
71 import org.openhab.core.types.Command;
72 import org.openhab.core.types.RefreshType;
73 import org.slf4j.Logger;
74 import org.slf4j.LoggerFactory;
77 * The {@link HeosBridgeHandler} is responsible for handling commands, which are
78 * sent to one of the channels.
80 * @author Johannes Einig - Initial contribution
81 * @author Martin van Wingerden - change handling of stop/pause depending on playing item type
84 public class HeosBridgeHandler extends BaseBridgeHandler implements HeosEventListener {
85 private final Logger logger = LoggerFactory.getLogger(HeosBridgeHandler.class);
87 private static final int HEOS_PORT = 1255;
89 private final Set<HeosMediaEventListener> heosMediaEventListeners = new CopyOnWriteArraySet<>();
90 private final List<HeosPlayerDiscoveryListener> playerDiscoveryList = new CopyOnWriteArrayList<>();
91 private final HeosChannelManager channelManager = new HeosChannelManager(this);
92 private final HeosChannelHandlerFactory channelHandlerFactory;
94 private final Map<String, HeosGroupHandler> groupHandlerMap = new ConcurrentHashMap<>();
95 private final Map<String, String> hashToGidMap = new ConcurrentHashMap<>();
97 private List<String[]> selectedPlayerList = new CopyOnWriteArrayList<>();
99 private @Nullable Future<?> startupFuture;
100 private final List<Future<?>> childHandlerInitializedFutures = new CopyOnWriteArrayList<>();
102 private final HeosSystem heosSystem;
103 private @Nullable HeosFacade apiConnection;
105 private boolean loggedIn = false;
106 private boolean bridgeHandlerDisposalOngoing = false;
108 private @NonNullByDefault({}) BridgeConfiguration configuration;
110 private int failureCount;
112 public HeosBridgeHandler(Bridge bridge, HeosDynamicStateDescriptionProvider heosDynamicStateDescriptionProvider) {
114 heosSystem = new HeosSystem(scheduler);
115 channelHandlerFactory = new HeosChannelHandlerFactory(this, heosDynamicStateDescriptionProvider);
119 public void handleCommand(ChannelUID channelUID, Command command) {
120 if (command instanceof RefreshType) {
124 Channel channel = this.getThing().getChannel(channelUID.getId());
125 if (channel == null) {
126 logger.debug("No valid channel found");
131 ChannelTypeUID channelTypeUID = channel.getChannelTypeUID();
133 HeosChannelHandler channelHandler = channelHandlerFactory.getChannelHandler(channelUID, this, channelTypeUID);
134 if (channelHandler != null) {
136 channelHandler.handleBridgeCommand(command, thing.getUID());
138 updateStatus(ONLINE);
139 } catch (IOException | ReadException e) {
140 logger.debug("Failed to handle bridge command", e);
143 if (failureCount > FAILURE_COUNT_LIMIT) {
144 updateStatus(OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
145 "Failed to handle command: " + e.getMessage());
152 public synchronized void initialize() {
153 configuration = thing.getConfiguration().as(BridgeConfiguration.class);
154 cancel(startupFuture);
155 startupFuture = scheduler.submit(this::delayedInitialize);
158 private void delayedInitialize() {
160 HeosFacade connection = null;
162 logger.debug("Running scheduledStartUp job");
164 connection = connectBridge();
165 updateStatus(ThingStatus.ONLINE);
166 updateState(CH_ID_REBOOT, OnOffType.OFF);
168 logger.debug("HEOS System heart beat started. Pulse time is {}s", configuration.heartbeat);
169 // gets all available player and groups to ensure that the system knows
170 // about the conjunction between the groupMemberHash and the GID
171 triggerPlayerDiscovery();
173 String username = configuration.username;
175 String password = configuration.password;
176 if (username != null && !"".equals(username) && password != null && !"".equals(password)) {
177 login(connection, username, password);
179 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.CONFIGURATION_ERROR,
180 "Can't log in. Username or password not set.");
183 fetchPlayersAndGroups();
184 } catch (Telnet.ReadException | IOException | RuntimeException e) {
185 logger.debug("Error occurred while connecting", e);
186 if (connection != null) {
187 connection.closeConnection();
189 updateStatus(OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Errors occurred: " + e.getMessage());
190 cancel(startupFuture, false);
191 startupFuture = scheduler.schedule(this::delayedInitialize, 30, TimeUnit.SECONDS);
195 private void fetchPlayersAndGroups() {
198 Player[] onlinePlayers = getApiConnection().getPlayers().payload;
200 Group[] onlineGroups = getApiConnection().getGroups().payload;
202 if (onlinePlayers != null && onlineGroups != null) {
203 updatePlayerStatus(onlinePlayers, onlineGroups);
205 } catch (ReadException | IOException e) {
206 logger.debug("Failed updating online state of groups/players", e);
210 private void updatePlayerStatus(@Nullable Player[] onlinePlayers, @Nullable Group[] onlineGroups) {
211 if (onlinePlayers == null || onlineGroups == null) {
214 Set<String> players = Stream.of(onlinePlayers).map(p -> Objects.toString(p.playerId))
215 .collect(Collectors.toSet());
216 Set<String> groups = Stream.of(onlineGroups).map(p -> p.id).collect(Collectors.toSet());
218 for (Thing thing : getThing().getThings()) {
221 ThingHandler handler = thing.getHandler();
222 if (handler instanceof HeosThingBaseHandler heosHandler) {
223 Set<String> target = handler instanceof HeosPlayerHandler ? players : groups;
224 String id = heosHandler.getId();
226 if (target.contains(id)) {
227 heosHandler.setStatusOnline();
229 heosHandler.setStatusOffline();
232 } catch (HeosNotFoundException e) {
233 logger.debug("SKipping handler which reported not found", e);
238 private HeosFacade connectBridge() throws IOException, Telnet.ReadException {
241 logger.debug("Initialize Bridge '{}' with IP '{}'", thing.getProperties().get(PROP_NAME),
242 configuration.ipAddress);
243 bridgeHandlerDisposalOngoing = false;
244 HeosFacade connection = heosSystem.establishConnection(configuration.ipAddress, HEOS_PORT,
245 configuration.heartbeat);
246 connection.registerForChangeEvents(this);
248 apiConnection = connection;
253 private void login(HeosFacade connection, String username, String password) throws IOException, ReadException {
254 logger.debug("Logging in to HEOS account.");
255 HeosResponseObject<Void> response = connection.logIn(username, password);
257 if (response.result) {
258 logger.debug("successfully logged-in, event is fired to handle post-login behaviour");
263 HeosError error = response.getError();
264 logger.debug("Failed to login: {}", error);
265 updateStatus(ONLINE, ThingStatusDetail.CONFIGURATION_ERROR,
266 error != null ? error.code.toString() : "Failed to login, no error was returned.");
270 public void dispose() {
271 bridgeHandlerDisposalOngoing = true; // Flag to prevent the handler from being updated during disposal
273 cancel(startupFuture);
274 for (Future<?> future : childHandlerInitializedFutures) {
279 HeosFacade localApiConnection = apiConnection;
280 if (localApiConnection == null) {
281 logger.debug("Not disposing bridge because of missing apiConnection");
285 localApiConnection.unregisterForChangeEvents(this);
286 logger.debug("HEOS bridge removed from change notifications");
288 logger.debug("Dispose bridge '{}'", thing.getProperties().get(PROP_NAME));
289 localApiConnection.closeConnection();
293 * Manages the removal of the player or group channels from the bridge.
296 public synchronized void childHandlerDisposed(ThingHandler childHandler, Thing childThing) {
297 logger.debug("Disposing child handler for: {}.", childThing.getUID().getId());
298 if (bridgeHandlerDisposalOngoing) { // Checks if bridgeHandler is going to disposed (by stopping the binding or
299 // openHAB for example) and prevents it from being updated which stops the
301 } else if (childHandler instanceof HeosPlayerHandler) {
302 String channelIdentifier = "P" + childThing.getUID().getId();
303 updateThingChannels(channelManager.removeSingleChannel(channelIdentifier));
304 } else if (childHandler instanceof HeosGroupHandler groupHandler) {
305 String channelIdentifier = "G" + childThing.getUID().getId();
306 updateThingChannels(channelManager.removeSingleChannel(channelIdentifier));
307 // removes the handler from the groupMemberMap that handler is no longer called
308 // if group is getting online
309 removeGroupHandlerInformation(groupHandler);
314 public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) {
315 logger.debug("Initialized child handler for: {}.", childThing.getUID().getId());
316 childHandlerInitializedFutures.add(scheduler.submit(() -> addPlayerChannel(childThing, null)));
319 void resetPlayerList(ChannelUID channelUID) {
320 selectedPlayerList.forEach(element -> updateState(element[1], OnOffType.OFF));
321 selectedPlayerList.clear();
322 updateState(channelUID, OnOffType.OFF);
326 * Sets the HEOS Thing offline
328 @SuppressWarnings("null")
329 public void setGroupOffline(String groupMemberHash) {
330 HeosGroupHandler groupHandler = groupHandlerMap.get(groupMemberHash);
331 if (groupHandler != null) {
332 groupHandler.setStatusOffline();
334 hashToGidMap.remove(groupMemberHash);
338 * Sets the HEOS Thing online. Also updates the link between
339 * the groupMemberHash value with the actual gid of this group
341 public void setGroupOnline(String groupMemberHash, String groupId) {
342 hashToGidMap.put(groupMemberHash, groupId);
343 Optional.ofNullable(groupHandlerMap.get(groupMemberHash)).ifPresent(handler -> {
344 handler.setStatusOnline();
345 addPlayerChannel(handler.getThing(), groupId);
350 * Create a channel for the childThing. Depending if it is a HEOS Group
351 * or a player an identification prefix is added
353 * @param childThing the thing the channel is created for
356 private void addPlayerChannel(Thing childThing, @Nullable String groupId) {
358 String channelIdentifier = "";
361 ThingHandler handler = childThing.getHandler();
362 if (handler instanceof HeosPlayerHandler playerHandler) {
363 channelIdentifier = "P" + childThing.getUID().getId();
364 pid = playerHandler.getId();
365 } else if (handler instanceof HeosGroupHandler groupHandler) {
366 channelIdentifier = "G" + childThing.getUID().getId();
367 if (groupId == null) {
368 pid = groupHandler.getId();
373 Map<String, String> properties = new HashMap<>();
375 String playerName = childThing.getLabel();
376 playerName = playerName == null ? pid : playerName;
377 ChannelUID channelUID = new ChannelUID(getThing().getUID(), channelIdentifier);
378 properties.put(PROP_NAME, playerName);
379 properties.put(PID, pid);
381 Channel channel = ChannelBuilder.create(channelUID, "Switch").withLabel(playerName).withType(CH_TYPE_PLAYER)
382 .withProperties(properties).build();
383 updateThingChannels(channelManager.addSingleChannel(channel));
384 } catch (HeosNotFoundException e) {
385 logger.debug("Group is not yet initialized fully");
389 public void addGroupHandlerInformation(HeosGroupHandler handler) {
390 groupHandlerMap.put(handler.getGroupMemberHash(), handler);
393 private void removeGroupHandlerInformation(HeosGroupHandler handler) {
394 groupHandlerMap.remove(handler.getGroupMemberHash());
397 public @Nullable String getActualGID(String groupHash) {
398 return hashToGidMap.get(groupHash);
402 public void playerStateChangeEvent(HeosEventObject eventObject) {
407 public void playerStateChangeEvent(HeosResponseObject<?> responseObject) {
412 public void playerMediaChangeEvent(String pid, Media media) {
413 heosMediaEventListeners.forEach(element -> element.playerMediaChangeEvent(pid, media));
417 public void bridgeChangeEvent(String event, boolean success, Object command) {
418 if (EVENT_TYPE_EVENT.equals(event)) {
419 if (HeosEvent.PLAYERS_CHANGED.equals(command) || HeosEvent.GROUPS_CHANGED.equals(command)) {
420 fetchPlayersAndGroups();
421 triggerPlayerDiscovery();
422 } else if (EVENT_STREAM_TIMEOUT.equals(command)) {
423 logger.debug("HEOS Bridge events timed-out might be nothing, trying to reconnect");
424 } else if (CONNECTION_LOST.equals(command)) {
425 updateStatus(OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
426 logger.debug("HEOS Bridge OFFLINE");
427 } else if (CONNECTION_RESTORED.equals(command)) {
431 if (EVENT_TYPE_SYSTEM.equals(event) && HeosEvent.USER_CHANGED == command) {
432 if (success && !loggedIn) {
438 private synchronized void updateThingChannels(List<Channel> channelList) {
439 ThingBuilder thingBuilder = editThing();
440 thingBuilder.withChannels(channelList);
441 updateThing(thingBuilder.build());
444 public Player[] getPlayers() throws IOException, ReadException {
445 HeosResponseObject<Player[]> response = getApiConnection().getPlayers();
447 Player[] players = response.payload;
448 if (players == null) {
449 throw new IOException("Received no valid payload");
454 public Group[] getGroups() throws IOException, ReadException {
455 HeosResponseObject<Group[]> response = getApiConnection().getGroups();
457 Group[] groups = response.payload;
458 if (groups == null) {
459 throw new IOException("Received no valid payload");
465 * The list with the currently selected player
467 * @return a HashMap which the currently selected player
469 public Map<String, String> getSelectedPlayer() {
470 return selectedPlayerList.stream().collect(Collectors.toMap(a -> a[0], a -> a[1], (a, b) -> a));
473 public List<String[]> getSelectedPlayerList() {
474 return selectedPlayerList;
477 public void setSelectedPlayerList(List<String[]> selectedPlayerList) {
478 this.selectedPlayerList = selectedPlayerList;
481 public HeosChannelHandlerFactory getChannelHandlerFactory() {
482 return channelHandlerFactory;
486 * Register an {@link HeosPlayerDiscoveryListener} to get informed
487 * if the amount of groups or players have changed
489 * @param listener the implementing class
491 public void registerPlayerDiscoverListener(HeosPlayerDiscoveryListener listener) {
492 playerDiscoveryList.add(listener);
495 private void triggerPlayerDiscovery() {
496 playerDiscoveryList.forEach(HeosPlayerDiscoveryListener::playerChanged);
499 public boolean isLoggedIn() {
503 public boolean isBridgeConnected() {
505 HeosFacade connection = apiConnection;
506 return connection != null && connection.isConnected();
509 public HeosFacade getApiConnection() throws HeosNotConnectedException {
511 HeosFacade localApiConnection = apiConnection;
512 if (localApiConnection != null) {
513 return localApiConnection;
515 throw new HeosNotConnectedException();
520 public Collection<Class<? extends ThingHandlerService>> getServices() {
521 return List.of(HeosActions.class);
524 public void registerMediaEventListener(HeosMediaEventListener heosMediaEventListener) {
525 heosMediaEventListeners.add(heosMediaEventListener);