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;
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
102 if ("updates".equals(localName)) {
103 // it just seems to be a ping - havn't seen any data on it..
104 if (checkDeviceId(localName, attributes, false)) {
105 state = XMLHandlerState.Updates;
107 state = XMLHandlerState.Unprocessed;
110 XMLHandlerState localState = stateMap.get(localName);
111 if (localState == null) {
112 logger.warn("{}: Unhandled XML entity during {}: '{}", handler.getDeviceName(), curState,
114 state = XMLHandlerState.Unprocessed;
121 if ("header".equals(localName)) {
123 if (checkDeviceId(localName, attributes, false)) {
124 state = XMLHandlerState.MsgHeader;
125 msgHeaderWasValid = true;
127 state = XMLHandlerState.Unprocessed;
129 } else if ("body".equals(localName)) {
130 if (msgHeaderWasValid) {
131 state = XMLHandlerState.MsgBody;
133 state = XMLHandlerState.Unprocessed;
136 logger.debug("{}: Unhandled XML entity during {}: '{}'", handler.getDeviceName(), curState,
139 state = XMLHandlerState.Unprocessed;
143 if ("request".equals(localName)) {
144 state = XMLHandlerState.Unprocessed; // TODO implement request id / response tracking...
146 logger.debug("{}: Unhandled XML entity during {}: '{}'", handler.getDeviceName(), curState,
148 state = XMLHandlerState.Unprocessed;
152 if ("nowPlaying".equals(localName)) {
154 * if (!checkDeviceId(localName, attributes, true)) {
155 * state = XMLHandlerState.Unprocessed;
159 rateEnabled = OnOffType.OFF;
160 skipEnabled = OnOffType.OFF;
161 skipPreviousEnabled = OnOffType.OFF;
162 state = XMLHandlerState.NowPlaying;
164 if (attributes != null) {
165 source = attributes.getValue("source");
167 if (nowPlayingSource == null || !nowPlayingSource.toString().equals(source)) {
169 nowPlayingSource = new StringType(source);
170 // reset enabled states
171 updateRateEnabled(OnOffType.OFF);
172 updateSkipEnabled(OnOffType.OFF);
173 updateSkipPreviousEnabled(OnOffType.OFF);
175 // clear all "nowPlaying" details on source change...
176 updateNowPlayingAlbum(UnDefType.NULL);
177 updateNowPlayingArtwork(UnDefType.NULL);
178 updateNowPlayingArtist(UnDefType.NULL);
179 updateNowPlayingDescription(UnDefType.NULL);
180 updateNowPlayingGenre(UnDefType.NULL);
181 updateNowPlayingItemName(UnDefType.NULL);
182 updateNowPlayingStationLocation(UnDefType.NULL);
183 updateNowPlayingStationName(UnDefType.NULL);
184 updateNowPlayingTrack(UnDefType.NULL);
186 } else if ("zone".equals(localName)) {
187 state = XMLHandlerState.Zone;
188 } else if ("presets".equals(localName)) {
189 // reset the current playerPrests
190 playerPresets = new HashMap<>();
191 for (int i = 1; i <= 6; i++) {
192 playerPresets.put(i, null);
194 state = XMLHandlerState.Presets;
195 } else if ("group".equals(localName)) {
196 this.masterDeviceId = new BoseSoundTouchConfiguration();
197 state = stateMap.get(localName);
199 state = stateMap.get(localName);
201 logger.warn("{}: 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 if (attributes != null) {
371 String source = attributes.getValue("source");
372 String location = attributes.getValue("location");
373 String sourceAccount = attributes.getValue("sourceAccount");
374 Boolean isPresetable = Boolean.parseBoolean(attributes.getValue("isPresetable"));
376 if (source != null) {
377 contentItem.setSource(source);
379 if (sourceAccount != null) {
380 contentItem.setSourceAccount(sourceAccount);
382 if (location != null) {
383 contentItem.setLocation(location);
385 contentItem.setPresetable(isPresetable);
387 for (int attrId = 0; attrId < attributes.getLength(); attrId++) {
388 String attrName = attributes.getLocalName(attrId);
389 if ("source".equalsIgnoreCase(attrName)) {
392 if ("location".equalsIgnoreCase(attrName)) {
395 if ("sourceAccount".equalsIgnoreCase(attrName)) {
398 if ("isPresetable".equalsIgnoreCase(attrName)) {
401 if (attrName != null) {
402 contentItem.setAdditionalAttribute(attrName, attributes.getValue(attrId));
410 public void endElement(String uri, String localName, String qName) throws SAXException {
411 super.endElement(uri, localName, qName);
412 logger.trace("{}: endElement('{}')", handler.getDeviceName(), localName);
413 final XMLHandlerState prevState = state;
414 state = states.pop();
417 commandExecutor.getInformations(APIRequest.VOLUME);
418 commandExecutor.getInformations(APIRequest.PRESETS);
419 commandExecutor.getInformations(APIRequest.NOW_PLAYING);
420 commandExecutor.getInformations(APIRequest.GET_ZONE);
421 commandExecutor.getInformations(APIRequest.BASS);
422 commandExecutor.getInformations(APIRequest.SOURCES);
423 commandExecutor.getInformations(APIRequest.BASSCAPABILITIES);
424 commandExecutor.getInformations(APIRequest.GET_GROUP);
427 if (state == XMLHandlerState.NowPlaying) {
428 // update now playing name...
429 updateNowPlayingItemName(new StringType(contentItem.getItemName()));
430 commandExecutor.setCurrentContentItem(contentItem);
434 if (state == XMLHandlerState.Presets) {
435 playerPresets.put(contentItem.getPresetID(), contentItem);
440 if (state == XMLHandlerState.MsgBody) {
441 updateRateEnabled(rateEnabled);
442 updateSkipEnabled(skipEnabled);
443 updateSkipPreviousEnabled(skipPreviousEnabled);
446 // handle special tags..
448 // request current bass level
449 commandExecutor.getInformations(APIRequest.BASS);
452 commandExecutor.getInformations(APIRequest.VOLUME);
454 case NowPlayingRateEnabled:
455 rateEnabled = OnOffType.ON;
457 case NowPlayingSkipEnabled:
458 skipEnabled = OnOffType.ON;
460 case NowPlayingSkipPreviousEnabled:
461 skipPreviousEnabled = OnOffType.ON;
464 OnOffType muted = OnOffType.from(volumeMuteEnabled);
465 commandExecutor.setCurrentMuted(volumeMuteEnabled);
466 commandExecutor.postVolumeMuted(muted);
469 commandExecutor.getInformations(APIRequest.GET_ZONE);
472 commandExecutor.updatePresetContainerFromPlayer(playerPresets);
473 playerPresets = null;
476 handler.handleGroupUpdated(masterDeviceId);
485 public void characters(char[] ch, int start, int length) throws SAXException {
486 logger.trace("{}: Text data during {}: '{}'", handler.getDeviceName(), state, new String(ch, start, length));
487 super.characters(ch, start, length);
502 case NowPlayingRateEnabled:
503 case NowPlayingSkipEnabled:
504 case NowPlayingSkipPreviousEnabled:
506 case UnprocessedNoTextExpected:
510 logger.debug("{}: Unexpected text data during {}: '{}'", handler.getDeviceName(), state,
511 new String(ch, start, length));
513 case BassMin: // @TODO - find out how to dynamically change "channel-type" bass configuration
514 case BassMax: // based on these values...
518 // this are currently unprocessed values.
520 case BassCapabilities:
521 logger.debug("{}: Unexpected text data during {}: '{}'", handler.getDeviceName(), state,
522 new String(ch, start, length));
528 commandExecutor.updateBassLevelGUIState(new DecimalType(new String(ch, start, length)));
531 setConfigOption(DEVICE_INFO_NAME, new String(ch, start, length));
534 setConfigOption(DEVICE_INFO_TYPE, new String(ch, start, length));
535 setConfigOption(PROPERTY_MODEL_ID, new String(ch, start, length));
538 setConfigOption(PROPERTY_HARDWARE_VERSION, new String(ch, start, length));
540 case InfoFirmwareVersion:
541 String[] fwVersion = new String(ch, start, length).split(" ");
542 setConfigOption(PROPERTY_FIRMWARE_VERSION, fwVersion[0]);
545 boolean bassAvailable = Boolean.parseBoolean(new String(ch, start, length));
546 commandExecutor.setBassAvailable(bassAvailable);
548 case NowPlayingAlbum:
549 updateNowPlayingAlbum(new StringType(new String(ch, start, length)));
552 String url = new String(ch, start, length);
553 if (url.startsWith("http")) {
554 // We download the cover art in a different thread to not delay the other operations
555 handler.getScheduler().submit(() -> {
556 RawType image = HttpUtil.downloadImage(url, true, 500000);
558 updateNowPlayingArtwork(image);
560 updateNowPlayingArtwork(UnDefType.UNDEF);
564 updateNowPlayingArtwork(UnDefType.UNDEF);
567 case NowPlayingArtist:
568 updateNowPlayingArtist(new StringType(new String(ch, start, length)));
570 case ContentItemItemName:
571 contentItem.setItemName(new String(ch, start, length));
573 case ContentItemContainerArt:
574 contentItem.setContainerArt(new String(ch, start, length));
576 case NowPlayingDescription:
577 updateNowPlayingDescription(new StringType(new String(ch, start, length)));
579 case NowPlayingGenre:
580 updateNowPlayingGenre(new StringType(new String(ch, start, length)));
582 case NowPlayingPlayStatus:
583 String playPauseState = new String(ch, start, length);
584 if ("PLAY_STATE".equals(playPauseState) || "BUFFERING_STATE".equals(playPauseState)) {
585 commandExecutor.updatePlayerControlGUIState(PlayPauseType.PLAY);
586 } else if ("STOP_STATE".equals(playPauseState) || "PAUSE_STATE".equals(playPauseState)) {
587 commandExecutor.updatePlayerControlGUIState(PlayPauseType.PAUSE);
590 case NowPlayingStationLocation:
591 updateNowPlayingStationLocation(new StringType(new String(ch, start, length)));
593 case NowPlayingStationName:
594 updateNowPlayingStationName(new StringType(new String(ch, start, length)));
596 case NowPlayingTrack:
597 updateNowPlayingTrack(new StringType(new String(ch, start, length)));
600 commandExecutor.updateVolumeGUIState(new PercentType(Integer.parseInt(new String(ch, start, length))));
602 case VolumeMuteEnabled:
603 volumeMuteEnabled = Boolean.parseBoolean(new String(ch, start, length));
604 commandExecutor.setCurrentMuted(volumeMuteEnabled);
607 if (masterDeviceId != null) {
608 masterDeviceId.macAddress = new String(ch, start, length);
612 if (masterDeviceId != null) {
613 masterDeviceId.groupName = new String(ch, start, length);
617 deviceId = new String(ch, start, length);
620 if (masterDeviceId != null && Objects.equals(masterDeviceId.macAddress, deviceId)) {
621 masterDeviceId.host = new String(ch, start, length);
631 public void skippedEntity(String name) throws SAXException {
632 super.skippedEntity(name);
635 private boolean checkDeviceId(@Nullable String localName, @Nullable Attributes attributes,
636 boolean allowFromMaster) {
637 String deviceID = (attributes != null) ? attributes.getValue("deviceID") : null;
638 if (deviceID == null) {
639 logger.warn("{}: No device-ID in entity {}", handler.getDeviceName(), localName);
642 if (deviceID.equals(handler.getMacAddress())) {
645 logger.warn("{}: Wrong device-ID in entity '{}': Got: '{}', expected: '{}'", handler.getDeviceName(), localName,
646 deviceID, handler.getMacAddress());
650 private XMLHandlerState nextState(Map<String, XMLHandlerState> stateMap, XMLHandlerState curState,
652 XMLHandlerState state = stateMap.get(localName);
654 if (logger.isDebugEnabled()) {
655 logger.warn("{}: Unhandled XML entity during {}: '{}'", handler.getDeviceName(), curState, localName);
657 state = XMLHandlerState.Unprocessed;
662 private void setConfigOption(String option, String value) {
663 if (option != null) {
664 Map<String, String> prop = handler.getThing().getProperties();
665 String cur = prop.get(option);
666 if (cur == null || !cur.equals(value)) {
667 logger.debug("{}: Option '{}' updated: From '{}' to '{}'", handler.getDeviceName(), option, cur, value);
668 handler.getThing().setProperty(option, value);
673 private void updateNowPlayingAlbum(State state) {
674 handler.updateState(CHANNEL_NOWPLAYING_ALBUM, state);
677 private void updateNowPlayingArtwork(State state) {
678 handler.updateState(CHANNEL_NOWPLAYING_ARTWORK, state);
681 private void updateNowPlayingArtist(State state) {
682 handler.updateState(CHANNEL_NOWPLAYING_ARTIST, state);
685 private void updateNowPlayingDescription(State state) {
686 handler.updateState(CHANNEL_NOWPLAYING_DESCRIPTION, state);
689 private void updateNowPlayingGenre(State state) {
690 handler.updateState(CHANNEL_NOWPLAYING_GENRE, state);
693 private void updateNowPlayingItemName(State state) {
694 handler.updateState(CHANNEL_NOWPLAYING_ITEMNAME, state);
697 private void updateNowPlayingStationLocation(State state) {
698 handler.updateState(CHANNEL_NOWPLAYING_STATIONLOCATION, state);
701 private void updateNowPlayingStationName(State state) {
702 handler.updateState(CHANNEL_NOWPLAYING_STATIONNAME, state);
705 private void updateNowPlayingTrack(State state) {
706 handler.updateState(CHANNEL_NOWPLAYING_TRACK, state);
709 private void updateRateEnabled(OnOffType state) {
710 handler.updateState(CHANNEL_RATEENABLED, state);
713 private void updateSkipEnabled(OnOffType state) {
714 handler.updateState(CHANNEL_SKIPENABLED, state);
717 private void updateSkipPreviousEnabled(OnOffType state) {
718 handler.updateState(CHANNEL_SKIPPREVIOUSENABLED, state);