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