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.allplay.internal.handler;
15 import static org.openhab.binding.allplay.internal.AllPlayBindingConstants.*;
18 import java.net.URLConnection;
19 import java.util.ArrayList;
20 import java.util.List;
22 import java.util.concurrent.ScheduledExecutorService;
23 import java.util.concurrent.ScheduledFuture;
24 import java.util.concurrent.TimeUnit;
26 import org.openhab.binding.allplay.internal.AllPlayBindingConstants;
27 import org.openhab.binding.allplay.internal.AllPlayBindingProperties;
28 import org.openhab.core.common.ThreadPoolManager;
29 import org.openhab.core.io.net.http.HttpUtil;
30 import org.openhab.core.library.types.DecimalType;
31 import org.openhab.core.library.types.IncreaseDecreaseType;
32 import org.openhab.core.library.types.NextPreviousType;
33 import org.openhab.core.library.types.OnOffType;
34 import org.openhab.core.library.types.PercentType;
35 import org.openhab.core.library.types.PlayPauseType;
36 import org.openhab.core.library.types.RawType;
37 import org.openhab.core.library.types.RewindFastforwardType;
38 import org.openhab.core.library.types.StringType;
39 import org.openhab.core.thing.ChannelUID;
40 import org.openhab.core.thing.Thing;
41 import org.openhab.core.thing.ThingRegistry;
42 import org.openhab.core.thing.ThingStatus;
43 import org.openhab.core.thing.ThingStatusDetail;
44 import org.openhab.core.thing.binding.BaseThingHandler;
45 import org.openhab.core.types.Command;
46 import org.openhab.core.types.RefreshType;
47 import org.openhab.core.types.UnDefType;
48 import org.slf4j.Logger;
49 import org.slf4j.LoggerFactory;
51 import de.kaizencode.tchaikovsky.AllPlay;
52 import de.kaizencode.tchaikovsky.exception.AllPlayException;
53 import de.kaizencode.tchaikovsky.exception.ConnectionException;
54 import de.kaizencode.tchaikovsky.exception.DiscoveryException;
55 import de.kaizencode.tchaikovsky.exception.SpeakerException;
56 import de.kaizencode.tchaikovsky.listener.SpeakerAnnouncedListener;
57 import de.kaizencode.tchaikovsky.listener.SpeakerChangedListener;
58 import de.kaizencode.tchaikovsky.listener.SpeakerConnectionListener;
59 import de.kaizencode.tchaikovsky.speaker.PlayState;
60 import de.kaizencode.tchaikovsky.speaker.PlayState.State;
61 import de.kaizencode.tchaikovsky.speaker.PlaylistItem;
62 import de.kaizencode.tchaikovsky.speaker.Speaker;
63 import de.kaizencode.tchaikovsky.speaker.Speaker.LoopMode;
64 import de.kaizencode.tchaikovsky.speaker.Speaker.ShuffleMode;
65 import de.kaizencode.tchaikovsky.speaker.VolumeRange;
66 import de.kaizencode.tchaikovsky.speaker.ZoneItem;
69 * The {@link AllPlayHandler} is responsible for handling commands, which are
70 * sent to one of the channels.
72 * @author Dominic Lerbs - Initial contribution
74 public class AllPlayHandler extends BaseThingHandler
75 implements SpeakerChangedListener, SpeakerAnnouncedListener, SpeakerConnectionListener {
77 private final Logger logger = LoggerFactory.getLogger(AllPlayHandler.class);
79 private final ThingRegistry localThingRegistry;
80 private final AllPlay allPlay;
81 private final AllPlayBindingProperties bindingProperties;
82 private Speaker speaker;
83 private VolumeRange volumeRange;
85 private static final String ALLPLAY_THREADPOOL_NAME = "allplayHandler";
86 private ScheduledFuture<?> reconnectionJob;
87 private final ScheduledExecutorService scheduler = ThreadPoolManager.getScheduledPool(ALLPLAY_THREADPOOL_NAME);
89 public AllPlayHandler(ThingRegistry thingRegistry, Thing thing, AllPlay allPlay,
90 AllPlayBindingProperties properties) {
92 this.localThingRegistry = thingRegistry;
93 this.allPlay = allPlay;
94 this.bindingProperties = properties;
98 public void initialize() {
99 logger.debug("Initializing AllPlay handler for speaker {}", getDeviceId());
100 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "Waiting for speaker to be discovered");
102 allPlay.addSpeakerAnnouncedListener(this);
104 } catch (DiscoveryException e) {
105 logger.error("Unable to discover speaker {}", getDeviceId(), e);
110 * Tries to discover the speaker which is associated with this thing.
112 public void discoverSpeaker() {
114 logger.debug("Starting discovery for speaker {}", getDeviceId());
115 allPlay.discoverSpeaker(getDeviceId());
116 } catch (DiscoveryException e) {
117 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
118 "Unable to discover speaker: " + e.getMessage());
119 logger.error("Unable to discover speaker {}", getDeviceId(), e);
124 public void onSpeakerAnnounced(Speaker speaker) {
125 logger.debug("Speaker announcement received for speaker {}. Own id is {}", speaker, getDeviceId());
126 if (isHandledSpeaker(speaker)) {
127 logger.debug("Speaker announcement received for handled speaker {}", speaker);
128 if (this.speaker != null) {
129 // Make sure to disconnect first in case the speaker is re-announced
130 disconnectFromSpeaker(this.speaker);
132 this.speaker = speaker;
133 cancelReconnectionJob();
136 } catch (AllPlayException e) {
137 logger.debug("Connection error", e);
138 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
139 "Error while communicating with speaker: " + e.getMessage());
140 scheduleReconnectionJob(speaker);
145 private void connectToSpeaker() throws ConnectionException {
146 if (speaker != null) {
147 logger.debug("Connecting to speaker {}", speaker);
148 speaker.addSpeakerChangedListener(this);
149 speaker.addSpeakerConnectionListener(this);
151 logger.debug("Connected to speaker {}", speaker);
152 updateStatus(ThingStatus.ONLINE);
155 } catch (SpeakerException e) {
156 logger.error("Unable to init speaker state", e);
159 logger.error("Speaker {} not discovered yet, cannot connect", getDeviceId());
163 private void initSpeakerState() throws SpeakerException {
165 onMuteChanged(speaker.volume().isMute());
166 onLoopModeChanged(speaker.getLoopMode());
167 onShuffleModeChanged(speaker.getShuffleMode());
168 onPlayStateChanged(speaker.getPlayState());
169 onVolumeChanged(speaker.volume().getVolume());
170 onVolumeControlChanged(speaker.volume().isControlEnabled());
174 * Cache the volume range as it will not change for the speaker.
176 private void cacheVolumeRange() throws SpeakerException {
177 volumeRange = speaker.volume().getVolumeRange();
181 public void onConnectionLost(String wellKnownName, int alljoynReasonCode) {
182 if (isHandledSpeaker(wellKnownName)) {
183 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Lost connection to speaker");
184 speaker.removeSpeakerConnectionListener(this);
185 speaker.removeSpeakerChangedListener(this);
186 scheduleReconnectionJob(speaker);
191 public void dispose() {
192 allPlay.removeSpeakerAnnouncedListener(this);
193 if (speaker != null) {
194 disconnectFromSpeaker(speaker);
199 private void disconnectFromSpeaker(Speaker speaker) {
200 logger.debug("Disconnecting from speaker {}", speaker);
201 speaker.removeSpeakerChangedListener(this);
202 speaker.removeSpeakerConnectionListener(this);
203 cancelReconnectionJob();
204 if (speaker.isConnected()) {
205 speaker.disconnect();
210 public void handleCommand(ChannelUID channelUID, Command command) {
211 logger.debug("Channel {} triggered with command {}", channelUID.getId(), command);
212 if (isSpeakerReady()) {
214 if (command instanceof RefreshType) {
215 handleRefreshCommand(channelUID.getId());
217 handleSpeakerCommand(channelUID.getId(), command);
219 } catch (SpeakerException e) {
220 logger.error("Unable to execute command {} on channel {}", command, channelUID.getId(), e);
225 private void handleSpeakerCommand(String channelId, Command command) throws SpeakerException {
228 if (OnOffType.ON.equals(command)) {
229 speaker.zoneManager().releaseZone();
233 handleControlCommand(command);
236 speaker.input().setInput(command.toString());
239 speaker.setLoopMode(LoopMode.parse(command.toString()));
242 speaker.volume().mute(OnOffType.ON.equals(command));
248 handleShuffleModeCommand(command);
251 logger.debug("Starting to stream URL: {}", command.toString());
252 speaker.playItem(command.toString());
255 handleVolumeCommand(command);
258 handleZoneMembersCommand(command);
261 logger.warn("Unable to handle command {} on unknown channel {}", command, channelId);
265 private void handleRefreshCommand(String channelId) throws SpeakerException {
269 case CURRENT_DURATION:
273 updatePlaylistItemsState(speaker.getPlayState().getPlaylistItems());
276 updatePlayState(speaker.getPlayState());
279 onInputChanged(speaker.input().getActiveInput());
282 onLoopModeChanged(speaker.getLoopMode());
285 onMuteChanged(speaker.volume().isMute());
288 onShuffleModeChanged(speaker.getShuffleMode());
291 onVolumeChanged(speaker.volume().getVolume());
292 onVolumeControlChanged(speaker.volume().isControlEnabled());
295 updateState(ZONE_ID, new StringType(speaker.getPlayerInfo().getZoneInfo().getZoneId()));
298 logger.debug("REFRESH command not implemented on channel {}", channelId);
302 private void handleControlCommand(Command command) throws SpeakerException {
303 if (command instanceof PlayPauseType) {
304 if (command == PlayPauseType.PLAY) {
306 } else if (command == PlayPauseType.PAUSE) {
309 } else if (command instanceof NextPreviousType) {
310 if (command == NextPreviousType.NEXT) {
312 } else if (command == NextPreviousType.PREVIOUS) {
315 } else if (command instanceof RewindFastforwardType) {
316 if (command == RewindFastforwardType.FASTFORWARD) {
317 changeTrackPosition(bindingProperties.getFastForwardSkipTimeInSec() * 1000);
318 } else if (command == RewindFastforwardType.REWIND) {
319 changeTrackPosition(-bindingProperties.getRewindSkipTimeInSec() * 1000);
322 logger.warn("Unknown control command: {}", command);
327 * Changes the position in the current track.
329 * @param positionOffsetInMs The offset to adjust the current position. Can be negative or positive.
330 * @throws SpeakerException Exception if the position could not be changed
332 private void changeTrackPosition(long positionOffsetInMs) throws SpeakerException {
333 long currentPosition = speaker.getPlayState().getPositionInMs();
334 logger.debug("Jumping from old track position {} ms to new position {} ms", currentPosition,
335 currentPosition + positionOffsetInMs);
336 speaker.setPosition(currentPosition + positionOffsetInMs);
340 * Uses the given {@link Command} to change the volume of the speaker.
342 * @param command The {@link Command} with the new volume
343 * @throws SpeakerException Exception if the volume change failed
345 public void handleVolumeCommand(Command command) throws SpeakerException {
346 if (command instanceof PercentType percentCommand) {
347 speaker.volume().setVolume(convertPercentToAbsoluteVolume(percentCommand));
348 } else if (command instanceof IncreaseDecreaseType) {
349 int stepSize = (command == IncreaseDecreaseType.DECREASE ? -getVolumeStepSize() : getVolumeStepSize());
350 speaker.volume().adjustVolume(stepSize);
354 private void handleShuffleModeCommand(Command command) throws SpeakerException {
355 if (OnOffType.ON.equals(command)) {
356 speaker.setShuffleMode(ShuffleMode.SHUFFLE);
357 } else if (OnOffType.OFF.equals(command)) {
358 speaker.setShuffleMode(ShuffleMode.LINEAR);
362 private void handleZoneMembersCommand(Command command) throws SpeakerException {
363 String[] memberNames = command.toString().split(bindingProperties.getZoneMemberSeparator());
364 logger.debug("{}: Creating new zone with members {}", speaker, String.join(", ", memberNames));
365 List<String> memberIds = new ArrayList<>();
366 for (String memberName : memberNames) {
367 memberIds.add(getHandlerIdByLabel(memberName.trim()));
369 createZoneInNewThread(memberIds);
372 private void createZoneInNewThread(List<String> memberIds) {
373 scheduler.execute(() -> {
375 // This call blocks up to 10 seconds if one of the members is unreachable,
376 // therefore it is executed in a new thread
377 ZoneItem zone = speaker.zoneManager().createZone(memberIds);
378 logger.debug("{}: Zone {} with member ids {} has been created", speaker, zone.getZoneId(),
379 String.join(", ", zone.getSlaves().keySet()));
380 } catch (SpeakerException e) {
381 logger.warn("{}: Cannot create zone", speaker, e);
387 public void onPlayStateChanged(PlayState playState) {
388 updatePlayState(playState);
389 updatePlaylistItemsState(playState.getPlaylistItems());
393 public void onPlaylistChanged() {
394 logger.debug("{}: Playlist changed: No action", speaker.getName());
398 public void onLoopModeChanged(LoopMode loopMode) {
399 logger.debug("{}: LoopMode changed to {}", speaker.getName(), loopMode);
400 updateState(LOOP_MODE, new StringType(loopMode.toString()));
404 public void onShuffleModeChanged(ShuffleMode shuffleMode) {
405 logger.debug("{}: ShuffleMode changed to {}", speaker.getName(), shuffleMode);
406 OnOffType shuffleOnOff = OnOffType.from(shuffleMode == ShuffleMode.SHUFFLE);
407 updateState(SHUFFLE_MODE, shuffleOnOff);
411 public void onMuteChanged(boolean mute) {
412 logger.debug("{}: Mute changed to {}", speaker.getName(), mute);
413 updateState(MUTE, OnOffType.from(mute));
417 public void onVolumeChanged(int volume) {
418 logger.debug("{}: Volume changed to {}", speaker.getName(), volume);
420 updateState(VOLUME, convertAbsoluteVolumeToPercent(volume));
421 } catch (SpeakerException e) {
422 logger.warn("Cannot convert new volume to percent", e);
427 public void onVolumeControlChanged(boolean enabled) {
428 updateState(VOLUME_CONTROL, OnOffType.from(enabled));
432 public void onZoneChanged(String zoneId, int timestamp, Map<String, Integer> slaves) {
433 logger.debug("{}: Zone changed to {}", speaker.getName(), zoneId);
434 updateState(ZONE_ID, new StringType(zoneId));
438 public void onInputChanged(String input) {
439 logger.debug("{}: Input changed to {}", speaker.getName(), input);
440 updateState(INPUT, new StringType(input));
443 private void updatePlayState(PlayState playState) {
444 logger.debug("{}: PlayState changed to {}", speaker.getName(), playState);
445 updateState(PLAY_STATE, new StringType(playState.getState().toString()));
447 if (playState.getState() == State.PLAYING) {
448 updateState(CONTROL, PlayPauseType.PLAY);
450 updateState(CONTROL, PlayPauseType.PAUSE);
454 private void updatePlaylistItemsState(List<PlaylistItem> items) {
455 if (!items.isEmpty()) {
456 PlaylistItem currentItem = items.iterator().next();
457 updateCurrentItemState(currentItem);
459 updateState(CURRENT_ARTIST, UnDefType.NULL);
460 updateState(CURRENT_ALBUM, UnDefType.NULL);
461 updateState(CURRENT_TITLE, UnDefType.NULL);
462 updateState(CURRENT_GENRE, UnDefType.NULL);
463 updateState(CURRENT_URL, UnDefType.NULL);
464 updateState(COVER_ART_URL, UnDefType.NULL);
465 updateState(COVER_ART, UnDefType.NULL);
469 private void updateCurrentItemState(PlaylistItem currentItem) {
470 logger.debug("{}: PlaylistItem changed to {}", speaker.getName(), currentItem);
471 updateState(CURRENT_ARTIST, new StringType(currentItem.getArtist()));
472 updateState(CURRENT_ALBUM, new StringType(currentItem.getAlbum()));
473 updateState(CURRENT_TITLE, new StringType(currentItem.getTitle()));
474 updateState(CURRENT_GENRE, new StringType(currentItem.getGenre()));
475 updateDuration(currentItem.getDurationInMs());
476 updateState(CURRENT_URL, new StringType(currentItem.getUrl()));
477 updateCoverArtState(currentItem.getThumbnailUrl());
480 updateState(CURRENT_USER_DATA, new StringType(String.valueOf(currentItem.getUserData())));
481 } catch (SpeakerException e) {
482 logger.warn("Unable to update current user data: {}", e.getMessage(), e);
484 logger.debug("MediaType: {}", currentItem.getMediaType());
487 private void updateDuration(long durationInMs) {
488 DecimalType duration = new DecimalType(durationInMs / 1000);
489 duration.format("%d s");
490 updateState(CURRENT_DURATION, duration);
493 private void updateCoverArtState(String coverArtUrl) {
495 logger.debug("{}: Cover art URL changed to {}", speaker.getName(), coverArtUrl);
496 updateState(COVER_ART_URL, new StringType(coverArtUrl));
497 if (!coverArtUrl.isEmpty()) {
498 byte[] bytes = getRawDataFromUrl(coverArtUrl);
499 String contentType = HttpUtil.guessContentTypeFromData(bytes);
500 updateState(COVER_ART, new RawType(bytes,
501 contentType == null || contentType.isEmpty() ? RawType.DEFAULT_MIME_TYPE : contentType));
503 updateState(COVER_ART, UnDefType.NULL);
505 } catch (Exception e) {
506 logger.warn("Error getting cover art", e);
511 * Starts streaming the audio at the given URL.
513 * @param url The URL to stream
514 * @throws SpeakerException Exception if the URL could not be streamed
516 public void playUrl(String url) throws SpeakerException {
517 if (isSpeakerReady()) {
518 speaker.playItem(url);
520 throw new SpeakerException(
521 "Cannot play audio stream, speaker " + speaker + " is not discovered/connected!");
526 * @return The current volume of the speaker
527 * @throws SpeakerException Exception if the volume could not be retrieved
529 public PercentType getVolume() throws SpeakerException {
530 if (isSpeakerReady()) {
531 return convertAbsoluteVolumeToPercent(speaker.volume().getVolume());
533 throw new SpeakerException("Cannot get volume, speaker " + speaker + " is not discovered/connected!");
537 private byte[] getRawDataFromUrl(String urlString) throws Exception {
538 URL url = new URL(urlString);
539 URLConnection connection = url.openConnection();
540 return connection.getInputStream().readAllBytes();
543 private int convertPercentToAbsoluteVolume(PercentType percentVolume) throws SpeakerException {
544 int range = volumeRange.getMax() - volumeRange.getMin();
545 int volume = (percentVolume.shortValue() * range) / 100;
546 logger.debug("Volume {}% has been converted to absolute volume {}", percentVolume.intValue(), volume);
550 private PercentType convertAbsoluteVolumeToPercent(int volume) throws SpeakerException {
551 int range = volumeRange.getMax() - volumeRange.getMin();
552 int percentVolume = 0;
554 percentVolume = (volume * 100) / range;
556 logger.debug("Absolute volume {} has been converted to volume {}%", volume, percentVolume);
557 return new PercentType(percentVolume);
560 private boolean isSpeakerReady() {
561 if (speaker == null || !speaker.isConnected()) {
562 logger.warn("Cannot execute command, speaker {} is not discovered/connected!", speaker);
569 * @param speaker The {@link Speaker} to check
570 * @return True if the {@link Speaker} is managed by this handler, else false
572 private boolean isHandledSpeaker(Speaker speaker) {
573 return speaker.getId().equals(getDeviceId());
576 private boolean isHandledSpeaker(String wellKnownName) {
577 return wellKnownName.equals(speaker.details().getWellKnownName());
580 private String getDeviceId() {
581 return (String) getConfig().get(AllPlayBindingConstants.DEVICE_ID);
584 private Integer getVolumeStepSize() {
585 return (Integer) getConfig().get(AllPlayBindingConstants.VOLUME_STEP_SIZE);
589 * Schedules a reconnection job.
591 private void scheduleReconnectionJob(final Speaker speaker) {
592 logger.debug("Scheduling job to rediscover to speaker {}", speaker);
593 // TODO: Check if it makes sense to repeat the discovery every x minutes or if the AllJoyn library is able to
594 // handle re-discovery in _all_ cases.
595 cancelReconnectionJob();
596 reconnectionJob = scheduler.scheduleWithFixedDelay(this::discoverSpeaker, 5, 600, TimeUnit.SECONDS);
600 * Cancels a scheduled reconnection job.
602 private void cancelReconnectionJob() {
603 if (reconnectionJob != null) {
604 reconnectionJob.cancel(true);
608 private String getHandlerIdByLabel(String thingLabel) throws IllegalStateException {
609 for (Thing thing : localThingRegistry.getAll()) {
610 if (thingLabel.equals(thing.getLabel())) {
611 return thing.getUID().getId();
614 throw new IllegalStateException("Could not find thing with label " + thingLabel);