]> git.basschouten.com Git - openhab-addons.git/blob
8dea3c4a9199721726f5d3707ce430b03f47fec0
[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.binding.heos.internal.json.dto.HeosCommandGroup.*;
18 import static org.openhab.binding.heos.internal.json.dto.HeosCommunicationAttribute.*;
19 import static org.openhab.binding.heos.internal.resources.HeosConstants.*;
20 import static org.openhab.core.thing.ThingStatus.*;
21
22 import java.io.IOException;
23 import java.net.MalformedURLException;
24 import java.net.URL;
25 import java.util.HashMap;
26 import java.util.List;
27 import java.util.Map;
28 import java.util.concurrent.Future;
29 import java.util.concurrent.TimeUnit;
30
31 import javax.measure.quantity.Time;
32
33 import org.eclipse.jdt.annotation.NonNullByDefault;
34 import org.eclipse.jdt.annotation.Nullable;
35 import org.openhab.binding.heos.internal.HeosChannelHandlerFactory;
36 import org.openhab.binding.heos.internal.api.HeosFacade;
37 import org.openhab.binding.heos.internal.exception.HeosFunctionalException;
38 import org.openhab.binding.heos.internal.exception.HeosNotConnectedException;
39 import org.openhab.binding.heos.internal.exception.HeosNotFoundException;
40 import org.openhab.binding.heos.internal.json.dto.HeosCommandTuple;
41 import org.openhab.binding.heos.internal.json.dto.HeosCommunicationAttribute;
42 import org.openhab.binding.heos.internal.json.dto.HeosError;
43 import org.openhab.binding.heos.internal.json.dto.HeosEvent;
44 import org.openhab.binding.heos.internal.json.dto.HeosEventObject;
45 import org.openhab.binding.heos.internal.json.dto.HeosObject;
46 import org.openhab.binding.heos.internal.json.dto.HeosResponseObject;
47 import org.openhab.binding.heos.internal.json.payload.Media;
48 import org.openhab.binding.heos.internal.json.payload.Player;
49 import org.openhab.binding.heos.internal.resources.HeosEventListener;
50 import org.openhab.binding.heos.internal.resources.Telnet.ReadException;
51 import org.openhab.core.io.net.http.HttpUtil;
52 import org.openhab.core.library.types.OnOffType;
53 import org.openhab.core.library.types.PercentType;
54 import org.openhab.core.library.types.PlayPauseType;
55 import org.openhab.core.library.types.QuantityType;
56 import org.openhab.core.library.types.RawType;
57 import org.openhab.core.library.types.StringType;
58 import org.openhab.core.library.unit.Units;
59 import org.openhab.core.thing.Bridge;
60 import org.openhab.core.thing.ChannelUID;
61 import org.openhab.core.thing.Thing;
62 import org.openhab.core.thing.ThingStatus;
63 import org.openhab.core.thing.ThingStatusDetail;
64 import org.openhab.core.thing.ThingStatusInfo;
65 import org.openhab.core.thing.binding.BaseThingHandler;
66 import org.openhab.core.types.UnDefType;
67 import org.slf4j.Logger;
68 import org.slf4j.LoggerFactory;
69
70 /**
71  * The {@link HeosThingBaseHandler} class is the base Class all HEOS handler have to extend.
72  * It provides basic command handling and common needed methods.
73  *
74  * @author Johannes Einig - Initial contribution
75  */
76 @NonNullByDefault
77 public abstract class HeosThingBaseHandler extends BaseThingHandler implements HeosEventListener {
78     private final Logger logger = LoggerFactory.getLogger(HeosThingBaseHandler.class);
79     private final HeosDynamicStateDescriptionProvider heosDynamicStateDescriptionProvider;
80     private final ChannelUID favoritesChannelUID;
81     private final ChannelUID playlistsChannelUID;
82     private final ChannelUID queueChannelUID;
83
84     private @Nullable HeosChannelHandlerFactory channelHandlerFactory;
85     protected @Nullable HeosBridgeHandler bridgeHandler;
86
87     private String notificationVolume = "0";
88
89     private int failureCount;
90     private @Nullable Future<?> scheduleQueueFetchFuture;
91     private @Nullable Future<?> handleDynamicStatesFuture;
92
93     HeosThingBaseHandler(Thing thing, HeosDynamicStateDescriptionProvider heosDynamicStateDescriptionProvider) {
94         super(thing);
95         this.heosDynamicStateDescriptionProvider = heosDynamicStateDescriptionProvider;
96         favoritesChannelUID = new ChannelUID(thing.getUID(), CH_ID_FAVORITES);
97         playlistsChannelUID = new ChannelUID(thing.getUID(), CH_ID_PLAYLISTS);
98         queueChannelUID = new ChannelUID(thing.getUID(), CH_ID_QUEUE);
99     }
100
101     @Override
102     public void initialize() {
103         @Nullable
104         Bridge bridge = getBridge();
105         @Nullable
106         HeosBridgeHandler localBridgeHandler;
107         if (bridge != null) {
108             localBridgeHandler = (HeosBridgeHandler) bridge.getHandler();
109             if (localBridgeHandler != null) {
110                 bridgeHandler = localBridgeHandler;
111                 channelHandlerFactory = localBridgeHandler.getChannelHandlerFactory();
112             } else {
113                 updateStatus(OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
114                 return;
115             }
116         } else {
117             logger.warn("No Bridge set within child handler");
118             updateStatus(OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
119             return;
120         }
121
122         try {
123             getApiConnection().registerForChangeEvents(this);
124             cancel(scheduleQueueFetchFuture);
125             scheduleQueueFetchFuture = scheduler.submit(this::fetchQueueFromPlayer);
126
127             if (localBridgeHandler.isLoggedIn()) {
128                 scheduleImmediatelyHandleDynamicStatesSignedIn();
129             }
130         } catch (HeosNotConnectedException e) {
131             updateStatus(OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
132         }
133     }
134
135     void handleSuccess() {
136         failureCount = 0;
137         updateStatus(ONLINE);
138     }
139
140     void handleError(Exception e) {
141         logger.debug("Failed to handle player/group command", e);
142         failureCount++;
143
144         if (failureCount > FAILURE_COUNT_LIMIT) {
145             updateStatus(OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Failed to handle command: " + e.getMessage());
146         }
147     }
148
149     public HeosFacade getApiConnection() throws HeosNotConnectedException {
150         @Nullable
151         HeosBridgeHandler localBridge = bridgeHandler;
152         if (localBridge != null) {
153             return localBridge.getApiConnection();
154         }
155         throw new HeosNotConnectedException();
156     }
157
158     public abstract String getId() throws HeosNotFoundException;
159
160     public abstract void setStatusOffline();
161
162     public abstract void setStatusOnline();
163
164     public PercentType getNotificationSoundVolume() {
165         return PercentType.valueOf(notificationVolume);
166     }
167
168     public void setNotificationSoundVolume(PercentType volume) {
169         notificationVolume = volume.toString();
170     }
171
172     @Nullable
173     HeosChannelHandler getHeosChannelHandler(ChannelUID channelUID) {
174         @Nullable
175         HeosChannelHandlerFactory localChannelHandlerFactory = this.channelHandlerFactory;
176         return localChannelHandlerFactory != null ? localChannelHandlerFactory.getChannelHandler(channelUID, this, null)
177                 : null;
178     }
179
180     @Override
181     public void bridgeChangeEvent(String event, boolean success, Object command) {
182         logger.debug("BridgeChangeEvent: {}", command);
183         if (HeosEvent.USER_CHANGED == command) {
184             handleDynamicStatesSignedIn();
185         }
186
187         if (EVENT_TYPE_EVENT.equals(event)) {
188             if (HeosEvent.GROUPS_CHANGED == command) {
189                 fetchQueueFromPlayer();
190             } else if (CONNECTION_RESTORED.equals(command)) {
191                 try {
192                     refreshPlayState(getId());
193                 } catch (IOException | ReadException e) {
194                     logger.debug("Failed to refreshPlayState", e);
195                 }
196             }
197         }
198     }
199
200     void scheduleImmediatelyHandleDynamicStatesSignedIn() {
201         cancel(handleDynamicStatesFuture);
202         handleDynamicStatesFuture = scheduler.submit(this::handleDynamicStatesSignedIn);
203     }
204
205     void handleDynamicStatesSignedIn() {
206         try {
207             heosDynamicStateDescriptionProvider.setFavorites(favoritesChannelUID, getApiConnection().getFavorites());
208             heosDynamicStateDescriptionProvider.setPlaylists(playlistsChannelUID, getApiConnection().getPlaylists());
209         } catch (IOException | ReadException e) {
210             logger.debug("Failed to set favorites / playlists, rescheduling", e);
211             cancel(handleDynamicStatesFuture, false);
212             handleDynamicStatesFuture = scheduler.schedule(this::handleDynamicStatesSignedIn, 30, TimeUnit.SECONDS);
213         }
214     }
215
216     @Override
217     public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
218         if (ThingStatus.OFFLINE.equals(bridgeStatusInfo.getStatus())) {
219             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
220         } else if (ThingStatus.ONLINE.equals(bridgeStatusInfo.getStatus())) {
221             updateStatus(ThingStatus.ONLINE);
222         } else if (ThingStatus.UNINITIALIZED.equals(bridgeStatusInfo.getStatus())) {
223             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED);
224         }
225     }
226
227     /**
228      * Dispose the handler and unregister the handler
229      * form Change Events
230      */
231     @Override
232     public void dispose() {
233         try {
234             logger.debug("Disposing this: {}", this);
235             getApiConnection().unregisterForChangeEvents(this);
236         } catch (HeosNotConnectedException e) {
237             logger.trace("No connection available while trying to unregister");
238         }
239
240         cancel(scheduleQueueFetchFuture);
241         cancel(handleDynamicStatesFuture);
242     }
243
244     /**
245      * Plays a media file from an external source. Can be
246      * used for audio sink function
247      *
248      * @param urlStr The external URL where the file is located
249      * @throws ReadException
250      * @throws IOException
251      */
252     public void playURL(String urlStr) throws IOException, ReadException {
253         try {
254             URL url = new URL(urlStr);
255             getApiConnection().playURL(getId(), url);
256         } catch (MalformedURLException e) {
257             logger.debug("Command '{}' is not a proper URL. Error: {}", urlStr, e.getMessage());
258         }
259     }
260
261     /**
262      * Handles the updates send from the HEOS system to
263      * the binding. To receive updates the handler has
264      * to register itself via {@link HeosFacade} via the method:
265      * {@link HeosFacade#registerForChangeEvents(HeosEventListener)}
266      *
267      * @param eventObject containing information about the even which was sent to us by the HEOS device
268      */
269     protected void handleThingStateUpdate(HeosEventObject eventObject) {
270         updateStatus(ONLINE, ThingStatusDetail.NONE, "Receiving events");
271
272         @Nullable
273         HeosEvent command = eventObject.command;
274
275         if (command == null) {
276             logger.debug("Ignoring event with null command");
277             return;
278         }
279
280         switch (command) {
281             case PLAYER_STATE_CHANGED:
282                 playerStateChanged(eventObject);
283                 break;
284
285             case PLAYER_VOLUME_CHANGED:
286             case GROUP_VOLUME_CHANGED:
287                 @Nullable
288                 String level = eventObject.getAttribute(LEVEL);
289                 if (level != null) {
290                     notificationVolume = level;
291                     updateState(CH_ID_VOLUME, PercentType.valueOf(level));
292                     updateState(CH_ID_MUTE, OnOffType.from(eventObject.getBooleanAttribute(MUTE)));
293                 }
294                 break;
295
296             case SHUFFLE_MODE_CHANGED:
297                 handleShuffleMode(eventObject);
298                 break;
299
300             case PLAYER_NOW_PLAYING_PROGRESS:
301                 @Nullable
302                 Long position = eventObject.getNumericAttribute(CURRENT_POSITION);
303                 @Nullable
304                 Long duration = eventObject.getNumericAttribute(DURATION);
305                 if (position != null && duration != null) {
306                     updateState(CH_ID_CUR_POS, quantityFromMilliSeconds(position));
307                     updateState(CH_ID_DURATION, quantityFromMilliSeconds(duration));
308                 }
309                 break;
310
311             case REPEAT_MODE_CHANGED:
312                 handleRepeatMode(eventObject);
313                 break;
314
315             case PLAYER_PLAYBACK_ERROR:
316                 updateStatus(UNKNOWN, ThingStatusDetail.NONE, eventObject.getAttribute(ERROR));
317                 break;
318
319             case PLAYER_QUEUE_CHANGED:
320                 fetchQueueFromPlayer();
321                 break;
322
323             case SOURCES_CHANGED:
324                 // we are not yet handling the actual sources, although we might want to do that in the future
325                 logger.trace("Ignoring {}, support might be added in the future", command);
326                 break;
327
328             case GROUPS_CHANGED:
329             case PLAYERS_CHANGED:
330             case PLAYER_NOW_PLAYING_CHANGED:
331             case USER_CHANGED:
332                 logger.trace("Ignoring {}, will be handled inside HeosEventController", command);
333                 break;
334         }
335     }
336
337     private QuantityType<Time> quantityFromMilliSeconds(long position) {
338         return new QuantityType<>(position / 1000, Units.SECOND);
339     }
340
341     private void handleShuffleMode(HeosObject eventObject) {
342         updateState(CH_ID_SHUFFLE_MODE,
343                 OnOffType.from(eventObject.getBooleanAttribute(HeosCommunicationAttribute.SHUFFLE)));
344     }
345
346     void refreshPlayState(String id) throws IOException, ReadException {
347         handleThingStateUpdate(getApiConnection().getPlayMode(id));
348         handleThingStateUpdate(getApiConnection().getPlayState(id));
349         handleThingStateUpdate(getApiConnection().getNowPlayingMedia(id));
350     }
351
352     protected <T> void handleThingStateUpdate(HeosResponseObject<T> responseObject) throws HeosFunctionalException {
353         handleResponseError(responseObject);
354
355         @Nullable
356         HeosCommandTuple cmd = responseObject.heosCommand;
357
358         if (cmd == null) {
359             logger.debug("Ignoring response with null command");
360             return;
361         }
362
363         if (cmd.commandGroup == PLAYER || cmd.commandGroup == GROUP) {
364             switch (cmd.command) {
365                 case GET_PLAY_STATE:
366                     playerStateChanged(responseObject);
367                     break;
368
369                 case GET_MUTE:
370                     updateState(CH_ID_MUTE, OnOffType.from(responseObject.getBooleanAttribute(MUTE)));
371                     break;
372
373                 case GET_VOLUME:
374                     @Nullable
375                     String level = responseObject.getAttribute(LEVEL);
376                     if (level != null) {
377                         notificationVolume = level;
378                         updateState(CH_ID_VOLUME, PercentType.valueOf(level));
379                     }
380                     break;
381
382                 case GET_PLAY_MODE:
383                     handleRepeatMode(responseObject);
384                     handleShuffleMode(responseObject);
385                     break;
386
387                 case GET_NOW_PLAYING_MEDIA:
388                     @Nullable
389                     T mediaPayload = responseObject.payload;
390                     if (mediaPayload instanceof Media) {
391                         handleThingMediaUpdate((Media) mediaPayload);
392                     }
393                     break;
394
395                 case GET_PLAYER_INFO:
396                     @Nullable
397                     T playerPayload = responseObject.payload;
398                     if (playerPayload instanceof Player) {
399                         handlePlayerInfo((Player) playerPayload);
400                     }
401                     break;
402             }
403         }
404     }
405
406     private <T> void handleResponseError(HeosResponseObject<T> responseObject) throws HeosFunctionalException {
407         @Nullable
408         HeosError error = responseObject.getError();
409         if (error != null) {
410             throw new HeosFunctionalException(error.code);
411         }
412     }
413
414     private void handleRepeatMode(HeosObject eventObject) {
415         @Nullable
416         String repeatMode = eventObject.getAttribute(REPEAT);
417         if (repeatMode == null) {
418             updateState(CH_ID_REPEAT_MODE, UnDefType.NULL);
419             return;
420         }
421
422         switch (repeatMode) {
423             case REPEAT_ALL:
424                 updateState(CH_ID_REPEAT_MODE, StringType.valueOf(HEOS_UI_ALL));
425                 break;
426
427             case REPEAT_ONE:
428                 updateState(CH_ID_REPEAT_MODE, StringType.valueOf(HEOS_UI_ONE));
429                 break;
430
431             case OFF:
432                 updateState(CH_ID_REPEAT_MODE, StringType.valueOf(HEOS_UI_OFF));
433                 break;
434         }
435     }
436
437     private void playerStateChanged(HeosObject eventObject) {
438         @Nullable
439         String attribute = eventObject.getAttribute(STATE);
440         if (attribute == null) {
441             updateState(CH_ID_CONTROL, UnDefType.NULL);
442             return;
443         }
444         switch (attribute) {
445             case PLAY:
446                 updateState(CH_ID_CONTROL, PlayPauseType.PLAY);
447                 break;
448             case PAUSE:
449             case STOP:
450                 updateState(CH_ID_CONTROL, PlayPauseType.PAUSE);
451                 break;
452         }
453     }
454
455     private synchronized void fetchQueueFromPlayer() {
456         try {
457             List<Media> queue = getApiConnection().getQueue(getId());
458             heosDynamicStateDescriptionProvider.setQueue(queueChannelUID, queue);
459             return;
460         } catch (HeosNotFoundException e) {
461             logger.debug("HEOS player/group is not found, rescheduling");
462         } catch (IOException | ReadException e) {
463             logger.debug("Failed to set queue, rescheduling", e);
464         }
465         cancel(scheduleQueueFetchFuture, false);
466         scheduleQueueFetchFuture = scheduler.schedule(this::fetchQueueFromPlayer, 30, TimeUnit.SECONDS);
467     }
468
469     protected void handleThingMediaUpdate(Media info) {
470         logger.debug("Received updated media state: {}", info);
471
472         updateState(CH_ID_SONG, StringType.valueOf(info.song));
473         updateState(CH_ID_ARTIST, StringType.valueOf(info.artist));
474         updateState(CH_ID_ALBUM, StringType.valueOf(info.album));
475         if (SONG.equals(info.type)) {
476             updateState(CH_ID_QUEUE, StringType.valueOf(String.valueOf(info.queueId)));
477             updateState(CH_ID_FAVORITES, UnDefType.UNDEF);
478         } else if (STATION.equals(info.type)) {
479             updateState(CH_ID_QUEUE, UnDefType.UNDEF);
480             updateState(CH_ID_FAVORITES, StringType.valueOf(info.albumId));
481         } else {
482             updateState(CH_ID_QUEUE, UnDefType.UNDEF);
483             updateState(CH_ID_FAVORITES, UnDefType.UNDEF);
484         }
485         handleImageUrl(info);
486         handleStation(info);
487         handleSourceId(info);
488     }
489
490     private void handleImageUrl(Media info) {
491         String imageUrl = info.imageUrl;
492         if (imageUrl != null && !imageUrl.isBlank()) {
493             try {
494                 URL url = new URL(imageUrl); // checks if String is proper URL
495                 RawType cover = HttpUtil.downloadImage(url.toString());
496                 if (cover != null) {
497                     updateState(CH_ID_COVER, cover);
498                     return;
499                 }
500             } catch (MalformedURLException e) {
501                 logger.debug("Cover can't be loaded. No proper URL: {}", imageUrl, e);
502             }
503         }
504         updateState(CH_ID_COVER, UnDefType.NULL);
505     }
506
507     private void handleStation(Media info) {
508         if (STATION.equals(info.type)) {
509             updateState(CH_ID_STATION, StringType.valueOf(info.station));
510         } else {
511             updateState(CH_ID_STATION, UnDefType.UNDEF);
512         }
513     }
514
515     private void handleSourceId(Media info) {
516         if (info.sourceId == INPUT_SID && info.mediaId != null) {
517             String inputName = info.mediaId.substring(info.mediaId.indexOf("/") + 1);
518             updateState(CH_ID_INPUTS, StringType.valueOf(inputName));
519             updateState(CH_ID_TYPE, StringType.valueOf(info.station));
520         } else {
521             updateState(CH_ID_TYPE, StringType.valueOf(info.type));
522             updateState(CH_ID_INPUTS, UnDefType.UNDEF);
523         }
524     }
525
526     private void handlePlayerInfo(Player player) {
527         Map<String, String> prop = new HashMap<>();
528         HeosPlayerHandler.propertiesFromPlayer(prop, player);
529         updateProperties(prop);
530     }
531 }