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