2 * Copyright (c) 2010-2024 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.sonos.internal;
15 import java.io.IOException;
16 import java.io.StringReader;
18 import java.text.MessageFormat;
19 import java.util.ArrayList;
20 import java.util.HashMap;
21 import java.util.HashSet;
22 import java.util.List;
25 import java.util.regex.Matcher;
26 import java.util.regex.Pattern;
28 import javax.xml.parsers.ParserConfigurationException;
29 import javax.xml.parsers.SAXParser;
30 import javax.xml.parsers.SAXParserFactory;
32 import org.eclipse.jdt.annotation.NonNullByDefault;
33 import org.eclipse.jdt.annotation.Nullable;
34 import org.openhab.core.util.StringUtils;
35 import org.slf4j.Logger;
36 import org.slf4j.LoggerFactory;
37 import org.xml.sax.Attributes;
38 import org.xml.sax.InputSource;
39 import org.xml.sax.SAXException;
40 import org.xml.sax.helpers.DefaultHandler;
43 * The {@link SonosXMLParser} is a class of helper functions
44 * to parse XML data returned by the Zone Players
46 * @author Karel Goderis - Initial contribution
49 public class SonosXMLParser {
51 static final Logger LOGGER = LoggerFactory.getLogger(SonosXMLParser.class);
53 private static final String METADATA_FORMAT_PATTERN = """
54 <DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" \
55 xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" \
56 xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/" \
57 xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/">\
58 <item id="{0}" parentID="{1}" restricted="true">\
59 <dc:title>{2}</dc:title>\
60 <upnp:class>{3}</upnp:class>\
61 <desc id="cdudn" nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">{4}</desc>\
66 private enum Element {
78 private enum CurrentElement {
93 * @return a list of alarms from the given xml string.
95 public static List<SonosAlarm> getAlarmsFromStringResult(String xml) {
96 AlarmHandler handler = new AlarmHandler();
98 SAXParserFactory factory = SAXParserFactory.newInstance();
99 SAXParser saxParser = factory.newSAXParser();
100 saxParser.parse(new InputSource(new StringReader(xml)), handler);
101 } catch (IOException | SAXException | ParserConfigurationException e) {
102 LOGGER.warn("Could not parse Alarms from string '{}'", xml);
104 return handler.getAlarms();
109 * @return a list of Entries from the given xml string.
111 public static List<SonosEntry> getEntriesFromString(String xml) {
112 EntryHandler handler = new EntryHandler();
114 SAXParserFactory factory = SAXParserFactory.newInstance();
115 SAXParser saxParser = factory.newSAXParser();
116 saxParser.parse(new InputSource(new StringReader(xml)), handler);
117 } catch (IOException | SAXException | ParserConfigurationException e) {
118 LOGGER.warn("Could not parse Entries from string '{}'", xml);
121 return handler.getArtists();
125 * Returns the meta data which is needed to play Pandora
126 * (and others?) favorites
129 * @return The value of the desc xml tag
130 * @throws SAXException
131 * @throws ParserConfigurationException
133 public static @Nullable SonosResourceMetaData getResourceMetaData(String xml)
134 throws SAXException, ParserConfigurationException {
135 SAXParserFactory factory = SAXParserFactory.newInstance();
136 factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
137 SAXParser saxParser = factory.newSAXParser();
138 ResourceMetaDataHandler handler = new ResourceMetaDataHandler();
140 saxParser.parse(new InputSource(new StringReader(xml)), handler);
141 } catch (IOException | SAXException e) {
142 LOGGER.warn("Could not parse Resource MetaData from string '{}'", xml);
144 return handler.getMetaData();
149 * @return zone group from the given xml
151 public static List<SonosZoneGroup> getZoneGroupFromXML(String xml) {
152 ZoneGroupHandler handler = new ZoneGroupHandler();
154 SAXParserFactory factory = SAXParserFactory.newInstance();
155 SAXParser saxParser = factory.newSAXParser();
156 saxParser.parse(new InputSource(new StringReader(xml)), handler);
157 } catch (IOException | SAXException | ParserConfigurationException e) {
158 LOGGER.warn("Could not parse ZoneGroup from string '{}'", xml);
161 return handler.getGroups();
164 public static List<String> getRadioTimeFromXML(String xml) {
165 OpmlHandler handler = new OpmlHandler();
167 SAXParserFactory factory = SAXParserFactory.newInstance();
168 SAXParser saxParser = factory.newSAXParser();
169 saxParser.parse(new InputSource(new StringReader(xml)), handler);
170 } catch (IOException | SAXException | ParserConfigurationException e) {
171 LOGGER.warn("Could not parse RadioTime from string '{}'", xml);
174 return handler.getTextFields();
177 public static Map<String, String> getRenderingControlFromXML(String xml) {
178 RenderingControlEventHandler handler = new RenderingControlEventHandler();
180 SAXParserFactory factory = SAXParserFactory.newInstance();
181 SAXParser saxParser = factory.newSAXParser();
182 saxParser.parse(new InputSource(new StringReader(xml)), handler);
183 } catch (IOException | SAXException | ParserConfigurationException e) {
184 LOGGER.warn("Could not parse Rendering Control from string '{}'", xml);
186 return handler.getChanges();
189 public static Map<String, String> getAVTransportFromXML(String xml) {
190 AVTransportEventHandler handler = new AVTransportEventHandler();
192 SAXParserFactory factory = SAXParserFactory.newInstance();
193 SAXParser saxParser = factory.newSAXParser();
194 saxParser.parse(new InputSource(new StringReader(xml)), handler);
195 } catch (IOException | SAXException | ParserConfigurationException e) {
196 LOGGER.warn("Could not parse AV Transport from string '{}'", xml);
198 return handler.getChanges();
201 public static SonosMetaData getMetaDataFromXML(String xml) {
202 MetaDataHandler handler = new MetaDataHandler();
204 SAXParserFactory factory = SAXParserFactory.newInstance();
205 SAXParser saxParser = factory.newSAXParser();
206 saxParser.parse(new InputSource(new StringReader(xml)), handler);
207 } catch (IOException | SAXException | ParserConfigurationException e) {
208 LOGGER.warn("Could not parse MetaData from string '{}'", xml);
211 return handler.getMetaData();
214 public static List<SonosMusicService> getMusicServicesFromXML(String xml) {
215 MusicServiceHandler handler = new MusicServiceHandler();
217 SAXParserFactory factory = SAXParserFactory.newInstance();
218 SAXParser saxParser = factory.newSAXParser();
219 saxParser.parse(new InputSource(new StringReader(xml)), handler);
220 } catch (IOException | SAXException | ParserConfigurationException e) {
221 LOGGER.warn("Could not parse music services from string '{}'", xml);
223 return handler.getServices();
226 private static class EntryHandler extends DefaultHandler {
228 // Maintain a set of elements about which it is unuseful to complain about.
229 // This list will be initialized on the first failure case
230 private static @Nullable List<String> ignore;
232 private String id = "";
233 private String parentId = "";
234 private StringBuilder upnpClass = new StringBuilder();
235 private StringBuilder res = new StringBuilder();
236 private StringBuilder title = new StringBuilder();
237 private StringBuilder album = new StringBuilder();
238 private StringBuilder albumArtUri = new StringBuilder();
239 private StringBuilder creator = new StringBuilder();
240 private StringBuilder trackNumber = new StringBuilder();
241 private StringBuilder desc = new StringBuilder();
242 private @Nullable Element element;
244 private List<SonosEntry> artists = new ArrayList<>();
247 // shouldn't be used outside of this package.
251 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
252 @Nullable Attributes attributes) throws SAXException {
253 String name = qName == null ? "" : qName;
257 if (attributes != null) {
258 id = attributes.getValue("id");
259 parentId = attributes.getValue("parentID");
263 element = Element.RES;
266 element = Element.TITLE;
269 element = Element.CLASS;
272 element = Element.CREATOR;
275 element = Element.ALBUM;
277 case "upnp:albumArtURI":
278 element = Element.ALBUM_ART_URI;
280 case "upnp:originalTrackNumber":
281 element = Element.TRACK_NUMBER;
284 element = Element.RESMD;
287 List<String> curIgnore = ignore;
288 if (curIgnore == null) {
289 curIgnore = new ArrayList<>();
290 curIgnore.add("DIDL-Lite");
291 curIgnore.add("r:type");
292 curIgnore.add("r:ordinal");
293 curIgnore.add("r:description");
297 if (!curIgnore.contains(qName)) {
298 LOGGER.debug("Did not recognise element named {}", qName);
306 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
307 Element elt = element;
308 if (elt == null || ch == null) {
313 title.append(ch, start, length);
316 upnpClass.append(ch, start, length);
319 res.append(ch, start, length);
322 album.append(ch, start, length);
325 albumArtUri.append(ch, start, length);
328 creator.append(ch, start, length);
331 trackNumber.append(ch, start, length);
334 desc.append(ch, start, length);
342 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
343 throws SAXException {
344 if (("container".equals(qName) || "item".equals(qName))) {
347 int trackNumberVal = 0;
349 trackNumberVal = Integer.parseInt(trackNumber.toString());
350 } catch (NumberFormatException e) {
353 SonosResourceMetaData md = null;
355 // The resource description is needed for playing favorites on pandora
356 if (!desc.toString().isEmpty()) {
358 md = getResourceMetaData(desc.toString());
359 } catch (SAXException | ParserConfigurationException ignore) {
360 LOGGER.debug("Failed to parse embeded", ignore);
364 artists.add(new SonosEntry(id, title.toString(), parentId, album.toString(), albumArtUri.toString(),
365 creator.toString(), upnpClass.toString(), res.toString(), trackNumberVal, md));
366 title = new StringBuilder();
367 upnpClass = new StringBuilder();
368 res = new StringBuilder();
369 album = new StringBuilder();
370 albumArtUri = new StringBuilder();
371 creator = new StringBuilder();
372 trackNumber = new StringBuilder();
373 desc = new StringBuilder();
377 public List<SonosEntry> getArtists() {
382 private static class ResourceMetaDataHandler extends DefaultHandler {
384 private String id = "";
385 private String parentId = "";
386 private StringBuilder title = new StringBuilder();
387 private StringBuilder upnpClass = new StringBuilder();
388 private StringBuilder desc = new StringBuilder();
389 private @Nullable Element element;
390 private @Nullable SonosResourceMetaData metaData;
392 ResourceMetaDataHandler() {
393 // shouldn't be used outside of this package.
397 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
398 @Nullable Attributes attributes) throws SAXException {
399 String name = qName == null ? "" : qName;
403 if (attributes != null) {
404 id = attributes.getValue("id");
405 parentId = attributes.getValue("parentID");
409 element = Element.DESC;
412 element = Element.CLASS;
415 element = Element.TITLE;
424 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
425 Element elt = element;
426 if (elt == null || ch == null) {
431 title.append(ch, start, length);
434 upnpClass.append(ch, start, length);
437 desc.append(ch, start, length);
445 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
446 throws SAXException {
447 if ("DIDL-Lite".equals(qName)) {
448 metaData = new SonosResourceMetaData(id, parentId, title.toString(), upnpClass.toString(),
451 desc = new StringBuilder();
452 upnpClass = new StringBuilder();
453 title = new StringBuilder();
457 public @Nullable SonosResourceMetaData getMetaData() {
462 private static class AlarmHandler extends DefaultHandler {
464 private @Nullable String id;
465 private String startTime = "";
466 private String duration = "";
467 private String recurrence = "";
468 private @Nullable String enabled;
469 private String roomUUID = "";
470 private String programURI = "";
471 private String programMetaData = "";
472 private String playMode = "";
473 private @Nullable String volume;
474 private @Nullable String includeLinkedZones;
476 private List<SonosAlarm> alarms = new ArrayList<>();
479 // shouldn't be used outside of this package.
483 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
484 @Nullable Attributes attributes) throws SAXException {
485 if ("Alarm".equals(qName) && attributes != null) {
486 id = attributes.getValue("ID");
487 duration = attributes.getValue("Duration");
488 recurrence = attributes.getValue("Recurrence");
489 startTime = attributes.getValue("StartTime");
490 enabled = attributes.getValue("Enabled");
491 roomUUID = attributes.getValue("RoomUUID");
492 programURI = attributes.getValue("ProgramURI");
493 programMetaData = attributes.getValue("ProgramMetaData");
494 playMode = attributes.getValue("PlayMode");
495 volume = attributes.getValue("Volume");
496 includeLinkedZones = attributes.getValue("IncludeLinkedZones");
501 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
502 throws SAXException {
503 if ("Alarm".equals(qName)) {
506 boolean finalEnabled = !"0".equals(enabled);
507 boolean finalIncludeLinkedZones = !"0".equals(includeLinkedZones);
512 throw new NumberFormatException();
514 finalID = Integer.parseInt(id);
515 String volume = this.volume;
516 if (volume == null) {
517 throw new NumberFormatException();
519 finalVolume = Integer.parseInt(volume);
520 } catch (NumberFormatException e) {
521 LOGGER.debug("Error parsing Integer");
524 alarms.add(new SonosAlarm(finalID, startTime, duration, recurrence, finalEnabled, roomUUID, programURI,
525 programMetaData, playMode, finalVolume, finalIncludeLinkedZones));
529 public List<SonosAlarm> getAlarms() {
534 private static class ZoneGroupHandler extends DefaultHandler {
536 private final List<SonosZoneGroup> groups = new ArrayList<>();
537 private final List<String> currentGroupPlayers = new ArrayList<>();
538 private final List<String> currentGroupPlayerZones = new ArrayList<>();
539 private String coordinator = "";
540 private String groupId = "";
543 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
544 @Nullable Attributes attributes) throws SAXException {
545 if ("ZoneGroup".equals(qName) && attributes != null) {
546 groupId = attributes.getValue("ID");
547 coordinator = attributes.getValue("Coordinator");
548 } else if ("ZoneGroupMember".equals(qName) && attributes != null) {
549 currentGroupPlayers.add(attributes.getValue("UUID"));
550 String zoneName = attributes.getValue("ZoneName");
551 if (zoneName != null) {
552 currentGroupPlayerZones.add(zoneName);
554 String htInfoSet = attributes.getValue("HTSatChanMapSet");
555 if (htInfoSet != null) {
556 currentGroupPlayers.addAll(getAllHomeTheaterMembers(htInfoSet));
562 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
563 throws SAXException {
564 if ("ZoneGroup".equals(qName)) {
565 groups.add(new SonosZoneGroup(groupId, coordinator, currentGroupPlayers, currentGroupPlayerZones));
566 currentGroupPlayers.clear();
567 currentGroupPlayerZones.clear();
571 public List<SonosZoneGroup> getGroups() {
575 private Set<String> getAllHomeTheaterMembers(String homeTheaterDescription) {
576 Set<String> homeTheaterMembers = new HashSet<>();
577 Matcher matcher = Pattern.compile("(RINCON_\\w+)").matcher(homeTheaterDescription);
578 while (matcher.find()) {
579 String member = matcher.group();
580 homeTheaterMembers.add(member);
582 return homeTheaterMembers;
586 private static class OpmlHandler extends DefaultHandler {
588 // <opml version="1">
590 // <status>200</status>
594 // <outline type="text" text="Q-Music 103.3" guide_id="s2398" key="station"
595 // image="http://radiotime-logos.s3.amazonaws.com/s87683q.png" preset_id="s2398"/>
596 // <outline type="text" text="Bjorn Verhoeven" guide_id="p257265" seconds_remaining="2230" duration="7200"
598 // <outline type="text" text="Top 40-Pop"/>
599 // <outline type="text" text="37m remaining"/>
600 // <outline type="object" text="NowPlaying">
602 // <logo>http://radiotime-logos.s3.amazonaws.com/s87683.png</logo>
609 private final List<String> textFields = new ArrayList<>();
610 private @Nullable String textField;
611 private @Nullable String type;
612 // private String logo;
615 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
616 @Nullable Attributes attributes) throws SAXException {
617 if ("outline".equals(qName)) {
618 type = attributes == null ? null : attributes.getValue("type");
619 if ("text".equals(type)) {
620 textField = attributes == null ? null : attributes.getValue("text");
628 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
629 throws SAXException {
630 if ("outline".equals(qName)) {
631 String field = textField;
633 textFields.add(field);
638 public List<String> getTextFields() {
643 private static class AVTransportEventHandler extends DefaultHandler {
646 * <Event xmlns="urn:schemas-upnp-org:metadata-1-0/AVT/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/">
647 * <InstanceID val="0">
648 * <TransportState val="PLAYING"/>
649 * <CurrentPlayMode val="NORMAL"/>
650 * <CurrentPlayMode val="0"/>
651 * <NumberOfTracks val="29"/>
652 * <CurrentTrack val="12"/>
653 * <CurrentSection val="0"/>
654 * <CurrentTrackURI val=
655 * "x-file-cifs://192.168.1.1/Storage4/Sonos%20Music/Queens%20Of%20The%20Stone%20Age/Lullabies%20To%20Paralyze/Queens%20Of%20The%20Stone%20Age%20-%20Lullabies%20To%20Paralyze%20-%2012%20-%20Broken%20Box.wma"
657 * <CurrentTrackDuration val="0:03:02"/>
658 * <CurrentTrackMetaData val=
659 * "<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"><item id="-1" parentID="-1" restricted="true"><res protocolInfo="x-file-cifs:*:audio/x-ms-wma:*" duration="0:03:02">x-file-cifs://192.168.1.1/Storage4/Sonos%20Music/Queens%20Of%20The%20Stone%20Age/Lullabies%20To%20Paralyze/Queens%20Of%20The%20Stone%20Age%20-%20Lullabies%20To%20Paralyze%20-%2012%20-%20Broken%20Box.wma</res><r:streamContent></r:streamContent><dc:title>Broken Box</dc:title><upnp:class>object.item.audioItem.musicTrack</upnp:class><dc:creator>Queens Of The Stone Age</dc:creator><upnp:album>Lullabies To Paralyze</upnp:album><r:albumArtist>Queens Of The Stone Age</r:albumArtist></item></DIDL-Lite>"
660 * /><r:NextTrackURI val=
661 * "x-file-cifs://192.168.1.1/Storage4/Sonos%20Music/Queens%20Of%20The%20Stone%20Age/Lullabies%20To%20Paralyze/Queens%20Of%20The%20Stone%20Age%20-%20Lullabies%20To%20Paralyze%20-%2013%20-%20''You%20Got%20A%20Killer%20Scene%20There,%20Man...''.wma"
662 * /><r:NextTrackMetaData val=
663 * "<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"><item id="-1" parentID="-1" restricted="true"><res protocolInfo="x-file-cifs:*:audio/x-ms-wma:*" duration="0:04:56">x-file-cifs://192.168.1.1/Storage4/Sonos%20Music/Queens%20Of%20The%20Stone%20Age/Lullabies%20To%20Paralyze/Queens%20Of%20The%20Stone%20Age%20-%20Lullabies%20To%20Paralyze%20-%2013%20-%20&apos;&apos;You%20Got%20A%20Killer%20Scene%20There,%20Man...&apos;&apos;.wma</res><dc:title>&apos;&apos;You Got A Killer Scene There, Man...&apos;&apos;</dc:title><upnp:class>object.item.audioItem.musicTrack</upnp:class><dc:creator>Queens Of The Stone Age</dc:creator><upnp:album>Lullabies To Paralyze</upnp:album><r:albumArtist>Queens Of The Stone Age</r:albumArtist></item></DIDL-Lite>"
664 * /><r:EnqueuedTransportURI
665 * val="x-rincon-playlist:RINCON_000E582126EE01400#A:ALBUMARTIST/Queens%20Of%20The%20Stone%20Age"/><r:
666 * EnqueuedTransportURIMetaData val=
667 * "<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"><item id="A:ALBUMARTIST/Queens%20Of%20The%20Stone%20Age" parentID="A:ALBUMARTIST" restricted="true"><dc:title>Queens Of The Stone Age</dc:title><upnp:class>object.container</upnp:class><desc id="cdudn" nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">RINCON_AssociatedZPUDN</desc></item></DIDL-Lite>"
669 * <PlaybackStorageMedium val="NETWORK"/>
670 * <AVTransportURI val="x-rincon-queue:RINCON_000E5812BC1801400#0"/>
671 * <AVTransportURIMetaData val=""/>
672 * <CurrentTransportActions val="Play, Stop, Pause, Seek, Next, Previous"/>
673 * <TransportStatus val="OK"/>
674 * <r:SleepTimerGeneration val="0"/>
675 * <r:AlarmRunning val="0"/>
676 * <r:SnoozeRunning val="0"/>
677 * <r:RestartPending val="0"/>
678 * <TransportPlaySpeed val="NOT_IMPLEMENTED"/>
679 * <CurrentMediaDuration val="NOT_IMPLEMENTED"/>
680 * <RecordStorageMedium val="NOT_IMPLEMENTED"/>
681 * <PossiblePlaybackStorageMedia val="NONE, NETWORK"/>
682 * <PossibleRecordStorageMedia val="NOT_IMPLEMENTED"/>
683 * <RecordMediumWriteStatus val="NOT_IMPLEMENTED"/>
684 * <CurrentRecordQualityMode val="NOT_IMPLEMENTED"/>
685 * <PossibleRecordQualityModes val="NOT_IMPLEMENTED"/>
686 * <NextAVTransportURI val="NOT_IMPLEMENTED"/>
687 * <NextAVTransportURIMetaData val="NOT_IMPLEMENTED"/>
692 private final Map<String, String> changes = new HashMap<>();
695 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
696 @Nullable Attributes attributes) throws SAXException {
698 * The events are all of the form <qName val="value"/> so we can get all
699 * the info we need from here.
702 // this means that qName isn't defined in EventType, which is expected for some elements
703 LOGGER.info("{} is not defined in EventType. ", qName);
705 String val = attributes == null ? null : attributes.getValue("val");
707 String key = qName.contains(":") ? qName.split(":")[1] : qName;
708 changes.put(key, val);
713 public Map<String, String> getChanges() {
718 private static class MetaDataHandler extends DefaultHandler {
720 private @Nullable CurrentElement currentElement;
722 private String id = "-1";
723 private String parentId = "-1";
724 private StringBuilder resource = new StringBuilder();
725 private StringBuilder streamContent = new StringBuilder();
726 private StringBuilder albumArtUri = new StringBuilder();
727 private StringBuilder title = new StringBuilder();
728 private StringBuilder upnpClass = new StringBuilder();
729 private StringBuilder creator = new StringBuilder();
730 private StringBuilder album = new StringBuilder();
731 private StringBuilder albumArtist = new StringBuilder();
734 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
735 @Nullable Attributes attributes) throws SAXException {
736 String name = qName == null ? "" : qName;
739 currentElement = CurrentElement.item;
740 if (attributes != null) {
741 id = attributes.getValue("id");
742 parentId = attributes.getValue("parentID");
746 currentElement = CurrentElement.res;
748 case "r:streamContent":
749 currentElement = CurrentElement.streamContent;
751 case "upnp:albumArtURI":
752 currentElement = CurrentElement.albumArtURI;
755 currentElement = CurrentElement.title;
758 currentElement = CurrentElement.upnpClass;
761 currentElement = CurrentElement.creator;
764 currentElement = CurrentElement.album;
766 case "r:albumArtist":
767 currentElement = CurrentElement.albumArtist;
771 currentElement = null;
777 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
778 CurrentElement elt = currentElement;
779 if (elt == null || ch == null) {
786 resource.append(ch, start, length);
789 streamContent.append(ch, start, length);
792 albumArtUri.append(ch, start, length);
795 title.append(ch, start, length);
798 upnpClass.append(ch, start, length);
801 creator.append(ch, start, length);
804 album.append(ch, start, length);
807 albumArtist.append(ch, start, length);
814 public SonosMetaData getMetaData() {
815 return new SonosMetaData(id, parentId, resource.toString(), streamContent.toString(),
816 albumArtUri.toString(), title.toString(), upnpClass.toString(), creator.toString(),
817 album.toString(), albumArtist.toString());
821 private static class RenderingControlEventHandler extends DefaultHandler {
823 private final Map<String, String> changes = new HashMap<>();
825 private boolean getPresetName = false;
826 private @Nullable String presetName;
829 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
830 @Nullable Attributes attributes) throws SAXException {
840 channel = attributes == null ? null : attributes.getValue("channel");
841 val = attributes == null ? null : attributes.getValue("val");
842 if (channel != null && val != null) {
843 changes.put(qName + channel, val);
853 case "SurroundEnabled":
855 case "SurroundLevel":
857 case "MusicSurroundLevel":
858 case "HeightChannelLevel":
859 val = attributes == null ? null : attributes.getValue("val");
861 changes.put(qName, val);
864 case "PresetNameList":
865 getPresetName = true;
873 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
874 if (getPresetName && ch != null) {
875 presetName = new String(ch, start, length);
880 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
881 throws SAXException {
883 getPresetName = false;
884 String preset = presetName;
885 if (qName != null && preset != null) {
886 changes.put(qName, preset);
891 public Map<String, String> getChanges() {
896 private static class MusicServiceHandler extends DefaultHandler {
898 private final List<SonosMusicService> services = new ArrayList<>();
901 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
902 @Nullable Attributes attributes) throws SAXException {
903 // All services are of the form <services Id="value" Name="value">...</Service>
904 if ("Service".equals(qName) && attributes != null && attributes.getValue("Id") != null
905 && attributes.getValue("Name") != null) {
906 services.add(new SonosMusicService(attributes.getValue("Id"), attributes.getValue("Name")));
910 public List<SonosMusicService> getServices() {
915 public static @Nullable String getRoomName(URL descriptorURL) {
916 RoomNameHandler roomNameHandler = new RoomNameHandler();
918 SAXParserFactory factory = SAXParserFactory.newInstance();
919 SAXParser saxParser = factory.newSAXParser();
920 saxParser.parse(new InputSource(descriptorURL.openStream()), roomNameHandler);
921 } catch (SAXException | ParserConfigurationException e) {
922 LOGGER.warn("Could not parse Sonos room name from URL '{}'", descriptorURL);
923 } catch (IOException e) {
924 LOGGER.debug("Could not fetch descriptor XML from URL '{}': {}", descriptorURL, e.getMessage());
926 return roomNameHandler.getRoomName();
929 private static class RoomNameHandler extends DefaultHandler {
931 private @Nullable String roomName;
932 private boolean roomNameTag;
935 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
936 @Nullable Attributes attributes) throws SAXException {
937 if ("roomName".equalsIgnoreCase(qName)) {
943 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
944 if (roomNameTag && ch != null) {
945 roomName = new String(ch, start, length);
950 public @Nullable String getRoomName() {
955 public static @Nullable String parseModelDescription(URL descriptorURL) {
956 ModelNameHandler modelNameHandler = new ModelNameHandler();
958 SAXParserFactory factory = SAXParserFactory.newInstance();
959 SAXParser saxParser = factory.newSAXParser();
960 saxParser.parse(new InputSource(descriptorURL.openStream()), modelNameHandler);
961 } catch (SAXException | ParserConfigurationException e) {
962 LOGGER.warn("Could not parse Sonos model name from URL '{}'", descriptorURL);
963 } catch (IOException e) {
964 LOGGER.debug("Could not fetch descriptor XML from URL '{}': {}", descriptorURL, e.getMessage());
966 return modelNameHandler.getModelName();
969 private static class ModelNameHandler extends DefaultHandler {
971 private @Nullable String modelName;
972 private boolean modelNameTag;
975 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
976 @Nullable Attributes attributes) throws SAXException {
977 if ("modelName".equalsIgnoreCase(qName)) {
983 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
984 if (modelNameTag && ch != null) {
985 modelName = new String(ch, start, length);
986 modelNameTag = false;
990 public @Nullable String getModelName() {
996 * Build a valid thing type ID from the model name provided by UPnP
998 * @param sonosModelName Sonos model name provided via UPnP device
999 * @return a valid thing type ID that can then be used for ThingType creation
1001 public static String buildThingTypeIdFromModelName(String sonosModelName) {
1002 // For Ikea SYMFONISK models, the model name now starts with "SYMFONISK" with recent firmwares
1003 if (sonosModelName.toUpperCase().contains("SYMFONISK")) {
1006 String id = sonosModelName;
1007 // Remove until the first space (in practice, it removes the leading "Sonos " from the model name)
1008 Matcher matcher = Pattern.compile("\\s(.*)").matcher(id);
1009 if (matcher.find()) {
1010 id = matcher.group(1);
1011 // Remove a potential ending text surrounded with parenthesis
1012 matcher = Pattern.compile("(.*)\\s\\(.*\\)").matcher(id);
1013 if (matcher.find()) {
1014 id = matcher.group(1);
1017 // Finally remove unexpected characters in a thing type ID
1018 id = id.replaceAll("[^a-zA-Z0-9_]", "");
1019 // ZP80 is translated to CONNECT and ZP100 to CONNECTAMP
1033 public static String compileMetadataString(SonosEntry entry) {
1035 * If the entry contains resource meta data we will override this with
1038 String id = entry.getId();
1039 String parentId = entry.getParentId();
1040 String title = entry.getTitle();
1041 String upnpClass = entry.getUpnpClass();
1044 * By default 'RINCON_AssociatedZPUDN' is used for most operations,
1045 * however when playing a favorite entry that is associated withh a
1046 * subscription like pandora we need to use the desc string asscoiated
1049 String desc = entry.getDesc();
1051 desc = "RINCON_AssociatedZPUDN";
1055 * If resource meta data exists, use it over the parent data
1057 SonosResourceMetaData resourceMetaData = entry.getResourceMetaData();
1058 if (resourceMetaData != null) {
1059 id = resourceMetaData.getId();
1060 parentId = resourceMetaData.getParentId();
1061 title = resourceMetaData.getTitle();
1062 desc = resourceMetaData.getDesc();
1063 upnpClass = resourceMetaData.getUpnpClass();
1066 title = StringUtils.escapeXml(title);
1068 return new MessageFormat(METADATA_FORMAT_PATTERN).format(new Object[] { id, parentId, title, upnpClass, desc });