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.bosesoundtouch.internal.handler;
15 import static org.openhab.binding.bosesoundtouch.internal.BoseSoundTouchBindingConstants.*;
17 import java.io.IOException;
19 import java.util.Arrays;
20 import java.util.Collections;
21 import java.util.Comparator;
22 import java.util.List;
23 import java.util.Objects;
24 import java.util.concurrent.Future;
25 import java.util.concurrent.ScheduledExecutorService;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
28 import java.util.stream.Collectors;
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.eclipse.jetty.websocket.api.Session;
33 import org.eclipse.jetty.websocket.api.StatusCode;
34 import org.eclipse.jetty.websocket.api.WebSocketFrameListener;
35 import org.eclipse.jetty.websocket.api.WebSocketListener;
36 import org.eclipse.jetty.websocket.api.extensions.Frame;
37 import org.eclipse.jetty.websocket.api.extensions.Frame.Type;
38 import org.eclipse.jetty.websocket.client.ClientUpgradeRequest;
39 import org.eclipse.jetty.websocket.client.WebSocketClient;
40 import org.openhab.binding.bosesoundtouch.internal.APIRequest;
41 import org.openhab.binding.bosesoundtouch.internal.BoseSoundTouchConfiguration;
42 import org.openhab.binding.bosesoundtouch.internal.BoseSoundTouchNotificationChannelConfiguration;
43 import org.openhab.binding.bosesoundtouch.internal.BoseStateDescriptionOptionProvider;
44 import org.openhab.binding.bosesoundtouch.internal.CommandExecutor;
45 import org.openhab.binding.bosesoundtouch.internal.OperationModeType;
46 import org.openhab.binding.bosesoundtouch.internal.PresetContainer;
47 import org.openhab.binding.bosesoundtouch.internal.RemoteKeyType;
48 import org.openhab.binding.bosesoundtouch.internal.XMLResponseProcessor;
49 import org.openhab.core.library.types.DecimalType;
50 import org.openhab.core.library.types.NextPreviousType;
51 import org.openhab.core.library.types.OnOffType;
52 import org.openhab.core.library.types.PercentType;
53 import org.openhab.core.library.types.PlayPauseType;
54 import org.openhab.core.library.types.StringType;
55 import org.openhab.core.thing.Channel;
56 import org.openhab.core.thing.ChannelUID;
57 import org.openhab.core.thing.Thing;
58 import org.openhab.core.thing.ThingStatus;
59 import org.openhab.core.thing.ThingStatusDetail;
60 import org.openhab.core.thing.ThingTypeUID;
61 import org.openhab.core.thing.binding.BaseThingHandler;
62 import org.openhab.core.thing.binding.ThingHandlerCallback;
63 import org.openhab.core.thing.type.ChannelTypeUID;
64 import org.openhab.core.types.Command;
65 import org.openhab.core.types.RefreshType;
66 import org.openhab.core.types.State;
67 import org.openhab.core.types.StateOption;
68 import org.slf4j.Logger;
69 import org.slf4j.LoggerFactory;
72 * The {@link BoseSoundTouchHandler} is responsible for handling commands, which are
73 * sent to one of the channels.
75 * @author Christian Niessner - Initial contribution
76 * @author Thomas Traunbauer - Initial contribution
77 * @author Kai Kreuzer - code clean up
78 * @author Alexander Kostadinov - Handling of websocket ping-pong mechanism for thing status check
81 public class BoseSoundTouchHandler extends BaseThingHandler implements WebSocketListener, WebSocketFrameListener {
83 private static final int MAX_MISSED_PONGS_COUNT = 2;
85 private static final int RETRY_INTERVAL_IN_SECS = 30;
87 private final Logger logger = LoggerFactory.getLogger(BoseSoundTouchHandler.class);
89 private @Nullable ScheduledFuture<?> connectionChecker;
90 private @Nullable WebSocketClient client;
91 private @Nullable volatile Session session;
92 private @Nullable volatile CommandExecutor commandExecutor;
93 private volatile int missedPongsCount = 0;
95 private XMLResponseProcessor xmlResponseProcessor;
97 private PresetContainer presetContainer;
98 private BoseStateDescriptionOptionProvider stateOptionProvider;
100 private @Nullable Future<?> sessionFuture;
103 * Creates a new instance of this class for the {@link Thing}.
105 * @param thing the thing that should be handled, not null
106 * @param presetContainer the preset container instance to use for managing presets
108 * @throws IllegalArgumentException if thing or factory argument is null
110 public BoseSoundTouchHandler(Thing thing, PresetContainer presetContainer,
111 BoseStateDescriptionOptionProvider stateOptionProvider) {
113 this.presetContainer = presetContainer;
114 this.stateOptionProvider = stateOptionProvider;
115 xmlResponseProcessor = new XMLResponseProcessor(this);
119 public void initialize() {
120 connectionChecker = scheduler.scheduleWithFixedDelay(() -> checkConnection(), 0, RETRY_INTERVAL_IN_SECS,
125 public void dispose() {
126 ScheduledFuture<?> localConnectionChecker = connectionChecker;
127 if (localConnectionChecker != null) {
128 if (!localConnectionChecker.isCancelled()) {
129 localConnectionChecker.cancel(true);
130 connectionChecker = null;
138 public void handleRemoval() {
139 presetContainer.clear();
140 super.handleRemoval();
144 public void updateState(String channelID, State state) {
145 // don't update channel if it's not linked (in case of Stereo Pair slave device)
146 if (isLinked(channelID)) {
147 super.updateState(channelID, state);
149 logger.debug("{}: Skipping state update because of not linked channel '{}'", getDeviceName(), channelID);
154 public void handleCommand(ChannelUID channelUID, Command command) {
155 CommandExecutor localCommandExecutor = commandExecutor;
156 if (localCommandExecutor == null) {
157 logger.debug("{}: Can't handle command '{}' for channel '{}' because of not initialized connection.",
158 getDeviceName(), command, channelUID);
161 logger.debug("{}: handleCommand({}, {});", getDeviceName(), channelUID, command);
164 if (command.equals(RefreshType.REFRESH)) {
165 switch (channelUID.getIdWithoutGroup()) {
167 localCommandExecutor.getInformations(APIRequest.BASS);
169 case CHANNEL_KEY_CODE:
170 // refresh makes no sense... ?
172 case CHANNEL_NOWPLAYING_ALBUM:
173 case CHANNEL_NOWPLAYING_ARTIST:
174 case CHANNEL_NOWPLAYING_ARTWORK:
175 case CHANNEL_NOWPLAYING_DESCRIPTION:
176 case CHANNEL_NOWPLAYING_GENRE:
177 case CHANNEL_NOWPLAYING_ITEMNAME:
178 case CHANNEL_NOWPLAYING_STATIONLOCATION:
179 case CHANNEL_NOWPLAYING_STATIONNAME:
180 case CHANNEL_NOWPLAYING_TRACK:
181 case CHANNEL_RATEENABLED:
182 case CHANNEL_SKIPENABLED:
183 case CHANNEL_SKIPPREVIOUSENABLED:
184 localCommandExecutor.getInformations(APIRequest.NOW_PLAYING);
187 localCommandExecutor.getInformations(APIRequest.VOLUME);
190 logger.debug("{} : Got command '{}' for channel '{}' which is unhandled!", getDeviceName(), command,
195 switch (channelUID.getIdWithoutGroup()) {
197 if (command instanceof OnOffType) {
198 localCommandExecutor.postPower((OnOffType) command);
200 logger.debug("{}: Unhandled command type: {}: {}", getDeviceName(), command.getClass(), command);
204 if (command instanceof PercentType) {
205 localCommandExecutor.postVolume((PercentType) command);
207 logger.debug("{}: Unhandled command type: {}: {}", getDeviceName(), command.getClass(), command);
211 if (command instanceof OnOffType) {
212 localCommandExecutor.postVolumeMuted((OnOffType) command);
214 logger.debug("{}: Unhandled command type: {}: {}", getDeviceName(), command.getClass(), command);
217 case CHANNEL_OPERATIONMODE:
218 if (command instanceof StringType) {
219 String cmd = command.toString().toUpperCase().trim();
221 OperationModeType mode = OperationModeType.valueOf(cmd);
222 localCommandExecutor.postOperationMode(mode);
223 } catch (IllegalArgumentException iae) {
224 logger.warn("{}: OperationMode \"{}\" is not valid!", getDeviceName(), cmd);
228 case CHANNEL_PLAYER_CONTROL:
229 if ((command instanceof PlayPauseType) || (command instanceof NextPreviousType)) {
230 localCommandExecutor.postPlayerControl(command);
232 logger.debug("{}: Unhandled command type: {}: {}", getDeviceName(), command.getClass(), command);
236 if (command instanceof DecimalType) {
237 localCommandExecutor.postPreset((DecimalType) command);
239 logger.debug("{}: Unhandled command type: {}: {}", getDeviceName(), command.getClass(), command);
243 if (command instanceof DecimalType) {
244 localCommandExecutor.postBass((DecimalType) command);
246 logger.debug("{}: Unhandled command type: {}: {}", getDeviceName(), command.getClass(), command);
249 case CHANNEL_SAVE_AS_PRESET:
250 if (command instanceof DecimalType) {
251 localCommandExecutor.addCurrentContentItemToPresetContainer((DecimalType) command);
253 logger.debug("{}: Unhandled command type: {}: {}", getDeviceName(), command.getClass(), command);
256 case CHANNEL_KEY_CODE:
257 if (command instanceof StringType) {
258 String cmd = command.toString().toUpperCase().trim();
260 RemoteKeyType keyCommand = RemoteKeyType.valueOf(cmd);
261 localCommandExecutor.postRemoteKey(keyCommand);
262 } catch (IllegalArgumentException e) {
263 logger.debug("{}: Unhandled remote key: {}", getDeviceName(), cmd);
268 Channel channel = getThing().getChannel(channelUID.getId());
269 if (channel != null) {
270 ChannelTypeUID chTypeUid = channel.getChannelTypeUID();
271 if (chTypeUid != null) {
272 switch (chTypeUid.getId()) {
273 case CHANNEL_NOTIFICATION_SOUND:
274 String appKey = Objects.toString(getConfig().get(BoseSoundTouchConfiguration.APP_KEY),
276 if (appKey != null && !appKey.isEmpty()) {
277 if (command instanceof StringType) {
278 String url = command.toString();
279 BoseSoundTouchNotificationChannelConfiguration notificationConfiguration = channel
281 .as(BoseSoundTouchNotificationChannelConfiguration.class);
282 if (!url.isEmpty()) {
283 localCommandExecutor.playNotificationSound(appKey,
284 notificationConfiguration, url);
288 logger.warn("Missing app key - cannot use notification api");
294 logger.warn("{} : Got command '{}' for channel '{}' which is unhandled!", getDeviceName(), command,
301 * Returns the CommandExecutor of this handler
303 * @return the CommandExecutor of this handler
305 public @Nullable CommandExecutor getCommandExecutor() {
306 return commandExecutor;
310 * Sets the CommandExecutor of this handler
313 public void setCommandExecutor(@Nullable CommandExecutor commandExecutor) {
314 this.commandExecutor = commandExecutor;
318 * Returns the Session this handler has opened
320 * @return the Session this handler has opened
322 public @Nullable Session getSession() {
327 * Returns the name of the device delivered from itself
329 * @return the name of the device delivered from itself
331 public @Nullable String getDeviceName() {
332 return getThing().getProperties().get(DEVICE_INFO_NAME);
336 * Returns the type of the device delivered from itself
338 * @return the type of the device delivered from itself
340 public @Nullable String getDeviceType() {
341 return getThing().getProperties().get(DEVICE_INFO_TYPE);
345 * Returns the MAC Address of this device
347 * @return the MAC Address of this device (in format "123456789ABC")
349 public @Nullable String getMacAddress() {
350 return ((String) getThing().getConfiguration().get(BoseSoundTouchConfiguration.MAC_ADDRESS)).replaceAll(":",
355 * Returns the IP Address of this device
357 * @return the IP Address of this device
359 public @Nullable String getIPAddress() {
360 return (String) getThing().getConfiguration().getProperties().get(BoseSoundTouchConfiguration.HOST);
364 * Provides the handler internal scheduler instance
366 * @return the {@link ScheduledExecutorService} instance used by this handler
368 public ScheduledExecutorService getScheduler() {
372 public PresetContainer getPresetContainer() {
373 return this.presetContainer;
377 public void onWebSocketConnect(@Nullable Session session) {
378 logger.debug("{}: onWebSocketConnect('{}')", getDeviceName(), session);
379 this.session = session;
380 commandExecutor = new CommandExecutor(this);
381 updateStatus(ThingStatus.ONLINE);
385 public void onWebSocketError(@Nullable Throwable e) {
386 Throwable localThrowable = (e != null) ? e
387 : new IllegalStateException("Null Exception passed to onWebSocketError");
388 logger.debug("{}: Error during websocket communication: {}", getDeviceName(), localThrowable.getMessage(),
390 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, localThrowable.getMessage());
391 CommandExecutor localCommandExecutor = commandExecutor;
392 if (localCommandExecutor != null) {
393 localCommandExecutor.postOperationMode(OperationModeType.OFFLINE);
394 commandExecutor = null;
396 Session localSession = session;
397 if (localSession != null) {
398 localSession.close(StatusCode.SERVER_ERROR, getDeviceName() + ": Failure: " + localThrowable.getMessage());
404 public void onWebSocketText(@Nullable String msg) {
405 logger.debug("{}: onWebSocketText('{}')", getDeviceName(), msg);
407 String localMessage = msg;
408 if (localMessage != null) {
409 xmlResponseProcessor.handleMessage(localMessage);
411 } catch (Exception e) {
412 logger.warn("{}: Could not parse XML from string '{}'.", getDeviceName(), msg, e);
417 public void onWebSocketBinary(byte @Nullable [] payload, int offset, int len) {
418 // we don't expect binary data so just dump if we get some...
419 logger.debug("{}: onWebSocketBinary({}, {}, '{}')", getDeviceName(), offset, len, Arrays.toString(payload));
423 public void onWebSocketClose(int code, @Nullable String reason) {
424 logger.debug("{}: onClose({}, '{}')", getDeviceName(), code, reason);
425 missedPongsCount = 0;
426 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, reason);
427 CommandExecutor localCommandExecutor = commandExecutor;
428 if (localCommandExecutor != null) {
429 localCommandExecutor.postOperationMode(OperationModeType.OFFLINE);
434 public void onWebSocketFrame(@Nullable Frame frame) {
435 Frame localFrame = frame;
436 if (localFrame != null) {
437 if (localFrame.getType() == Type.PONG) {
438 missedPongsCount = 0;
443 private synchronized void openConnection() {
446 WebSocketClient localClient = new WebSocketClient();
447 // we need longer timeouts for web socket.
448 localClient.setMaxIdleTimeout(360 * 1000);
449 // Port seems to be hard coded, therefore no user input or discovery is necessary
450 String wsUrl = "ws://" + getIPAddress() + ":8080/";
451 logger.debug("{}: Connecting to: {}", getDeviceName(), wsUrl);
452 ClientUpgradeRequest request = new ClientUpgradeRequest();
453 request.setSubProtocols("gabbo");
454 localClient.setStopTimeout(1000);
456 sessionFuture = localClient.connect(this, new URI(wsUrl), request);
457 client = localClient;
458 } catch (Exception e) {
463 private synchronized void closeConnection() {
464 Session localSession = this.session;
465 if (localSession != null) {
467 localSession.close(StatusCode.NORMAL, "Binding shutdown");
468 } catch (Exception e) {
469 logger.debug("{}: Error while closing websocket communication: {} ({})", getDeviceName(),
470 e.getClass().getName(), e.getMessage());
474 Future<?> localSessionFuture = sessionFuture;
475 if (localSessionFuture != null) {
476 if (!localSessionFuture.isDone()) {
477 localSessionFuture.cancel(true);
480 WebSocketClient localClient = client;
481 if (localClient != null) {
484 localClient.destroy();
485 } catch (Exception e) {
486 logger.debug("{}: Error while closing websocket communication: {} ({})", getDeviceName(),
487 e.getClass().getName(), e.getMessage());
492 commandExecutor = null;
495 private void checkConnection() {
496 if (getThing().getStatus() != ThingStatus.ONLINE || session == null || client == null
497 || commandExecutor == null) {
498 openConnection(); // try to reconnect....
500 Session localSession = this.session;
501 if (localSession != null) {
502 if (getThing().getStatus() == ThingStatus.ONLINE && localSession.isOpen()) {
504 localSession.getRemote().sendPing(null);
506 } catch (IOException e) {
512 if (missedPongsCount >= MAX_MISSED_PONGS_COUNT) {
513 logger.debug("{}: Closing connection because of too many missed PONGs: {} (max allowed {}) ",
514 getDeviceName(), missedPongsCount, MAX_MISSED_PONGS_COUNT);
515 missedPongsCount = 0;
523 public void refreshPresetChannel() {
524 List<StateOption> stateOptions = presetContainer.getAllPresets().stream().map(e -> e.toStateOption())
525 .sorted(Comparator.comparing(StateOption::getValue)).collect(Collectors.toList());
526 stateOptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_PRESET), stateOptions);
529 public void handleGroupUpdated(BoseSoundTouchConfiguration masterPlayerConfiguration) {
530 String deviceId = getMacAddress();
532 if (masterPlayerConfiguration.macAddress != null) {
534 if (Objects.equals(masterPlayerConfiguration.macAddress, deviceId)) {
535 if (getThing().getThingTypeUID().equals(BST_10_THING_TYPE_UID)) {
536 logger.debug("{}: Stereo Pair was created and this is the master device.", getDeviceName());
538 logger.debug("{}: Unsupported operation for player of type: {}", getDeviceName(),
539 getThing().getThingTypeUID());
542 if (getThing().getThingTypeUID().equals(BST_10_THING_TYPE_UID)) {
543 logger.debug("{}: Stereo Pair was created and this is NOT the master device.", getDeviceName());
544 updateThing(editThing().withChannels(Collections.emptyList()).build());
546 logger.debug("{}: Unsupported operation for player of type: {}", getDeviceName(),
547 getThing().getThingTypeUID());
552 if (getThing().getThingTypeUID().equals(BST_10_THING_TYPE_UID)) {
553 if (getThing().getChannels().isEmpty()) {
554 logger.debug("{}: Stereo Pair was disbounded. Restoring channels", getDeviceName());
555 updateThing(editThing().withChannels(getAllChannels(BST_10_THING_TYPE_UID)).build());
557 logger.debug("{}: Stereo Pair was disbounded.", getDeviceName());
560 logger.debug("{}: Unsupported operation for player of type: {}", getDeviceName(),
561 getThing().getThingTypeUID());
566 private List<Channel> getAllChannels(ThingTypeUID thingTypeUID) {
567 ThingHandlerCallback callback = getCallback();
568 if (callback == null) {
569 return Collections.emptyList();
572 return CHANNEL_IDS.stream()
573 .map(channelId -> callback.createChannelBuilder(new ChannelUID(getThing().getUID(), channelId),
574 createChannelTypeUID(thingTypeUID, channelId)).build())
575 .collect(Collectors.toList());
578 private ChannelTypeUID createChannelTypeUID(ThingTypeUID thingTypeUID, String channelId) {
579 if (CHANNEL_OPERATIONMODE.equals(channelId)) {
580 return createOperationModeChannelTypeUID(thingTypeUID);
583 return new ChannelTypeUID(BINDING_ID, channelId);
586 private ChannelTypeUID createOperationModeChannelTypeUID(ThingTypeUID thingTypeUID) {
587 String channelTypeId = CHANNEL_TYPE_OPERATION_MODE_DEFAULT;
589 if (BST_10_THING_TYPE_UID.equals(thingTypeUID) || BST_20_THING_TYPE_UID.equals(thingTypeUID)
590 || BST_30_THING_TYPE_UID.equals(thingTypeUID)) {
591 channelTypeId = CHANNEL_TYPE_OPERATION_MODE_BST_10_20_30;
592 } else if (BST_300_THING_TYPE_UID.equals(thingTypeUID)) {
593 channelTypeId = CHANNEL_TYPE_OPERATION_MODE_BST_300;
594 } else if (BST_SA5A_THING_TYPE_UID.equals(thingTypeUID)) {
595 channelTypeId = CHANNEL_TYPE_OPERATION_MODE_BST_SA5A;
596 } else if (BST_WLA_THING_TYPE_UID.equals(thingTypeUID)) {
597 channelTypeId = CHANNEL_TYPE_OPERATION_MODE_BST_WLA;
598 } else if (BST_WSMS_THING_TYPE_UID.equals(thingTypeUID)) {
599 channelTypeId = CHANNEL_TYPE_OPERATION_MODE_DEFAULT;
602 return new ChannelTypeUID(BINDING_ID, channelTypeId);