]> git.basschouten.com Git - openhab-addons.git/blob
d1939f0987b9ee4b35532cbad11e9452ef715c56
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.sonos.internal;
14
15 import java.io.IOException;
16 import java.io.StringReader;
17 import java.net.URL;
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;
23 import java.util.Map;
24 import java.util.Set;
25 import java.util.regex.Matcher;
26 import java.util.regex.Pattern;
27
28 import javax.xml.parsers.ParserConfigurationException;
29 import javax.xml.parsers.SAXParser;
30 import javax.xml.parsers.SAXParserFactory;
31
32 import org.eclipse.jdt.annotation.NonNullByDefault;
33 import org.eclipse.jdt.annotation.Nullable;
34 import org.openhab.core.util.StringUtils;
35 import org.slf4j.Logger;
36 import org.slf4j.LoggerFactory;
37 import org.xml.sax.Attributes;
38 import org.xml.sax.InputSource;
39 import org.xml.sax.SAXException;
40 import org.xml.sax.helpers.DefaultHandler;
41
42 /**
43  * The {@link SonosXMLParser} is a class of helper functions
44  * to parse XML data returned by the Zone Players
45  *
46  * @author Karel Goderis - Initial contribution
47  */
48 @NonNullByDefault
49 public class SonosXMLParser {
50
51     static final Logger LOGGER = LoggerFactory.getLogger(SonosXMLParser.class);
52
53     private static final String METADATA_FORMAT_PATTERN = """
54             <DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" \
55             xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" \
56             xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/" \
57             xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/">\
58             <item id="{0}" parentID="{1}" restricted="true">\
59             <dc:title>{2}</dc:title>\
60             <upnp:class>{3}</upnp:class>\
61             <desc id="cdudn" nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">{4}</desc>\
62             </item>\
63             </DIDL-Lite>\
64             """;
65
66     private enum Element {
67         TITLE,
68         CLASS,
69         ALBUM,
70         ALBUM_ART_URI,
71         CREATOR,
72         RES,
73         TRACK_NUMBER,
74         RESMD,
75         DESC
76     }
77
78     private enum CurrentElement {
79         item,
80         res,
81         streamContent,
82         albumArtURI,
83         title,
84         upnpClass,
85         creator,
86         album,
87         albumArtist,
88         desc
89     }
90
91     /**
92      * @param xml
93      * @return a list of alarms from the given xml string.
94      */
95     public static List<SonosAlarm> getAlarmsFromStringResult(String xml) {
96         AlarmHandler handler = new AlarmHandler();
97         try {
98             SAXParserFactory factory = SAXParserFactory.newInstance();
99             SAXParser saxParser = factory.newSAXParser();
100             saxParser.parse(new InputSource(new StringReader(xml)), handler);
101         } catch (IOException | SAXException | ParserConfigurationException e) {
102             LOGGER.warn("Could not parse Alarms from string '{}'", xml);
103         }
104         return handler.getAlarms();
105     }
106
107     /**
108      * @param xml
109      * @return a list of Entries from the given xml string.
110      */
111     public static List<SonosEntry> getEntriesFromString(String xml) {
112         EntryHandler handler = new EntryHandler();
113         try {
114             SAXParserFactory factory = SAXParserFactory.newInstance();
115             SAXParser saxParser = factory.newSAXParser();
116             saxParser.parse(new InputSource(new StringReader(xml)), handler);
117         } catch (IOException | SAXException | ParserConfigurationException e) {
118             LOGGER.warn("Could not parse Entries from string '{}'", xml);
119         }
120
121         return handler.getArtists();
122     }
123
124     /**
125      * Returns the meta data which is needed to play Pandora
126      * (and others?) favorites
127      *
128      * @param xml
129      * @return The value of the desc xml tag
130      * @throws SAXException
131      * @throws ParserConfigurationException
132      */
133     public static @Nullable SonosResourceMetaData getResourceMetaData(String xml)
134             throws SAXException, ParserConfigurationException {
135         SAXParserFactory factory = SAXParserFactory.newInstance();
136         factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
137         SAXParser saxParser = factory.newSAXParser();
138         ResourceMetaDataHandler handler = new ResourceMetaDataHandler();
139         try {
140             saxParser.parse(new InputSource(new StringReader(xml)), handler);
141         } catch (IOException | SAXException e) {
142             LOGGER.warn("Could not parse Resource MetaData from string '{}'", xml);
143         }
144         return handler.getMetaData();
145     }
146
147     /**
148      * @param xml
149      * @return zone group from the given xml
150      */
151     public static List<SonosZoneGroup> getZoneGroupFromXML(String xml) {
152         ZoneGroupHandler handler = new ZoneGroupHandler();
153         try {
154             SAXParserFactory factory = SAXParserFactory.newInstance();
155             SAXParser saxParser = factory.newSAXParser();
156             saxParser.parse(new InputSource(new StringReader(xml)), handler);
157         } catch (IOException | SAXException | ParserConfigurationException e) {
158             LOGGER.warn("Could not parse ZoneGroup from string '{}'", xml);
159         }
160
161         return handler.getGroups();
162     }
163
164     public static List<String> getRadioTimeFromXML(String xml) {
165         OpmlHandler handler = new OpmlHandler();
166         try {
167             SAXParserFactory factory = SAXParserFactory.newInstance();
168             SAXParser saxParser = factory.newSAXParser();
169             saxParser.parse(new InputSource(new StringReader(xml)), handler);
170         } catch (IOException | SAXException | ParserConfigurationException e) {
171             LOGGER.warn("Could not parse RadioTime from string '{}'", xml);
172         }
173
174         return handler.getTextFields();
175     }
176
177     public static Map<String, String> getRenderingControlFromXML(String xml) {
178         RenderingControlEventHandler handler = new RenderingControlEventHandler();
179         try {
180             SAXParserFactory factory = SAXParserFactory.newInstance();
181             SAXParser saxParser = factory.newSAXParser();
182             saxParser.parse(new InputSource(new StringReader(xml)), handler);
183         } catch (IOException | SAXException | ParserConfigurationException e) {
184             LOGGER.warn("Could not parse Rendering Control from string '{}'", xml);
185         }
186         return handler.getChanges();
187     }
188
189     public static Map<String, String> getAVTransportFromXML(String xml) {
190         AVTransportEventHandler handler = new AVTransportEventHandler();
191         try {
192             SAXParserFactory factory = SAXParserFactory.newInstance();
193             SAXParser saxParser = factory.newSAXParser();
194             saxParser.parse(new InputSource(new StringReader(xml)), handler);
195         } catch (IOException | SAXException | ParserConfigurationException e) {
196             LOGGER.warn("Could not parse AV Transport from string '{}'", xml);
197         }
198         return handler.getChanges();
199     }
200
201     public static SonosMetaData getMetaDataFromXML(String xml) {
202         MetaDataHandler handler = new MetaDataHandler();
203         try {
204             SAXParserFactory factory = SAXParserFactory.newInstance();
205             SAXParser saxParser = factory.newSAXParser();
206             saxParser.parse(new InputSource(new StringReader(xml)), handler);
207         } catch (IOException | SAXException | ParserConfigurationException e) {
208             LOGGER.warn("Could not parse MetaData from string '{}'", xml);
209         }
210
211         return handler.getMetaData();
212     }
213
214     public static List<SonosMusicService> getMusicServicesFromXML(String xml) {
215         MusicServiceHandler handler = new MusicServiceHandler();
216         try {
217             SAXParserFactory factory = SAXParserFactory.newInstance();
218             SAXParser saxParser = factory.newSAXParser();
219             saxParser.parse(new InputSource(new StringReader(xml)), handler);
220         } catch (IOException | SAXException | ParserConfigurationException e) {
221             LOGGER.warn("Could not parse music services from string '{}'", xml);
222         }
223         return handler.getServices();
224     }
225
226     private static class EntryHandler extends DefaultHandler {
227
228         // Maintain a set of elements about which it is unuseful to complain about.
229         // This list will be initialized on the first failure case
230         private static @Nullable List<String> ignore;
231
232         private String id = "";
233         private String parentId = "";
234         private StringBuilder upnpClass = new StringBuilder();
235         private StringBuilder res = new StringBuilder();
236         private StringBuilder title = new StringBuilder();
237         private StringBuilder album = new StringBuilder();
238         private StringBuilder albumArtUri = new StringBuilder();
239         private StringBuilder creator = new StringBuilder();
240         private StringBuilder trackNumber = new StringBuilder();
241         private StringBuilder desc = new StringBuilder();
242         private @Nullable Element element;
243
244         private List<SonosEntry> artists = new ArrayList<>();
245
246         EntryHandler() {
247             // shouldn't be used outside of this package.
248         }
249
250         @Override
251         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
252                 @Nullable Attributes attributes) throws SAXException {
253             String name = qName == null ? "" : qName;
254             switch (name) {
255                 case "container":
256                 case "item":
257                     if (attributes != null) {
258                         id = attributes.getValue("id");
259                         parentId = attributes.getValue("parentID");
260                     }
261                     break;
262                 case "res":
263                     element = Element.RES;
264                     break;
265                 case "dc:title":
266                     element = Element.TITLE;
267                     break;
268                 case "upnp:class":
269                     element = Element.CLASS;
270                     break;
271                 case "dc:creator":
272                     element = Element.CREATOR;
273                     break;
274                 case "upnp:album":
275                     element = Element.ALBUM;
276                     break;
277                 case "upnp:albumArtURI":
278                     element = Element.ALBUM_ART_URI;
279                     break;
280                 case "upnp:originalTrackNumber":
281                     element = Element.TRACK_NUMBER;
282                     break;
283                 case "r:resMD":
284                     element = Element.RESMD;
285                     break;
286                 default:
287                     List<String> curIgnore = ignore;
288                     if (curIgnore == null) {
289                         curIgnore = new ArrayList<>();
290                         curIgnore.add("DIDL-Lite");
291                         curIgnore.add("r:type");
292                         curIgnore.add("r:ordinal");
293                         curIgnore.add("r:description");
294                         ignore = curIgnore;
295                     }
296
297                     if (!curIgnore.contains(qName)) {
298                         LOGGER.debug("Did not recognise element named {}", qName);
299                     }
300                     element = null;
301                     break;
302             }
303         }
304
305         @Override
306         public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
307             Element elt = element;
308             if (elt == null || ch == null) {
309                 return;
310             }
311             switch (elt) {
312                 case TITLE:
313                     title.append(ch, start, length);
314                     break;
315                 case CLASS:
316                     upnpClass.append(ch, start, length);
317                     break;
318                 case RES:
319                     res.append(ch, start, length);
320                     break;
321                 case ALBUM:
322                     album.append(ch, start, length);
323                     break;
324                 case ALBUM_ART_URI:
325                     albumArtUri.append(ch, start, length);
326                     break;
327                 case CREATOR:
328                     creator.append(ch, start, length);
329                     break;
330                 case TRACK_NUMBER:
331                     trackNumber.append(ch, start, length);
332                     break;
333                 case RESMD:
334                     desc.append(ch, start, length);
335                     break;
336                 case DESC:
337                     break;
338             }
339         }
340
341         @Override
342         public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
343                 throws SAXException {
344             if (("container".equals(qName) || "item".equals(qName))) {
345                 element = null;
346
347                 int trackNumberVal = 0;
348                 try {
349                     trackNumberVal = Integer.parseInt(trackNumber.toString());
350                 } catch (NumberFormatException e) {
351                 }
352
353                 SonosResourceMetaData md = null;
354
355                 // The resource description is needed for playing favorites on pandora
356                 if (!desc.toString().isEmpty()) {
357                     try {
358                         md = getResourceMetaData(desc.toString());
359                     } catch (SAXException | ParserConfigurationException ignore) {
360                         LOGGER.debug("Failed to parse embeded", ignore);
361                     }
362                 }
363
364                 artists.add(new SonosEntry(id, title.toString(), parentId, album.toString(), albumArtUri.toString(),
365                         creator.toString(), upnpClass.toString(), res.toString(), trackNumberVal, md));
366                 title = new StringBuilder();
367                 upnpClass = new StringBuilder();
368                 res = new StringBuilder();
369                 album = new StringBuilder();
370                 albumArtUri = new StringBuilder();
371                 creator = new StringBuilder();
372                 trackNumber = new StringBuilder();
373                 desc = new StringBuilder();
374             }
375         }
376
377         public List<SonosEntry> getArtists() {
378             return artists;
379         }
380     }
381
382     private static class ResourceMetaDataHandler extends DefaultHandler {
383
384         private String id = "";
385         private String parentId = "";
386         private StringBuilder title = new StringBuilder();
387         private StringBuilder upnpClass = new StringBuilder();
388         private StringBuilder desc = new StringBuilder();
389         private @Nullable Element element;
390         private @Nullable SonosResourceMetaData metaData;
391
392         ResourceMetaDataHandler() {
393             // shouldn't be used outside of this package.
394         }
395
396         @Override
397         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
398                 @Nullable Attributes attributes) throws SAXException {
399             String name = qName == null ? "" : qName;
400             switch (name) {
401                 case "container":
402                 case "item":
403                     if (attributes != null) {
404                         id = attributes.getValue("id");
405                         parentId = attributes.getValue("parentID");
406                     }
407                     break;
408                 case "desc":
409                     element = Element.DESC;
410                     break;
411                 case "upnp:class":
412                     element = Element.CLASS;
413                     break;
414                 case "dc:title":
415                     element = Element.TITLE;
416                     break;
417                 default:
418                     element = null;
419                     break;
420             }
421         }
422
423         @Override
424         public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
425             Element elt = element;
426             if (elt == null || ch == null) {
427                 return;
428             }
429             switch (elt) {
430                 case TITLE:
431                     title.append(ch, start, length);
432                     break;
433                 case CLASS:
434                     upnpClass.append(ch, start, length);
435                     break;
436                 case DESC:
437                     desc.append(ch, start, length);
438                     break;
439                 default:
440                     break;
441             }
442         }
443
444         @Override
445         public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
446                 throws SAXException {
447             if ("DIDL-Lite".equals(qName)) {
448                 metaData = new SonosResourceMetaData(id, parentId, title.toString(), upnpClass.toString(),
449                         desc.toString());
450                 element = null;
451                 desc = new StringBuilder();
452                 upnpClass = new StringBuilder();
453                 title = new StringBuilder();
454             }
455         }
456
457         public @Nullable SonosResourceMetaData getMetaData() {
458             return metaData;
459         }
460     }
461
462     private static class AlarmHandler extends DefaultHandler {
463
464         private @Nullable String id;
465         private String startTime = "";
466         private String duration = "";
467         private String recurrence = "";
468         private @Nullable String enabled;
469         private String roomUUID = "";
470         private String programURI = "";
471         private String programMetaData = "";
472         private String playMode = "";
473         private @Nullable String volume;
474         private @Nullable String includeLinkedZones;
475
476         private List<SonosAlarm> alarms = new ArrayList<>();
477
478         AlarmHandler() {
479             // shouldn't be used outside of this package.
480         }
481
482         @Override
483         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
484                 @Nullable Attributes attributes) throws SAXException {
485             if ("Alarm".equals(qName) && attributes != null) {
486                 id = attributes.getValue("ID");
487                 duration = attributes.getValue("Duration");
488                 recurrence = attributes.getValue("Recurrence");
489                 startTime = attributes.getValue("StartTime");
490                 enabled = attributes.getValue("Enabled");
491                 roomUUID = attributes.getValue("RoomUUID");
492                 programURI = attributes.getValue("ProgramURI");
493                 programMetaData = attributes.getValue("ProgramMetaData");
494                 playMode = attributes.getValue("PlayMode");
495                 volume = attributes.getValue("Volume");
496                 includeLinkedZones = attributes.getValue("IncludeLinkedZones");
497             }
498         }
499
500         @Override
501         public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
502                 throws SAXException {
503             if ("Alarm".equals(qName)) {
504                 int finalID = 0;
505                 int finalVolume = 0;
506                 boolean finalEnabled = !"0".equals(enabled);
507                 boolean finalIncludeLinkedZones = !"0".equals(includeLinkedZones);
508
509                 try {
510                     String id = this.id;
511                     if (id == null) {
512                         throw new NumberFormatException();
513                     }
514                     finalID = Integer.parseInt(id);
515                     String volume = this.volume;
516                     if (volume == null) {
517                         throw new NumberFormatException();
518                     }
519                     finalVolume = Integer.parseInt(volume);
520                 } catch (NumberFormatException e) {
521                     LOGGER.debug("Error parsing Integer");
522                 }
523
524                 alarms.add(new SonosAlarm(finalID, startTime, duration, recurrence, finalEnabled, roomUUID, programURI,
525                         programMetaData, playMode, finalVolume, finalIncludeLinkedZones));
526             }
527         }
528
529         public List<SonosAlarm> getAlarms() {
530             return alarms;
531         }
532     }
533
534     private static class ZoneGroupHandler extends DefaultHandler {
535
536         private final List<SonosZoneGroup> groups = new ArrayList<>();
537         private final List<String> currentGroupPlayers = new ArrayList<>();
538         private final List<String> currentGroupPlayerZones = new ArrayList<>();
539         private String coordinator = "";
540         private String groupId = "";
541
542         @Override
543         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
544                 @Nullable Attributes attributes) throws SAXException {
545             if ("ZoneGroup".equals(qName) && attributes != null) {
546                 groupId = attributes.getValue("ID");
547                 coordinator = attributes.getValue("Coordinator");
548             } else if ("ZoneGroupMember".equals(qName) && attributes != null) {
549                 currentGroupPlayers.add(attributes.getValue("UUID"));
550                 String zoneName = attributes.getValue("ZoneName");
551                 if (zoneName != null) {
552                     currentGroupPlayerZones.add(zoneName);
553                 }
554                 String htInfoSet = attributes.getValue("HTSatChanMapSet");
555                 if (htInfoSet != null) {
556                     currentGroupPlayers.addAll(getAllHomeTheaterMembers(htInfoSet));
557                 }
558             }
559         }
560
561         @Override
562         public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
563                 throws SAXException {
564             if ("ZoneGroup".equals(qName)) {
565                 groups.add(new SonosZoneGroup(groupId, coordinator, currentGroupPlayers, currentGroupPlayerZones));
566                 currentGroupPlayers.clear();
567                 currentGroupPlayerZones.clear();
568             }
569         }
570
571         public List<SonosZoneGroup> getGroups() {
572             return groups;
573         }
574
575         private Set<String> getAllHomeTheaterMembers(String homeTheaterDescription) {
576             Set<String> homeTheaterMembers = new HashSet<>();
577             Matcher matcher = Pattern.compile("(RINCON_\\w+)").matcher(homeTheaterDescription);
578             while (matcher.find()) {
579                 String member = matcher.group();
580                 homeTheaterMembers.add(member);
581             }
582             return homeTheaterMembers;
583         }
584     }
585
586     private static class OpmlHandler extends DefaultHandler {
587
588         // <opml version="1">
589         // <head>
590         // <status>200</status>
591         //
592         // </head>
593         // <body>
594         // <outline type="text" text="Q-Music 103.3" guide_id="s2398" key="station"
595         // image="http://radiotime-logos.s3.amazonaws.com/s87683q.png" preset_id="s2398"/>
596         // <outline type="text" text="Bjorn Verhoeven" guide_id="p257265" seconds_remaining="2230" duration="7200"
597         // key="show"/>
598         // <outline type="text" text="Top 40-Pop"/>
599         // <outline type="text" text="37m remaining"/>
600         // <outline type="object" text="NowPlaying">
601         // <nowplaying>
602         // <logo>http://radiotime-logos.s3.amazonaws.com/s87683.png</logo>
603         // <twitter_id />
604         // </nowplaying>
605         // </outline>
606         // </body>
607         // </opml>
608
609         private final List<String> textFields = new ArrayList<>();
610         private @Nullable String textField;
611         private @Nullable String type;
612         // private String logo;
613
614         @Override
615         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
616                 @Nullable Attributes attributes) throws SAXException {
617             if ("outline".equals(qName)) {
618                 type = attributes == null ? null : attributes.getValue("type");
619                 if ("text".equals(type)) {
620                     textField = attributes == null ? null : attributes.getValue("text");
621                 } else {
622                     textField = null;
623                 }
624             }
625         }
626
627         @Override
628         public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
629                 throws SAXException {
630             if ("outline".equals(qName)) {
631                 String field = textField;
632                 if (field != null) {
633                     textFields.add(field);
634                 }
635             }
636         }
637
638         public List<String> getTextFields() {
639             return textFields;
640         }
641     }
642
643     private static class AVTransportEventHandler extends DefaultHandler {
644
645         /*
646          * <Event xmlns="urn:schemas-upnp-org:metadata-1-0/AVT/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/">
647          * <InstanceID val="0">
648          * <TransportState val="PLAYING"/>
649          * <CurrentPlayMode val="NORMAL"/>
650          * <CurrentPlayMode val="0"/>
651          * <NumberOfTracks val="29"/>
652          * <CurrentTrack val="12"/>
653          * <CurrentSection val="0"/>
654          * <CurrentTrackURI val=
655          * "x-file-cifs://192.168.1.1/Storage4/Sonos%20Music/Queens%20Of%20The%20Stone%20Age/Lullabies%20To%20Paralyze/Queens%20Of%20The%20Stone%20Age%20-%20Lullabies%20To%20Paralyze%20-%2012%20-%20Broken%20Box.wma"
656          * />
657          * <CurrentTrackDuration val="0:03:02"/>
658          * <CurrentTrackMetaData val=
659          * "&lt;DIDL-Lite xmlns:dc=&quot;http://purl.org/dc/elements/1.1/&quot; xmlns:upnp=&quot;urn:schemas-upnp-org:metadata-1-0/upnp/&quot; xmlns:r=&quot;urn:schemas-rinconnetworks-com:metadata-1-0/&quot; xmlns=&quot;urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/&quot;&gt;&lt;item id=&quot;-1&quot; parentID=&quot;-1&quot; restricted=&quot;true&quot;&gt;&lt;res protocolInfo=&quot;x-file-cifs:*:audio/x-ms-wma:*&quot; duration=&quot;0:03:02&quot;&gt;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&lt;/res&gt;&lt;r:streamContent&gt;&lt;/r:streamContent&gt;&lt;dc:title&gt;Broken Box&lt;/dc:title&gt;&lt;upnp:class&gt;object.item.audioItem.musicTrack&lt;/upnp:class&gt;&lt;dc:creator&gt;Queens Of The Stone Age&lt;/dc:creator&gt;&lt;upnp:album&gt;Lullabies To Paralyze&lt;/upnp:album&gt;&lt;r:albumArtist&gt;Queens Of The Stone Age&lt;/r:albumArtist&gt;&lt;/item&gt;&lt;/DIDL-Lite&gt;"
660          * /><r:NextTrackURI val=
661          * "x-file-cifs://192.168.1.1/Storage4/Sonos%20Music/Queens%20Of%20The%20Stone%20Age/Lullabies%20To%20Paralyze/Queens%20Of%20The%20Stone%20Age%20-%20Lullabies%20To%20Paralyze%20-%2013%20-%20&apos;&apos;You%20Got%20A%20Killer%20Scene%20There,%20Man...&apos;&apos;.wma"
662          * /><r:NextTrackMetaData val=
663          * "&lt;DIDL-Lite xmlns:dc=&quot;http://purl.org/dc/elements/1.1/&quot; xmlns:upnp=&quot;urn:schemas-upnp-org:metadata-1-0/upnp/&quot; xmlns:r=&quot;urn:schemas-rinconnetworks-com:metadata-1-0/&quot; xmlns=&quot;urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/&quot;&gt;&lt;item id=&quot;-1&quot; parentID=&quot;-1&quot; restricted=&quot;true&quot;&gt;&lt;res protocolInfo=&quot;x-file-cifs:*:audio/x-ms-wma:*&quot; duration=&quot;0:04:56&quot;&gt;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&amp;apos;&amp;apos;You%20Got%20A%20Killer%20Scene%20There,%20Man...&amp;apos;&amp;apos;.wma&lt;/res&gt;&lt;dc:title&gt;&amp;apos;&amp;apos;You Got A Killer Scene There, Man...&amp;apos;&amp;apos;&lt;/dc:title&gt;&lt;upnp:class&gt;object.item.audioItem.musicTrack&lt;/upnp:class&gt;&lt;dc:creator&gt;Queens Of The Stone Age&lt;/dc:creator&gt;&lt;upnp:album&gt;Lullabies To Paralyze&lt;/upnp:album&gt;&lt;r:albumArtist&gt;Queens Of The Stone Age&lt;/r:albumArtist&gt;&lt;/item&gt;&lt;/DIDL-Lite&gt;"
664          * /><r:EnqueuedTransportURI
665          * val="x-rincon-playlist:RINCON_000E582126EE01400#A:ALBUMARTIST/Queens%20Of%20The%20Stone%20Age"/><r:
666          * EnqueuedTransportURIMetaData val=
667          * "&lt;DIDL-Lite xmlns:dc=&quot;http://purl.org/dc/elements/1.1/&quot; xmlns:upnp=&quot;urn:schemas-upnp-org:metadata-1-0/upnp/&quot; xmlns:r=&quot;urn:schemas-rinconnetworks-com:metadata-1-0/&quot; xmlns=&quot;urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/&quot;&gt;&lt;item id=&quot;A:ALBUMARTIST/Queens%20Of%20The%20Stone%20Age&quot; parentID=&quot;A:ALBUMARTIST&quot; restricted=&quot;true&quot;&gt;&lt;dc:title&gt;Queens Of The Stone Age&lt;/dc:title&gt;&lt;upnp:class&gt;object.container&lt;/upnp:class&gt;&lt;desc id=&quot;cdudn&quot; nameSpace=&quot;urn:schemas-rinconnetworks-com:metadata-1-0/&quot;&gt;RINCON_AssociatedZPUDN&lt;/desc&gt;&lt;/item&gt;&lt;/DIDL-Lite&gt;"
668          * />
669          * <PlaybackStorageMedium val="NETWORK"/>
670          * <AVTransportURI val="x-rincon-queue:RINCON_000E5812BC1801400#0"/>
671          * <AVTransportURIMetaData val=""/>
672          * <CurrentTransportActions val="Play, Stop, Pause, Seek, Next, Previous"/>
673          * <TransportStatus val="OK"/>
674          * <r:SleepTimerGeneration val="0"/>
675          * <r:AlarmRunning val="0"/>
676          * <r:SnoozeRunning val="0"/>
677          * <r:RestartPending val="0"/>
678          * <TransportPlaySpeed val="NOT_IMPLEMENTED"/>
679          * <CurrentMediaDuration val="NOT_IMPLEMENTED"/>
680          * <RecordStorageMedium val="NOT_IMPLEMENTED"/>
681          * <PossiblePlaybackStorageMedia val="NONE, NETWORK"/>
682          * <PossibleRecordStorageMedia val="NOT_IMPLEMENTED"/>
683          * <RecordMediumWriteStatus val="NOT_IMPLEMENTED"/>
684          * <CurrentRecordQualityMode val="NOT_IMPLEMENTED"/>
685          * <PossibleRecordQualityModes val="NOT_IMPLEMENTED"/>
686          * <NextAVTransportURI val="NOT_IMPLEMENTED"/>
687          * <NextAVTransportURIMetaData val="NOT_IMPLEMENTED"/>
688          * </InstanceID>
689          * </Event>
690          */
691
692         private final Map<String, String> changes = new HashMap<>();
693
694         @Override
695         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
696                 @Nullable Attributes attributes) throws SAXException {
697             /*
698              * The events are all of the form <qName val="value"/> so we can get all
699              * the info we need from here.
700              */
701             if (qName == null) {
702                 // this means that qName isn't defined in EventType, which is expected for some elements
703                 LOGGER.info("{} is not defined in EventType. ", qName);
704             } else {
705                 String val = attributes == null ? null : attributes.getValue("val");
706                 if (val != null) {
707                     String key = qName.contains(":") ? qName.split(":")[1] : qName;
708                     changes.put(key, val);
709                 }
710             }
711         }
712
713         public Map<String, String> getChanges() {
714             return changes;
715         }
716     }
717
718     private static class MetaDataHandler extends DefaultHandler {
719
720         private @Nullable CurrentElement currentElement;
721
722         private String id = "-1";
723         private String parentId = "-1";
724         private StringBuilder resource = new StringBuilder();
725         private StringBuilder streamContent = new StringBuilder();
726         private StringBuilder albumArtUri = new StringBuilder();
727         private StringBuilder title = new StringBuilder();
728         private StringBuilder upnpClass = new StringBuilder();
729         private StringBuilder creator = new StringBuilder();
730         private StringBuilder album = new StringBuilder();
731         private StringBuilder albumArtist = new StringBuilder();
732
733         @Override
734         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
735                 @Nullable Attributes attributes) throws SAXException {
736             String name = qName == null ? "" : qName;
737             switch (name) {
738                 case "item":
739                     currentElement = CurrentElement.item;
740                     if (attributes != null) {
741                         id = attributes.getValue("id");
742                         parentId = attributes.getValue("parentID");
743                     }
744                     break;
745                 case "res":
746                     currentElement = CurrentElement.res;
747                     break;
748                 case "r:streamContent":
749                     currentElement = CurrentElement.streamContent;
750                     break;
751                 case "upnp:albumArtURI":
752                     currentElement = CurrentElement.albumArtURI;
753                     break;
754                 case "dc:title":
755                     currentElement = CurrentElement.title;
756                     break;
757                 case "upnp:class":
758                     currentElement = CurrentElement.upnpClass;
759                     break;
760                 case "dc:creator":
761                     currentElement = CurrentElement.creator;
762                     break;
763                 case "upnp:album":
764                     currentElement = CurrentElement.album;
765                     break;
766                 case "r:albumArtist":
767                     currentElement = CurrentElement.albumArtist;
768                     break;
769                 default:
770                     // unknown element
771                     currentElement = null;
772                     break;
773             }
774         }
775
776         @Override
777         public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
778             CurrentElement elt = currentElement;
779             if (elt == null || ch == null) {
780                 return;
781             }
782             switch (elt) {
783                 case item:
784                     break;
785                 case res:
786                     resource.append(ch, start, length);
787                     break;
788                 case streamContent:
789                     streamContent.append(ch, start, length);
790                     break;
791                 case albumArtURI:
792                     albumArtUri.append(ch, start, length);
793                     break;
794                 case title:
795                     title.append(ch, start, length);
796                     break;
797                 case upnpClass:
798                     upnpClass.append(ch, start, length);
799                     break;
800                 case creator:
801                     creator.append(ch, start, length);
802                     break;
803                 case album:
804                     album.append(ch, start, length);
805                     break;
806                 case albumArtist:
807                     albumArtist.append(ch, start, length);
808                     break;
809                 case desc:
810                     break;
811             }
812         }
813
814         public SonosMetaData getMetaData() {
815             return new SonosMetaData(id, parentId, resource.toString(), streamContent.toString(),
816                     albumArtUri.toString(), title.toString(), upnpClass.toString(), creator.toString(),
817                     album.toString(), albumArtist.toString());
818         }
819     }
820
821     private static class RenderingControlEventHandler extends DefaultHandler {
822
823         private final Map<String, String> changes = new HashMap<>();
824
825         private boolean getPresetName = false;
826         private @Nullable String presetName;
827
828         @Override
829         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
830                 @Nullable Attributes attributes) throws SAXException {
831             if (qName == null) {
832                 return;
833             }
834             String channel;
835             String val;
836             switch (qName) {
837                 case "Volume":
838                 case "Mute":
839                 case "Loudness":
840                     channel = attributes == null ? null : attributes.getValue("channel");
841                     val = attributes == null ? null : attributes.getValue("val");
842                     if (channel != null && val != null) {
843                         changes.put(qName + channel, val);
844                     }
845                     break;
846                 case "Bass":
847                 case "Treble":
848                 case "OutputFixed":
849                 case "NightMode":
850                 case "DialogLevel":
851                 case "SubEnabled":
852                 case "SubGain":
853                 case "SurroundEnabled":
854                 case "SurroundMode":
855                 case "SurroundLevel":
856                 case "HTAudioIn":
857                 case "MusicSurroundLevel":
858                 case "HeightChannelLevel":
859                     val = attributes == null ? null : attributes.getValue("val");
860                     if (val != null) {
861                         changes.put(qName, val);
862                     }
863                     break;
864                 case "PresetNameList":
865                     getPresetName = true;
866                     break;
867                 default:
868                     break;
869             }
870         }
871
872         @Override
873         public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
874             if (getPresetName && ch != null) {
875                 presetName = new String(ch, start, length);
876             }
877         }
878
879         @Override
880         public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
881                 throws SAXException {
882             if (getPresetName) {
883                 getPresetName = false;
884                 String preset = presetName;
885                 if (qName != null && preset != null) {
886                     changes.put(qName, preset);
887                 }
888             }
889         }
890
891         public Map<String, String> getChanges() {
892             return changes;
893         }
894     }
895
896     private static class MusicServiceHandler extends DefaultHandler {
897
898         private final List<SonosMusicService> services = new ArrayList<>();
899
900         @Override
901         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
902                 @Nullable Attributes attributes) throws SAXException {
903             // All services are of the form <services Id="value" Name="value">...</Service>
904             if ("Service".equals(qName) && attributes != null && attributes.getValue("Id") != null
905                     && attributes.getValue("Name") != null) {
906                 services.add(new SonosMusicService(attributes.getValue("Id"), attributes.getValue("Name")));
907             }
908         }
909
910         public List<SonosMusicService> getServices() {
911             return services;
912         }
913     }
914
915     public static @Nullable String getRoomName(URL descriptorURL) {
916         RoomNameHandler roomNameHandler = new RoomNameHandler();
917         try {
918             SAXParserFactory factory = SAXParserFactory.newInstance();
919             SAXParser saxParser = factory.newSAXParser();
920             saxParser.parse(new InputSource(descriptorURL.openStream()), roomNameHandler);
921         } catch (SAXException | ParserConfigurationException e) {
922             LOGGER.warn("Could not parse Sonos room name from URL '{}'", descriptorURL);
923         } catch (IOException e) {
924             LOGGER.debug("Could not fetch descriptor XML from URL '{}': {}", descriptorURL, e.getMessage());
925         }
926         return roomNameHandler.getRoomName();
927     }
928
929     private static class RoomNameHandler extends DefaultHandler {
930
931         private @Nullable String roomName;
932         private boolean roomNameTag;
933
934         @Override
935         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
936                 @Nullable Attributes attributes) throws SAXException {
937             if ("roomName".equalsIgnoreCase(qName)) {
938                 roomNameTag = true;
939             }
940         }
941
942         @Override
943         public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
944             if (roomNameTag && ch != null) {
945                 roomName = new String(ch, start, length);
946                 roomNameTag = false;
947             }
948         }
949
950         public @Nullable String getRoomName() {
951             return roomName;
952         }
953     }
954
955     public static @Nullable String parseModelDescription(URL descriptorURL) {
956         ModelNameHandler modelNameHandler = new ModelNameHandler();
957         try {
958             SAXParserFactory factory = SAXParserFactory.newInstance();
959             SAXParser saxParser = factory.newSAXParser();
960             saxParser.parse(new InputSource(descriptorURL.openStream()), modelNameHandler);
961         } catch (SAXException | ParserConfigurationException e) {
962             LOGGER.warn("Could not parse Sonos model name from URL '{}'", descriptorURL);
963         } catch (IOException e) {
964             LOGGER.debug("Could not fetch descriptor XML from URL '{}': {}", descriptorURL, e.getMessage());
965         }
966         return modelNameHandler.getModelName();
967     }
968
969     private static class ModelNameHandler extends DefaultHandler {
970
971         private @Nullable String modelName;
972         private boolean modelNameTag;
973
974         @Override
975         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
976                 @Nullable Attributes attributes) throws SAXException {
977             if ("modelName".equalsIgnoreCase(qName)) {
978                 modelNameTag = true;
979             }
980         }
981
982         @Override
983         public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
984             if (modelNameTag && ch != null) {
985                 modelName = new String(ch, start, length);
986                 modelNameTag = false;
987             }
988         }
989
990         public @Nullable String getModelName() {
991             return modelName;
992         }
993     }
994
995     /**
996      * Build a valid thing type ID from the model name provided by UPnP
997      *
998      * @param sonosModelName Sonos model name provided via UPnP device
999      * @return a valid thing type ID that can then be used for ThingType creation
1000      */
1001     public static String buildThingTypeIdFromModelName(String sonosModelName) {
1002         // For Ikea SYMFONISK models, the model name now starts with "SYMFONISK" with recent firmwares
1003         if (sonosModelName.toUpperCase().contains("SYMFONISK")) {
1004             return "SYMFONISK";
1005         }
1006         String id = sonosModelName;
1007         // Remove until the first space (in practice, it removes the leading "Sonos " from the model name)
1008         Matcher matcher = Pattern.compile("\\s(.*)").matcher(id);
1009         if (matcher.find()) {
1010             id = matcher.group(1);
1011             // Remove a potential ending text surrounded with parenthesis
1012             matcher = Pattern.compile("(.*)\\s\\(.*\\)").matcher(id);
1013             if (matcher.find()) {
1014                 id = matcher.group(1);
1015             }
1016         }
1017         // Finally remove unexpected characters in a thing type ID
1018         id = id.replaceAll("[^a-zA-Z0-9_]", "");
1019         // ZP80 is translated to CONNECT and ZP100 to CONNECTAMP
1020         switch (id) {
1021             case "ZP80":
1022                 id = "CONNECT";
1023                 break;
1024             case "ZP100":
1025                 id = "CONNECTAMP";
1026                 break;
1027             default:
1028                 break;
1029         }
1030         return id;
1031     }
1032
1033     public static String compileMetadataString(SonosEntry entry) {
1034         /**
1035          * If the entry contains resource meta data we will override this with
1036          * that data.
1037          */
1038         String id = entry.getId();
1039         String parentId = entry.getParentId();
1040         String title = entry.getTitle();
1041         String upnpClass = entry.getUpnpClass();
1042
1043         /**
1044          * By default 'RINCON_AssociatedZPUDN' is used for most operations,
1045          * however when playing a favorite entry that is associated withh a
1046          * subscription like pandora we need to use the desc string asscoiated
1047          * with that item.
1048          */
1049         String desc = entry.getDesc();
1050         if (desc == null) {
1051             desc = "RINCON_AssociatedZPUDN";
1052         }
1053
1054         /**
1055          * If resource meta data exists, use it over the parent data
1056          */
1057         SonosResourceMetaData resourceMetaData = entry.getResourceMetaData();
1058         if (resourceMetaData != null) {
1059             id = resourceMetaData.getId();
1060             parentId = resourceMetaData.getParentId();
1061             title = resourceMetaData.getTitle();
1062             desc = resourceMetaData.getDesc();
1063             upnpClass = resourceMetaData.getUpnpClass();
1064         }
1065
1066         title = StringUtils.escapeXml(title);
1067
1068         return new MessageFormat(METADATA_FORMAT_PATTERN).format(new Object[] { id, parentId, title, upnpClass, desc });
1069     }
1070 }