2 * Copyright (c) 2010-2022 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;
15 import static org.openhab.binding.bosesoundtouch.internal.BoseSoundTouchBindingConstants.*;
16 import static org.openhab.core.thing.Thing.PROPERTY_FIRMWARE_VERSION;
17 import static org.openhab.core.thing.Thing.PROPERTY_HARDWARE_VERSION;
18 import static org.openhab.core.thing.Thing.PROPERTY_MODEL_ID;
20 import java.util.HashMap;
22 import java.util.Objects;
23 import java.util.Stack;
25 import org.eclipse.jdt.annotation.Nullable;
26 import org.openhab.binding.bosesoundtouch.internal.handler.BoseSoundTouchHandler;
27 import org.openhab.core.io.net.http.HttpUtil;
28 import org.openhab.core.library.types.DecimalType;
29 import org.openhab.core.library.types.OnOffType;
30 import org.openhab.core.library.types.PercentType;
31 import org.openhab.core.library.types.PlayPauseType;
32 import org.openhab.core.library.types.RawType;
33 import org.openhab.core.library.types.StringType;
34 import org.openhab.core.types.State;
35 import org.openhab.core.types.UnDefType;
36 import org.slf4j.Logger;
37 import org.slf4j.LoggerFactory;
38 import org.xml.sax.Attributes;
39 import org.xml.sax.SAXException;
40 import org.xml.sax.helpers.DefaultHandler;
43 * The {@link XMLResponseHandler} class handles the XML communication with the Soundtouch
45 * @author Christian Niessner - Initial contribution
46 * @author Thomas Traunbauer - Initial contribution
47 * @author Kai Kreuzer - code clean up
49 public class XMLResponseHandler extends DefaultHandler {
51 private final Logger logger = LoggerFactory.getLogger(XMLResponseHandler.class);
53 private BoseSoundTouchHandler handler;
54 private CommandExecutor commandExecutor;
56 private Map<XMLHandlerState, Map<String, XMLHandlerState>> stateSwitchingMap;
58 private final Stack<XMLHandlerState> states = new Stack<>();
59 private XMLHandlerState state = XMLHandlerState.INIT;
60 private boolean msgHeaderWasValid;
62 private ContentItem contentItem;
63 private boolean volumeMuteEnabled;
64 private OnOffType rateEnabled;
65 private OnOffType skipEnabled;
66 private OnOffType skipPreviousEnabled;
67 private State nowPlayingSource;
69 private BoseSoundTouchConfiguration masterDeviceId;
73 private Map<Integer, ContentItem> playerPresets;
76 * Creates a new instance of this class
79 * @param stateSwitchingMap the stateSwitchingMap is the XMLState Map, that says which Flags are computed
81 public XMLResponseHandler(BoseSoundTouchHandler handler,
82 Map<XMLHandlerState, Map<String, XMLHandlerState>> stateSwitchingMap) {
83 this.handler = handler;
84 this.commandExecutor = handler.getCommandExecutor();
85 this.stateSwitchingMap = stateSwitchingMap;
89 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
90 @Nullable Attributes attributes) throws SAXException {
91 super.startElement(uri, localName, qName, attributes);
92 logger.trace("{}: startElement('{}'; state: {})", handler.getDeviceName(), localName, state);
94 XMLHandlerState curState = state; // save for switch statement
95 Map<String, XMLHandlerState> stateMap = stateSwitchingMap.get(state);
96 state = XMLHandlerState.Unprocessed; // set default value; we avoid default in select to have the compiler
98 // warning for unhandled states
100 XMLHandlerState localState = null;
101 if (stateMap != null) {
102 localState = stateMap.get(localName);
107 if ("updates".equals(localName)) {
108 // it just seems to be a ping - havn't seen any data on it..
109 if (checkDeviceId(localName, attributes, false)) {
110 state = XMLHandlerState.Updates;
112 state = XMLHandlerState.Unprocessed;
115 if (localState == null) {
116 logger.debug("{}: Unhandled XML entity during {}: '{}", handler.getDeviceName(), curState,
118 state = XMLHandlerState.Unprocessed;
123 if ("header".equals(localName)) {
125 if (checkDeviceId(localName, attributes, false)) {
126 state = XMLHandlerState.MsgHeader;
127 msgHeaderWasValid = true;
129 state = XMLHandlerState.Unprocessed;
131 } else if ("body".equals(localName)) {
132 if (msgHeaderWasValid) {
133 state = XMLHandlerState.MsgBody;
135 state = XMLHandlerState.Unprocessed;
138 logger.debug("{}: Unhandled XML entity during {}: '{}'", handler.getDeviceName(), curState,
141 state = XMLHandlerState.Unprocessed;
145 if ("request".equals(localName)) {
146 state = XMLHandlerState.Unprocessed; // TODO implement request id / response tracking...
148 logger.debug("{}: Unhandled XML entity during {}: '{}'", handler.getDeviceName(), curState,
150 state = XMLHandlerState.Unprocessed;
154 if ("nowPlaying".equals(localName)) {
156 * if (!checkDeviceId(localName, attributes, true)) {
157 * state = XMLHandlerState.Unprocessed;
161 rateEnabled = OnOffType.OFF;
162 skipEnabled = OnOffType.OFF;
163 skipPreviousEnabled = OnOffType.OFF;
164 state = XMLHandlerState.NowPlaying;
166 if (attributes != null) {
167 source = attributes.getValue("source");
169 if (nowPlayingSource == null || !nowPlayingSource.toString().equals(source)) {
171 nowPlayingSource = new StringType(source);
172 // reset enabled states
173 updateRateEnabled(OnOffType.OFF);
174 updateSkipEnabled(OnOffType.OFF);
175 updateSkipPreviousEnabled(OnOffType.OFF);
177 // clear all "nowPlaying" details on source change...
178 updateNowPlayingAlbum(UnDefType.NULL);
179 updateNowPlayingArtwork(UnDefType.NULL);
180 updateNowPlayingArtist(UnDefType.NULL);
181 updateNowPlayingDescription(UnDefType.NULL);
182 updateNowPlayingGenre(UnDefType.NULL);
183 updateNowPlayingItemName(UnDefType.NULL);
184 updateNowPlayingStationLocation(UnDefType.NULL);
185 updateNowPlayingStationName(UnDefType.NULL);
186 updateNowPlayingTrack(UnDefType.NULL);
188 } else if ("zone".equals(localName)) {
189 state = XMLHandlerState.Zone;
190 } else if ("presets".equals(localName)) {
191 // reset the current playerPrests
192 playerPresets = new HashMap<>();
193 for (int i = 1; i <= 6; i++) {
194 playerPresets.put(i, null);
196 state = XMLHandlerState.Presets;
197 } else if ("group".equals(localName)) {
198 this.masterDeviceId = new BoseSoundTouchConfiguration();
200 if (localState == null) {
201 logger.debug("{}: Unhandled XML entity during {}: '{}", handler.getDeviceName(), curState,
204 state = XMLHandlerState.Unprocessed;
205 } else if (state != XMLHandlerState.Volume && state != XMLHandlerState.Presets
206 && state != XMLHandlerState.Group && state != XMLHandlerState.Unprocessed) {
207 if (!checkDeviceId(localName, attributes, false)) {
208 state = XMLHandlerState.Unprocessed;
215 if ("preset".equals(localName)) {
216 state = XMLHandlerState.Preset;
218 if (attributes != null) {
219 id = attributes.getValue("id");
221 if (contentItem == null) {
222 contentItem = new ContentItem();
224 contentItem.setPresetID(Integer.parseInt(id));
226 logger.debug("{}: Unhandled XML entity during {}: '{}'", handler.getDeviceName(), curState,
229 state = XMLHandlerState.Unprocessed;
233 if ("sourceItem".equals(localName)) {
234 state = XMLHandlerState.Unprocessed;
237 String sourceAccount = "";
238 if (attributes != null) {
239 source = attributes.getValue("source");
240 sourceAccount = attributes.getValue("sourceAccount");
241 status = attributes.getValue("status");
243 if ("READY".equals(status)) {
246 if ("AUX".equals(sourceAccount)) {
247 commandExecutor.setAUXAvailable(true);
249 if ("AUX1".equals(sourceAccount)) {
250 commandExecutor.setAUX1Available(true);
252 if ("AUX2".equals(sourceAccount)) {
253 commandExecutor.setAUX2Available(true);
255 if ("AUX3".equals(sourceAccount)) {
256 commandExecutor.setAUX3Available(true);
260 commandExecutor.setStoredMusicAvailable(true);
262 case "INTERNET_RADIO":
263 commandExecutor.setInternetRadioAvailable(true);
266 commandExecutor.setBluetoothAvailable(true);
269 switch (sourceAccount) {
271 commandExecutor.setTVAvailable(true);
274 commandExecutor.setHDMI1Available(true);
277 logger.debug("{}: has an unknown source account: '{}'", handler.getDeviceName(),
282 logger.debug("{}: has an unknown source: '{}'", handler.getDeviceName(), source);
287 logger.debug("{}: Unhandled XML entity during {}: '{}'", handler.getDeviceName(), curState,
289 state = XMLHandlerState.Unprocessed;
292 // auto go trough the state map
308 state = nextState(stateMap, curState, localName);
310 case BassCapabilities:
311 state = nextState(stateMap, curState, localName);
313 // all entities without any children expected..
320 case ContentItemItemName:
321 case ContentItemContainerArt:
324 case InfoFirmwareVersion:
326 case NowPlayingAlbum:
328 case NowPlayingArtist:
329 case NowPlayingGenre:
330 case NowPlayingDescription:
331 case NowPlayingPlayStatus:
332 case NowPlayingRateEnabled:
333 case NowPlayingSkipEnabled:
334 case NowPlayingSkipPreviousEnabled:
335 case NowPlayingStationLocation:
336 case NowPlayingStationName:
337 case NowPlayingTrack:
341 case VolumeMuteEnabled:
343 case ZoneUpdated: // currently this dosn't provide any zone details..
344 if (logger.isDebugEnabled()) {
345 logger.warn("{}: Unhandled XML entity during {}: '{}'", handler.getDeviceName(), curState,
348 state = XMLHandlerState.Unprocessed;
351 if (logger.isDebugEnabled()) {
352 logger.warn("{}: Unhandled XML entity during {}: '{}'", handler.getDeviceName(), curState,
355 state = XMLHandlerState.Unprocessed;
358 // all further things are also unprocessed
359 state = XMLHandlerState.Unprocessed;
361 case UnprocessedNoTextExpected:
362 state = XMLHandlerState.UnprocessedNoTextExpected;
365 if (state == XMLHandlerState.ContentItem) {
366 if (contentItem == null) {
367 contentItem = new ContentItem();
370 String location = "";
371 String sourceAccount = "";
372 Boolean isPresetable = false;
374 if (attributes != null) {
375 source = attributes.getValue("source");
376 sourceAccount = attributes.getValue("sourceAccount");
377 location = attributes.getValue("location");
378 isPresetable = Boolean.parseBoolean(attributes.getValue("isPresetable"));
380 if (source != null) {
381 contentItem.setSource(source);
383 if (sourceAccount != null) {
384 contentItem.setSourceAccount(sourceAccount);
386 if (location != null) {
387 contentItem.setLocation(location);
389 contentItem.setPresetable(isPresetable);
391 for (int attrId = 0; attrId < attributes.getLength(); attrId++) {
392 String attrName = attributes.getLocalName(attrId);
393 if ("source".equalsIgnoreCase(attrName)) {
396 if ("location".equalsIgnoreCase(attrName)) {
399 if ("sourceAccount".equalsIgnoreCase(attrName)) {
402 if ("isPresetable".equalsIgnoreCase(attrName)) {
405 if (attrName != null) {
406 contentItem.setAdditionalAttribute(attrName, attributes.getValue(attrId));
414 public void endElement(String uri, String localName, String qName) throws SAXException {
415 super.endElement(uri, localName, qName);
416 logger.trace("{}: endElement('{}')", handler.getDeviceName(), localName);
417 final XMLHandlerState prevState = state;
418 state = states.pop();
421 commandExecutor.getInformations(APIRequest.VOLUME);
422 commandExecutor.getInformations(APIRequest.PRESETS);
423 commandExecutor.getInformations(APIRequest.NOW_PLAYING);
424 commandExecutor.getInformations(APIRequest.GET_ZONE);
425 commandExecutor.getInformations(APIRequest.BASS);
426 commandExecutor.getInformations(APIRequest.SOURCES);
427 commandExecutor.getInformations(APIRequest.BASSCAPABILITIES);
428 commandExecutor.getInformations(APIRequest.GET_GROUP);
431 if (state == XMLHandlerState.NowPlaying) {
432 // update now playing name...
433 updateNowPlayingItemName(new StringType(contentItem.getItemName()));
434 commandExecutor.setCurrentContentItem(contentItem);
438 if (state == XMLHandlerState.Presets) {
439 playerPresets.put(contentItem.getPresetID(), contentItem);
444 if (state == XMLHandlerState.MsgBody) {
445 updateRateEnabled(rateEnabled);
446 updateSkipEnabled(skipEnabled);
447 updateSkipPreviousEnabled(skipPreviousEnabled);
450 // handle special tags..
452 // request current bass level
453 commandExecutor.getInformations(APIRequest.BASS);
456 commandExecutor.getInformations(APIRequest.VOLUME);
458 case NowPlayingRateEnabled:
459 rateEnabled = OnOffType.ON;
461 case NowPlayingSkipEnabled:
462 skipEnabled = OnOffType.ON;
464 case NowPlayingSkipPreviousEnabled:
465 skipPreviousEnabled = OnOffType.ON;
468 OnOffType muted = volumeMuteEnabled ? OnOffType.ON : OnOffType.OFF;
469 commandExecutor.setCurrentMuted(volumeMuteEnabled);
470 commandExecutor.postVolumeMuted(muted);
473 commandExecutor.getInformations(APIRequest.GET_ZONE);
476 commandExecutor.updatePresetContainerFromPlayer(playerPresets);
477 playerPresets = null;
480 handler.handleGroupUpdated(masterDeviceId);
489 public void characters(char[] ch, int start, int length) throws SAXException {
490 logger.trace("{}: Text data during {}: '{}'", handler.getDeviceName(), state, new String(ch, start, length));
491 super.characters(ch, start, length);
506 case NowPlayingRateEnabled:
507 case NowPlayingSkipEnabled:
508 case NowPlayingSkipPreviousEnabled:
510 case UnprocessedNoTextExpected:
514 logger.debug("{}: Unexpected text data during {}: '{}'", handler.getDeviceName(), state,
515 new String(ch, start, length));
517 case BassMin: // @TODO - find out how to dynamically change "channel-type" bass configuration
518 case BassMax: // based on these values...
522 // this are currently unprocessed values.
524 case BassCapabilities:
525 logger.debug("{}: Unexpected text data during {}: '{}'", handler.getDeviceName(), state,
526 new String(ch, start, length));
532 commandExecutor.updateBassLevelGUIState(new DecimalType(new String(ch, start, length)));
535 setConfigOption(DEVICE_INFO_NAME, new String(ch, start, length));
538 setConfigOption(DEVICE_INFO_TYPE, new String(ch, start, length));
539 setConfigOption(PROPERTY_MODEL_ID, new String(ch, start, length));
542 setConfigOption(PROPERTY_HARDWARE_VERSION, new String(ch, start, length));
544 case InfoFirmwareVersion:
545 String[] fwVersion = new String(ch, start, length).split(" ");
546 setConfigOption(PROPERTY_FIRMWARE_VERSION, fwVersion[0]);
549 boolean bassAvailable = Boolean.parseBoolean(new String(ch, start, length));
550 commandExecutor.setBassAvailable(bassAvailable);
552 case NowPlayingAlbum:
553 updateNowPlayingAlbum(new StringType(new String(ch, start, length)));
556 String url = new String(ch, start, length);
557 if (url.startsWith("http")) {
558 // We download the cover art in a different thread to not delay the other operations
559 handler.getScheduler().submit(() -> {
560 RawType image = HttpUtil.downloadImage(url, true, 500000);
562 updateNowPlayingArtwork(image);
564 updateNowPlayingArtwork(UnDefType.UNDEF);
568 updateNowPlayingArtwork(UnDefType.UNDEF);
571 case NowPlayingArtist:
572 updateNowPlayingArtist(new StringType(new String(ch, start, length)));
574 case ContentItemItemName:
575 contentItem.setItemName(new String(ch, start, length));
577 case ContentItemContainerArt:
578 contentItem.setContainerArt(new String(ch, start, length));
580 case NowPlayingDescription:
581 updateNowPlayingDescription(new StringType(new String(ch, start, length)));
583 case NowPlayingGenre:
584 updateNowPlayingGenre(new StringType(new String(ch, start, length)));
586 case NowPlayingPlayStatus:
587 String playPauseState = new String(ch, start, length);
588 if ("PLAY_STATE".equals(playPauseState) || "BUFFERING_STATE".equals(playPauseState)) {
589 commandExecutor.updatePlayerControlGUIState(PlayPauseType.PLAY);
590 } else if ("STOP_STATE".equals(playPauseState) || "PAUSE_STATE".equals(playPauseState)) {
591 commandExecutor.updatePlayerControlGUIState(PlayPauseType.PAUSE);
594 case NowPlayingStationLocation:
595 updateNowPlayingStationLocation(new StringType(new String(ch, start, length)));
597 case NowPlayingStationName:
598 updateNowPlayingStationName(new StringType(new String(ch, start, length)));
600 case NowPlayingTrack:
601 updateNowPlayingTrack(new StringType(new String(ch, start, length)));
604 commandExecutor.updateVolumeGUIState(new PercentType(Integer.parseInt(new String(ch, start, length))));
606 case VolumeMuteEnabled:
607 volumeMuteEnabled = Boolean.parseBoolean(new String(ch, start, length));
608 commandExecutor.setCurrentMuted(volumeMuteEnabled);
611 if (masterDeviceId != null) {
612 masterDeviceId.macAddress = new String(ch, start, length);
616 if (masterDeviceId != null) {
617 masterDeviceId.groupName = new String(ch, start, length);
621 deviceId = new String(ch, start, length);
624 if (masterDeviceId != null && Objects.equals(masterDeviceId.macAddress, deviceId)) {
625 masterDeviceId.host = new String(ch, start, length);
635 public void skippedEntity(String name) throws SAXException {
636 super.skippedEntity(name);
639 private boolean checkDeviceId(@Nullable String localName, @Nullable Attributes attributes,
640 boolean allowFromMaster) {
641 String deviceID = (attributes != null) ? attributes.getValue("deviceID") : null;
642 if (deviceID == null) {
643 logger.warn("{}: No device-ID in entity {}", handler.getDeviceName(), localName);
646 if (deviceID.equals(handler.getMacAddress())) {
649 logger.warn("{}: Wrong device-ID in entity '{}': Got: '{}', expected: '{}'", handler.getDeviceName(), localName,
650 deviceID, handler.getMacAddress());
654 private XMLHandlerState nextState(Map<String, XMLHandlerState> stateMap, XMLHandlerState curState,
656 XMLHandlerState state = stateMap.get(localName);
658 if (logger.isDebugEnabled()) {
659 logger.warn("{}: Unhandled XML entity during {}: '{}'", handler.getDeviceName(), curState, localName);
661 state = XMLHandlerState.Unprocessed;
666 private void setConfigOption(String option, String value) {
667 if (option != null) {
668 Map<String, String> prop = handler.getThing().getProperties();
669 String cur = prop.get(option);
670 if (cur == null || !cur.equals(value)) {
671 logger.debug("{}: Option '{}' updated: From '{}' to '{}'", handler.getDeviceName(), option, cur, value);
672 handler.getThing().setProperty(option, value);
677 private void updateNowPlayingAlbum(State state) {
678 handler.updateState(CHANNEL_NOWPLAYING_ALBUM, state);
681 private void updateNowPlayingArtwork(State state) {
682 handler.updateState(CHANNEL_NOWPLAYING_ARTWORK, state);
685 private void updateNowPlayingArtist(State state) {
686 handler.updateState(CHANNEL_NOWPLAYING_ARTIST, state);
689 private void updateNowPlayingDescription(State state) {
690 handler.updateState(CHANNEL_NOWPLAYING_DESCRIPTION, state);
693 private void updateNowPlayingGenre(State state) {
694 handler.updateState(CHANNEL_NOWPLAYING_GENRE, state);
697 private void updateNowPlayingItemName(State state) {
698 handler.updateState(CHANNEL_NOWPLAYING_ITEMNAME, state);
701 private void updateNowPlayingStationLocation(State state) {
702 handler.updateState(CHANNEL_NOWPLAYING_STATIONLOCATION, state);
705 private void updateNowPlayingStationName(State state) {
706 handler.updateState(CHANNEL_NOWPLAYING_STATIONNAME, state);
709 private void updateNowPlayingTrack(State state) {
710 handler.updateState(CHANNEL_NOWPLAYING_TRACK, state);
713 private void updateRateEnabled(OnOffType state) {
714 handler.updateState(CHANNEL_RATEENABLED, state);
717 private void updateSkipEnabled(OnOffType state) {
718 handler.updateState(CHANNEL_SKIPENABLED, state);
721 private void updateSkipPreviousEnabled(OnOffType state) {
722 handler.updateState(CHANNEL_SKIPPREVIOUSENABLED, state);