2 * Copyright (c) 2010-2021 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.apache.commons.io.IOUtils;
27 import org.openhab.binding.allplay.internal.AllPlayBindingConstants;
28 import org.openhab.binding.allplay.internal.AllPlayBindingProperties;
29 import org.openhab.core.common.ThreadPoolManager;
30 import org.openhab.core.io.net.http.HttpUtil;
31 import org.openhab.core.library.types.DecimalType;
32 import org.openhab.core.library.types.IncreaseDecreaseType;
33 import org.openhab.core.library.types.NextPreviousType;
34 import org.openhab.core.library.types.OnOffType;
35 import org.openhab.core.library.types.PercentType;
36 import org.openhab.core.library.types.PlayPauseType;
37 import org.openhab.core.library.types.RawType;
38 import org.openhab.core.library.types.RewindFastforwardType;
39 import org.openhab.core.library.types.StringType;
40 import org.openhab.core.thing.ChannelUID;
41 import org.openhab.core.thing.Thing;
42 import org.openhab.core.thing.ThingRegistry;
43 import org.openhab.core.thing.ThingStatus;
44 import org.openhab.core.thing.ThingStatusDetail;
45 import org.openhab.core.thing.binding.BaseThingHandler;
46 import org.openhab.core.types.Command;
47 import org.openhab.core.types.RefreshType;
48 import org.openhab.core.types.UnDefType;
49 import org.slf4j.Logger;
50 import org.slf4j.LoggerFactory;
52 import de.kaizencode.tchaikovsky.AllPlay;
53 import de.kaizencode.tchaikovsky.exception.AllPlayException;
54 import de.kaizencode.tchaikovsky.exception.ConnectionException;
55 import de.kaizencode.tchaikovsky.exception.DiscoveryException;
56 import de.kaizencode.tchaikovsky.exception.SpeakerException;
57 import de.kaizencode.tchaikovsky.listener.SpeakerAnnouncedListener;
58 import de.kaizencode.tchaikovsky.listener.SpeakerChangedListener;
59 import de.kaizencode.tchaikovsky.listener.SpeakerConnectionListener;
60 import de.kaizencode.tchaikovsky.speaker.PlayState;
61 import de.kaizencode.tchaikovsky.speaker.PlayState.State;
62 import de.kaizencode.tchaikovsky.speaker.PlaylistItem;
63 import de.kaizencode.tchaikovsky.speaker.Speaker;
64 import de.kaizencode.tchaikovsky.speaker.Speaker.LoopMode;
65 import de.kaizencode.tchaikovsky.speaker.Speaker.ShuffleMode;
66 import de.kaizencode.tchaikovsky.speaker.VolumeRange;
67 import de.kaizencode.tchaikovsky.speaker.ZoneItem;
70 * The {@link AllPlayHandler} is responsible for handling commands, which are
71 * sent to one of the channels.
73 * @author Dominic Lerbs - Initial contribution
75 public class AllPlayHandler extends BaseThingHandler
76 implements SpeakerChangedListener, SpeakerAnnouncedListener, SpeakerConnectionListener {
78 private final Logger logger = LoggerFactory.getLogger(AllPlayHandler.class);
80 private final ThingRegistry localThingRegistry;
81 private final AllPlay allPlay;
82 private final AllPlayBindingProperties bindingProperties;
83 private Speaker speaker;
84 private VolumeRange volumeRange;
86 private static final String ALLPLAY_THREADPOOL_NAME = "allplayHandler";
87 private ScheduledFuture<?> reconnectionJob;
88 private final ScheduledExecutorService scheduler = ThreadPoolManager.getScheduledPool(ALLPLAY_THREADPOOL_NAME);
90 public AllPlayHandler(ThingRegistry thingRegistry, Thing thing, AllPlay allPlay,
91 AllPlayBindingProperties properties) {
93 this.localThingRegistry = thingRegistry;
94 this.allPlay = allPlay;
95 this.bindingProperties = properties;
99 public void initialize() {
100 logger.debug("Initializing AllPlay handler for speaker {}", getDeviceId());
101 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "Waiting for speaker to be discovered");
103 allPlay.addSpeakerAnnouncedListener(this);
105 } catch (DiscoveryException e) {
106 logger.error("Unable to discover speaker {}", getDeviceId(), e);
111 * Tries to discover the speaker which is associated with this thing.
113 public void discoverSpeaker() {
115 logger.debug("Starting discovery for speaker {}", getDeviceId());
116 allPlay.discoverSpeaker(getDeviceId());
117 } catch (DiscoveryException e) {
118 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
119 "Unable to discover speaker: " + e.getMessage());
120 logger.error("Unable to discover speaker {}", getDeviceId(), e);
125 public void onSpeakerAnnounced(Speaker speaker) {
126 logger.debug("Speaker announcement received for speaker {}. Own id is {}", speaker, getDeviceId());
127 if (isHandledSpeaker(speaker)) {
128 logger.debug("Speaker announcement received for handled speaker {}", speaker);
129 if (this.speaker != null) {
130 // Make sure to disconnect first in case the speaker is re-announced
131 disconnectFromSpeaker(this.speaker);
133 this.speaker = speaker;
134 cancelReconnectionJob();
137 } catch (AllPlayException e) {
138 logger.debug("Connection error", e);
139 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
140 "Error while communicating with speaker: " + e.getMessage());
141 scheduleReconnectionJob(speaker);
146 private void connectToSpeaker() throws ConnectionException {
147 if (speaker != null) {
148 logger.debug("Connecting to speaker {}", speaker);
149 speaker.addSpeakerChangedListener(this);
150 speaker.addSpeakerConnectionListener(this);
152 logger.debug("Connected to speaker {}", speaker);
153 updateStatus(ThingStatus.ONLINE);
156 } catch (SpeakerException e) {
157 logger.error("Unable to init speaker state", e);
160 logger.error("Speaker {} not discovered yet, cannot connect", getDeviceId());
164 private void initSpeakerState() throws SpeakerException {
166 onMuteChanged(speaker.volume().isMute());
167 onLoopModeChanged(speaker.getLoopMode());
168 onShuffleModeChanged(speaker.getShuffleMode());
169 onPlayStateChanged(speaker.getPlayState());
170 onVolumeChanged(speaker.volume().getVolume());
171 onVolumeControlChanged(speaker.volume().isControlEnabled());
175 * Cache the volume range as it will not change for the speaker.
177 private void cacheVolumeRange() throws SpeakerException {
178 volumeRange = speaker.volume().getVolumeRange();
182 public void onConnectionLost(String wellKnownName, int alljoynReasonCode) {
183 if (isHandledSpeaker(wellKnownName)) {
184 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Lost connection to speaker");
185 speaker.removeSpeakerConnectionListener(this);
186 speaker.removeSpeakerChangedListener(this);
187 scheduleReconnectionJob(speaker);
192 public void dispose() {
193 allPlay.removeSpeakerAnnouncedListener(this);
194 if (speaker != null) {
195 disconnectFromSpeaker(speaker);
200 private void disconnectFromSpeaker(Speaker speaker) {
201 logger.debug("Disconnecting from speaker {}", speaker);
202 speaker.removeSpeakerChangedListener(this);
203 speaker.removeSpeakerConnectionListener(this);
204 cancelReconnectionJob();
205 if (speaker.isConnected()) {
206 speaker.disconnect();
211 public void handleCommand(ChannelUID channelUID, Command command) {
212 logger.debug("Channel {} triggered with command {}", channelUID.getId(), command);
213 if (isSpeakerReady()) {
215 if (command instanceof RefreshType) {
216 handleRefreshCommand(channelUID.getId());
218 handleSpeakerCommand(channelUID.getId(), command);
220 } catch (SpeakerException e) {
221 logger.error("Unable to execute command {} on channel {}", command, channelUID.getId(), e);
226 private void handleSpeakerCommand(String channelId, Command command) throws SpeakerException {
229 if (OnOffType.ON.equals(command)) {
230 speaker.zoneManager().releaseZone();
234 handleControlCommand(command);
237 speaker.input().setInput(command.toString());
240 speaker.setLoopMode(LoopMode.parse(command.toString()));
243 speaker.volume().mute(OnOffType.ON.equals(command));
249 handleShuffleModeCommand(command);
252 logger.debug("Starting to stream URL: {}", command.toString());
253 speaker.playItem(command.toString());
256 handleVolumeCommand(command);
259 handleZoneMembersCommand(command);
262 logger.warn("Unable to handle command {} on unknown channel {}", command, channelId);
266 private void handleRefreshCommand(String channelId) throws SpeakerException {
270 case CURRENT_DURATION:
274 updatePlaylistItemsState(speaker.getPlayState().getPlaylistItems());
277 updatePlayState(speaker.getPlayState());
280 onInputChanged(speaker.input().getActiveInput());
283 onLoopModeChanged(speaker.getLoopMode());
286 onMuteChanged(speaker.volume().isMute());
289 onShuffleModeChanged(speaker.getShuffleMode());
292 onVolumeChanged(speaker.volume().getVolume());
293 onVolumeControlChanged(speaker.volume().isControlEnabled());
296 updateState(ZONE_ID, new StringType(speaker.getPlayerInfo().getZoneInfo().getZoneId()));
299 logger.debug("REFRESH command not implemented on channel {}", channelId);
303 private void handleControlCommand(Command command) throws SpeakerException {
304 if (command instanceof PlayPauseType) {
305 if (command == PlayPauseType.PLAY) {
307 } else if (command == PlayPauseType.PAUSE) {
310 } else if (command instanceof NextPreviousType) {
311 if (command == NextPreviousType.NEXT) {
313 } else if (command == NextPreviousType.PREVIOUS) {
316 } else if (command instanceof RewindFastforwardType) {
317 if (command == RewindFastforwardType.FASTFORWARD) {
318 changeTrackPosition(bindingProperties.getFastForwardSkipTimeInSec() * 1000);
319 } else if (command == RewindFastforwardType.REWIND) {
320 changeTrackPosition(-bindingProperties.getRewindSkipTimeInSec() * 1000);
323 logger.warn("Unknown control command: {}", command);
328 * Changes the position in the current track.
330 * @param positionOffsetInMs The offset to adjust the current position. Can be negative or positive.
331 * @throws SpeakerException Exception if the position could not be changed
333 private void changeTrackPosition(long positionOffsetInMs) throws SpeakerException {
334 long currentPosition = speaker.getPlayState().getPositionInMs();
335 logger.debug("Jumping from old track position {} ms to new position {} ms", currentPosition,
336 currentPosition + positionOffsetInMs);
337 speaker.setPosition(currentPosition + positionOffsetInMs);
341 * Uses the given {@link Command} to change the volume of the speaker.
343 * @param command The {@link Command} with the new volume
344 * @throws SpeakerException Exception if the volume change failed
346 public void handleVolumeCommand(Command command) throws SpeakerException {
347 if (command instanceof PercentType) {
348 speaker.volume().setVolume(convertPercentToAbsoluteVolume((PercentType) command));
349 } else if (command instanceof IncreaseDecreaseType) {
350 int stepSize = (command == IncreaseDecreaseType.DECREASE ? -getVolumeStepSize() : getVolumeStepSize());
351 speaker.volume().adjustVolume(stepSize);
355 private void handleShuffleModeCommand(Command command) throws SpeakerException {
356 if (OnOffType.ON.equals(command)) {
357 speaker.setShuffleMode(ShuffleMode.SHUFFLE);
358 } else if (OnOffType.OFF.equals(command)) {
359 speaker.setShuffleMode(ShuffleMode.LINEAR);
363 private void handleZoneMembersCommand(Command command) throws SpeakerException {
364 String[] memberNames = command.toString().split(bindingProperties.getZoneMemberSeparator());
365 logger.debug("{}: Creating new zone with members {}", speaker, String.join(", ", memberNames));
366 List<String> memberIds = new ArrayList<>();
367 for (String memberName : memberNames) {
368 memberIds.add(getHandlerIdByLabel(memberName.trim()));
370 createZoneInNewThread(memberIds);
373 private void createZoneInNewThread(List<String> memberIds) {
374 scheduler.execute(() -> {
376 // This call blocks up to 10 seconds if one of the members is unreachable,
377 // therefore it is executed in a new thread
378 ZoneItem zone = speaker.zoneManager().createZone(memberIds);
379 logger.debug("{}: Zone {} with member ids {} has been created", speaker, zone.getZoneId(),
380 String.join(", ", zone.getSlaves().keySet()));
381 } catch (SpeakerException e) {
382 logger.warn("{}: Cannot create zone", speaker, e);
388 public void onPlayStateChanged(PlayState playState) {
389 updatePlayState(playState);
390 updatePlaylistItemsState(playState.getPlaylistItems());
394 public void onPlaylistChanged() {
395 logger.debug("{}: Playlist changed: No action", speaker.getName());
399 public void onLoopModeChanged(LoopMode loopMode) {
400 logger.debug("{}: LoopMode changed to {}", speaker.getName(), loopMode);
401 updateState(LOOP_MODE, new StringType(loopMode.toString()));
405 public void onShuffleModeChanged(ShuffleMode shuffleMode) {
406 logger.debug("{}: ShuffleMode changed to {}", speaker.getName(), shuffleMode);
407 OnOffType shuffleOnOff = (shuffleMode == ShuffleMode.SHUFFLE) ? OnOffType.ON : OnOffType.OFF;
408 updateState(SHUFFLE_MODE, shuffleOnOff);
412 public void onMuteChanged(boolean mute) {
413 logger.debug("{}: Mute changed to {}", speaker.getName(), mute);
414 updateState(MUTE, mute ? OnOffType.ON : OnOffType.OFF);
418 public void onVolumeChanged(int volume) {
419 logger.debug("{}: Volume changed to {}", speaker.getName(), volume);
421 updateState(VOLUME, convertAbsoluteVolumeToPercent(volume));
422 } catch (SpeakerException e) {
423 logger.warn("Cannot convert new volume to percent", e);
428 public void onVolumeControlChanged(boolean enabled) {
429 updateState(VOLUME_CONTROL, enabled ? OnOffType.ON : OnOffType.OFF);
433 public void onZoneChanged(String zoneId, int timestamp, Map<String, Integer> slaves) {
434 logger.debug("{}: Zone changed to {}", speaker.getName(), zoneId);
435 updateState(ZONE_ID, new StringType(zoneId));
439 public void onInputChanged(String input) {
440 logger.debug("{}: Input changed to {}", speaker.getName(), input);
441 updateState(INPUT, new StringType(input));
444 private void updatePlayState(PlayState playState) {
445 logger.debug("{}: PlayState changed to {}", speaker.getName(), playState);
446 updateState(PLAY_STATE, new StringType(playState.getState().toString()));
448 if (playState.getState() == State.PLAYING) {
449 updateState(CONTROL, PlayPauseType.PLAY);
451 updateState(CONTROL, PlayPauseType.PAUSE);
455 private void updatePlaylistItemsState(List<PlaylistItem> items) {
456 if (!items.isEmpty()) {
457 PlaylistItem currentItem = items.iterator().next();
458 updateCurrentItemState(currentItem);
460 updateState(CURRENT_ARTIST, UnDefType.NULL);
461 updateState(CURRENT_ALBUM, UnDefType.NULL);
462 updateState(CURRENT_TITLE, UnDefType.NULL);
463 updateState(CURRENT_GENRE, UnDefType.NULL);
464 updateState(CURRENT_URL, UnDefType.NULL);
465 updateState(COVER_ART_URL, UnDefType.NULL);
466 updateState(COVER_ART, UnDefType.NULL);
470 private void updateCurrentItemState(PlaylistItem currentItem) {
471 logger.debug("{}: PlaylistItem changed to {}", speaker.getName(), currentItem);
472 updateState(CURRENT_ARTIST, new StringType(currentItem.getArtist()));
473 updateState(CURRENT_ALBUM, new StringType(currentItem.getAlbum()));
474 updateState(CURRENT_TITLE, new StringType(currentItem.getTitle()));
475 updateState(CURRENT_GENRE, new StringType(currentItem.getGenre()));
476 updateDuration(currentItem.getDurationInMs());
477 updateState(CURRENT_URL, new StringType(currentItem.getUrl()));
478 updateCoverArtState(currentItem.getThumbnailUrl());
481 updateState(CURRENT_USER_DATA, new StringType(String.valueOf(currentItem.getUserData())));
482 } catch (SpeakerException e) {
483 logger.warn("Unable to update current user data: {}", e.getMessage(), e);
485 logger.debug("MediaType: {}", currentItem.getMediaType());
488 private void updateDuration(long durationInMs) {
489 DecimalType duration = new DecimalType(durationInMs / 1000);
490 duration.format("%d s");
491 updateState(CURRENT_DURATION, duration);
494 private void updateCoverArtState(String coverArtUrl) {
496 logger.debug("{}: Cover art URL changed to {}", speaker.getName(), coverArtUrl);
497 updateState(COVER_ART_URL, new StringType(coverArtUrl));
498 if (!coverArtUrl.isEmpty()) {
499 byte[] bytes = getRawDataFromUrl(coverArtUrl);
500 String contentType = HttpUtil.guessContentTypeFromData(bytes);
501 updateState(COVER_ART, new RawType(bytes,
502 contentType == null || contentType.isEmpty() ? RawType.DEFAULT_MIME_TYPE : contentType));
504 updateState(COVER_ART, UnDefType.NULL);
506 } catch (Exception e) {
507 logger.warn("Error getting cover art", e);
512 * Starts streaming the audio at the given URL.
514 * @param url The URL to stream
515 * @throws SpeakerException Exception if the URL could not be streamed
517 public void playUrl(String url) throws SpeakerException {
518 if (isSpeakerReady()) {
519 speaker.playItem(url);
521 throw new SpeakerException(
522 "Cannot play audio stream, speaker " + speaker + " is not discovered/connected!");
527 * @return The current volume of the speaker
528 * @throws SpeakerException Exception if the volume could not be retrieved
530 public PercentType getVolume() throws SpeakerException {
531 if (isSpeakerReady()) {
532 return convertAbsoluteVolumeToPercent(speaker.volume().getVolume());
534 throw new SpeakerException("Cannot get volume, speaker " + speaker + " is not discovered/connected!");
538 private byte[] getRawDataFromUrl(String urlString) throws Exception {
539 URL url = new URL(urlString);
540 URLConnection connection = url.openConnection();
541 return IOUtils.toByteArray(connection.getInputStream());
544 private int convertPercentToAbsoluteVolume(PercentType percentVolume) throws SpeakerException {
545 int range = volumeRange.getMax() - volumeRange.getMin();
546 int volume = (percentVolume.shortValue() * range) / 100;
547 logger.debug("Volume {}% has been converted to absolute volume {}", percentVolume.intValue(), volume);
551 private PercentType convertAbsoluteVolumeToPercent(int volume) throws SpeakerException {
552 int range = volumeRange.getMax() - volumeRange.getMin();
553 int percentVolume = 0;
555 percentVolume = (volume * 100) / range;
557 logger.debug("Absolute volume {} has been converted to volume {}%", volume, percentVolume);
558 return new PercentType(percentVolume);
561 private boolean isSpeakerReady() {
562 if (speaker == null || !speaker.isConnected()) {
563 logger.warn("Cannot execute command, speaker {} is not discovered/connected!", speaker);
570 * @param speaker The {@link Speaker} to check
571 * @return True if the {@link Speaker} is managed by this handler, else false
573 private boolean isHandledSpeaker(Speaker speaker) {
574 return speaker.getId().equals(getDeviceId());
577 private boolean isHandledSpeaker(String wellKnownName) {
578 return wellKnownName.equals(speaker.details().getWellKnownName());
581 private String getDeviceId() {
582 return (String) getConfig().get(AllPlayBindingConstants.DEVICE_ID);
585 private Integer getVolumeStepSize() {
586 return (Integer) getConfig().get(AllPlayBindingConstants.VOLUME_STEP_SIZE);
590 * Schedules a reconnection job.
592 private void scheduleReconnectionJob(final Speaker speaker) {
593 logger.debug("Scheduling job to rediscover to speaker {}", speaker);
594 // TODO: Check if it makes sense to repeat the discovery every x minutes or if the AllJoyn library is able to
595 // handle re-discovery in _all_ cases.
596 cancelReconnectionJob();
597 reconnectionJob = scheduler.scheduleWithFixedDelay(this::discoverSpeaker, 5, 600, TimeUnit.SECONDS);
601 * Cancels a scheduled reconnection job.
603 private void cancelReconnectionJob() {
604 if (reconnectionJob != null) {
605 reconnectionJob.cancel(true);
609 private String getHandlerIdByLabel(String thingLabel) throws IllegalStateException {
610 for (Thing thing : localThingRegistry.getAll()) {
611 if (thingLabel.equals(thing.getLabel())) {
612 return thing.getUID().getId();
615 throw new IllegalStateException("Could not find thing with label " + thingLabel);