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;
331 if (elt == null || ch == null) {
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;
449 if (elt == null || ch == null) {
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);
535 throw new NumberFormatException();
537 finalID = Integer.parseInt(id);
538 String volume = this.volume;
539 if (volume == null) {
540 throw new NumberFormatException();
542 finalVolume = Integer.parseInt(volume);
543 } catch (NumberFormatException e) {
544 LOGGER.debug("Error parsing Integer");
547 alarms.add(new SonosAlarm(finalID, startTime, duration, recurrence, finalEnabled, roomUUID, programURI,
548 programMetaData, playMode, finalVolume, finalIncludeLinkedZones));
552 public List<SonosAlarm> getAlarms() {
557 private static class ZoneGroupHandler extends DefaultHandler {
559 private final List<SonosZoneGroup> groups = new ArrayList<>();
560 private final List<String> currentGroupPlayers = new ArrayList<>();
561 private final List<String> currentGroupPlayerZones = new ArrayList<>();
562 private String coordinator = "";
563 private String groupId = "";
566 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
567 @Nullable Attributes attributes) throws SAXException {
568 if ("ZoneGroup".equals(qName) && attributes != null) {
569 groupId = attributes.getValue("ID");
570 coordinator = attributes.getValue("Coordinator");
571 } else if ("ZoneGroupMember".equals(qName) && attributes != null) {
572 currentGroupPlayers.add(attributes.getValue("UUID"));
573 String zoneName = attributes.getValue("ZoneName");
574 if (zoneName != null) {
575 currentGroupPlayerZones.add(zoneName);
577 String htInfoSet = attributes.getValue("HTSatChanMapSet");
578 if (htInfoSet != null) {
579 currentGroupPlayers.addAll(getAllHomeTheaterMembers(htInfoSet));
585 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
586 throws SAXException {
587 if ("ZoneGroup".equals(qName)) {
588 groups.add(new SonosZoneGroup(groupId, coordinator, currentGroupPlayers, currentGroupPlayerZones));
589 currentGroupPlayers.clear();
590 currentGroupPlayerZones.clear();
594 public List<SonosZoneGroup> getGroups() {
598 private Set<String> getAllHomeTheaterMembers(String homeTheaterDescription) {
599 Set<String> homeTheaterMembers = new HashSet<>();
600 Matcher matcher = Pattern.compile("(RINCON_\\w+)").matcher(homeTheaterDescription);
601 while (matcher.find()) {
602 String member = matcher.group();
603 homeTheaterMembers.add(member);
605 return homeTheaterMembers;
609 private static class OpmlHandler extends DefaultHandler {
611 // <opml version="1">
613 // <status>200</status>
617 // <outline type="text" text="Q-Music 103.3" guide_id="s2398" key="station"
618 // image="http://radiotime-logos.s3.amazonaws.com/s87683q.png" preset_id="s2398"/>
619 // <outline type="text" text="Bjorn Verhoeven" guide_id="p257265" seconds_remaining="2230" duration="7200"
621 // <outline type="text" text="Top 40-Pop"/>
622 // <outline type="text" text="37m remaining"/>
623 // <outline type="object" text="NowPlaying">
625 // <logo>http://radiotime-logos.s3.amazonaws.com/s87683.png</logo>
632 private final List<String> textFields = new ArrayList<>();
633 private @Nullable String textField;
634 private @Nullable String type;
635 // private String logo;
638 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
639 @Nullable Attributes attributes) throws SAXException {
640 if ("outline".equals(qName)) {
641 type = attributes == null ? null : attributes.getValue("type");
642 if ("text".equals(type)) {
643 textField = attributes == null ? null : attributes.getValue("text");
651 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
652 throws SAXException {
653 if ("outline".equals(qName)) {
654 String field = textField;
656 textFields.add(field);
661 public List<String> getTextFields() {
666 private static class AVTransportEventHandler extends DefaultHandler {
669 * <Event xmlns="urn:schemas-upnp-org:metadata-1-0/AVT/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/">
670 * <InstanceID val="0">
671 * <TransportState val="PLAYING"/>
672 * <CurrentPlayMode val="NORMAL"/>
673 * <CurrentPlayMode val="0"/>
674 * <NumberOfTracks val="29"/>
675 * <CurrentTrack val="12"/>
676 * <CurrentSection val="0"/>
677 * <CurrentTrackURI 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-%2012%20-%20Broken%20Box.wma"
680 * <CurrentTrackDuration val="0:03:02"/>
681 * <CurrentTrackMetaData 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="-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>"
683 * /><r:NextTrackURI val=
684 * "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"
685 * /><r:NextTrackMetaData val=
686 * "<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>"
687 * /><r:EnqueuedTransportURI
688 * val="x-rincon-playlist:RINCON_000E582126EE01400#A:ALBUMARTIST/Queens%20Of%20The%20Stone%20Age"/><r:
689 * EnqueuedTransportURIMetaData val=
690 * "<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>"
692 * <PlaybackStorageMedium val="NETWORK"/>
693 * <AVTransportURI val="x-rincon-queue:RINCON_000E5812BC1801400#0"/>
694 * <AVTransportURIMetaData val=""/>
695 * <CurrentTransportActions val="Play, Stop, Pause, Seek, Next, Previous"/>
696 * <TransportStatus val="OK"/>
697 * <r:SleepTimerGeneration val="0"/>
698 * <r:AlarmRunning val="0"/>
699 * <r:SnoozeRunning val="0"/>
700 * <r:RestartPending val="0"/>
701 * <TransportPlaySpeed val="NOT_IMPLEMENTED"/>
702 * <CurrentMediaDuration val="NOT_IMPLEMENTED"/>
703 * <RecordStorageMedium val="NOT_IMPLEMENTED"/>
704 * <PossiblePlaybackStorageMedia val="NONE, NETWORK"/>
705 * <PossibleRecordStorageMedia val="NOT_IMPLEMENTED"/>
706 * <RecordMediumWriteStatus val="NOT_IMPLEMENTED"/>
707 * <CurrentRecordQualityMode val="NOT_IMPLEMENTED"/>
708 * <PossibleRecordQualityModes val="NOT_IMPLEMENTED"/>
709 * <NextAVTransportURI val="NOT_IMPLEMENTED"/>
710 * <NextAVTransportURIMetaData val="NOT_IMPLEMENTED"/>
715 private final Map<String, @Nullable String> changes = new HashMap<>();
718 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
719 @Nullable Attributes attributes) throws SAXException {
721 * The events are all of the form <qName val="value"/> so we can get all
722 * the info we need from here.
724 if (localName == null) {
725 // this means that localName isn't defined in EventType, which is expected for some elements
726 LOGGER.info("{} is not defined in EventType. ", localName);
728 String val = attributes == null ? null : attributes.getValue("val");
730 changes.put(localName, val);
735 public Map<String, @Nullable String> getChanges() {
740 private static class MetaDataHandler extends DefaultHandler {
742 private @Nullable CurrentElement currentElement;
744 private String id = "-1";
745 private String parentId = "-1";
746 private StringBuilder resource = new StringBuilder();
747 private StringBuilder streamContent = new StringBuilder();
748 private StringBuilder albumArtUri = new StringBuilder();
749 private StringBuilder title = new StringBuilder();
750 private StringBuilder upnpClass = new StringBuilder();
751 private StringBuilder creator = new StringBuilder();
752 private StringBuilder album = new StringBuilder();
753 private StringBuilder albumArtist = new StringBuilder();
756 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
757 @Nullable Attributes attributes) throws SAXException {
758 String name = localName == null ? "" : localName;
761 currentElement = CurrentElement.item;
762 if (attributes != null) {
763 id = attributes.getValue("id");
764 parentId = attributes.getValue("parentID");
768 currentElement = CurrentElement.res;
770 case "streamContent":
771 currentElement = CurrentElement.streamContent;
774 currentElement = CurrentElement.albumArtURI;
777 currentElement = CurrentElement.title;
780 currentElement = CurrentElement.upnpClass;
783 currentElement = CurrentElement.creator;
786 currentElement = CurrentElement.album;
789 currentElement = CurrentElement.albumArtist;
793 currentElement = null;
799 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
800 CurrentElement elt = currentElement;
801 if (elt == null || ch == null) {
808 resource.append(ch, start, length);
811 streamContent.append(ch, start, length);
814 albumArtUri.append(ch, start, length);
817 title.append(ch, start, length);
820 upnpClass.append(ch, start, length);
823 creator.append(ch, start, length);
826 album.append(ch, start, length);
829 albumArtist.append(ch, start, length);
836 public SonosMetaData getMetaData() {
837 return new SonosMetaData(id, parentId, resource.toString(), streamContent.toString(),
838 albumArtUri.toString(), title.toString(), upnpClass.toString(), creator.toString(),
839 album.toString(), albumArtist.toString());
843 private static class RenderingControlEventHandler extends DefaultHandler {
845 private final Map<String, @Nullable String> changes = new HashMap<>();
847 private boolean getPresetName = false;
848 private @Nullable String presetName;
851 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
852 @Nullable Attributes attributes) throws SAXException {
862 channel = attributes == null ? null : attributes.getValue("channel");
863 val = attributes == null ? null : attributes.getValue("val");
864 if (channel != null && val != null) {
865 changes.put(qName + channel, val);
871 val = attributes == null ? null : attributes.getValue("val");
873 changes.put(qName, val);
876 case "PresetNameList":
877 getPresetName = true;
885 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
886 if (getPresetName && ch != null) {
887 presetName = new String(ch, start, length);
892 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
893 throws SAXException {
895 getPresetName = false;
896 String preset = presetName;
897 if (qName != null && preset != null) {
898 changes.put(qName, preset);
903 public Map<String, @Nullable String> getChanges() {
908 private static class MusicServiceHandler extends DefaultHandler {
910 private final List<SonosMusicService> services = new ArrayList<>();
913 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
914 @Nullable Attributes attributes) throws SAXException {
915 // All services are of the form <services Id="value" Name="value">...</Service>
916 if ("Service".equals(qName) && attributes != null && attributes.getValue("Id") != null
917 && attributes.getValue("Name") != null) {
918 services.add(new SonosMusicService(attributes.getValue("Id"), attributes.getValue("Name")));
922 public List<SonosMusicService> getServices() {
927 public static @Nullable String getRoomName(String descriptorXML) {
928 RoomNameHandler roomNameHandler = new RoomNameHandler();
930 XMLReader reader = XMLReaderFactory.createXMLReader();
931 reader.setContentHandler(roomNameHandler);
932 URL url = new URL(descriptorXML);
933 reader.parse(new InputSource(url.openStream()));
934 } catch (IOException | SAXException e) {
935 LOGGER.error("Could not parse Sonos room name from string '{}'", descriptorXML);
937 return roomNameHandler.getRoomName();
940 private static class RoomNameHandler extends DefaultHandler {
942 private @Nullable String roomName;
943 private boolean roomNameTag;
946 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
947 @Nullable Attributes attributes) throws SAXException {
948 if ("roomName".equalsIgnoreCase(localName)) {
954 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
955 if (roomNameTag && ch != null) {
956 roomName = new String(ch, start, length);
961 public @Nullable String getRoomName() {
966 public static @Nullable String parseModelDescription(URL descriptorURL) {
967 ModelNameHandler modelNameHandler = new ModelNameHandler();
969 XMLReader reader = XMLReaderFactory.createXMLReader();
970 reader.setContentHandler(modelNameHandler);
971 URL url = new URL(descriptorURL.toString());
972 reader.parse(new InputSource(url.openStream()));
973 } catch (IOException | SAXException e) {
974 LOGGER.error("Could not parse Sonos model name from string '{}'", descriptorURL.toString());
976 return modelNameHandler.getModelName();
979 private static class ModelNameHandler extends DefaultHandler {
981 private @Nullable String modelName;
982 private boolean modelNameTag;
985 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
986 @Nullable Attributes attributes) throws SAXException {
987 if ("modelName".equalsIgnoreCase(localName)) {
993 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
994 if (modelNameTag && ch != null) {
995 modelName = new String(ch, start, length);
996 modelNameTag = false;
1000 public @Nullable String getModelName() {
1006 * The model name provided by upnp is formated like in the example form "Sonos PLAY:1" or "Sonos PLAYBAR"
1008 * @param sonosModelName Sonos model name provided via upnp device
1009 * @return the extracted players model name without column (:) character used for ThingType creation
1011 public static String extractModelName(String sonosModelName) {
1012 String ret = sonosModelName;
1013 Matcher matcher = Pattern.compile("\\s(.*)").matcher(ret);
1014 if (matcher.find()) {
1015 ret = matcher.group(1);
1017 if (ret.contains(":")) {
1018 ret = ret.replace(":", "");
1023 public static String compileMetadataString(SonosEntry entry) {
1025 * If the entry contains resource meta data we will override this with
1028 String id = entry.getId();
1029 String parentId = entry.getParentId();
1030 String title = entry.getTitle();
1031 String upnpClass = entry.getUpnpClass();
1034 * By default 'RINCON_AssociatedZPUDN' is used for most operations,
1035 * however when playing a favorite entry that is associated withh a
1036 * subscription like pandora we need to use the desc string asscoiated
1039 String desc = entry.getDesc();
1041 desc = "RINCON_AssociatedZPUDN";
1045 * If resource meta data exists, use it over the parent data
1047 SonosResourceMetaData resourceMetaData = entry.getResourceMetaData();
1048 if (resourceMetaData != null) {
1049 id = resourceMetaData.getId();
1050 parentId = resourceMetaData.getParentId();
1051 title = resourceMetaData.getTitle();
1052 desc = resourceMetaData.getDesc();
1053 upnpClass = resourceMetaData.getUpnpClass();
1056 title = StringEscapeUtils.escapeXml(title);
1058 String metadata = METADATA_FORMAT.format(new Object[] { id, parentId, title, upnpClass, desc });