2 * Copyright (c) 2010-2020 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.heos.internal.handler;
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.*;
22 import java.io.IOException;
23 import java.net.MalformedURLException;
25 import java.util.HashMap;
26 import java.util.List;
28 import java.util.concurrent.Future;
29 import java.util.concurrent.TimeUnit;
31 import javax.measure.quantity.Time;
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.Units;
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;
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.
59 * @author Johannes Einig - Initial contribution
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;
69 private @Nullable HeosChannelHandlerFactory channelHandlerFactory;
70 protected @Nullable HeosBridgeHandler bridgeHandler;
72 private String notificationVolume = "0";
74 private int failureCount;
75 private @Nullable Future<?> scheduleQueueFetchFuture;
76 private @Nullable Future<?> handleDynamicStatesFuture;
78 HeosThingBaseHandler(Thing thing, HeosDynamicStateDescriptionProvider heosDynamicStateDescriptionProvider) {
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);
87 public void initialize() {
89 Bridge bridge = getBridge();
91 HeosBridgeHandler localBridgeHandler;
93 localBridgeHandler = (HeosBridgeHandler) bridge.getHandler();
94 if (localBridgeHandler != null) {
95 bridgeHandler = localBridgeHandler;
96 channelHandlerFactory = localBridgeHandler.getChannelHandlerFactory();
98 updateStatus(OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
102 logger.warn("No Bridge set within child handler");
103 updateStatus(OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
108 getApiConnection().registerForChangeEvents(this);
109 cancel(scheduleQueueFetchFuture);
110 scheduleQueueFetchFuture = scheduler.submit(this::fetchQueueFromPlayer);
112 if (localBridgeHandler.isLoggedIn()) {
113 scheduleImmediatelyHandleDynamicStatesSignedIn();
115 } catch (HeosNotConnectedException e) {
116 updateStatus(OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
120 void handleSuccess() {
122 updateStatus(ONLINE);
125 void handleError(Exception e) {
126 logger.debug("Failed to handle player/group command", e);
129 if (failureCount > FAILURE_COUNT_LIMIT) {
130 updateStatus(OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Failed to handle command: " + e.getMessage());
134 public HeosFacade getApiConnection() throws HeosNotConnectedException {
136 HeosBridgeHandler localBridge = bridgeHandler;
137 if (localBridge != null) {
138 return localBridge.getApiConnection();
140 throw new HeosNotConnectedException();
143 public abstract String getId() throws HeosNotFoundException;
145 public abstract void setStatusOffline();
147 public abstract void setStatusOnline();
149 public PercentType getNotificationSoundVolume() {
150 return PercentType.valueOf(notificationVolume);
153 public void setNotificationSoundVolume(PercentType volume) {
154 notificationVolume = volume.toString();
158 HeosChannelHandler getHeosChannelHandler(ChannelUID channelUID) {
160 HeosChannelHandlerFactory localChannelHandlerFactory = this.channelHandlerFactory;
161 return localChannelHandlerFactory != null ? localChannelHandlerFactory.getChannelHandler(channelUID, this, null)
166 public void bridgeChangeEvent(String event, boolean success, Object command) {
167 logger.debug("BridgeChangeEvent: {}", command);
168 if (HeosEvent.USER_CHANGED == command) {
169 handleDynamicStatesSignedIn();
172 if (EVENT_TYPE_EVENT.equals(event)) {
173 if (HeosEvent.GROUPS_CHANGED == command) {
174 fetchQueueFromPlayer();
175 } else if (CONNECTION_RESTORED.equals(command)) {
177 refreshPlayState(getId());
178 } catch (IOException | ReadException e) {
179 logger.debug("Failed to refreshPlayState", e);
185 void scheduleImmediatelyHandleDynamicStatesSignedIn() {
186 cancel(handleDynamicStatesFuture);
187 handleDynamicStatesFuture = scheduler.submit(this::handleDynamicStatesSignedIn);
190 void handleDynamicStatesSignedIn() {
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);
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);
213 * Dispose the handler and unregister the handler
217 public void dispose() {
219 logger.debug("Disposing this: {}", this);
220 getApiConnection().unregisterForChangeEvents(this);
221 } catch (HeosNotConnectedException e) {
222 logger.trace("No connection available while trying to unregister");
225 cancel(scheduleQueueFetchFuture);
226 cancel(handleDynamicStatesFuture);
230 * Plays a media file from an external source. Can be
231 * used for audio sink function
233 * @param urlStr The external URL where the file is located
234 * @throws ReadException
235 * @throws IOException
237 public void playURL(String urlStr) throws IOException, ReadException {
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());
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)}
252 * @param eventObject containing information about the even which was sent to us by the HEOS device
254 protected void handleThingStateUpdate(HeosEventObject eventObject) {
255 updateStatus(ONLINE, ThingStatusDetail.NONE, "Receiving events");
258 HeosEvent command = eventObject.command;
260 if (command == null) {
261 logger.debug("Ignoring event with null command");
267 case PLAYER_STATE_CHANGED:
268 playerStateChanged(eventObject);
271 case PLAYER_VOLUME_CHANGED:
272 case GROUP_VOLUME_CHANGED:
274 String level = eventObject.getAttribute(LEVEL);
276 notificationVolume = level;
277 updateState(CH_ID_VOLUME, PercentType.valueOf(level));
278 updateState(CH_ID_MUTE, OnOffType.from(eventObject.getBooleanAttribute(MUTE)));
282 case SHUFFLE_MODE_CHANGED:
283 handleShuffleMode(eventObject);
286 case PLAYER_NOW_PLAYING_PROGRESS:
288 Long position = eventObject.getNumericAttribute(CURRENT_POSITION);
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));
297 case REPEAT_MODE_CHANGED:
298 handleRepeatMode(eventObject);
301 case PLAYER_PLAYBACK_ERROR:
302 updateStatus(UNKNOWN, ThingStatusDetail.NONE, eventObject.getAttribute(ERROR));
305 case PLAYER_QUEUE_CHANGED:
306 fetchQueueFromPlayer();
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);
315 case PLAYERS_CHANGED:
316 case PLAYER_NOW_PLAYING_CHANGED:
318 logger.trace("Ignoring {}, will be handled inside HeosEventController", command);
323 private QuantityType<Time> quantityFromMilliSeconds(long position) {
324 return new QuantityType<>(position / 1000, Units.SECOND);
327 private void handleShuffleMode(HeosObject eventObject) {
328 updateState(CH_ID_SHUFFLE_MODE,
329 OnOffType.from(eventObject.getBooleanAttribute(HeosCommunicationAttribute.SHUFFLE)));
332 void refreshPlayState(String id) throws IOException, ReadException {
333 handleThingStateUpdate(getApiConnection().getPlayMode(id));
334 handleThingStateUpdate(getApiConnection().getPlayState(id));
335 handleThingStateUpdate(getApiConnection().getNowPlayingMedia(id));
338 protected <T> void handleThingStateUpdate(HeosResponseObject<T> responseObject) throws HeosFunctionalException {
339 handleResponseError(responseObject);
342 HeosCommandTuple cmd = responseObject.heosCommand;
345 logger.debug("Ignoring response with null command");
349 if (cmd.commandGroup == PLAYER || cmd.commandGroup == GROUP) {
350 switch (cmd.command) {
352 playerStateChanged(responseObject);
356 updateState(CH_ID_MUTE, OnOffType.from(responseObject.getBooleanAttribute(MUTE)));
361 String level = responseObject.getAttribute(LEVEL);
363 notificationVolume = level;
364 updateState(CH_ID_VOLUME, PercentType.valueOf(level));
369 handleRepeatMode(responseObject);
370 handleShuffleMode(responseObject);
373 case GET_NOW_PLAYING_MEDIA:
375 T mediaPayload = responseObject.payload;
376 if (mediaPayload instanceof Media) {
377 handleThingMediaUpdate((Media) mediaPayload);
381 case GET_PLAYER_INFO:
383 T playerPayload = responseObject.payload;
384 if (playerPayload instanceof Player) {
385 handlePlayerInfo((Player) playerPayload);
392 private <T> void handleResponseError(HeosResponseObject<T> responseObject) throws HeosFunctionalException {
394 HeosError error = responseObject.getError();
396 throw new HeosFunctionalException(error.code);
400 private void handleRepeatMode(HeosObject eventObject) {
402 String repeatMode = eventObject.getAttribute(REPEAT);
403 if (repeatMode == null) {
404 updateState(CH_ID_REPEAT_MODE, UnDefType.NULL);
408 switch (repeatMode) {
410 updateState(CH_ID_REPEAT_MODE, StringType.valueOf(HEOS_UI_ALL));
414 updateState(CH_ID_REPEAT_MODE, StringType.valueOf(HEOS_UI_ONE));
418 updateState(CH_ID_REPEAT_MODE, StringType.valueOf(HEOS_UI_OFF));
423 private void playerStateChanged(HeosObject eventObject) {
425 String attribute = eventObject.getAttribute(STATE);
426 if (attribute == null) {
427 updateState(CH_ID_CONTROL, UnDefType.NULL);
432 updateState(CH_ID_CONTROL, PlayPauseType.PLAY);
436 updateState(CH_ID_CONTROL, PlayPauseType.PAUSE);
441 private synchronized void fetchQueueFromPlayer() {
443 List<Media> queue = getApiConnection().getQueue(getId());
444 heosDynamicStateDescriptionProvider.setQueue(queueChannelUID, queue);
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);
451 cancel(scheduleQueueFetchFuture, false);
452 scheduleQueueFetchFuture = scheduler.schedule(this::fetchQueueFromPlayer, 30, TimeUnit.SECONDS);
455 protected void handleThingMediaUpdate(Media info) {
456 logger.debug("Received updated media state: {}", info);
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));
468 updateState(CH_ID_QUEUE, UnDefType.UNDEF);
469 updateState(CH_ID_FAVORITES, UnDefType.UNDEF);
471 handleImageUrl(info);
473 handleSourceId(info);
476 private void handleImageUrl(Media info) {
477 if (StringUtils.isNotBlank(info.imageUrl)) {
479 URL url = new URL(info.imageUrl); // checks if String is proper URL
480 RawType cover = HttpUtil.downloadImage(url.toString());
482 updateState(CH_ID_COVER, cover);
485 } catch (MalformedURLException e) {
486 logger.debug("Cover can't be loaded. No proper URL: {}", info.imageUrl, e);
489 updateState(CH_ID_COVER, UnDefType.NULL);
492 private void handleStation(Media info) {
493 if (STATION.equals(info.type)) {
494 updateState(CH_ID_STATION, StringType.valueOf(info.station));
496 updateState(CH_ID_STATION, UnDefType.UNDEF);
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));
506 updateState(CH_ID_TYPE, StringType.valueOf(info.type));
507 updateState(CH_ID_INPUTS, UnDefType.UNDEF);
511 private void handlePlayerInfo(Player player) {
512 Map<String, String> prop = new HashMap<>();
513 HeosPlayerHandler.propertiesFromPlayer(prop, player);
514 updateProperties(prop);