]> git.basschouten.com Git - openhab-addons.git/blob
a37caa68dadc007c0c1620d859d89a7fab897876
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 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
282             case PLAYER_STATE_CHANGED:
283                 playerStateChanged(eventObject);
284                 break;
285
286             case PLAYER_VOLUME_CHANGED:
287             case GROUP_VOLUME_CHANGED:
288                 @Nullable
289                 String level = eventObject.getAttribute(LEVEL);
290                 if (level != null) {
291                     notificationVolume = level;
292                     updateState(CH_ID_VOLUME, PercentType.valueOf(level));
293                     updateState(CH_ID_MUTE, OnOffType.from(eventObject.getBooleanAttribute(MUTE)));
294                 }
295                 break;
296
297             case SHUFFLE_MODE_CHANGED:
298                 handleShuffleMode(eventObject);
299                 break;
300
301             case PLAYER_NOW_PLAYING_PROGRESS:
302                 @Nullable
303                 Long position = eventObject.getNumericAttribute(CURRENT_POSITION);
304                 @Nullable
305                 Long duration = eventObject.getNumericAttribute(DURATION);
306                 if (position != null && duration != null) {
307                     updateState(CH_ID_CUR_POS, quantityFromMilliSeconds(position));
308                     updateState(CH_ID_DURATION, quantityFromMilliSeconds(duration));
309                 }
310                 break;
311
312             case REPEAT_MODE_CHANGED:
313                 handleRepeatMode(eventObject);
314                 break;
315
316             case PLAYER_PLAYBACK_ERROR:
317                 updateStatus(UNKNOWN, ThingStatusDetail.NONE, eventObject.getAttribute(ERROR));
318                 break;
319
320             case PLAYER_QUEUE_CHANGED:
321                 fetchQueueFromPlayer();
322                 break;
323
324             case SOURCES_CHANGED:
325                 // we are not yet handling the actual sources, although we might want to do that in the future
326                 logger.trace("Ignoring {}, support might be added in the future", command);
327                 break;
328
329             case GROUPS_CHANGED:
330             case PLAYERS_CHANGED:
331             case PLAYER_NOW_PLAYING_CHANGED:
332             case USER_CHANGED:
333                 logger.trace("Ignoring {}, will be handled inside HeosEventController", command);
334                 break;
335         }
336     }
337
338     private QuantityType<Time> quantityFromMilliSeconds(long position) {
339         return new QuantityType<>(position / 1000, Units.SECOND);
340     }
341
342     private void handleShuffleMode(HeosObject eventObject) {
343         updateState(CH_ID_SHUFFLE_MODE,
344                 OnOffType.from(eventObject.getBooleanAttribute(HeosCommunicationAttribute.SHUFFLE)));
345     }
346
347     void refreshPlayState(String id) throws IOException, ReadException {
348         handleThingStateUpdate(getApiConnection().getPlayMode(id));
349         handleThingStateUpdate(getApiConnection().getPlayState(id));
350         handleThingStateUpdate(getApiConnection().getNowPlayingMedia(id));
351     }
352
353     protected <T> void handleThingStateUpdate(HeosResponseObject<T> responseObject) throws HeosFunctionalException {
354         handleResponseError(responseObject);
355
356         @Nullable
357         HeosCommandTuple cmd = responseObject.heosCommand;
358
359         if (cmd == null) {
360             logger.debug("Ignoring response with null command");
361             return;
362         }
363
364         if (cmd.commandGroup == PLAYER || cmd.commandGroup == GROUP) {
365             switch (cmd.command) {
366                 case GET_PLAY_STATE:
367                     playerStateChanged(responseObject);
368                     break;
369
370                 case GET_MUTE:
371                     updateState(CH_ID_MUTE, OnOffType.from(responseObject.getBooleanAttribute(MUTE)));
372                     break;
373
374                 case GET_VOLUME:
375                     @Nullable
376                     String level = responseObject.getAttribute(LEVEL);
377                     if (level != null) {
378                         notificationVolume = level;
379                         updateState(CH_ID_VOLUME, PercentType.valueOf(level));
380                     }
381                     break;
382
383                 case GET_PLAY_MODE:
384                     handleRepeatMode(responseObject);
385                     handleShuffleMode(responseObject);
386                     break;
387
388                 case GET_NOW_PLAYING_MEDIA:
389                     @Nullable
390                     T mediaPayload = responseObject.payload;
391                     if (mediaPayload instanceof Media) {
392                         handleThingMediaUpdate((Media) mediaPayload);
393                     }
394                     break;
395
396                 case GET_PLAYER_INFO:
397                     @Nullable
398                     T playerPayload = responseObject.payload;
399                     if (playerPayload instanceof Player) {
400                         handlePlayerInfo((Player) playerPayload);
401                     }
402                     break;
403             }
404         }
405     }
406
407     private <T> void handleResponseError(HeosResponseObject<T> responseObject) throws HeosFunctionalException {
408         @Nullable
409         HeosError error = responseObject.getError();
410         if (error != null) {
411             throw new HeosFunctionalException(error.code);
412         }
413     }
414
415     private void handleRepeatMode(HeosObject eventObject) {
416         @Nullable
417         String repeatMode = eventObject.getAttribute(REPEAT);
418         if (repeatMode == null) {
419             updateState(CH_ID_REPEAT_MODE, UnDefType.NULL);
420             return;
421         }
422
423         switch (repeatMode) {
424             case REPEAT_ALL:
425                 updateState(CH_ID_REPEAT_MODE, StringType.valueOf(HEOS_UI_ALL));
426                 break;
427
428             case REPEAT_ONE:
429                 updateState(CH_ID_REPEAT_MODE, StringType.valueOf(HEOS_UI_ONE));
430                 break;
431
432             case OFF:
433                 updateState(CH_ID_REPEAT_MODE, StringType.valueOf(HEOS_UI_OFF));
434                 break;
435         }
436     }
437
438     private void playerStateChanged(HeosObject eventObject) {
439         @Nullable
440         String attribute = eventObject.getAttribute(STATE);
441         if (attribute == null) {
442             updateState(CH_ID_CONTROL, UnDefType.NULL);
443             return;
444         }
445         switch (attribute) {
446             case PLAY:
447                 updateState(CH_ID_CONTROL, PlayPauseType.PLAY);
448                 break;
449             case PAUSE:
450             case STOP:
451                 updateState(CH_ID_CONTROL, PlayPauseType.PAUSE);
452                 break;
453         }
454     }
455
456     private synchronized void fetchQueueFromPlayer() {
457         try {
458             List<Media> queue = getApiConnection().getQueue(getId());
459             heosDynamicStateDescriptionProvider.setQueue(queueChannelUID, queue);
460             return;
461         } catch (HeosNotFoundException e) {
462             logger.debug("HEOS player/group is not found, rescheduling");
463         } catch (IOException | ReadException e) {
464             logger.debug("Failed to set queue, rescheduling", e);
465         }
466         cancel(scheduleQueueFetchFuture, false);
467         scheduleQueueFetchFuture = scheduler.schedule(this::fetchQueueFromPlayer, 30, TimeUnit.SECONDS);
468     }
469
470     protected void handleThingMediaUpdate(Media info) {
471         logger.debug("Received updated media state: {}", info);
472
473         updateState(CH_ID_SONG, StringType.valueOf(info.song));
474         updateState(CH_ID_ARTIST, StringType.valueOf(info.artist));
475         updateState(CH_ID_ALBUM, StringType.valueOf(info.album));
476         if (SONG.equals(info.type)) {
477             updateState(CH_ID_QUEUE, StringType.valueOf(String.valueOf(info.queueId)));
478             updateState(CH_ID_FAVORITES, UnDefType.UNDEF);
479         } else if (STATION.equals(info.type)) {
480             updateState(CH_ID_QUEUE, UnDefType.UNDEF);
481             updateState(CH_ID_FAVORITES, StringType.valueOf(info.albumId));
482         } else {
483             updateState(CH_ID_QUEUE, UnDefType.UNDEF);
484             updateState(CH_ID_FAVORITES, UnDefType.UNDEF);
485         }
486         handleImageUrl(info);
487         handleStation(info);
488         handleSourceId(info);
489     }
490
491     private void handleImageUrl(Media info) {
492         String imageUrl = info.imageUrl;
493         if (imageUrl != null && !imageUrl.isBlank()) {
494             try {
495                 URL url = new URL(imageUrl); // checks if String is proper URL
496                 RawType cover = HttpUtil.downloadImage(url.toString());
497                 if (cover != null) {
498                     updateState(CH_ID_COVER, cover);
499                     return;
500                 }
501             } catch (MalformedURLException e) {
502                 logger.debug("Cover can't be loaded. No proper URL: {}", imageUrl, e);
503             }
504         }
505         updateState(CH_ID_COVER, UnDefType.NULL);
506     }
507
508     private void handleStation(Media info) {
509         if (STATION.equals(info.type)) {
510             updateState(CH_ID_STATION, StringType.valueOf(info.station));
511         } else {
512             updateState(CH_ID_STATION, UnDefType.UNDEF);
513         }
514     }
515
516     private void handleSourceId(Media info) {
517         if (info.sourceId == INPUT_SID && info.mediaId != null) {
518             String inputName = info.mediaId.substring(info.mediaId.indexOf("/") + 1);
519             updateState(CH_ID_INPUTS, StringType.valueOf(inputName));
520             updateState(CH_ID_TYPE, StringType.valueOf(info.station));
521         } else {
522             updateState(CH_ID_TYPE, StringType.valueOf(info.type));
523             updateState(CH_ID_INPUTS, UnDefType.UNDEF);
524         }
525     }
526
527     private void handlePlayerInfo(Player player) {
528         Map<String, String> prop = new HashMap<>();
529         HeosPlayerHandler.propertiesFromPlayer(prop, player);
530         updateProperties(prop);
531     }
532 }