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.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.jetty.websocket.api.Session;
31 import org.eclipse.jetty.websocket.api.StatusCode;
32 import org.eclipse.jetty.websocket.api.WebSocketFrameListener;
33 import org.eclipse.jetty.websocket.api.WebSocketListener;
34 import org.eclipse.jetty.websocket.api.extensions.Frame;
35 import org.eclipse.jetty.websocket.api.extensions.Frame.Type;
36 import org.eclipse.jetty.websocket.client.ClientUpgradeRequest;
37 import org.eclipse.jetty.websocket.client.WebSocketClient;
38 import org.openhab.binding.bosesoundtouch.internal.APIRequest;
39 import org.openhab.binding.bosesoundtouch.internal.BoseSoundTouchConfiguration;
40 import org.openhab.binding.bosesoundtouch.internal.BoseSoundTouchNotificationChannelConfiguration;
41 import org.openhab.binding.bosesoundtouch.internal.BoseStateDescriptionOptionProvider;
42 import org.openhab.binding.bosesoundtouch.internal.CommandExecutor;
43 import org.openhab.binding.bosesoundtouch.internal.OperationModeType;
44 import org.openhab.binding.bosesoundtouch.internal.PresetContainer;
45 import org.openhab.binding.bosesoundtouch.internal.RemoteKeyType;
46 import org.openhab.binding.bosesoundtouch.internal.XMLResponseProcessor;
47 import org.openhab.core.library.types.DecimalType;
48 import org.openhab.core.library.types.NextPreviousType;
49 import org.openhab.core.library.types.OnOffType;
50 import org.openhab.core.library.types.PercentType;
51 import org.openhab.core.library.types.PlayPauseType;
52 import org.openhab.core.library.types.StringType;
53 import org.openhab.core.thing.Channel;
54 import org.openhab.core.thing.ChannelUID;
55 import org.openhab.core.thing.Thing;
56 import org.openhab.core.thing.ThingStatus;
57 import org.openhab.core.thing.ThingStatusDetail;
58 import org.openhab.core.thing.ThingTypeUID;
59 import org.openhab.core.thing.binding.BaseThingHandler;
60 import org.openhab.core.thing.binding.ThingHandlerCallback;
61 import org.openhab.core.thing.type.ChannelTypeUID;
62 import org.openhab.core.types.Command;
63 import org.openhab.core.types.RefreshType;
64 import org.openhab.core.types.State;
65 import org.openhab.core.types.StateOption;
66 import org.slf4j.Logger;
67 import org.slf4j.LoggerFactory;
70 * The {@link BoseSoundTouchHandler} is responsible for handling commands, which are
71 * sent to one of the channels.
73 * @author Christian Niessner - Initial contribution
74 * @author Thomas Traunbauer - Initial contribution
75 * @author Kai Kreuzer - code clean up
76 * @author Alexander Kostadinov - Handling of websocket ping-pong mechanism for thing status check
78 public class BoseSoundTouchHandler extends BaseThingHandler implements WebSocketListener, WebSocketFrameListener {
80 private static final int MAX_MISSED_PONGS_COUNT = 2;
82 private static final int RETRY_INTERVAL_IN_SECS = 30;
84 private final Logger logger = LoggerFactory.getLogger(BoseSoundTouchHandler.class);
86 private ScheduledFuture<?> connectionChecker;
87 private WebSocketClient client;
88 private volatile Session session;
89 private volatile CommandExecutor commandExecutor;
90 private volatile int missedPongsCount = 0;
92 private XMLResponseProcessor xmlResponseProcessor;
94 private PresetContainer presetContainer;
95 private BoseStateDescriptionOptionProvider stateOptionProvider;
97 private Future<?> sessionFuture;
100 * Creates a new instance of this class for the {@link Thing}.
102 * @param thing the thing that should be handled, not null
103 * @param presetContainer the preset container instance to use for managing presets
105 * @throws IllegalArgumentException if thing or factory argument is null
107 public BoseSoundTouchHandler(Thing thing, PresetContainer presetContainer,
108 BoseStateDescriptionOptionProvider stateOptionProvider) {
110 this.presetContainer = presetContainer;
111 this.stateOptionProvider = stateOptionProvider;
112 xmlResponseProcessor = new XMLResponseProcessor(this);
116 public void initialize() {
117 connectionChecker = scheduler.scheduleWithFixedDelay(() -> checkConnection(), 0, RETRY_INTERVAL_IN_SECS,
122 public void dispose() {
123 if (connectionChecker != null && !connectionChecker.isCancelled()) {
124 connectionChecker.cancel(true);
125 connectionChecker = null;
132 public void handleRemoval() {
133 presetContainer.clear();
134 super.handleRemoval();
138 public void updateState(String channelID, State state) {
139 // don't update channel if it's not linked (in case of Stereo Pair slave device)
140 if (isLinked(channelID)) {
141 super.updateState(channelID, state);
143 logger.debug("{}: Skipping state update because of not linked channel '{}'", getDeviceName(), channelID);
148 public void handleCommand(ChannelUID channelUID, Command command) {
149 if (commandExecutor == null) {
150 logger.debug("{}: Can't handle command '{}' for channel '{}' because of not initialized connection.",
151 getDeviceName(), command, channelUID);
154 logger.debug("{}: handleCommand({}, {});", getDeviceName(), channelUID, command);
157 if (command.equals(RefreshType.REFRESH)) {
158 switch (channelUID.getIdWithoutGroup()) {
160 commandExecutor.getInformations(APIRequest.BASS);
162 case CHANNEL_KEY_CODE:
163 // refresh makes no sense... ?
165 case CHANNEL_NOWPLAYING_ALBUM:
166 case CHANNEL_NOWPLAYING_ARTIST:
167 case CHANNEL_NOWPLAYING_ARTWORK:
168 case CHANNEL_NOWPLAYING_DESCRIPTION:
169 case CHANNEL_NOWPLAYING_GENRE:
170 case CHANNEL_NOWPLAYING_ITEMNAME:
171 case CHANNEL_NOWPLAYING_STATIONLOCATION:
172 case CHANNEL_NOWPLAYING_STATIONNAME:
173 case CHANNEL_NOWPLAYING_TRACK:
174 case CHANNEL_RATEENABLED:
175 case CHANNEL_SKIPENABLED:
176 case CHANNEL_SKIPPREVIOUSENABLED:
177 commandExecutor.getInformations(APIRequest.NOW_PLAYING);
180 commandExecutor.getInformations(APIRequest.VOLUME);
183 logger.debug("{} : Got command '{}' for channel '{}' which is unhandled!", getDeviceName(), command,
188 switch (channelUID.getIdWithoutGroup()) {
190 if (command instanceof OnOffType) {
191 commandExecutor.postPower((OnOffType) command);
193 logger.debug("{}: Unhandled command type: {}: {}", getDeviceName(), command.getClass(), command);
197 if (command instanceof PercentType) {
198 commandExecutor.postVolume((PercentType) command);
200 logger.debug("{}: Unhandled command type: {}: {}", getDeviceName(), command.getClass(), command);
204 if (command instanceof OnOffType) {
205 commandExecutor.postVolumeMuted((OnOffType) command);
207 logger.debug("{}: Unhandled command type: {}: {}", getDeviceName(), command.getClass(), command);
210 case CHANNEL_OPERATIONMODE:
211 if (command instanceof StringType) {
212 String cmd = command.toString().toUpperCase().trim();
214 OperationModeType mode = OperationModeType.valueOf(cmd);
215 commandExecutor.postOperationMode(mode);
216 } catch (IllegalArgumentException iae) {
217 logger.warn("{}: OperationMode \"{}\" is not valid!", getDeviceName(), cmd);
221 case CHANNEL_PLAYER_CONTROL:
222 if ((command instanceof PlayPauseType) || (command instanceof NextPreviousType)) {
223 commandExecutor.postPlayerControl(command);
225 logger.debug("{}: Unhandled command type: {}: {}", getDeviceName(), command.getClass(), command);
229 if (command instanceof DecimalType) {
230 commandExecutor.postPreset((DecimalType) command);
232 logger.debug("{}: Unhandled command type: {}: {}", getDeviceName(), command.getClass(), command);
236 if (command instanceof DecimalType) {
237 commandExecutor.postBass((DecimalType) command);
239 logger.debug("{}: Unhandled command type: {}: {}", getDeviceName(), command.getClass(), command);
242 case CHANNEL_SAVE_AS_PRESET:
243 if (command instanceof DecimalType) {
244 commandExecutor.addCurrentContentItemToPresetContainer((DecimalType) command);
246 logger.debug("{}: Unhandled command type: {}: {}", getDeviceName(), command.getClass(), command);
249 case CHANNEL_KEY_CODE:
250 if (command instanceof StringType) {
251 String cmd = command.toString().toUpperCase().trim();
253 RemoteKeyType keyCommand = RemoteKeyType.valueOf(cmd);
254 commandExecutor.postRemoteKey(keyCommand);
255 } catch (IllegalArgumentException e) {
256 logger.debug("{}: Unhandled remote key: {}", getDeviceName(), cmd);
261 Channel channel = getThing().getChannel(channelUID.getId());
262 if (channel != null) {
263 ChannelTypeUID chTypeUid = channel.getChannelTypeUID();
264 if (chTypeUid != null) {
265 switch (channel.getChannelTypeUID().getId()) {
266 case CHANNEL_NOTIFICATION_SOUND:
267 String appKey = Objects.toString(getConfig().get(BoseSoundTouchConfiguration.APP_KEY),
269 if (appKey != null && !appKey.isEmpty()) {
270 if (command instanceof StringType) {
271 String url = command.toString();
272 BoseSoundTouchNotificationChannelConfiguration notificationConfiguration = channel
274 .as(BoseSoundTouchNotificationChannelConfiguration.class);
275 if (!url.isEmpty()) {
276 commandExecutor.playNotificationSound(appKey, notificationConfiguration,
281 logger.warn("Missing app key - cannot use notification api");
287 logger.warn("{} : Got command '{}' for channel '{}' which is unhandled!", getDeviceName(), command,
294 * Returns the CommandExecutor of this handler
296 * @return the CommandExecutor of this handler
298 public CommandExecutor getCommandExecutor() {
299 return commandExecutor;
303 * Returns the Session this handler has opened
305 * @return the Session this handler has opened
307 public Session getSession() {
312 * Returns the name of the device delivered from itself
314 * @return the name of the device delivered from itself
316 public String getDeviceName() {
317 return getThing().getProperties().get(DEVICE_INFO_NAME);
321 * Returns the type of the device delivered from itself
323 * @return the type of the device delivered from itself
325 public String getDeviceType() {
326 return getThing().getProperties().get(DEVICE_INFO_TYPE);
330 * Returns the MAC Address of this device
332 * @return the MAC Address of this device (in format "123456789ABC")
334 public String getMacAddress() {
335 return ((String) getThing().getConfiguration().get(BoseSoundTouchConfiguration.MAC_ADDRESS)).replaceAll(":",
340 * Returns the IP Address of this device
342 * @return the IP Address of this device
344 public String getIPAddress() {
345 return (String) getThing().getConfiguration().getProperties().get(BoseSoundTouchConfiguration.HOST);
349 * Provides the handler internal scheduler instance
351 * @return the {@link ScheduledExecutorService} instance used by this handler
353 public ScheduledExecutorService getScheduler() {
357 public PresetContainer getPresetContainer() {
358 return this.presetContainer;
362 public void onWebSocketConnect(Session session) {
363 logger.debug("{}: onWebSocketConnect('{}')", getDeviceName(), session);
364 this.session = session;
365 commandExecutor = new CommandExecutor(this);
366 updateStatus(ThingStatus.ONLINE);
370 public void onWebSocketError(Throwable e) {
371 logger.debug("{}: Error during websocket communication: {}", getDeviceName(), e.getMessage(), e);
372 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
373 if (commandExecutor != null) {
374 commandExecutor.postOperationMode(OperationModeType.OFFLINE);
375 commandExecutor = null;
377 if (session != null) {
378 session.close(StatusCode.SERVER_ERROR, getDeviceName() + ": Failure: " + e.getMessage());
384 public void onWebSocketText(String msg) {
385 logger.debug("{}: onWebSocketText('{}')", getDeviceName(), msg);
387 xmlResponseProcessor.handleMessage(msg);
388 } catch (Exception e) {
389 logger.warn("{}: Could not parse XML from string '{}'.", getDeviceName(), msg, e);
394 public void onWebSocketBinary(byte[] arr, int pos, int len) {
395 // we don't expect binary data so just dump if we get some...
396 logger.debug("{}: onWebSocketBinary({}, {}, '{}')", getDeviceName(), pos, len, Arrays.toString(arr));
400 public void onWebSocketClose(int code, String reason) {
401 logger.debug("{}: onClose({}, '{}')", getDeviceName(), code, reason);
402 missedPongsCount = 0;
403 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, reason);
404 if (commandExecutor != null) {
405 commandExecutor.postOperationMode(OperationModeType.OFFLINE);
410 public void onWebSocketFrame(Frame frame) {
411 if (frame.getType() == Type.PONG) {
412 missedPongsCount = 0;
416 private synchronized void openConnection() {
419 client = new WebSocketClient();
420 // we need longer timeouts for web socket.
421 client.setMaxIdleTimeout(360 * 1000);
422 // Port seems to be hard coded, therefore no user input or discovery is necessary
423 String wsUrl = "ws://" + getIPAddress() + ":8080/";
424 logger.debug("{}: Connecting to: {}", getDeviceName(), wsUrl);
425 ClientUpgradeRequest request = new ClientUpgradeRequest();
426 request.setSubProtocols("gabbo");
427 client.setStopTimeout(1000);
429 sessionFuture = client.connect(this, new URI(wsUrl), request);
430 } catch (Exception e) {
435 private synchronized void closeConnection() {
436 if (session != null) {
438 session.close(StatusCode.NORMAL, "Binding shutdown");
439 } catch (Exception e) {
440 logger.debug("{}: Error while closing websocket communication: {} ({})", getDeviceName(),
441 e.getClass().getName(), e.getMessage());
445 if (sessionFuture != null && !sessionFuture.isDone()) {
446 sessionFuture.cancel(true);
448 if (client != null) {
452 } catch (Exception e) {
453 logger.debug("{}: Error while closing websocket communication: {} ({})", getDeviceName(),
454 e.getClass().getName(), e.getMessage());
459 commandExecutor = null;
462 private void checkConnection() {
463 if (getThing().getStatus() != ThingStatus.ONLINE || session == null || client == null
464 || commandExecutor == null) {
465 openConnection(); // try to reconnect....
468 if (getThing().getStatus() == ThingStatus.ONLINE && this.session != null && this.session.isOpen()) {
470 this.session.getRemote().sendPing(null);
472 } catch (IOException | NullPointerException e) {
478 if (missedPongsCount >= MAX_MISSED_PONGS_COUNT) {
479 logger.debug("{}: Closing connection because of too many missed PONGs: {} (max allowed {}) ",
480 getDeviceName(), missedPongsCount, MAX_MISSED_PONGS_COUNT);
481 missedPongsCount = 0;
488 public void refreshPresetChannel() {
489 List<StateOption> stateOptions = presetContainer.getAllPresets().stream().map(e -> e.toStateOption())
490 .sorted(Comparator.comparing(StateOption::getValue)).collect(Collectors.toList());
491 stateOptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_PRESET), stateOptions);
494 public void handleGroupUpdated(BoseSoundTouchConfiguration masterPlayerConfiguration) {
495 String deviceId = getMacAddress();
497 if (masterPlayerConfiguration != null && masterPlayerConfiguration.macAddress != null) {
499 if (Objects.equals(masterPlayerConfiguration.macAddress, deviceId)) {
500 if (getThing().getThingTypeUID().equals(BST_10_THING_TYPE_UID)) {
501 logger.debug("{}: Stereo Pair was created and this is the master device.", getDeviceName());
503 logger.debug("{}: Unsupported operation for player of type: {}", getDeviceName(),
504 getThing().getThingTypeUID());
507 if (getThing().getThingTypeUID().equals(BST_10_THING_TYPE_UID)) {
508 logger.debug("{}: Stereo Pair was created and this is NOT the master device.", getDeviceName());
509 updateThing(editThing().withChannels(Collections.emptyList()).build());
511 logger.debug("{}: Unsupported operation for player of type: {}", getDeviceName(),
512 getThing().getThingTypeUID());
517 if (getThing().getThingTypeUID().equals(BST_10_THING_TYPE_UID)) {
518 if (getThing().getChannels().isEmpty()) {
519 logger.debug("{}: Stereo Pair was disbounded. Restoring channels", getDeviceName());
520 updateThing(editThing().withChannels(getAllChannels(BST_10_THING_TYPE_UID)).build());
522 logger.debug("{}: Stereo Pair was disbounded.", getDeviceName());
525 logger.debug("{}: Unsupported operation for player of type: {}", getDeviceName(),
526 getThing().getThingTypeUID());
531 private List<Channel> getAllChannels(ThingTypeUID thingTypeUID) {
532 ThingHandlerCallback callback = getCallback();
533 if (callback == null) {
534 return Collections.emptyList();
537 return CHANNEL_IDS.stream()
538 .map(channelId -> callback.createChannelBuilder(new ChannelUID(getThing().getUID(), channelId),
539 createChannelTypeUID(thingTypeUID, channelId)).build())
540 .collect(Collectors.toList());
543 private ChannelTypeUID createChannelTypeUID(ThingTypeUID thingTypeUID, String channelId) {
544 if (CHANNEL_OPERATIONMODE.equals(channelId)) {
545 return createOperationModeChannelTypeUID(thingTypeUID);
548 return new ChannelTypeUID(BINDING_ID, channelId);
551 private ChannelTypeUID createOperationModeChannelTypeUID(ThingTypeUID thingTypeUID) {
552 String channelTypeId = CHANNEL_TYPE_OPERATION_MODE_DEFAULT;
554 if (BST_10_THING_TYPE_UID.equals(thingTypeUID) || BST_20_THING_TYPE_UID.equals(thingTypeUID)
555 || BST_30_THING_TYPE_UID.equals(thingTypeUID)) {
556 channelTypeId = CHANNEL_TYPE_OPERATION_MODE_BST_10_20_30;
557 } else if (BST_300_THING_TYPE_UID.equals(thingTypeUID)) {
558 channelTypeId = CHANNEL_TYPE_OPERATION_MODE_BST_300;
559 } else if (BST_SA5A_THING_TYPE_UID.equals(thingTypeUID)) {
560 channelTypeId = CHANNEL_TYPE_OPERATION_MODE_BST_SA5A;
561 } else if (BST_WLA_THING_TYPE_UID.equals(thingTypeUID)) {
562 channelTypeId = CHANNEL_TYPE_OPERATION_MODE_BST_WLA;
563 } else if (BST_WSMS_THING_TYPE_UID.equals(thingTypeUID)) {
564 channelTypeId = CHANNEL_TYPE_OPERATION_MODE_DEFAULT;
567 return new ChannelTypeUID(BINDING_ID, channelTypeId);