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