2 * Copyright (c) 2010-2020 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.apache.commons.lang.StringEscapeUtils;
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
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 ResourceMetaDataHandler handler = new ResourceMetaDataHandler();
138 reader.setContentHandler(handler);
140 reader.parse(new InputSource(new StringReader(xml)));
141 } catch (IOException e) {
142 LOGGER.error("Could not parse Resource MetaData from String '{}'", xml);
143 } catch (SAXException s) {
144 LOGGER.error("Could not parse Resource MetaData from string '{}'", xml);
146 return handler.getMetaData();
152 * @return zone group from the given xml
153 * @throws IOException
154 * @throws SAXException
156 public static List<SonosZoneGroup> getZoneGroupFromXML(String xml) {
157 ZoneGroupHandler handler = new ZoneGroupHandler();
159 XMLReader reader = XMLReaderFactory.createXMLReader();
160 reader.setContentHandler(handler);
161 reader.parse(new InputSource(new StringReader(xml)));
162 } catch (IOException e) {
163 // This should never happen - we're not performing I/O!
164 LOGGER.error("Could not parse ZoneGroup from string '{}'", xml);
165 } catch (SAXException s) {
166 LOGGER.error("Could not parse ZoneGroup from string '{}'", xml);
169 return handler.getGroups();
172 public static List<String> getRadioTimeFromXML(String xml) {
173 OpmlHandler handler = new OpmlHandler();
175 XMLReader reader = XMLReaderFactory.createXMLReader();
176 reader.setContentHandler(handler);
177 reader.parse(new InputSource(new StringReader(xml)));
178 } catch (IOException e) {
179 // This should never happen - we're not performing I/O!
180 LOGGER.error("Could not parse RadioTime from string '{}'", xml);
181 } catch (SAXException s) {
182 LOGGER.error("Could not parse RadioTime from string '{}'", xml);
185 return handler.getTextFields();
188 public static Map<String, @Nullable String> getRenderingControlFromXML(String xml) {
189 RenderingControlEventHandler handler = new RenderingControlEventHandler();
191 XMLReader reader = XMLReaderFactory.createXMLReader();
192 reader.setContentHandler(handler);
193 reader.parse(new InputSource(new StringReader(xml)));
194 } catch (IOException e) {
195 // This should never happen - we're not performing I/O!
196 LOGGER.error("Could not parse Rendering Control from string '{}'", xml);
197 } catch (SAXException s) {
198 LOGGER.error("Could not parse Rendering Control from string '{}'", xml);
200 return handler.getChanges();
203 public static Map<String, @Nullable String> getAVTransportFromXML(String xml) {
204 AVTransportEventHandler handler = new AVTransportEventHandler();
206 XMLReader reader = XMLReaderFactory.createXMLReader();
207 reader.setContentHandler(handler);
208 reader.parse(new InputSource(new StringReader(xml)));
209 } catch (IOException e) {
210 // This should never happen - we're not performing I/O!
211 LOGGER.error("Could not parse AV Transport from string '{}'", xml);
212 } catch (SAXException s) {
213 LOGGER.error("Could not parse AV Transport from string '{}'", xml);
215 return handler.getChanges();
218 public static SonosMetaData getMetaDataFromXML(String xml) {
219 MetaDataHandler handler = new MetaDataHandler();
221 XMLReader reader = XMLReaderFactory.createXMLReader();
222 reader.setContentHandler(handler);
223 reader.parse(new InputSource(new StringReader(xml)));
224 } catch (IOException e) {
225 // This should never happen - we're not performing I/O!
226 LOGGER.error("Could not parse MetaData from string '{}'", xml);
227 } catch (SAXException s) {
228 LOGGER.error("Could not parse MetaData from string '{}'", xml);
231 return handler.getMetaData();
234 public static List<SonosMusicService> getMusicServicesFromXML(String xml) {
235 MusicServiceHandler handler = new MusicServiceHandler();
237 XMLReader reader = XMLReaderFactory.createXMLReader();
238 reader.setContentHandler(handler);
239 reader.parse(new InputSource(new StringReader(xml)));
240 } catch (IOException e) {
241 // This should never happen - we're not performing I/O!
242 LOGGER.error("Could not parse music services from string '{}'", xml);
243 } catch (SAXException s) {
244 LOGGER.error("Could not parse music services from string '{}'", xml);
246 return handler.getServices();
249 private static class EntryHandler extends DefaultHandler {
251 // Maintain a set of elements about which it is unuseful to complain about.
252 // This list will be initialized on the first failure case
253 private static @Nullable List<String> ignore;
255 private String id = "";
256 private String parentId = "";
257 private StringBuilder upnpClass = new StringBuilder();
258 private StringBuilder res = new StringBuilder();
259 private StringBuilder title = new StringBuilder();
260 private StringBuilder album = new StringBuilder();
261 private StringBuilder albumArtUri = new StringBuilder();
262 private StringBuilder creator = new StringBuilder();
263 private StringBuilder trackNumber = new StringBuilder();
264 private StringBuilder desc = new StringBuilder();
265 private @Nullable Element element;
267 private List<SonosEntry> artists = new ArrayList<>();
270 // shouldn't be used outside of this package.
274 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
275 @Nullable Attributes attributes) throws SAXException {
276 String name = qName == null ? "" : qName;
280 if (attributes != null) {
281 id = attributes.getValue("id");
282 parentId = attributes.getValue("parentID");
286 element = Element.RES;
289 element = Element.TITLE;
292 element = Element.CLASS;
295 element = Element.CREATOR;
298 element = Element.ALBUM;
300 case "upnp:albumArtURI":
301 element = Element.ALBUM_ART_URI;
303 case "upnp:originalTrackNumber":
304 element = Element.TRACK_NUMBER;
307 element = Element.RESMD;
310 List<String> curIgnore = ignore;
311 if (curIgnore == null) {
312 curIgnore = new ArrayList<>();
313 curIgnore.add("DIDL-Lite");
314 curIgnore.add("type");
315 curIgnore.add("ordinal");
316 curIgnore.add("description");
320 if (!curIgnore.contains(localName)) {
321 LOGGER.debug("Did not recognise element named {}", localName);
329 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
330 Element elt = element;
336 title.append(ch, start, length);
339 upnpClass.append(ch, start, length);
342 res.append(ch, start, length);
345 album.append(ch, start, length);
348 albumArtUri.append(ch, start, length);
351 creator.append(ch, start, length);
354 trackNumber.append(ch, start, length);
357 desc.append(ch, start, length);
365 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
366 throws SAXException {
367 if (("container".equals(qName) || "item".equals(qName))) {
370 int trackNumberVal = 0;
372 trackNumberVal = Integer.parseInt(trackNumber.toString());
373 } catch (Exception e) {
376 SonosResourceMetaData md = null;
378 // The resource description is needed for playing favorites on pandora
379 if (!desc.toString().isEmpty()) {
381 md = getResourceMetaData(desc.toString());
382 } catch (SAXException ignore) {
383 LOGGER.debug("Failed to parse embeded", ignore);
387 artists.add(new SonosEntry(id, title.toString(), parentId, album.toString(), albumArtUri.toString(),
388 creator.toString(), upnpClass.toString(), res.toString(), trackNumberVal, md));
389 title = new StringBuilder();
390 upnpClass = new StringBuilder();
391 res = new StringBuilder();
392 album = new StringBuilder();
393 albumArtUri = new StringBuilder();
394 creator = new StringBuilder();
395 trackNumber = new StringBuilder();
396 desc = new StringBuilder();
400 public List<SonosEntry> getArtists() {
405 private static class ResourceMetaDataHandler extends DefaultHandler {
407 private String id = "";
408 private String parentId = "";
409 private StringBuilder title = new StringBuilder();
410 private StringBuilder upnpClass = new StringBuilder();
411 private StringBuilder desc = new StringBuilder();
412 private @Nullable Element element;
413 private @Nullable SonosResourceMetaData metaData;
415 ResourceMetaDataHandler() {
416 // shouldn't be used outside of this package.
420 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
421 @Nullable Attributes attributes) throws SAXException {
422 String name = qName == null ? "" : qName;
426 if (attributes != null) {
427 id = attributes.getValue("id");
428 parentId = attributes.getValue("parentID");
432 element = Element.DESC;
435 element = Element.CLASS;
438 element = Element.TITLE;
447 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
448 Element elt = element;
454 title.append(ch, start, length);
457 upnpClass.append(ch, start, length);
460 desc.append(ch, start, length);
468 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
469 throws SAXException {
470 if ("DIDL-Lite".equals(qName)) {
471 metaData = new SonosResourceMetaData(id, parentId, title.toString(), upnpClass.toString(),
474 desc = new StringBuilder();
475 upnpClass = new StringBuilder();
476 title = new StringBuilder();
480 public @Nullable SonosResourceMetaData getMetaData() {
485 private static class AlarmHandler extends DefaultHandler {
487 private @Nullable String id;
488 private String startTime = "";
489 private String duration = "";
490 private String recurrence = "";
491 private @Nullable String enabled;
492 private String roomUUID = "";
493 private String programURI = "";
494 private String programMetaData = "";
495 private String playMode = "";
496 private @Nullable String volume;
497 private @Nullable String includeLinkedZones;
499 private List<SonosAlarm> alarms = new ArrayList<>();
502 // shouldn't be used outside of this package.
506 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
507 @Nullable Attributes attributes) throws SAXException {
508 if ("Alarm".equals(qName) && attributes != null) {
509 id = attributes.getValue("ID");
510 duration = attributes.getValue("Duration");
511 recurrence = attributes.getValue("Recurrence");
512 startTime = attributes.getValue("StartTime");
513 enabled = attributes.getValue("Enabled");
514 roomUUID = attributes.getValue("RoomUUID");
515 programURI = attributes.getValue("ProgramURI");
516 programMetaData = attributes.getValue("ProgramMetaData");
517 playMode = attributes.getValue("PlayMode");
518 volume = attributes.getValue("Volume");
519 includeLinkedZones = attributes.getValue("IncludeLinkedZones");
524 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
525 throws SAXException {
526 if ("Alarm".equals(qName)) {
529 boolean finalEnabled = !"0".equals(enabled);
530 boolean finalIncludeLinkedZones = !"0".equals(includeLinkedZones);
533 finalID = Integer.parseInt(id);
534 finalVolume = Integer.parseInt(volume);
535 } catch (Exception e) {
536 LOGGER.debug("Error parsing Integer");
539 alarms.add(new SonosAlarm(finalID, startTime, duration, recurrence, finalEnabled, roomUUID, programURI,
540 programMetaData, playMode, finalVolume, finalIncludeLinkedZones));
544 public List<SonosAlarm> getAlarms() {
549 private static class ZoneGroupHandler extends DefaultHandler {
551 private final List<SonosZoneGroup> groups = new ArrayList<>();
552 private final List<String> currentGroupPlayers = new ArrayList<>();
553 private final List<String> currentGroupPlayerZones = new ArrayList<>();
554 private String coordinator = "";
555 private String groupId = "";
558 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
559 @Nullable Attributes attributes) throws SAXException {
560 if ("ZoneGroup".equals(qName) && attributes != null) {
561 groupId = attributes.getValue("ID");
562 coordinator = attributes.getValue("Coordinator");
563 } else if ("ZoneGroupMember".equals(qName) && attributes != null) {
564 currentGroupPlayers.add(attributes.getValue("UUID"));
565 String zoneName = attributes.getValue("ZoneName");
566 if (zoneName != null) {
567 currentGroupPlayerZones.add(zoneName);
569 String htInfoSet = attributes.getValue("HTSatChanMapSet");
570 if (htInfoSet != null) {
571 currentGroupPlayers.addAll(getAllHomeTheaterMembers(htInfoSet));
577 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
578 throws SAXException {
579 if ("ZoneGroup".equals(qName)) {
580 groups.add(new SonosZoneGroup(groupId, coordinator, currentGroupPlayers, currentGroupPlayerZones));
581 currentGroupPlayers.clear();
582 currentGroupPlayerZones.clear();
586 public List<SonosZoneGroup> getGroups() {
590 private Set<String> getAllHomeTheaterMembers(String homeTheaterDescription) {
591 Set<String> homeTheaterMembers = new HashSet<>();
592 Matcher matcher = Pattern.compile("(RINCON_\\w+)").matcher(homeTheaterDescription);
593 while (matcher.find()) {
594 String member = matcher.group();
595 homeTheaterMembers.add(member);
597 return homeTheaterMembers;
601 private static class OpmlHandler extends DefaultHandler {
603 // <opml version="1">
605 // <status>200</status>
609 // <outline type="text" text="Q-Music 103.3" guide_id="s2398" key="station"
610 // image="http://radiotime-logos.s3.amazonaws.com/s87683q.png" preset_id="s2398"/>
611 // <outline type="text" text="Bjorn Verhoeven" guide_id="p257265" seconds_remaining="2230" duration="7200"
613 // <outline type="text" text="Top 40-Pop"/>
614 // <outline type="text" text="37m remaining"/>
615 // <outline type="object" text="NowPlaying">
617 // <logo>http://radiotime-logos.s3.amazonaws.com/s87683.png</logo>
624 private final List<String> textFields = new ArrayList<>();
625 private @Nullable String textField;
626 private @Nullable String type;
627 // private String logo;
630 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
631 @Nullable Attributes attributes) throws SAXException {
632 if ("outline".equals(qName)) {
633 type = attributes == null ? null : attributes.getValue("type");
634 if ("text".equals(type)) {
635 textField = attributes == null ? null : attributes.getValue("text");
643 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
644 throws SAXException {
645 if ("outline".equals(qName)) {
646 String field = textField;
648 textFields.add(field);
653 public List<String> getTextFields() {
658 private static class AVTransportEventHandler extends DefaultHandler {
661 * <Event xmlns="urn:schemas-upnp-org:metadata-1-0/AVT/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/">
662 * <InstanceID val="0">
663 * <TransportState val="PLAYING"/>
664 * <CurrentPlayMode val="NORMAL"/>
665 * <CurrentPlayMode val="0"/>
666 * <NumberOfTracks val="29"/>
667 * <CurrentTrack val="12"/>
668 * <CurrentSection val="0"/>
669 * <CurrentTrackURI val=
670 * "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"
672 * <CurrentTrackDuration val="0:03:02"/>
673 * <CurrentTrackMetaData val=
674 * "<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>"
675 * /><r:NextTrackURI val=
676 * "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"
677 * /><r:NextTrackMetaData val=
678 * "<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>"
679 * /><r:EnqueuedTransportURI
680 * val="x-rincon-playlist:RINCON_000E582126EE01400#A:ALBUMARTIST/Queens%20Of%20The%20Stone%20Age"/><r:
681 * EnqueuedTransportURIMetaData val=
682 * "<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>"
684 * <PlaybackStorageMedium val="NETWORK"/>
685 * <AVTransportURI val="x-rincon-queue:RINCON_000E5812BC1801400#0"/>
686 * <AVTransportURIMetaData val=""/>
687 * <CurrentTransportActions val="Play, Stop, Pause, Seek, Next, Previous"/>
688 * <TransportStatus val="OK"/>
689 * <r:SleepTimerGeneration val="0"/>
690 * <r:AlarmRunning val="0"/>
691 * <r:SnoozeRunning val="0"/>
692 * <r:RestartPending val="0"/>
693 * <TransportPlaySpeed val="NOT_IMPLEMENTED"/>
694 * <CurrentMediaDuration val="NOT_IMPLEMENTED"/>
695 * <RecordStorageMedium val="NOT_IMPLEMENTED"/>
696 * <PossiblePlaybackStorageMedia val="NONE, NETWORK"/>
697 * <PossibleRecordStorageMedia val="NOT_IMPLEMENTED"/>
698 * <RecordMediumWriteStatus val="NOT_IMPLEMENTED"/>
699 * <CurrentRecordQualityMode val="NOT_IMPLEMENTED"/>
700 * <PossibleRecordQualityModes val="NOT_IMPLEMENTED"/>
701 * <NextAVTransportURI val="NOT_IMPLEMENTED"/>
702 * <NextAVTransportURIMetaData val="NOT_IMPLEMENTED"/>
707 private final Map<String, @Nullable String> changes = new HashMap<>();
710 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
711 @Nullable Attributes attributes) throws SAXException {
713 * The events are all of the form <qName val="value"/> so we can get all
714 * the info we need from here.
716 if (localName == null) {
717 // this means that localName isn't defined in EventType, which is expected for some elements
718 LOGGER.info("{} is not defined in EventType. ", localName);
720 String val = attributes == null ? null : attributes.getValue("val");
722 changes.put(localName, val);
727 public Map<String, @Nullable String> getChanges() {
732 private static class MetaDataHandler extends DefaultHandler {
734 private @Nullable CurrentElement currentElement;
736 private String id = "-1";
737 private String parentId = "-1";
738 private StringBuilder resource = new StringBuilder();
739 private StringBuilder streamContent = new StringBuilder();
740 private StringBuilder albumArtUri = new StringBuilder();
741 private StringBuilder title = new StringBuilder();
742 private StringBuilder upnpClass = new StringBuilder();
743 private StringBuilder creator = new StringBuilder();
744 private StringBuilder album = new StringBuilder();
745 private StringBuilder albumArtist = new StringBuilder();
748 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
749 @Nullable Attributes attributes) throws SAXException {
750 String name = localName == null ? "" : localName;
753 currentElement = CurrentElement.item;
754 if (attributes != null) {
755 id = attributes.getValue("id");
756 parentId = attributes.getValue("parentID");
760 currentElement = CurrentElement.res;
762 case "streamContent":
763 currentElement = CurrentElement.streamContent;
766 currentElement = CurrentElement.albumArtURI;
769 currentElement = CurrentElement.title;
772 currentElement = CurrentElement.upnpClass;
775 currentElement = CurrentElement.creator;
778 currentElement = CurrentElement.album;
781 currentElement = CurrentElement.albumArtist;
785 currentElement = null;
791 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
792 CurrentElement elt = currentElement;
800 resource.append(ch, start, length);
803 streamContent.append(ch, start, length);
806 albumArtUri.append(ch, start, length);
809 title.append(ch, start, length);
812 upnpClass.append(ch, start, length);
815 creator.append(ch, start, length);
818 album.append(ch, start, length);
821 albumArtist.append(ch, start, length);
828 public SonosMetaData getMetaData() {
829 return new SonosMetaData(id, parentId, resource.toString(), streamContent.toString(),
830 albumArtUri.toString(), title.toString(), upnpClass.toString(), creator.toString(),
831 album.toString(), albumArtist.toString());
835 private static class RenderingControlEventHandler extends DefaultHandler {
837 private final Map<String, @Nullable String> changes = new HashMap<>();
839 private boolean getPresetName = false;
840 private @Nullable String presetName;
843 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
844 @Nullable Attributes attributes) throws SAXException {
854 channel = attributes == null ? null : attributes.getValue("channel");
855 val = attributes == null ? null : attributes.getValue("val");
856 if (channel != null && val != null) {
857 changes.put(qName + channel, val);
863 val = attributes == null ? null : attributes.getValue("val");
865 changes.put(qName, val);
868 case "PresetNameList":
869 getPresetName = true;
877 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
879 presetName = new String(ch, start, length);
884 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
885 throws SAXException {
887 getPresetName = false;
888 String preset = presetName;
889 if (qName != null && preset != null) {
890 changes.put(qName, preset);
895 public Map<String, @Nullable String> getChanges() {
900 private static class MusicServiceHandler extends DefaultHandler {
902 private final List<SonosMusicService> services = new ArrayList<>();
905 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
906 @Nullable Attributes attributes) throws SAXException {
907 // All services are of the form <services Id="value" Name="value">...</Service>
908 if ("Service".equals(qName) && attributes != null && attributes.getValue("Id") != null
909 && attributes.getValue("Name") != null) {
910 services.add(new SonosMusicService(attributes.getValue("Id"), attributes.getValue("Name")));
914 public List<SonosMusicService> getServices() {
919 public static @Nullable String getRoomName(String descriptorXML) {
920 RoomNameHandler roomNameHandler = new RoomNameHandler();
922 XMLReader reader = XMLReaderFactory.createXMLReader();
923 reader.setContentHandler(roomNameHandler);
924 URL url = new URL(descriptorXML);
925 reader.parse(new InputSource(url.openStream()));
926 } catch (IOException | SAXException e) {
927 LOGGER.error("Could not parse Sonos room name from string '{}'", descriptorXML);
929 return roomNameHandler.getRoomName();
932 private static class RoomNameHandler extends DefaultHandler {
934 private @Nullable String roomName;
935 private boolean roomNameTag;
938 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
939 @Nullable Attributes attributes) throws SAXException {
940 if ("roomName".equalsIgnoreCase(localName)) {
946 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
948 roomName = new String(ch, start, length);
953 public @Nullable String getRoomName() {
958 public static @Nullable String parseModelDescription(URL descriptorURL) {
959 ModelNameHandler modelNameHandler = new ModelNameHandler();
961 XMLReader reader = XMLReaderFactory.createXMLReader();
962 reader.setContentHandler(modelNameHandler);
963 URL url = new URL(descriptorURL.toString());
964 reader.parse(new InputSource(url.openStream()));
965 } catch (IOException | SAXException e) {
966 LOGGER.error("Could not parse Sonos model name from string '{}'", descriptorURL.toString());
968 return modelNameHandler.getModelName();
971 private static class ModelNameHandler extends DefaultHandler {
973 private @Nullable String modelName;
974 private boolean modelNameTag;
977 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
978 @Nullable Attributes attributes) throws SAXException {
979 if ("modelName".equalsIgnoreCase(localName)) {
985 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
987 modelName = new String(ch, start, length);
988 modelNameTag = false;
992 public @Nullable String getModelName() {
998 * The model name provided by upnp is formated like in the example form "Sonos PLAY:1" or "Sonos PLAYBAR"
1000 * @param sonosModelName Sonos model name provided via upnp device
1001 * @return the extracted players model name without column (:) character used for ThingType creation
1003 public static String extractModelName(String sonosModelName) {
1004 String ret = sonosModelName;
1005 Matcher matcher = Pattern.compile("\\s(.*)").matcher(ret);
1006 if (matcher.find()) {
1007 ret = matcher.group(1);
1009 if (ret.contains(":")) {
1010 ret = ret.replace(":", "");
1015 public static String compileMetadataString(SonosEntry entry) {
1017 * If the entry contains resource meta data we will override this with
1020 String id = entry.getId();
1021 String parentId = entry.getParentId();
1022 String title = entry.getTitle();
1023 String upnpClass = entry.getUpnpClass();
1026 * By default 'RINCON_AssociatedZPUDN' is used for most operations,
1027 * however when playing a favorite entry that is associated withh a
1028 * subscription like pandora we need to use the desc string asscoiated
1031 String desc = entry.getDesc();
1033 desc = "RINCON_AssociatedZPUDN";
1037 * If resource meta data exists, use it over the parent data
1039 SonosResourceMetaData resourceMetaData = entry.getResourceMetaData();
1040 if (resourceMetaData != null) {
1041 id = resourceMetaData.getId();
1042 parentId = resourceMetaData.getParentId();
1043 title = resourceMetaData.getTitle();
1044 desc = resourceMetaData.getDesc();
1045 upnpClass = resourceMetaData.getUpnpClass();
1048 title = StringEscapeUtils.escapeXml(title);
1050 String metadata = METADATA_FORMAT.format(new Object[] { id, parentId, title, upnpClass, desc });