]> git.basschouten.com Git - openhab-addons.git/blob
bc97f0dcb96cb845947965be02cdc5a212d9f589
[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.heos.internal.handler;
14
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;
19
20 import java.io.IOException;
21 import java.util.Collection;
22 import java.util.HashMap;
23 import java.util.List;
24 import java.util.Map;
25 import java.util.Objects;
26 import java.util.Optional;
27 import java.util.Set;
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;
35
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;
75
76 /**
77  * The {@link HeosBridgeHandler} is responsible for handling commands, which are
78  * sent to one of the channels.
79  *
80  * @author Johannes Einig - Initial contribution
81  * @author Martin van Wingerden - change handling of stop/pause depending on playing item type
82  */
83 @NonNullByDefault
84 public class HeosBridgeHandler extends BaseBridgeHandler implements HeosEventListener {
85     private final Logger logger = LoggerFactory.getLogger(HeosBridgeHandler.class);
86
87     private static final int HEOS_PORT = 1255;
88
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;
93
94     private final Map<String, HeosGroupHandler> groupHandlerMap = new ConcurrentHashMap<>();
95     private final Map<String, String> hashToGidMap = new ConcurrentHashMap<>();
96
97     private List<String[]> selectedPlayerList = new CopyOnWriteArrayList<>();
98
99     private @Nullable Future<?> startupFuture;
100     private final List<Future<?>> childHandlerInitializedFutures = new CopyOnWriteArrayList<>();
101
102     private final HeosSystem heosSystem;
103     private @Nullable HeosFacade apiConnection;
104
105     private boolean loggedIn = false;
106     private boolean bridgeHandlerDisposalOngoing = false;
107
108     private @NonNullByDefault({}) BridgeConfiguration configuration;
109
110     private int failureCount;
111
112     public HeosBridgeHandler(Bridge bridge, HeosDynamicStateDescriptionProvider heosDynamicStateDescriptionProvider) {
113         super(bridge);
114         heosSystem = new HeosSystem(scheduler);
115         channelHandlerFactory = new HeosChannelHandlerFactory(this, heosDynamicStateDescriptionProvider);
116     }
117
118     @Override
119     public void handleCommand(ChannelUID channelUID, Command command) {
120         if (command instanceof RefreshType) {
121             return;
122         }
123         @Nullable
124         Channel channel = this.getThing().getChannel(channelUID.getId());
125         if (channel == null) {
126             logger.debug("No valid channel found");
127             return;
128         }
129
130         @Nullable
131         ChannelTypeUID channelTypeUID = channel.getChannelTypeUID();
132         @Nullable
133         HeosChannelHandler channelHandler = channelHandlerFactory.getChannelHandler(channelUID, this, channelTypeUID);
134         if (channelHandler != null) {
135             try {
136                 channelHandler.handleBridgeCommand(command, thing.getUID());
137                 failureCount = 0;
138                 updateStatus(ONLINE);
139             } catch (IOException | ReadException e) {
140                 logger.debug("Failed to handle bridge command", e);
141                 failureCount++;
142
143                 if (failureCount > FAILURE_COUNT_LIMIT) {
144                     updateStatus(OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
145                             "Failed to handle command: " + e.getMessage());
146                 }
147             }
148         }
149     }
150
151     @Override
152     public synchronized void initialize() {
153         configuration = thing.getConfiguration().as(BridgeConfiguration.class);
154         cancel(startupFuture);
155         startupFuture = scheduler.submit(this::delayedInitialize);
156     }
157
158     private void delayedInitialize() {
159         @Nullable
160         HeosFacade connection = null;
161         try {
162             logger.debug("Running scheduledStartUp job");
163
164             connection = connectBridge();
165             updateStatus(ThingStatus.ONLINE);
166             updateState(CH_ID_REBOOT, OnOffType.OFF);
167
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();
172             @Nullable
173             String username = configuration.username;
174             @Nullable
175             String password = configuration.password;
176             if (username != null && !"".equals(username) && password != null && !"".equals(password)) {
177                 login(connection, username, password);
178             } else {
179                 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.CONFIGURATION_ERROR,
180                         "Can't log in. Username or password not set.");
181             }
182
183             fetchPlayersAndGroups();
184         } catch (Telnet.ReadException | IOException | RuntimeException e) {
185             logger.debug("Error occurred while connecting", e);
186             if (connection != null) {
187                 connection.closeConnection();
188             }
189             updateStatus(OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Errors occurred: " + e.getMessage());
190             cancel(startupFuture, false);
191             startupFuture = scheduler.schedule(this::delayedInitialize, 30, TimeUnit.SECONDS);
192         }
193     }
194
195     private void fetchPlayersAndGroups() {
196         try {
197             @Nullable
198             Player[] onlinePlayers = getApiConnection().getPlayers().payload;
199             @Nullable
200             Group[] onlineGroups = getApiConnection().getGroups().payload;
201
202             if (onlinePlayers != null && onlineGroups != null) {
203                 updatePlayerStatus(onlinePlayers, onlineGroups);
204             }
205         } catch (ReadException | IOException e) {
206             logger.debug("Failed updating online state of groups/players", e);
207         }
208     }
209
210     private void updatePlayerStatus(@Nullable Player[] onlinePlayers, @Nullable Group[] onlineGroups) {
211         if (onlinePlayers == null || onlineGroups == null) {
212             return;
213         }
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());
217
218         for (Thing thing : getThing().getThings()) {
219             try {
220                 @Nullable
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();
225
226                     if (target.contains(id)) {
227                         heosHandler.setStatusOnline();
228                     } else {
229                         heosHandler.setStatusOffline();
230                     }
231                 }
232             } catch (HeosNotFoundException e) {
233                 logger.debug("SKipping handler which reported not found", e);
234             }
235         }
236     }
237
238     private HeosFacade connectBridge() throws IOException, Telnet.ReadException {
239         loggedIn = false;
240
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);
247
248         apiConnection = connection;
249
250         return connection;
251     }
252
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);
256
257         if (response.result) {
258             logger.debug("successfully logged-in, event is fired to handle post-login behaviour");
259             return;
260         }
261
262         @Nullable
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.");
267     }
268
269     @Override
270     public void dispose() {
271         bridgeHandlerDisposalOngoing = true; // Flag to prevent the handler from being updated during disposal
272
273         cancel(startupFuture);
274         for (Future<?> future : childHandlerInitializedFutures) {
275             cancel(future);
276         }
277
278         @Nullable
279         HeosFacade localApiConnection = apiConnection;
280         if (localApiConnection == null) {
281             logger.debug("Not disposing bridge because of missing apiConnection");
282             return;
283         }
284
285         localApiConnection.unregisterForChangeEvents(this);
286         logger.debug("HEOS bridge removed from change notifications");
287
288         logger.debug("Dispose bridge '{}'", thing.getProperties().get(PROP_NAME));
289         localApiConnection.closeConnection();
290     }
291
292     /**
293      * Manages the removal of the player or group channels from the bridge.
294      */
295     @Override
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
300             // disposal process.
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);
310         }
311     }
312
313     @Override
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)));
317     }
318
319     void resetPlayerList(ChannelUID channelUID) {
320         selectedPlayerList.forEach(element -> updateState(element[1], OnOffType.OFF));
321         selectedPlayerList.clear();
322         updateState(channelUID, OnOffType.OFF);
323     }
324
325     /**
326      * Sets the HEOS Thing offline
327      */
328     @SuppressWarnings("null")
329     public void setGroupOffline(String groupMemberHash) {
330         HeosGroupHandler groupHandler = groupHandlerMap.get(groupMemberHash);
331         if (groupHandler != null) {
332             groupHandler.setStatusOffline();
333         }
334         hashToGidMap.remove(groupMemberHash);
335     }
336
337     /**
338      * Sets the HEOS Thing online. Also updates the link between
339      * the groupMemberHash value with the actual gid of this group
340      */
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);
346         });
347     }
348
349     /**
350      * Create a channel for the childThing. Depending if it is a HEOS Group
351      * or a player an identification prefix is added
352      *
353      * @param childThing the thing the channel is created for
354      * @param groupId
355      */
356     private void addPlayerChannel(Thing childThing, @Nullable String groupId) {
357         try {
358             String channelIdentifier = "";
359             String pid = "";
360             @Nullable
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();
369                 } else {
370                     pid = groupId;
371                 }
372             }
373             Map<String, String> properties = new HashMap<>();
374             @Nullable
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);
380
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");
386         }
387     }
388
389     public void addGroupHandlerInformation(HeosGroupHandler handler) {
390         groupHandlerMap.put(handler.getGroupMemberHash(), handler);
391     }
392
393     private void removeGroupHandlerInformation(HeosGroupHandler handler) {
394         groupHandlerMap.remove(handler.getGroupMemberHash());
395     }
396
397     public @Nullable String getActualGID(String groupHash) {
398         return hashToGidMap.get(groupHash);
399     }
400
401     @Override
402     public void playerStateChangeEvent(HeosEventObject eventObject) {
403         // do nothing
404     }
405
406     @Override
407     public void playerStateChangeEvent(HeosResponseObject<?> responseObject) {
408         // do nothing
409     }
410
411     @Override
412     public void playerMediaChangeEvent(String pid, Media media) {
413         heosMediaEventListeners.forEach(element -> element.playerMediaChangeEvent(pid, media));
414     }
415
416     @Override
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)) {
428                 initialize();
429             }
430         }
431         if (EVENT_TYPE_SYSTEM.equals(event) && HeosEvent.USER_CHANGED == command) {
432             if (success && !loggedIn) {
433                 loggedIn = true;
434             }
435         }
436     }
437
438     private synchronized void updateThingChannels(List<Channel> channelList) {
439         ThingBuilder thingBuilder = editThing();
440         thingBuilder.withChannels(channelList);
441         updateThing(thingBuilder.build());
442     }
443
444     public Player[] getPlayers() throws IOException, ReadException {
445         HeosResponseObject<Player[]> response = getApiConnection().getPlayers();
446         @Nullable
447         Player[] players = response.payload;
448         if (players == null) {
449             throw new IOException("Received no valid payload");
450         }
451         return players;
452     }
453
454     public Group[] getGroups() throws IOException, ReadException {
455         HeosResponseObject<Group[]> response = getApiConnection().getGroups();
456         @Nullable
457         Group[] groups = response.payload;
458         if (groups == null) {
459             throw new IOException("Received no valid payload");
460         }
461         return groups;
462     }
463
464     /**
465      * The list with the currently selected player
466      *
467      * @return a HashMap which the currently selected player
468      */
469     public Map<String, String> getSelectedPlayer() {
470         return selectedPlayerList.stream().collect(Collectors.toMap(a -> a[0], a -> a[1], (a, b) -> a));
471     }
472
473     public List<String[]> getSelectedPlayerList() {
474         return selectedPlayerList;
475     }
476
477     public void setSelectedPlayerList(List<String[]> selectedPlayerList) {
478         this.selectedPlayerList = selectedPlayerList;
479     }
480
481     public HeosChannelHandlerFactory getChannelHandlerFactory() {
482         return channelHandlerFactory;
483     }
484
485     /**
486      * Register an {@link HeosPlayerDiscoveryListener} to get informed
487      * if the amount of groups or players have changed
488      *
489      * @param listener the implementing class
490      */
491     public void registerPlayerDiscoverListener(HeosPlayerDiscoveryListener listener) {
492         playerDiscoveryList.add(listener);
493     }
494
495     private void triggerPlayerDiscovery() {
496         playerDiscoveryList.forEach(HeosPlayerDiscoveryListener::playerChanged);
497     }
498
499     public boolean isLoggedIn() {
500         return loggedIn;
501     }
502
503     public boolean isBridgeConnected() {
504         @Nullable
505         HeosFacade connection = apiConnection;
506         return connection != null && connection.isConnected();
507     }
508
509     public HeosFacade getApiConnection() throws HeosNotConnectedException {
510         @Nullable
511         HeosFacade localApiConnection = apiConnection;
512         if (localApiConnection != null) {
513             return localApiConnection;
514         } else {
515             throw new HeosNotConnectedException();
516         }
517     }
518
519     @Override
520     public Collection<Class<? extends ThingHandlerService>> getServices() {
521         return List.of(HeosActions.class);
522     }
523
524     public void registerMediaEventListener(HeosMediaEventListener heosMediaEventListener) {
525         heosMediaEventListeners.add(heosMediaEventListener);
526     }
527 }