]> git.basschouten.com Git - openhab-addons.git/blob
63093e1dae5891d4a10caabcae7c447c9b5179df
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.bosesoundtouch.internal.handler;
14
15 import static org.openhab.binding.bosesoundtouch.internal.BoseSoundTouchBindingConstants.*;
16
17 import java.io.IOException;
18 import java.net.URI;
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;
29
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;
68
69 /**
70  * The {@link BoseSoundTouchHandler} is responsible for handling commands, which are
71  * sent to one of the channels.
72  *
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
77  */
78 public class BoseSoundTouchHandler extends BaseThingHandler implements WebSocketListener, WebSocketFrameListener {
79
80     private static final int MAX_MISSED_PONGS_COUNT = 2;
81
82     private static final int RETRY_INTERVAL_IN_SECS = 30;
83
84     private final Logger logger = LoggerFactory.getLogger(BoseSoundTouchHandler.class);
85
86     private ScheduledFuture<?> connectionChecker;
87     private WebSocketClient client;
88     private volatile Session session;
89     private volatile CommandExecutor commandExecutor;
90     private volatile int missedPongsCount = 0;
91
92     private XMLResponseProcessor xmlResponseProcessor;
93
94     private PresetContainer presetContainer;
95     private BoseStateDescriptionOptionProvider stateOptionProvider;
96
97     private Future<?> sessionFuture;
98
99     /**
100      * Creates a new instance of this class for the {@link Thing}.
101      *
102      * @param thing the thing that should be handled, not null
103      * @param presetContainer the preset container instance to use for managing presets
104      *
105      * @throws IllegalArgumentException if thing or factory argument is null
106      */
107     public BoseSoundTouchHandler(Thing thing, PresetContainer presetContainer,
108             BoseStateDescriptionOptionProvider stateOptionProvider) {
109         super(thing);
110         this.presetContainer = presetContainer;
111         this.stateOptionProvider = stateOptionProvider;
112         xmlResponseProcessor = new XMLResponseProcessor(this);
113     }
114
115     @Override
116     public void initialize() {
117         connectionChecker = scheduler.scheduleWithFixedDelay(() -> checkConnection(), 0, RETRY_INTERVAL_IN_SECS,
118                 TimeUnit.SECONDS);
119     }
120
121     @Override
122     public void dispose() {
123         if (connectionChecker != null && !connectionChecker.isCancelled()) {
124             connectionChecker.cancel(true);
125             connectionChecker = null;
126         }
127         closeConnection();
128         super.dispose();
129     }
130
131     @Override
132     public void handleRemoval() {
133         presetContainer.clear();
134         super.handleRemoval();
135     }
136
137     @Override
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);
142         } else {
143             logger.debug("{}: Skipping state update because of not linked channel '{}'", getDeviceName(), channelID);
144         }
145     }
146
147     @Override
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);
152             return;
153         } else {
154             logger.debug("{}: handleCommand({}, {});", getDeviceName(), channelUID, command);
155         }
156
157         if (command.equals(RefreshType.REFRESH)) {
158             switch (channelUID.getIdWithoutGroup()) {
159                 case CHANNEL_BASS:
160                     commandExecutor.getInformations(APIRequest.BASS);
161                     break;
162                 case CHANNEL_KEY_CODE:
163                     // refresh makes no sense... ?
164                     break;
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);
178                     break;
179                 case CHANNEL_VOLUME:
180                     commandExecutor.getInformations(APIRequest.VOLUME);
181                     break;
182                 default:
183                     logger.debug("{} : Got command '{}' for channel '{}' which is unhandled!", getDeviceName(), command,
184                             channelUID.getId());
185             }
186             return;
187         }
188         switch (channelUID.getIdWithoutGroup()) {
189             case CHANNEL_POWER:
190                 if (command instanceof OnOffType) {
191                     commandExecutor.postPower((OnOffType) command);
192                 } else {
193                     logger.debug("{}: Unhandled command type: {}: {}", getDeviceName(), command.getClass(), command);
194                 }
195                 break;
196             case CHANNEL_VOLUME:
197                 if (command instanceof PercentType) {
198                     commandExecutor.postVolume((PercentType) command);
199                 } else {
200                     logger.debug("{}: Unhandled command type: {}: {}", getDeviceName(), command.getClass(), command);
201                 }
202                 break;
203             case CHANNEL_MUTE:
204                 if (command instanceof OnOffType) {
205                     commandExecutor.postVolumeMuted((OnOffType) command);
206                 } else {
207                     logger.debug("{}: Unhandled command type: {}: {}", getDeviceName(), command.getClass(), command);
208                 }
209                 break;
210             case CHANNEL_OPERATIONMODE:
211                 if (command instanceof StringType) {
212                     String cmd = command.toString().toUpperCase().trim();
213                     try {
214                         OperationModeType mode = OperationModeType.valueOf(cmd);
215                         commandExecutor.postOperationMode(mode);
216                     } catch (IllegalArgumentException iae) {
217                         logger.warn("{}: OperationMode \"{}\" is not valid!", getDeviceName(), cmd);
218                     }
219                 }
220                 break;
221             case CHANNEL_PLAYER_CONTROL:
222                 if ((command instanceof PlayPauseType) || (command instanceof NextPreviousType)) {
223                     commandExecutor.postPlayerControl(command);
224                 } else {
225                     logger.debug("{}: Unhandled command type: {}: {}", getDeviceName(), command.getClass(), command);
226                 }
227                 break;
228             case CHANNEL_PRESET:
229                 if (command instanceof DecimalType) {
230                     commandExecutor.postPreset((DecimalType) command);
231                 } else {
232                     logger.debug("{}: Unhandled command type: {}: {}", getDeviceName(), command.getClass(), command);
233                 }
234                 break;
235             case CHANNEL_BASS:
236                 if (command instanceof DecimalType) {
237                     commandExecutor.postBass((DecimalType) command);
238                 } else {
239                     logger.debug("{}: Unhandled command type: {}: {}", getDeviceName(), command.getClass(), command);
240                 }
241                 break;
242             case CHANNEL_SAVE_AS_PRESET:
243                 if (command instanceof DecimalType) {
244                     commandExecutor.addCurrentContentItemToPresetContainer((DecimalType) command);
245                 } else {
246                     logger.debug("{}: Unhandled command type: {}: {}", getDeviceName(), command.getClass(), command);
247                 }
248                 break;
249             case CHANNEL_KEY_CODE:
250                 if (command instanceof StringType) {
251                     String cmd = command.toString().toUpperCase().trim();
252                     try {
253                         RemoteKeyType keyCommand = RemoteKeyType.valueOf(cmd);
254                         commandExecutor.postRemoteKey(keyCommand);
255                     } catch (IllegalArgumentException e) {
256                         logger.debug("{}: Unhandled remote key: {}", getDeviceName(), cmd);
257                     }
258                 }
259                 break;
260             default:
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),
268                                         null);
269                                 if (appKey != null && !appKey.isEmpty()) {
270                                     if (command instanceof StringType) {
271                                         String url = command.toString();
272                                         BoseSoundTouchNotificationChannelConfiguration notificationConfiguration = channel
273                                                 .getConfiguration()
274                                                 .as(BoseSoundTouchNotificationChannelConfiguration.class);
275                                         if (!url.isEmpty()) {
276                                             commandExecutor.playNotificationSound(appKey, notificationConfiguration,
277                                                     url);
278                                         }
279                                     }
280                                 } else {
281                                     logger.warn("Missing app key - cannot use notification api");
282                                 }
283                                 return;
284                         }
285                     }
286                 }
287                 logger.warn("{} : Got command '{}' for channel '{}' which is unhandled!", getDeviceName(), command,
288                         channelUID.getId());
289                 break;
290         }
291     }
292
293     /**
294      * Returns the CommandExecutor of this handler
295      *
296      * @return the CommandExecutor of this handler
297      */
298     public CommandExecutor getCommandExecutor() {
299         return commandExecutor;
300     }
301
302     /**
303      * Returns the Session this handler has opened
304      *
305      * @return the Session this handler has opened
306      */
307     public Session getSession() {
308         return session;
309     }
310
311     /**
312      * Returns the name of the device delivered from itself
313      *
314      * @return the name of the device delivered from itself
315      */
316     public String getDeviceName() {
317         return getThing().getProperties().get(DEVICE_INFO_NAME);
318     }
319
320     /**
321      * Returns the type of the device delivered from itself
322      *
323      * @return the type of the device delivered from itself
324      */
325     public String getDeviceType() {
326         return getThing().getProperties().get(DEVICE_INFO_TYPE);
327     }
328
329     /**
330      * Returns the MAC Address of this device
331      *
332      * @return the MAC Address of this device (in format "123456789ABC")
333      */
334     public String getMacAddress() {
335         return ((String) getThing().getConfiguration().get(BoseSoundTouchConfiguration.MAC_ADDRESS)).replaceAll(":",
336                 "");
337     }
338
339     /**
340      * Returns the IP Address of this device
341      *
342      * @return the IP Address of this device
343      */
344     public String getIPAddress() {
345         return (String) getThing().getConfiguration().getProperties().get(BoseSoundTouchConfiguration.HOST);
346     }
347
348     /**
349      * Provides the handler internal scheduler instance
350      *
351      * @return the {@link ScheduledExecutorService} instance used by this handler
352      */
353     public ScheduledExecutorService getScheduler() {
354         return scheduler;
355     }
356
357     public PresetContainer getPresetContainer() {
358         return this.presetContainer;
359     }
360
361     @Override
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);
367     }
368
369     @Override
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;
376         }
377         if (session != null) {
378             session.close(StatusCode.SERVER_ERROR, getDeviceName() + ": Failure: " + e.getMessage());
379             session = null;
380         }
381     }
382
383     @Override
384     public void onWebSocketText(String msg) {
385         logger.debug("{}: onWebSocketText('{}')", getDeviceName(), msg);
386         try {
387             xmlResponseProcessor.handleMessage(msg);
388         } catch (Exception e) {
389             logger.warn("{}: Could not parse XML from string '{}'.", getDeviceName(), msg, e);
390         }
391     }
392
393     @Override
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));
397     }
398
399     @Override
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);
406         }
407     }
408
409     @Override
410     public void onWebSocketFrame(Frame frame) {
411         if (frame.getType() == Type.PONG) {
412             missedPongsCount = 0;
413         }
414     }
415
416     private synchronized void openConnection() {
417         closeConnection();
418         try {
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);
428             client.start();
429             sessionFuture = client.connect(this, new URI(wsUrl), request);
430         } catch (Exception e) {
431             onWebSocketError(e);
432         }
433     }
434
435     private synchronized void closeConnection() {
436         if (session != null) {
437             try {
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());
442             }
443             session = null;
444         }
445         if (sessionFuture != null && !sessionFuture.isDone()) {
446             sessionFuture.cancel(true);
447         }
448         if (client != null) {
449             try {
450                 client.stop();
451                 client.destroy();
452             } catch (Exception e) {
453                 logger.debug("{}: Error while closing websocket communication: {} ({})", getDeviceName(),
454                         e.getClass().getName(), e.getMessage());
455             }
456             client = null;
457         }
458
459         commandExecutor = null;
460     }
461
462     private void checkConnection() {
463         if (getThing().getStatus() != ThingStatus.ONLINE || session == null || client == null
464                 || commandExecutor == null) {
465             openConnection(); // try to reconnect....
466         }
467
468         if (getThing().getStatus() == ThingStatus.ONLINE && this.session != null && this.session.isOpen()) {
469             try {
470                 this.session.getRemote().sendPing(null);
471                 missedPongsCount++;
472             } catch (IOException | NullPointerException e) {
473                 onWebSocketError(e);
474                 closeConnection();
475                 openConnection();
476             }
477
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;
482                 closeConnection();
483                 openConnection();
484             }
485         }
486     }
487
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);
492     }
493
494     public void handleGroupUpdated(BoseSoundTouchConfiguration masterPlayerConfiguration) {
495         String deviceId = getMacAddress();
496
497         if (masterPlayerConfiguration != null && masterPlayerConfiguration.macAddress != null) {
498             // Stereo pair
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());
502                 } else {
503                     logger.debug("{}: Unsupported operation for player of type: {}", getDeviceName(),
504                             getThing().getThingTypeUID());
505                 }
506             } else {
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());
510                 } else {
511                     logger.debug("{}: Unsupported operation for player of type: {}", getDeviceName(),
512                             getThing().getThingTypeUID());
513                 }
514             }
515         } else {
516             // NO Stereo Pair
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());
521                 } else {
522                     logger.debug("{}: Stereo Pair was disbounded.", getDeviceName());
523                 }
524             } else {
525                 logger.debug("{}: Unsupported operation for player of type: {}", getDeviceName(),
526                         getThing().getThingTypeUID());
527             }
528         }
529     }
530
531     private List<Channel> getAllChannels(ThingTypeUID thingTypeUID) {
532         ThingHandlerCallback callback = getCallback();
533         if (callback == null) {
534             return Collections.emptyList();
535         }
536
537         return CHANNEL_IDS.stream()
538                 .map(channelId -> callback.createChannelBuilder(new ChannelUID(getThing().getUID(), channelId),
539                         createChannelTypeUID(thingTypeUID, channelId)).build())
540                 .collect(Collectors.toList());
541     }
542
543     private ChannelTypeUID createChannelTypeUID(ThingTypeUID thingTypeUID, String channelId) {
544         if (CHANNEL_OPERATIONMODE.equals(channelId)) {
545             return createOperationModeChannelTypeUID(thingTypeUID);
546         }
547
548         return new ChannelTypeUID(BINDING_ID, channelId);
549     }
550
551     private ChannelTypeUID createOperationModeChannelTypeUID(ThingTypeUID thingTypeUID) {
552         String channelTypeId = CHANNEL_TYPE_OPERATION_MODE_DEFAULT;
553
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;
565         }
566
567         return new ChannelTypeUID(BINDING_ID, channelTypeId);
568     }
569 }