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.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 org.eclipse.jdt.annotation.NonNullByDefault;
29 import org.eclipse.jdt.annotation.Nullable;
30 import org.openhab.binding.sonos.internal.util.StringUtils;
31 import org.slf4j.Logger;
32 import org.slf4j.LoggerFactory;
33 import org.xml.sax.Attributes;
34 import org.xml.sax.InputSource;
35 import org.xml.sax.SAXException;
36 import org.xml.sax.XMLReader;
37 import org.xml.sax.helpers.DefaultHandler;
38 import org.xml.sax.helpers.XMLReaderFactory;
41 * The {@link SonosXMLParser} is a class of helper functions
42 * to parse XML data returned by the Zone Players
44 * @author Karel Goderis - Initial contribution
47 public class SonosXMLParser {
49 static final Logger LOGGER = LoggerFactory.getLogger(SonosXMLParser.class);
51 private static final MessageFormat METADATA_FORMAT = new MessageFormat(
52 "<DIDL-Lite xmlns:dc=\"http://purl.org/dc/elements/1.1/\" "
53 + "xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" "
54 + "xmlns:r=\"urn:schemas-rinconnetworks-com:metadata-1-0/\" "
55 + "xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\">"
56 + "<item id=\"{0}\" parentID=\"{1}\" restricted=\"true\">" + "<dc:title>{2}</dc:title>"
57 + "<upnp:class>{3}</upnp:class>"
58 + "<desc id=\"cdudn\" nameSpace=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">" + "{4}</desc>"
59 + "</item></DIDL-Lite>");
61 private enum Element {
73 private enum CurrentElement {
88 * @return a list of alarms from the given xml string.
90 public static List<SonosAlarm> getAlarmsFromStringResult(String xml) {
91 AlarmHandler handler = new AlarmHandler();
93 XMLReader reader = XMLReaderFactory.createXMLReader();
94 reader.setContentHandler(handler);
95 reader.parse(new InputSource(new StringReader(xml)));
96 } catch (IOException e) {
97 LOGGER.error("Could not parse Alarms from string '{}'", xml);
98 } catch (SAXException s) {
99 LOGGER.error("Could not parse Alarms from string '{}'", xml);
101 return handler.getAlarms();
106 * @return a list of Entries from the given xml string.
108 public static List<SonosEntry> getEntriesFromString(String xml) {
109 EntryHandler handler = new EntryHandler();
111 XMLReader reader = XMLReaderFactory.createXMLReader();
112 reader.setContentHandler(handler);
113 reader.parse(new InputSource(new StringReader(xml)));
114 } catch (IOException e) {
115 LOGGER.error("Could not parse Entries from string '{}'", xml);
116 } catch (SAXException s) {
117 LOGGER.error("Could not parse Entries from string '{}'", xml);
120 return handler.getArtists();
124 * Returns the meta data which is needed to play Pandora
125 * (and others?) favorites
128 * @return The value of the desc xml tag
129 * @throws SAXException
131 public static @Nullable SonosResourceMetaData getResourceMetaData(String xml) throws SAXException {
132 XMLReader reader = XMLReaderFactory.createXMLReader();
133 reader.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
134 ResourceMetaDataHandler handler = new ResourceMetaDataHandler();
135 reader.setContentHandler(handler);
137 reader.parse(new InputSource(new StringReader(xml)));
138 } catch (IOException e) {
139 LOGGER.error("Could not parse Resource MetaData from String '{}'", xml);
140 } catch (SAXException s) {
141 LOGGER.error("Could not parse Resource MetaData from string '{}'", xml);
143 return handler.getMetaData();
148 * @return zone group from the given xml
150 public static List<SonosZoneGroup> getZoneGroupFromXML(String xml) {
151 ZoneGroupHandler handler = new ZoneGroupHandler();
153 XMLReader reader = XMLReaderFactory.createXMLReader();
154 reader.setContentHandler(handler);
155 reader.parse(new InputSource(new StringReader(xml)));
156 } catch (IOException e) {
157 // This should never happen - we're not performing I/O!
158 LOGGER.error("Could not parse ZoneGroup from string '{}'", xml);
159 } catch (SAXException s) {
160 LOGGER.error("Could not parse ZoneGroup from string '{}'", xml);
163 return handler.getGroups();
166 public static List<String> getRadioTimeFromXML(String xml) {
167 OpmlHandler handler = new OpmlHandler();
169 XMLReader reader = XMLReaderFactory.createXMLReader();
170 reader.setContentHandler(handler);
171 reader.parse(new InputSource(new StringReader(xml)));
172 } catch (IOException e) {
173 // This should never happen - we're not performing I/O!
174 LOGGER.error("Could not parse RadioTime from string '{}'", xml);
175 } catch (SAXException s) {
176 LOGGER.error("Could not parse RadioTime from string '{}'", xml);
179 return handler.getTextFields();
182 public static Map<String, String> getRenderingControlFromXML(String xml) {
183 RenderingControlEventHandler handler = new RenderingControlEventHandler();
185 XMLReader reader = XMLReaderFactory.createXMLReader();
186 reader.setContentHandler(handler);
187 reader.parse(new InputSource(new StringReader(xml)));
188 } catch (IOException e) {
189 // This should never happen - we're not performing I/O!
190 LOGGER.error("Could not parse Rendering Control from string '{}'", xml);
191 } catch (SAXException s) {
192 LOGGER.error("Could not parse Rendering Control from string '{}'", xml);
194 return handler.getChanges();
197 public static Map<String, String> getAVTransportFromXML(String xml) {
198 AVTransportEventHandler handler = new AVTransportEventHandler();
200 XMLReader reader = XMLReaderFactory.createXMLReader();
201 reader.setContentHandler(handler);
202 reader.parse(new InputSource(new StringReader(xml)));
203 } catch (IOException e) {
204 // This should never happen - we're not performing I/O!
205 LOGGER.error("Could not parse AV Transport from string '{}'", xml);
206 } catch (SAXException s) {
207 LOGGER.error("Could not parse AV Transport from string '{}'", xml);
209 return handler.getChanges();
212 public static SonosMetaData getMetaDataFromXML(String xml) {
213 MetaDataHandler handler = new MetaDataHandler();
215 XMLReader reader = XMLReaderFactory.createXMLReader();
216 reader.setContentHandler(handler);
217 reader.parse(new InputSource(new StringReader(xml)));
218 } catch (IOException e) {
219 // This should never happen - we're not performing I/O!
220 LOGGER.error("Could not parse MetaData from string '{}'", xml);
221 } catch (SAXException s) {
222 LOGGER.error("Could not parse MetaData from string '{}'", xml);
225 return handler.getMetaData();
228 public static List<SonosMusicService> getMusicServicesFromXML(String xml) {
229 MusicServiceHandler handler = new MusicServiceHandler();
231 XMLReader reader = XMLReaderFactory.createXMLReader();
232 reader.setContentHandler(handler);
233 reader.parse(new InputSource(new StringReader(xml)));
234 } catch (IOException e) {
235 // This should never happen - we're not performing I/O!
236 LOGGER.error("Could not parse music services from string '{}'", xml);
237 } catch (SAXException s) {
238 LOGGER.error("Could not parse music services from string '{}'", xml);
240 return handler.getServices();
243 private static class EntryHandler extends DefaultHandler {
245 // Maintain a set of elements about which it is unuseful to complain about.
246 // This list will be initialized on the first failure case
247 private static @Nullable List<String> ignore;
249 private String id = "";
250 private String parentId = "";
251 private StringBuilder upnpClass = new StringBuilder();
252 private StringBuilder res = new StringBuilder();
253 private StringBuilder title = new StringBuilder();
254 private StringBuilder album = new StringBuilder();
255 private StringBuilder albumArtUri = new StringBuilder();
256 private StringBuilder creator = new StringBuilder();
257 private StringBuilder trackNumber = new StringBuilder();
258 private StringBuilder desc = new StringBuilder();
259 private @Nullable Element element;
261 private List<SonosEntry> artists = new ArrayList<>();
264 // shouldn't be used outside of this package.
268 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
269 @Nullable Attributes attributes) throws SAXException {
270 String name = qName == null ? "" : qName;
274 if (attributes != null) {
275 id = attributes.getValue("id");
276 parentId = attributes.getValue("parentID");
280 element = Element.RES;
283 element = Element.TITLE;
286 element = Element.CLASS;
289 element = Element.CREATOR;
292 element = Element.ALBUM;
294 case "upnp:albumArtURI":
295 element = Element.ALBUM_ART_URI;
297 case "upnp:originalTrackNumber":
298 element = Element.TRACK_NUMBER;
301 element = Element.RESMD;
304 List<String> curIgnore = ignore;
305 if (curIgnore == null) {
306 curIgnore = new ArrayList<>();
307 curIgnore.add("DIDL-Lite");
308 curIgnore.add("type");
309 curIgnore.add("ordinal");
310 curIgnore.add("description");
314 if (!curIgnore.contains(localName)) {
315 LOGGER.debug("Did not recognise element named {}", localName);
323 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
324 Element elt = element;
325 if (elt == null || ch == null) {
330 title.append(ch, start, length);
333 upnpClass.append(ch, start, length);
336 res.append(ch, start, length);
339 album.append(ch, start, length);
342 albumArtUri.append(ch, start, length);
345 creator.append(ch, start, length);
348 trackNumber.append(ch, start, length);
351 desc.append(ch, start, length);
359 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
360 throws SAXException {
361 if (("container".equals(qName) || "item".equals(qName))) {
364 int trackNumberVal = 0;
366 trackNumberVal = Integer.parseInt(trackNumber.toString());
367 } catch (NumberFormatException e) {
370 SonosResourceMetaData md = null;
372 // The resource description is needed for playing favorites on pandora
373 if (!desc.toString().isEmpty()) {
375 md = getResourceMetaData(desc.toString());
376 } catch (SAXException ignore) {
377 LOGGER.debug("Failed to parse embeded", ignore);
381 artists.add(new SonosEntry(id, title.toString(), parentId, album.toString(), albumArtUri.toString(),
382 creator.toString(), upnpClass.toString(), res.toString(), trackNumberVal, md));
383 title = new StringBuilder();
384 upnpClass = new StringBuilder();
385 res = new StringBuilder();
386 album = new StringBuilder();
387 albumArtUri = new StringBuilder();
388 creator = new StringBuilder();
389 trackNumber = new StringBuilder();
390 desc = new StringBuilder();
394 public List<SonosEntry> getArtists() {
399 private static class ResourceMetaDataHandler extends DefaultHandler {
401 private String id = "";
402 private String parentId = "";
403 private StringBuilder title = new StringBuilder();
404 private StringBuilder upnpClass = new StringBuilder();
405 private StringBuilder desc = new StringBuilder();
406 private @Nullable Element element;
407 private @Nullable SonosResourceMetaData metaData;
409 ResourceMetaDataHandler() {
410 // shouldn't be used outside of this package.
414 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
415 @Nullable Attributes attributes) throws SAXException {
416 String name = qName == null ? "" : qName;
420 if (attributes != null) {
421 id = attributes.getValue("id");
422 parentId = attributes.getValue("parentID");
426 element = Element.DESC;
429 element = Element.CLASS;
432 element = Element.TITLE;
441 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
442 Element elt = element;
443 if (elt == null || ch == null) {
448 title.append(ch, start, length);
451 upnpClass.append(ch, start, length);
454 desc.append(ch, start, length);
462 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
463 throws SAXException {
464 if ("DIDL-Lite".equals(qName)) {
465 metaData = new SonosResourceMetaData(id, parentId, title.toString(), upnpClass.toString(),
468 desc = new StringBuilder();
469 upnpClass = new StringBuilder();
470 title = new StringBuilder();
474 public @Nullable SonosResourceMetaData getMetaData() {
479 private static class AlarmHandler extends DefaultHandler {
481 private @Nullable String id;
482 private String startTime = "";
483 private String duration = "";
484 private String recurrence = "";
485 private @Nullable String enabled;
486 private String roomUUID = "";
487 private String programURI = "";
488 private String programMetaData = "";
489 private String playMode = "";
490 private @Nullable String volume;
491 private @Nullable String includeLinkedZones;
493 private List<SonosAlarm> alarms = new ArrayList<>();
496 // shouldn't be used outside of this package.
500 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
501 @Nullable Attributes attributes) throws SAXException {
502 if ("Alarm".equals(qName) && attributes != null) {
503 id = attributes.getValue("ID");
504 duration = attributes.getValue("Duration");
505 recurrence = attributes.getValue("Recurrence");
506 startTime = attributes.getValue("StartTime");
507 enabled = attributes.getValue("Enabled");
508 roomUUID = attributes.getValue("RoomUUID");
509 programURI = attributes.getValue("ProgramURI");
510 programMetaData = attributes.getValue("ProgramMetaData");
511 playMode = attributes.getValue("PlayMode");
512 volume = attributes.getValue("Volume");
513 includeLinkedZones = attributes.getValue("IncludeLinkedZones");
518 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
519 throws SAXException {
520 if ("Alarm".equals(qName)) {
523 boolean finalEnabled = !"0".equals(enabled);
524 boolean finalIncludeLinkedZones = !"0".equals(includeLinkedZones);
529 throw new NumberFormatException();
531 finalID = Integer.parseInt(id);
532 String volume = this.volume;
533 if (volume == null) {
534 throw new NumberFormatException();
536 finalVolume = Integer.parseInt(volume);
537 } catch (NumberFormatException e) {
538 LOGGER.debug("Error parsing Integer");
541 alarms.add(new SonosAlarm(finalID, startTime, duration, recurrence, finalEnabled, roomUUID, programURI,
542 programMetaData, playMode, finalVolume, finalIncludeLinkedZones));
546 public List<SonosAlarm> getAlarms() {
551 private static class ZoneGroupHandler extends DefaultHandler {
553 private final List<SonosZoneGroup> groups = new ArrayList<>();
554 private final List<String> currentGroupPlayers = new ArrayList<>();
555 private final List<String> currentGroupPlayerZones = new ArrayList<>();
556 private String coordinator = "";
557 private String groupId = "";
560 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
561 @Nullable Attributes attributes) throws SAXException {
562 if ("ZoneGroup".equals(qName) && attributes != null) {
563 groupId = attributes.getValue("ID");
564 coordinator = attributes.getValue("Coordinator");
565 } else if ("ZoneGroupMember".equals(qName) && attributes != null) {
566 currentGroupPlayers.add(attributes.getValue("UUID"));
567 String zoneName = attributes.getValue("ZoneName");
568 if (zoneName != null) {
569 currentGroupPlayerZones.add(zoneName);
571 String htInfoSet = attributes.getValue("HTSatChanMapSet");
572 if (htInfoSet != null) {
573 currentGroupPlayers.addAll(getAllHomeTheaterMembers(htInfoSet));
579 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
580 throws SAXException {
581 if ("ZoneGroup".equals(qName)) {
582 groups.add(new SonosZoneGroup(groupId, coordinator, currentGroupPlayers, currentGroupPlayerZones));
583 currentGroupPlayers.clear();
584 currentGroupPlayerZones.clear();
588 public List<SonosZoneGroup> getGroups() {
592 private Set<String> getAllHomeTheaterMembers(String homeTheaterDescription) {
593 Set<String> homeTheaterMembers = new HashSet<>();
594 Matcher matcher = Pattern.compile("(RINCON_\\w+)").matcher(homeTheaterDescription);
595 while (matcher.find()) {
596 String member = matcher.group();
597 homeTheaterMembers.add(member);
599 return homeTheaterMembers;
603 private static class OpmlHandler extends DefaultHandler {
605 // <opml version="1">
607 // <status>200</status>
611 // <outline type="text" text="Q-Music 103.3" guide_id="s2398" key="station"
612 // image="http://radiotime-logos.s3.amazonaws.com/s87683q.png" preset_id="s2398"/>
613 // <outline type="text" text="Bjorn Verhoeven" guide_id="p257265" seconds_remaining="2230" duration="7200"
615 // <outline type="text" text="Top 40-Pop"/>
616 // <outline type="text" text="37m remaining"/>
617 // <outline type="object" text="NowPlaying">
619 // <logo>http://radiotime-logos.s3.amazonaws.com/s87683.png</logo>
626 private final List<String> textFields = new ArrayList<>();
627 private @Nullable String textField;
628 private @Nullable String type;
629 // private String logo;
632 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
633 @Nullable Attributes attributes) throws SAXException {
634 if ("outline".equals(qName)) {
635 type = attributes == null ? null : attributes.getValue("type");
636 if ("text".equals(type)) {
637 textField = attributes == null ? null : attributes.getValue("text");
645 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
646 throws SAXException {
647 if ("outline".equals(qName)) {
648 String field = textField;
650 textFields.add(field);
655 public List<String> getTextFields() {
660 private static class AVTransportEventHandler extends DefaultHandler {
663 * <Event xmlns="urn:schemas-upnp-org:metadata-1-0/AVT/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/">
664 * <InstanceID val="0">
665 * <TransportState val="PLAYING"/>
666 * <CurrentPlayMode val="NORMAL"/>
667 * <CurrentPlayMode val="0"/>
668 * <NumberOfTracks val="29"/>
669 * <CurrentTrack val="12"/>
670 * <CurrentSection val="0"/>
671 * <CurrentTrackURI val=
672 * "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"
674 * <CurrentTrackDuration val="0:03:02"/>
675 * <CurrentTrackMetaData val=
676 * "<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>"
677 * /><r:NextTrackURI val=
678 * "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"
679 * /><r:NextTrackMetaData val=
680 * "<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>"
681 * /><r:EnqueuedTransportURI
682 * val="x-rincon-playlist:RINCON_000E582126EE01400#A:ALBUMARTIST/Queens%20Of%20The%20Stone%20Age"/><r:
683 * EnqueuedTransportURIMetaData val=
684 * "<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>"
686 * <PlaybackStorageMedium val="NETWORK"/>
687 * <AVTransportURI val="x-rincon-queue:RINCON_000E5812BC1801400#0"/>
688 * <AVTransportURIMetaData val=""/>
689 * <CurrentTransportActions val="Play, Stop, Pause, Seek, Next, Previous"/>
690 * <TransportStatus val="OK"/>
691 * <r:SleepTimerGeneration val="0"/>
692 * <r:AlarmRunning val="0"/>
693 * <r:SnoozeRunning val="0"/>
694 * <r:RestartPending val="0"/>
695 * <TransportPlaySpeed val="NOT_IMPLEMENTED"/>
696 * <CurrentMediaDuration val="NOT_IMPLEMENTED"/>
697 * <RecordStorageMedium val="NOT_IMPLEMENTED"/>
698 * <PossiblePlaybackStorageMedia val="NONE, NETWORK"/>
699 * <PossibleRecordStorageMedia val="NOT_IMPLEMENTED"/>
700 * <RecordMediumWriteStatus val="NOT_IMPLEMENTED"/>
701 * <CurrentRecordQualityMode val="NOT_IMPLEMENTED"/>
702 * <PossibleRecordQualityModes val="NOT_IMPLEMENTED"/>
703 * <NextAVTransportURI val="NOT_IMPLEMENTED"/>
704 * <NextAVTransportURIMetaData val="NOT_IMPLEMENTED"/>
709 private final Map<String, String> changes = new HashMap<>();
712 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
713 @Nullable Attributes attributes) throws SAXException {
715 * The events are all of the form <qName val="value"/> so we can get all
716 * the info we need from here.
718 if (localName == null) {
719 // this means that localName isn't defined in EventType, which is expected for some elements
720 LOGGER.info("{} is not defined in EventType. ", localName);
722 String val = attributes == null ? null : attributes.getValue("val");
724 changes.put(localName, val);
729 public Map<String, String> getChanges() {
734 private static class MetaDataHandler extends DefaultHandler {
736 private @Nullable CurrentElement currentElement;
738 private String id = "-1";
739 private String parentId = "-1";
740 private StringBuilder resource = new StringBuilder();
741 private StringBuilder streamContent = new StringBuilder();
742 private StringBuilder albumArtUri = new StringBuilder();
743 private StringBuilder title = new StringBuilder();
744 private StringBuilder upnpClass = new StringBuilder();
745 private StringBuilder creator = new StringBuilder();
746 private StringBuilder album = new StringBuilder();
747 private StringBuilder albumArtist = new StringBuilder();
750 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
751 @Nullable Attributes attributes) throws SAXException {
752 String name = localName == null ? "" : localName;
755 currentElement = CurrentElement.item;
756 if (attributes != null) {
757 id = attributes.getValue("id");
758 parentId = attributes.getValue("parentID");
762 currentElement = CurrentElement.res;
764 case "streamContent":
765 currentElement = CurrentElement.streamContent;
768 currentElement = CurrentElement.albumArtURI;
771 currentElement = CurrentElement.title;
774 currentElement = CurrentElement.upnpClass;
777 currentElement = CurrentElement.creator;
780 currentElement = CurrentElement.album;
783 currentElement = CurrentElement.albumArtist;
787 currentElement = null;
793 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
794 CurrentElement elt = currentElement;
795 if (elt == null || ch == null) {
802 resource.append(ch, start, length);
805 streamContent.append(ch, start, length);
808 albumArtUri.append(ch, start, length);
811 title.append(ch, start, length);
814 upnpClass.append(ch, start, length);
817 creator.append(ch, start, length);
820 album.append(ch, start, length);
823 albumArtist.append(ch, start, length);
830 public SonosMetaData getMetaData() {
831 return new SonosMetaData(id, parentId, resource.toString(), streamContent.toString(),
832 albumArtUri.toString(), title.toString(), upnpClass.toString(), creator.toString(),
833 album.toString(), albumArtist.toString());
837 private static class RenderingControlEventHandler extends DefaultHandler {
839 private final Map<String, String> changes = new HashMap<>();
841 private boolean getPresetName = false;
842 private @Nullable String presetName;
845 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
846 @Nullable Attributes attributes) throws SAXException {
856 channel = attributes == null ? null : attributes.getValue("channel");
857 val = attributes == null ? null : attributes.getValue("val");
858 if (channel != null && val != null) {
859 changes.put(qName + channel, val);
869 case "SurroundEnabled":
871 case "SurroundLevel":
873 case "MusicSurroundLevel":
874 case "HeightChannelLevel":
875 val = attributes == null ? null : attributes.getValue("val");
877 changes.put(qName, val);
880 case "PresetNameList":
881 getPresetName = true;
889 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
890 if (getPresetName && ch != null) {
891 presetName = new String(ch, start, length);
896 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
897 throws SAXException {
899 getPresetName = false;
900 String preset = presetName;
901 if (qName != null && preset != null) {
902 changes.put(qName, preset);
907 public Map<String, String> getChanges() {
912 private static class MusicServiceHandler extends DefaultHandler {
914 private final List<SonosMusicService> services = new ArrayList<>();
917 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
918 @Nullable Attributes attributes) throws SAXException {
919 // All services are of the form <services Id="value" Name="value">...</Service>
920 if ("Service".equals(qName) && attributes != null && attributes.getValue("Id") != null
921 && attributes.getValue("Name") != null) {
922 services.add(new SonosMusicService(attributes.getValue("Id"), attributes.getValue("Name")));
926 public List<SonosMusicService> getServices() {
931 public static @Nullable String getRoomName(String descriptorXML) {
932 RoomNameHandler roomNameHandler = new RoomNameHandler();
934 XMLReader reader = XMLReaderFactory.createXMLReader();
935 reader.setContentHandler(roomNameHandler);
936 URL url = new URL(descriptorXML);
937 reader.parse(new InputSource(url.openStream()));
938 } catch (IOException | SAXException e) {
939 LOGGER.error("Could not parse Sonos room name from string '{}'", descriptorXML);
941 return roomNameHandler.getRoomName();
944 private static class RoomNameHandler extends DefaultHandler {
946 private @Nullable String roomName;
947 private boolean roomNameTag;
950 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
951 @Nullable Attributes attributes) throws SAXException {
952 if ("roomName".equalsIgnoreCase(localName)) {
958 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
959 if (roomNameTag && ch != null) {
960 roomName = new String(ch, start, length);
965 public @Nullable String getRoomName() {
970 public static @Nullable String parseModelDescription(URL descriptorURL) {
971 ModelNameHandler modelNameHandler = new ModelNameHandler();
973 XMLReader reader = XMLReaderFactory.createXMLReader();
974 reader.setContentHandler(modelNameHandler);
975 URL url = new URL(descriptorURL.toString());
976 reader.parse(new InputSource(url.openStream()));
977 } catch (IOException | SAXException e) {
978 LOGGER.error("Could not parse Sonos model name from string '{}'", descriptorURL.toString());
980 return modelNameHandler.getModelName();
983 private static class ModelNameHandler extends DefaultHandler {
985 private @Nullable String modelName;
986 private boolean modelNameTag;
989 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
990 @Nullable Attributes attributes) throws SAXException {
991 if ("modelName".equalsIgnoreCase(localName)) {
997 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
998 if (modelNameTag && ch != null) {
999 modelName = new String(ch, start, length);
1000 modelNameTag = false;
1004 public @Nullable String getModelName() {
1010 * Build a valid thing type ID from the model name provided by UPnP
1012 * @param sonosModelName Sonos model name provided via UPnP device
1013 * @return a valid thing type ID that can then be used for ThingType creation
1015 public static String buildThingTypeIdFromModelName(String sonosModelName) {
1016 // For Ikea SYMFONISK models, the model name now starts with "SYMFONISK" with recent firmwares
1017 if (sonosModelName.toUpperCase().contains("SYMFONISK")) {
1020 String id = sonosModelName;
1021 // Remove until the first space (in practice, it removes the leading "Sonos " from the model name)
1022 Matcher matcher = Pattern.compile("\\s(.*)").matcher(id);
1023 if (matcher.find()) {
1024 id = matcher.group(1);
1025 // Remove a potential ending text surrounded with parenthesis
1026 matcher = Pattern.compile("(.*)\\s\\(.*\\)").matcher(id);
1027 if (matcher.find()) {
1028 id = matcher.group(1);
1031 // Finally remove unexpected characters in a thing type ID
1032 id = id.replaceAll("[^a-zA-Z0-9_]", "");
1033 // ZP80 is translated to CONNECT and ZP100 to CONNECTAMP
1047 public static String compileMetadataString(SonosEntry entry) {
1049 * If the entry contains resource meta data we will override this with
1052 String id = entry.getId();
1053 String parentId = entry.getParentId();
1054 String title = entry.getTitle();
1055 String upnpClass = entry.getUpnpClass();
1058 * By default 'RINCON_AssociatedZPUDN' is used for most operations,
1059 * however when playing a favorite entry that is associated withh a
1060 * subscription like pandora we need to use the desc string asscoiated
1063 String desc = entry.getDesc();
1065 desc = "RINCON_AssociatedZPUDN";
1069 * If resource meta data exists, use it over the parent data
1071 SonosResourceMetaData resourceMetaData = entry.getResourceMetaData();
1072 if (resourceMetaData != null) {
1073 id = resourceMetaData.getId();
1074 parentId = resourceMetaData.getParentId();
1075 title = resourceMetaData.getTitle();
1076 desc = resourceMetaData.getDesc();
1077 upnpClass = resourceMetaData.getUpnpClass();
1080 title = StringUtils.escapeXml(title);
1082 String metadata = METADATA_FORMAT.format(new Object[] { id, parentId, title, upnpClass, desc });