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 * @throws SAXException
92 public static List<SonosAlarm> getAlarmsFromStringResult(String xml) {
93 AlarmHandler handler = new AlarmHandler();
95 XMLReader reader = XMLReaderFactory.createXMLReader();
96 reader.setContentHandler(handler);
97 reader.parse(new InputSource(new StringReader(xml)));
98 } catch (IOException e) {
99 LOGGER.error("Could not parse Alarms from string '{}'", xml);
100 } catch (SAXException s) {
101 LOGGER.error("Could not parse Alarms from string '{}'", xml);
103 return handler.getAlarms();
108 * @return a list of Entries from the given xml string.
109 * @throws IOException
110 * @throws SAXException
112 public static List<SonosEntry> getEntriesFromString(String xml) {
113 EntryHandler handler = new EntryHandler();
115 XMLReader reader = XMLReaderFactory.createXMLReader();
116 reader.setContentHandler(handler);
117 reader.parse(new InputSource(new StringReader(xml)));
118 } catch (IOException e) {
119 LOGGER.error("Could not parse Entries from string '{}'", xml);
120 } catch (SAXException s) {
121 LOGGER.error("Could not parse Entries from string '{}'", xml);
124 return handler.getArtists();
128 * Returns the meta data which is needed to play Pandora
129 * (and others?) favorites
132 * @return The value of the desc xml tag
133 * @throws SAXException
135 public static @Nullable SonosResourceMetaData getResourceMetaData(String xml) throws SAXException {
136 XMLReader reader = XMLReaderFactory.createXMLReader();
137 reader.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
138 ResourceMetaDataHandler handler = new ResourceMetaDataHandler();
139 reader.setContentHandler(handler);
141 reader.parse(new InputSource(new StringReader(xml)));
142 } catch (IOException e) {
143 LOGGER.error("Could not parse Resource MetaData from String '{}'", xml);
144 } catch (SAXException s) {
145 LOGGER.error("Could not parse Resource MetaData from string '{}'", xml);
147 return handler.getMetaData();
153 * @return zone group from the given xml
154 * @throws IOException
155 * @throws SAXException
157 public static List<SonosZoneGroup> getZoneGroupFromXML(String xml) {
158 ZoneGroupHandler handler = new ZoneGroupHandler();
160 XMLReader reader = XMLReaderFactory.createXMLReader();
161 reader.setContentHandler(handler);
162 reader.parse(new InputSource(new StringReader(xml)));
163 } catch (IOException e) {
164 // This should never happen - we're not performing I/O!
165 LOGGER.error("Could not parse ZoneGroup from string '{}'", xml);
166 } catch (SAXException s) {
167 LOGGER.error("Could not parse ZoneGroup from string '{}'", xml);
170 return handler.getGroups();
173 public static List<String> getRadioTimeFromXML(String xml) {
174 OpmlHandler handler = new OpmlHandler();
176 XMLReader reader = XMLReaderFactory.createXMLReader();
177 reader.setContentHandler(handler);
178 reader.parse(new InputSource(new StringReader(xml)));
179 } catch (IOException e) {
180 // This should never happen - we're not performing I/O!
181 LOGGER.error("Could not parse RadioTime from string '{}'", xml);
182 } catch (SAXException s) {
183 LOGGER.error("Could not parse RadioTime from string '{}'", xml);
186 return handler.getTextFields();
189 public static Map<String, String> getRenderingControlFromXML(String xml) {
190 RenderingControlEventHandler handler = new RenderingControlEventHandler();
192 XMLReader reader = XMLReaderFactory.createXMLReader();
193 reader.setContentHandler(handler);
194 reader.parse(new InputSource(new StringReader(xml)));
195 } catch (IOException e) {
196 // This should never happen - we're not performing I/O!
197 LOGGER.error("Could not parse Rendering Control from string '{}'", xml);
198 } catch (SAXException s) {
199 LOGGER.error("Could not parse Rendering Control from string '{}'", xml);
201 return handler.getChanges();
204 public static Map<String, String> getAVTransportFromXML(String xml) {
205 AVTransportEventHandler handler = new AVTransportEventHandler();
207 XMLReader reader = XMLReaderFactory.createXMLReader();
208 reader.setContentHandler(handler);
209 reader.parse(new InputSource(new StringReader(xml)));
210 } catch (IOException e) {
211 // This should never happen - we're not performing I/O!
212 LOGGER.error("Could not parse AV Transport from string '{}'", xml);
213 } catch (SAXException s) {
214 LOGGER.error("Could not parse AV Transport from string '{}'", xml);
216 return handler.getChanges();
219 public static SonosMetaData getMetaDataFromXML(String xml) {
220 MetaDataHandler handler = new MetaDataHandler();
222 XMLReader reader = XMLReaderFactory.createXMLReader();
223 reader.setContentHandler(handler);
224 reader.parse(new InputSource(new StringReader(xml)));
225 } catch (IOException e) {
226 // This should never happen - we're not performing I/O!
227 LOGGER.error("Could not parse MetaData from string '{}'", xml);
228 } catch (SAXException s) {
229 LOGGER.error("Could not parse MetaData from string '{}'", xml);
232 return handler.getMetaData();
235 public static List<SonosMusicService> getMusicServicesFromXML(String xml) {
236 MusicServiceHandler handler = new MusicServiceHandler();
238 XMLReader reader = XMLReaderFactory.createXMLReader();
239 reader.setContentHandler(handler);
240 reader.parse(new InputSource(new StringReader(xml)));
241 } catch (IOException e) {
242 // This should never happen - we're not performing I/O!
243 LOGGER.error("Could not parse music services from string '{}'", xml);
244 } catch (SAXException s) {
245 LOGGER.error("Could not parse music services from string '{}'", xml);
247 return handler.getServices();
250 private static class EntryHandler extends DefaultHandler {
252 // Maintain a set of elements about which it is unuseful to complain about.
253 // This list will be initialized on the first failure case
254 private static @Nullable List<String> ignore;
256 private String id = "";
257 private String parentId = "";
258 private StringBuilder upnpClass = new StringBuilder();
259 private StringBuilder res = new StringBuilder();
260 private StringBuilder title = new StringBuilder();
261 private StringBuilder album = new StringBuilder();
262 private StringBuilder albumArtUri = new StringBuilder();
263 private StringBuilder creator = new StringBuilder();
264 private StringBuilder trackNumber = new StringBuilder();
265 private StringBuilder desc = new StringBuilder();
266 private @Nullable Element element;
268 private List<SonosEntry> artists = new ArrayList<>();
271 // shouldn't be used outside of this package.
275 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
276 @Nullable Attributes attributes) throws SAXException {
277 String name = qName == null ? "" : qName;
281 if (attributes != null) {
282 id = attributes.getValue("id");
283 parentId = attributes.getValue("parentID");
287 element = Element.RES;
290 element = Element.TITLE;
293 element = Element.CLASS;
296 element = Element.CREATOR;
299 element = Element.ALBUM;
301 case "upnp:albumArtURI":
302 element = Element.ALBUM_ART_URI;
304 case "upnp:originalTrackNumber":
305 element = Element.TRACK_NUMBER;
308 element = Element.RESMD;
311 List<String> curIgnore = ignore;
312 if (curIgnore == null) {
313 curIgnore = new ArrayList<>();
314 curIgnore.add("DIDL-Lite");
315 curIgnore.add("type");
316 curIgnore.add("ordinal");
317 curIgnore.add("description");
321 if (!curIgnore.contains(localName)) {
322 LOGGER.debug("Did not recognise element named {}", localName);
330 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
331 Element elt = element;
332 if (elt == null || ch == null) {
337 title.append(ch, start, length);
340 upnpClass.append(ch, start, length);
343 res.append(ch, start, length);
346 album.append(ch, start, length);
349 albumArtUri.append(ch, start, length);
352 creator.append(ch, start, length);
355 trackNumber.append(ch, start, length);
358 desc.append(ch, start, length);
366 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
367 throws SAXException {
368 if (("container".equals(qName) || "item".equals(qName))) {
371 int trackNumberVal = 0;
373 trackNumberVal = Integer.parseInt(trackNumber.toString());
374 } catch (NumberFormatException e) {
377 SonosResourceMetaData md = null;
379 // The resource description is needed for playing favorites on pandora
380 if (!desc.toString().isEmpty()) {
382 md = getResourceMetaData(desc.toString());
383 } catch (SAXException ignore) {
384 LOGGER.debug("Failed to parse embeded", ignore);
388 artists.add(new SonosEntry(id, title.toString(), parentId, album.toString(), albumArtUri.toString(),
389 creator.toString(), upnpClass.toString(), res.toString(), trackNumberVal, md));
390 title = new StringBuilder();
391 upnpClass = new StringBuilder();
392 res = new StringBuilder();
393 album = new StringBuilder();
394 albumArtUri = new StringBuilder();
395 creator = new StringBuilder();
396 trackNumber = new StringBuilder();
397 desc = new StringBuilder();
401 public List<SonosEntry> getArtists() {
406 private static class ResourceMetaDataHandler extends DefaultHandler {
408 private String id = "";
409 private String parentId = "";
410 private StringBuilder title = new StringBuilder();
411 private StringBuilder upnpClass = new StringBuilder();
412 private StringBuilder desc = new StringBuilder();
413 private @Nullable Element element;
414 private @Nullable SonosResourceMetaData metaData;
416 ResourceMetaDataHandler() {
417 // shouldn't be used outside of this package.
421 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
422 @Nullable Attributes attributes) throws SAXException {
423 String name = qName == null ? "" : qName;
427 if (attributes != null) {
428 id = attributes.getValue("id");
429 parentId = attributes.getValue("parentID");
433 element = Element.DESC;
436 element = Element.CLASS;
439 element = Element.TITLE;
448 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
449 Element elt = element;
450 if (elt == null || ch == null) {
455 title.append(ch, start, length);
458 upnpClass.append(ch, start, length);
461 desc.append(ch, start, length);
469 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
470 throws SAXException {
471 if ("DIDL-Lite".equals(qName)) {
472 metaData = new SonosResourceMetaData(id, parentId, title.toString(), upnpClass.toString(),
475 desc = new StringBuilder();
476 upnpClass = new StringBuilder();
477 title = new StringBuilder();
481 public @Nullable SonosResourceMetaData getMetaData() {
486 private static class AlarmHandler extends DefaultHandler {
488 private @Nullable String id;
489 private String startTime = "";
490 private String duration = "";
491 private String recurrence = "";
492 private @Nullable String enabled;
493 private String roomUUID = "";
494 private String programURI = "";
495 private String programMetaData = "";
496 private String playMode = "";
497 private @Nullable String volume;
498 private @Nullable String includeLinkedZones;
500 private List<SonosAlarm> alarms = new ArrayList<>();
503 // shouldn't be used outside of this package.
507 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
508 @Nullable Attributes attributes) throws SAXException {
509 if ("Alarm".equals(qName) && attributes != null) {
510 id = attributes.getValue("ID");
511 duration = attributes.getValue("Duration");
512 recurrence = attributes.getValue("Recurrence");
513 startTime = attributes.getValue("StartTime");
514 enabled = attributes.getValue("Enabled");
515 roomUUID = attributes.getValue("RoomUUID");
516 programURI = attributes.getValue("ProgramURI");
517 programMetaData = attributes.getValue("ProgramMetaData");
518 playMode = attributes.getValue("PlayMode");
519 volume = attributes.getValue("Volume");
520 includeLinkedZones = attributes.getValue("IncludeLinkedZones");
525 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
526 throws SAXException {
527 if ("Alarm".equals(qName)) {
530 boolean finalEnabled = !"0".equals(enabled);
531 boolean finalIncludeLinkedZones = !"0".equals(includeLinkedZones);
536 throw new NumberFormatException();
538 finalID = Integer.parseInt(id);
539 String volume = this.volume;
540 if (volume == null) {
541 throw new NumberFormatException();
543 finalVolume = Integer.parseInt(volume);
544 } catch (NumberFormatException e) {
545 LOGGER.debug("Error parsing Integer");
548 alarms.add(new SonosAlarm(finalID, startTime, duration, recurrence, finalEnabled, roomUUID, programURI,
549 programMetaData, playMode, finalVolume, finalIncludeLinkedZones));
553 public List<SonosAlarm> getAlarms() {
558 private static class ZoneGroupHandler extends DefaultHandler {
560 private final List<SonosZoneGroup> groups = new ArrayList<>();
561 private final List<String> currentGroupPlayers = new ArrayList<>();
562 private final List<String> currentGroupPlayerZones = new ArrayList<>();
563 private String coordinator = "";
564 private String groupId = "";
567 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
568 @Nullable Attributes attributes) throws SAXException {
569 if ("ZoneGroup".equals(qName) && attributes != null) {
570 groupId = attributes.getValue("ID");
571 coordinator = attributes.getValue("Coordinator");
572 } else if ("ZoneGroupMember".equals(qName) && attributes != null) {
573 currentGroupPlayers.add(attributes.getValue("UUID"));
574 String zoneName = attributes.getValue("ZoneName");
575 if (zoneName != null) {
576 currentGroupPlayerZones.add(zoneName);
578 String htInfoSet = attributes.getValue("HTSatChanMapSet");
579 if (htInfoSet != null) {
580 currentGroupPlayers.addAll(getAllHomeTheaterMembers(htInfoSet));
586 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
587 throws SAXException {
588 if ("ZoneGroup".equals(qName)) {
589 groups.add(new SonosZoneGroup(groupId, coordinator, currentGroupPlayers, currentGroupPlayerZones));
590 currentGroupPlayers.clear();
591 currentGroupPlayerZones.clear();
595 public List<SonosZoneGroup> getGroups() {
599 private Set<String> getAllHomeTheaterMembers(String homeTheaterDescription) {
600 Set<String> homeTheaterMembers = new HashSet<>();
601 Matcher matcher = Pattern.compile("(RINCON_\\w+)").matcher(homeTheaterDescription);
602 while (matcher.find()) {
603 String member = matcher.group();
604 homeTheaterMembers.add(member);
606 return homeTheaterMembers;
610 private static class OpmlHandler extends DefaultHandler {
612 // <opml version="1">
614 // <status>200</status>
618 // <outline type="text" text="Q-Music 103.3" guide_id="s2398" key="station"
619 // image="http://radiotime-logos.s3.amazonaws.com/s87683q.png" preset_id="s2398"/>
620 // <outline type="text" text="Bjorn Verhoeven" guide_id="p257265" seconds_remaining="2230" duration="7200"
622 // <outline type="text" text="Top 40-Pop"/>
623 // <outline type="text" text="37m remaining"/>
624 // <outline type="object" text="NowPlaying">
626 // <logo>http://radiotime-logos.s3.amazonaws.com/s87683.png</logo>
633 private final List<String> textFields = new ArrayList<>();
634 private @Nullable String textField;
635 private @Nullable String type;
636 // private String logo;
639 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
640 @Nullable Attributes attributes) throws SAXException {
641 if ("outline".equals(qName)) {
642 type = attributes == null ? null : attributes.getValue("type");
643 if ("text".equals(type)) {
644 textField = attributes == null ? null : attributes.getValue("text");
652 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
653 throws SAXException {
654 if ("outline".equals(qName)) {
655 String field = textField;
657 textFields.add(field);
662 public List<String> getTextFields() {
667 private static class AVTransportEventHandler extends DefaultHandler {
670 * <Event xmlns="urn:schemas-upnp-org:metadata-1-0/AVT/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/">
671 * <InstanceID val="0">
672 * <TransportState val="PLAYING"/>
673 * <CurrentPlayMode val="NORMAL"/>
674 * <CurrentPlayMode val="0"/>
675 * <NumberOfTracks val="29"/>
676 * <CurrentTrack val="12"/>
677 * <CurrentSection val="0"/>
678 * <CurrentTrackURI val=
679 * "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"
681 * <CurrentTrackDuration val="0:03:02"/>
682 * <CurrentTrackMetaData val=
683 * "<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>"
684 * /><r:NextTrackURI val=
685 * "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"
686 * /><r:NextTrackMetaData val=
687 * "<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>"
688 * /><r:EnqueuedTransportURI
689 * val="x-rincon-playlist:RINCON_000E582126EE01400#A:ALBUMARTIST/Queens%20Of%20The%20Stone%20Age"/><r:
690 * EnqueuedTransportURIMetaData val=
691 * "<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>"
693 * <PlaybackStorageMedium val="NETWORK"/>
694 * <AVTransportURI val="x-rincon-queue:RINCON_000E5812BC1801400#0"/>
695 * <AVTransportURIMetaData val=""/>
696 * <CurrentTransportActions val="Play, Stop, Pause, Seek, Next, Previous"/>
697 * <TransportStatus val="OK"/>
698 * <r:SleepTimerGeneration val="0"/>
699 * <r:AlarmRunning val="0"/>
700 * <r:SnoozeRunning val="0"/>
701 * <r:RestartPending val="0"/>
702 * <TransportPlaySpeed val="NOT_IMPLEMENTED"/>
703 * <CurrentMediaDuration val="NOT_IMPLEMENTED"/>
704 * <RecordStorageMedium val="NOT_IMPLEMENTED"/>
705 * <PossiblePlaybackStorageMedia val="NONE, NETWORK"/>
706 * <PossibleRecordStorageMedia val="NOT_IMPLEMENTED"/>
707 * <RecordMediumWriteStatus val="NOT_IMPLEMENTED"/>
708 * <CurrentRecordQualityMode val="NOT_IMPLEMENTED"/>
709 * <PossibleRecordQualityModes val="NOT_IMPLEMENTED"/>
710 * <NextAVTransportURI val="NOT_IMPLEMENTED"/>
711 * <NextAVTransportURIMetaData val="NOT_IMPLEMENTED"/>
716 private final Map<String, String> changes = new HashMap<>();
719 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
720 @Nullable Attributes attributes) throws SAXException {
722 * The events are all of the form <qName val="value"/> so we can get all
723 * the info we need from here.
725 if (localName == null) {
726 // this means that localName isn't defined in EventType, which is expected for some elements
727 LOGGER.info("{} is not defined in EventType. ", localName);
729 String val = attributes == null ? null : attributes.getValue("val");
731 changes.put(localName, val);
736 public Map<String, String> getChanges() {
741 private static class MetaDataHandler extends DefaultHandler {
743 private @Nullable CurrentElement currentElement;
745 private String id = "-1";
746 private String parentId = "-1";
747 private StringBuilder resource = new StringBuilder();
748 private StringBuilder streamContent = new StringBuilder();
749 private StringBuilder albumArtUri = new StringBuilder();
750 private StringBuilder title = new StringBuilder();
751 private StringBuilder upnpClass = new StringBuilder();
752 private StringBuilder creator = new StringBuilder();
753 private StringBuilder album = new StringBuilder();
754 private StringBuilder albumArtist = new StringBuilder();
757 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
758 @Nullable Attributes attributes) throws SAXException {
759 String name = localName == null ? "" : localName;
762 currentElement = CurrentElement.item;
763 if (attributes != null) {
764 id = attributes.getValue("id");
765 parentId = attributes.getValue("parentID");
769 currentElement = CurrentElement.res;
771 case "streamContent":
772 currentElement = CurrentElement.streamContent;
775 currentElement = CurrentElement.albumArtURI;
778 currentElement = CurrentElement.title;
781 currentElement = CurrentElement.upnpClass;
784 currentElement = CurrentElement.creator;
787 currentElement = CurrentElement.album;
790 currentElement = CurrentElement.albumArtist;
794 currentElement = null;
800 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
801 CurrentElement elt = currentElement;
802 if (elt == null || ch == null) {
809 resource.append(ch, start, length);
812 streamContent.append(ch, start, length);
815 albumArtUri.append(ch, start, length);
818 title.append(ch, start, length);
821 upnpClass.append(ch, start, length);
824 creator.append(ch, start, length);
827 album.append(ch, start, length);
830 albumArtist.append(ch, start, length);
837 public SonosMetaData getMetaData() {
838 return new SonosMetaData(id, parentId, resource.toString(), streamContent.toString(),
839 albumArtUri.toString(), title.toString(), upnpClass.toString(), creator.toString(),
840 album.toString(), albumArtist.toString());
844 private static class RenderingControlEventHandler extends DefaultHandler {
846 private final Map<String, String> changes = new HashMap<>();
848 private boolean getPresetName = false;
849 private @Nullable String presetName;
852 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
853 @Nullable Attributes attributes) throws SAXException {
863 channel = attributes == null ? null : attributes.getValue("channel");
864 val = attributes == null ? null : attributes.getValue("val");
865 if (channel != null && val != null) {
866 changes.put(qName + channel, val);
876 case "SurroundEnabled":
878 case "SurroundLevel":
880 case "MusicSurroundLevel":
881 case "HeightChannelLevel":
882 val = attributes == null ? null : attributes.getValue("val");
884 changes.put(qName, val);
887 case "PresetNameList":
888 getPresetName = true;
896 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
897 if (getPresetName && ch != null) {
898 presetName = new String(ch, start, length);
903 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
904 throws SAXException {
906 getPresetName = false;
907 String preset = presetName;
908 if (qName != null && preset != null) {
909 changes.put(qName, preset);
914 public Map<String, String> getChanges() {
919 private static class MusicServiceHandler extends DefaultHandler {
921 private final List<SonosMusicService> services = new ArrayList<>();
924 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
925 @Nullable Attributes attributes) throws SAXException {
926 // All services are of the form <services Id="value" Name="value">...</Service>
927 if ("Service".equals(qName) && attributes != null && attributes.getValue("Id") != null
928 && attributes.getValue("Name") != null) {
929 services.add(new SonosMusicService(attributes.getValue("Id"), attributes.getValue("Name")));
933 public List<SonosMusicService> getServices() {
938 public static @Nullable String getRoomName(String descriptorXML) {
939 RoomNameHandler roomNameHandler = new RoomNameHandler();
941 XMLReader reader = XMLReaderFactory.createXMLReader();
942 reader.setContentHandler(roomNameHandler);
943 URL url = new URL(descriptorXML);
944 reader.parse(new InputSource(url.openStream()));
945 } catch (IOException | SAXException e) {
946 LOGGER.error("Could not parse Sonos room name from string '{}'", descriptorXML);
948 return roomNameHandler.getRoomName();
951 private static class RoomNameHandler extends DefaultHandler {
953 private @Nullable String roomName;
954 private boolean roomNameTag;
957 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
958 @Nullable Attributes attributes) throws SAXException {
959 if ("roomName".equalsIgnoreCase(localName)) {
965 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
966 if (roomNameTag && ch != null) {
967 roomName = new String(ch, start, length);
972 public @Nullable String getRoomName() {
977 public static @Nullable String parseModelDescription(URL descriptorURL) {
978 ModelNameHandler modelNameHandler = new ModelNameHandler();
980 XMLReader reader = XMLReaderFactory.createXMLReader();
981 reader.setContentHandler(modelNameHandler);
982 URL url = new URL(descriptorURL.toString());
983 reader.parse(new InputSource(url.openStream()));
984 } catch (IOException | SAXException e) {
985 LOGGER.error("Could not parse Sonos model name from string '{}'", descriptorURL.toString());
987 return modelNameHandler.getModelName();
990 private static class ModelNameHandler extends DefaultHandler {
992 private @Nullable String modelName;
993 private boolean modelNameTag;
996 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
997 @Nullable Attributes attributes) throws SAXException {
998 if ("modelName".equalsIgnoreCase(localName)) {
1004 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
1005 if (modelNameTag && ch != null) {
1006 modelName = new String(ch, start, length);
1007 modelNameTag = false;
1011 public @Nullable String getModelName() {
1017 * Build a valid thing type ID from the model name provided by UPnP
1019 * @param sonosModelName Sonos model name provided via UPnP device
1020 * @return a valid thing type ID that can then be used for ThingType creation
1022 public static String buildThingTypeIdFromModelName(String sonosModelName) {
1023 // For Ikea SYMFONISK models, the model name now starts with "SYMFONISK" with recent firmwares
1024 if (sonosModelName.toUpperCase().contains("SYMFONISK")) {
1027 String id = sonosModelName;
1028 // Remove until the first space (in practice, it removes the leading "Sonos " from the model name)
1029 Matcher matcher = Pattern.compile("\\s(.*)").matcher(id);
1030 if (matcher.find()) {
1031 id = matcher.group(1);
1032 // Remove a potential ending text surrounded with parenthesis
1033 matcher = Pattern.compile("(.*)\\s\\(.*\\)").matcher(id);
1034 if (matcher.find()) {
1035 id = matcher.group(1);
1038 // Finally remove unexpected characters in a thing type ID
1039 id = id.replaceAll("[^a-zA-Z0-9_]", "");
1040 // ZP80 is translated to CONNECT and ZP100 to CONNECTAMP
1054 public static String compileMetadataString(SonosEntry entry) {
1056 * If the entry contains resource meta data we will override this with
1059 String id = entry.getId();
1060 String parentId = entry.getParentId();
1061 String title = entry.getTitle();
1062 String upnpClass = entry.getUpnpClass();
1065 * By default 'RINCON_AssociatedZPUDN' is used for most operations,
1066 * however when playing a favorite entry that is associated withh a
1067 * subscription like pandora we need to use the desc string asscoiated
1070 String desc = entry.getDesc();
1072 desc = "RINCON_AssociatedZPUDN";
1076 * If resource meta data exists, use it over the parent data
1078 SonosResourceMetaData resourceMetaData = entry.getResourceMetaData();
1079 if (resourceMetaData != null) {
1080 id = resourceMetaData.getId();
1081 parentId = resourceMetaData.getParentId();
1082 title = resourceMetaData.getTitle();
1083 desc = resourceMetaData.getDesc();
1084 upnpClass = resourceMetaData.getUpnpClass();
1087 title = StringUtils.escapeXml(title);
1089 String metadata = METADATA_FORMAT.format(new Object[] { id, parentId, title, upnpClass, desc });