2 * Copyright (c) 2010-2023 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.*;
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.*;
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.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;
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.
74 * @author Johannes Einig - Initial contribution
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;
84 private @Nullable HeosChannelHandlerFactory channelHandlerFactory;
85 protected @Nullable HeosBridgeHandler bridgeHandler;
87 private String notificationVolume = "0";
89 private int failureCount;
90 private @Nullable Future<?> scheduleQueueFetchFuture;
91 private @Nullable Future<?> handleDynamicStatesFuture;
93 HeosThingBaseHandler(Thing thing, HeosDynamicStateDescriptionProvider heosDynamicStateDescriptionProvider) {
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);
102 public void initialize() {
104 Bridge bridge = getBridge();
106 HeosBridgeHandler localBridgeHandler;
107 if (bridge != null) {
108 localBridgeHandler = (HeosBridgeHandler) bridge.getHandler();
109 if (localBridgeHandler != null) {
110 bridgeHandler = localBridgeHandler;
111 channelHandlerFactory = localBridgeHandler.getChannelHandlerFactory();
113 updateStatus(OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
117 logger.warn("No Bridge set within child handler");
118 updateStatus(OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
123 getApiConnection().registerForChangeEvents(this);
124 cancel(scheduleQueueFetchFuture);
125 scheduleQueueFetchFuture = scheduler.submit(this::fetchQueueFromPlayer);
127 if (localBridgeHandler.isLoggedIn()) {
128 scheduleImmediatelyHandleDynamicStatesSignedIn();
130 } catch (HeosNotConnectedException e) {
131 updateStatus(OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
135 void handleSuccess() {
137 updateStatus(ONLINE);
140 void handleError(Exception e) {
141 logger.debug("Failed to handle player/group command", e);
144 if (failureCount > FAILURE_COUNT_LIMIT) {
145 updateStatus(OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Failed to handle command: " + e.getMessage());
149 public HeosFacade getApiConnection() throws HeosNotConnectedException {
151 HeosBridgeHandler localBridge = bridgeHandler;
152 if (localBridge != null) {
153 return localBridge.getApiConnection();
155 throw new HeosNotConnectedException();
158 public abstract String getId() throws HeosNotFoundException;
160 public abstract void setStatusOffline();
162 public abstract void setStatusOnline();
164 public PercentType getNotificationSoundVolume() {
165 return PercentType.valueOf(notificationVolume);
168 public void setNotificationSoundVolume(PercentType volume) {
169 notificationVolume = volume.toString();
173 HeosChannelHandler getHeosChannelHandler(ChannelUID channelUID) {
175 HeosChannelHandlerFactory localChannelHandlerFactory = this.channelHandlerFactory;
176 return localChannelHandlerFactory != null ? localChannelHandlerFactory.getChannelHandler(channelUID, this, null)
181 public void bridgeChangeEvent(String event, boolean success, Object command) {
182 logger.debug("BridgeChangeEvent: {}", command);
183 if (HeosEvent.USER_CHANGED == command) {
184 handleDynamicStatesSignedIn();
187 if (EVENT_TYPE_EVENT.equals(event)) {
188 if (HeosEvent.GROUPS_CHANGED == command) {
189 fetchQueueFromPlayer();
190 } else if (CONNECTION_RESTORED.equals(command)) {
192 refreshPlayState(getId());
193 } catch (IOException | ReadException e) {
194 logger.debug("Failed to refreshPlayState", e);
200 void scheduleImmediatelyHandleDynamicStatesSignedIn() {
201 cancel(handleDynamicStatesFuture);
202 handleDynamicStatesFuture = scheduler.submit(this::handleDynamicStatesSignedIn);
205 void handleDynamicStatesSignedIn() {
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);
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);
228 * Dispose the handler and unregister the handler
232 public void dispose() {
234 logger.debug("Disposing this: {}", this);
235 getApiConnection().unregisterForChangeEvents(this);
236 } catch (HeosNotConnectedException e) {
237 logger.trace("No connection available while trying to unregister");
240 cancel(scheduleQueueFetchFuture);
241 cancel(handleDynamicStatesFuture);
245 * Plays a media file from an external source. Can be
246 * used for audio sink function
248 * @param urlStr The external URL where the file is located
249 * @throws ReadException
250 * @throws IOException
252 public void playURL(String urlStr) throws IOException, ReadException {
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());
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)}
267 * @param eventObject containing information about the even which was sent to us by the HEOS device
269 protected void handleThingStateUpdate(HeosEventObject eventObject) {
270 updateStatus(ONLINE, ThingStatusDetail.NONE, "Receiving events");
273 HeosEvent command = eventObject.command;
275 if (command == null) {
276 logger.debug("Ignoring event with null command");
281 case PLAYER_STATE_CHANGED:
282 playerStateChanged(eventObject);
285 case PLAYER_VOLUME_CHANGED:
286 case GROUP_VOLUME_CHANGED:
288 String level = eventObject.getAttribute(LEVEL);
290 notificationVolume = level;
291 updateState(CH_ID_VOLUME, PercentType.valueOf(level));
292 updateState(CH_ID_MUTE, OnOffType.from(eventObject.getBooleanAttribute(MUTE)));
296 case SHUFFLE_MODE_CHANGED:
297 handleShuffleMode(eventObject);
300 case PLAYER_NOW_PLAYING_PROGRESS:
302 Long position = eventObject.getNumericAttribute(CURRENT_POSITION);
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));
311 case REPEAT_MODE_CHANGED:
312 handleRepeatMode(eventObject);
315 case PLAYER_PLAYBACK_ERROR:
316 updateStatus(UNKNOWN, ThingStatusDetail.NONE, eventObject.getAttribute(ERROR));
319 case PLAYER_QUEUE_CHANGED:
320 fetchQueueFromPlayer();
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);
329 case PLAYERS_CHANGED:
330 case PLAYER_NOW_PLAYING_CHANGED:
332 logger.trace("Ignoring {}, will be handled inside HeosEventController", command);
337 private QuantityType<Time> quantityFromMilliSeconds(long position) {
338 return new QuantityType<>(position / 1000, Units.SECOND);
341 private void handleShuffleMode(HeosObject eventObject) {
342 updateState(CH_ID_SHUFFLE_MODE,
343 OnOffType.from(eventObject.getBooleanAttribute(HeosCommunicationAttribute.SHUFFLE)));
346 void refreshPlayState(String id) throws IOException, ReadException {
347 handleThingStateUpdate(getApiConnection().getPlayMode(id));
348 handleThingStateUpdate(getApiConnection().getPlayState(id));
349 handleThingStateUpdate(getApiConnection().getNowPlayingMedia(id));
352 protected <T> void handleThingStateUpdate(HeosResponseObject<T> responseObject) throws HeosFunctionalException {
353 handleResponseError(responseObject);
356 HeosCommandTuple cmd = responseObject.heosCommand;
359 logger.debug("Ignoring response with null command");
363 if (cmd.commandGroup == PLAYER || cmd.commandGroup == GROUP) {
364 switch (cmd.command) {
366 playerStateChanged(responseObject);
370 updateState(CH_ID_MUTE, OnOffType.from(responseObject.getBooleanAttribute(MUTE)));
375 String level = responseObject.getAttribute(LEVEL);
377 notificationVolume = level;
378 updateState(CH_ID_VOLUME, PercentType.valueOf(level));
383 handleRepeatMode(responseObject);
384 handleShuffleMode(responseObject);
387 case GET_NOW_PLAYING_MEDIA:
389 T mediaPayload = responseObject.payload;
390 if (mediaPayload instanceof Media media) {
391 handleThingMediaUpdate(media);
395 case GET_PLAYER_INFO:
397 T playerPayload = responseObject.payload;
398 if (playerPayload instanceof Player player) {
399 handlePlayerInfo(player);
406 private <T> void handleResponseError(HeosResponseObject<T> responseObject) throws HeosFunctionalException {
408 HeosError error = responseObject.getError();
410 throw new HeosFunctionalException(error.code);
414 private void handleRepeatMode(HeosObject eventObject) {
416 String repeatMode = eventObject.getAttribute(REPEAT);
417 if (repeatMode == null) {
418 updateState(CH_ID_REPEAT_MODE, UnDefType.NULL);
422 switch (repeatMode) {
424 updateState(CH_ID_REPEAT_MODE, StringType.valueOf(HEOS_UI_ALL));
428 updateState(CH_ID_REPEAT_MODE, StringType.valueOf(HEOS_UI_ONE));
432 updateState(CH_ID_REPEAT_MODE, StringType.valueOf(HEOS_UI_OFF));
437 private void playerStateChanged(HeosObject eventObject) {
439 String attribute = eventObject.getAttribute(STATE);
440 if (attribute == null) {
441 updateState(CH_ID_CONTROL, UnDefType.NULL);
446 updateState(CH_ID_CONTROL, PlayPauseType.PLAY);
450 updateState(CH_ID_CONTROL, PlayPauseType.PAUSE);
455 private synchronized void fetchQueueFromPlayer() {
457 List<Media> queue = getApiConnection().getQueue(getId());
458 heosDynamicStateDescriptionProvider.setQueue(queueChannelUID, queue);
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);
465 cancel(scheduleQueueFetchFuture, false);
466 scheduleQueueFetchFuture = scheduler.schedule(this::fetchQueueFromPlayer, 30, TimeUnit.SECONDS);
469 protected void handleThingMediaUpdate(Media info) {
470 logger.debug("Received updated media state: {}", info);
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));
482 updateState(CH_ID_QUEUE, UnDefType.UNDEF);
483 updateState(CH_ID_FAVORITES, UnDefType.UNDEF);
485 handleImageUrl(info);
487 handleSourceId(info);
490 private void handleImageUrl(Media info) {
491 String imageUrl = info.imageUrl;
492 if (imageUrl != null && !imageUrl.isBlank()) {
494 URL url = new URL(imageUrl); // checks if String is proper URL
495 RawType cover = HttpUtil.downloadImage(url.toString());
497 updateState(CH_ID_COVER, cover);
500 } catch (MalformedURLException e) {
501 logger.debug("Cover can't be loaded. No proper URL: {}", imageUrl, e);
504 updateState(CH_ID_COVER, UnDefType.NULL);
507 private void handleStation(Media info) {
508 if (STATION.equals(info.type)) {
509 updateState(CH_ID_STATION, StringType.valueOf(info.station));
511 updateState(CH_ID_STATION, UnDefType.UNDEF);
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));
521 updateState(CH_ID_TYPE, StringType.valueOf(info.type));
522 updateState(CH_ID_INPUTS, UnDefType.UNDEF);
526 private void handlePlayerInfo(Player player) {
527 Map<String, String> prop = new HashMap<>();
528 HeosPlayerHandler.propertiesFromPlayer(prop, player);
529 updateProperties(prop);