You can select a renderer to play the media served from a server.
The full content hierarchy of the media on the server can be browsed hierarchically.
Searching the media library is also supported using UPnP search syntax.
+Playlists can be created and maintained.
Controls are available to control the playback of the media on the renderer.
+Currently playing media can be stored as a favorite.
Each discovered renderer will also be registered as an openHAB audio sink.
+
## Supported Things
Two thing types are supported, a server thing, `upnpserver`, and a renderer thing, `upnprenderer`.
Tests have focused on the playback of audio, but if the server and renderer support it, other media types should play as well.
+## Binding Configuration
+
+The binding has one configuration parameter, `path`.
+This is used as the disk location for storing and retrieving playlists and favorites.
+The default location is `$OPENHAB_USERDATA/upnpcontrol`.
+
+
## Discovery
UPnP media servers and media renderers in the network will be discovered automatically.
This `udn` uniquely defines the UPnP device.
It can be retrieved from the thing ID when using auto discovery.
+Both also have `refresh` configuration parameter. This parameter defines a polling interval for polling the state of the `upnprenderer` or `upnpserver`.
+The default polling interval is 60s.
+0 turns off polling.
+
+An advanced configuration parameter `responseTimeout` permits tweaking how long the `upnprenderer` and `upnpserver` will wait for GENA events from the UPnP device.
+This timeout is checked when there is a dependency between an action invocation and an event with expected result.
+The default is 2500ms.
+It should not be changed in normal circumstances.
+
Additionally, a `upnpserver` device has the following optional configuration parameters:
* `filter`: when true, only list content that is playable on the renderer, default is `false`.
-* `sortcriteria`: Sort criteria for the titles in the selection list and when sending for playing to a renderer.
-The criteria are defined in UPnP sort criteria format, examples: `+dc:title`, `-dc:creator`, `+upnp:album`.
-Support for sort criteria will depend on the media server.
-The default is to sort ascending on title, `+dc:title`.
+
+* `sortCriteria`: sort criteria for the titles in the selection list and when sending for playing to a renderer.
+
+ The criteria are defined in UPnP sort criteria format, examples: `+dc:title`, `-dc:creator`, `+upnp:album`.
+ Support for sort criteria will depend on the media server.
+ The default is to sort ascending on title, `+dc:title`.
+
+* `browseDown`: when browse or search results in exactly one container entry, iteratively browse down until the result contains multiple container entries or at least one media entry, default is `true`.
+
+* `searchFromRoot`: always start search from root instead of the current id, default is `false`.
+
+A `upnprenderer` has the following optional configuration parameters:
+
+* `seekStep`: step in seconds when sending fast forward or rewind command on the player control, default 5s.
+
+* `notificationVolumeAdjustment`: volume adjustment from current volume in percent (range -100 to +100) for notifications when no volume is set in `playSound` command, default 10.
+
+* `maxNotificationDuration`: maximum duration for notifications (default 15s), no maximum duration when set to 0s.
The full syntax for manual configuration is:
```
-Thing upnpcontrol:upnpserver:<serverId> [udn="<udn of media server>"]
-Thing upnpcontrol:upnprenderer:<rendererId> [udn="<udn of media renderer>", filter=<true/false>, sortcriteria="<sort criteria string>"]
+Thing upnpcontrol:upnpserver:<serverId> [udn="<udn of media server>", refresh=<polling interval>, seekStep=<step>]
+Thing upnpcontrol:upnprenderer:<rendererId> [udn="<udn of media renderer>", refresh=<polling interval>, filter=<true/false>, sortCriteria="<sort criteria string>", browseDown=<true/false>, searchfromroot=<true/false>]
```
+
## Channels
-The `upnpserver` has the following channels:
-
-* `upnprenderer`: The renderer to send the media content to for playback.
-The channel allows selecting from all discovered media renderers.
-This list is dynamically adjusted as media renderers are being added/removed.
-* `currentid`: Current ID of media container or entry ready for playback.
-This channel can be used to skip to a specific container or entry in the content directory.
-This is especially useful in rules.
-* `browse`: Browse and serve media content.
-The browsing will start at the top of the content directory tree and allows you to go down and up (represented by ..) in the tree.
-The list of containers (directories) and media entries for selection in the content hierarchy is updated dynamically when selecting a container or entry.
-All media in the selection list, playable on the currently selected `upnprenderer` channel, are automatically queued to the renderer as next media for playback.
-* `search`: Search for media content on the server.
-Search criteria are defined in UPnP search criteria format.
-Examples: `dc:title contains "song"`, `dc:creator contains "SpringSteen"`, `unp:class = "object.item.audioItem"`, `upnp:album contains "Born in"`.
-The search starts at the value of the `currentid` channel and searches down from there.
-When no `currentid` is selected, the search starts at the top.
-All media in the search result list, playable on the current selected `upnprenderer` channel, are automatically queued to the renderer as next media for playback.
-
-The `upnprenderer` has the following channels:
-
-| Channel Type ID | Item Type | Access Mode | Description |
-|-----------------|-----------|-------------|----------------------------------------------------|
-| `volume` | Dimmer | RW | playback volume |
-| `control` | Player | RW | play, pause, next, previous control |
-| `stop` | Switch | RW | stop media playback |
-| `title` | String | R | media title |
-| `album` | String | R | media album |
-| `albumart` | Image | R | image for media album |
-| `creator` | String | R | media creator |
-| `artist` | String | R | media artist |
-| `publisher` | String | R | media publisher |
-| `genre` | String | R | media genre |
-| `tracknumber` | Number | R | track number of current track in album |
-| `trackduration` | Number:Time | R | track duration of current track in album |
-| `trackposition` | Number:Time | R | current position in track during playback or pause |
+### `upnpserver`
+
+The `upnpserver` has the following channels (item type and access mode indicated in brackets):
+
+* `upnprenderer` (String, RW): The renderer to receive media content for playback.
+
+ The channel allows selecting from all discovered media renderers.
+ This list is dynamically adjusted as media renderers are being added/removed.
+
+* `currenttitle` (String, R): Current title of media container or entry ready for playback.
+
+* `browse` (String, RW): Browse and serve media content, current ID of media container or entry ready for playback.
+
+ The browsing will start at the top of the content directory tree and allows you to go down and up (represented by ..) in the tree.
+ The list of containers (directories) and media entries for selection in the content hierarchy is updated dynamically when selecting a container or entry.
+
+ This channel can also be used to skip to a specific container or entry in the content directory.
+ Setting it to 0 will reposition to the top of the content hierarchy.
+
+ All media in the selection list, playable on the currently selected `upnprenderer` channel, are automatically queued to the renderer as next media for playback.
+
+ The `browseDown` configuration parameter influences the result in such a way that, for `browseDown = true`, if the result only contains exactly one container entry, the result will be the content of the container and not the container itself.
+
+* `search` (String, W): Search for media content on the server.
+
+ Search criteria are defined in UPnP search criteria format.
+ Examples: `dc:title contains "song"`, `dc:creator contains "SpringSteen"`, `unp:class = "object.item.audioItem"`, `upnp:album contains "Born in"`.
+
+ The search, by default, starts at the value of the `currentid` and searches down from there unless the `searchfromroot` thing configuration parameter is set to `true`.
+ The result (media and containers) will be available in the `browse` command option list.
+ The `currentid` channel will be put to the id of the top container where the search started.
+
+ All media in the search result list, playable on the current selected `upnprenderer` channel, are automatically queued to the renderer as next media for playback.
+
+ The `browseDown` configuration parameter influences the result in such a way that, for `browseDown = true`, if the result only contains exactly one container entry, the result will be the content of the container and not the container itself.
+
+* `playlistselect` (String, W): Select a playlist from the available playlists currently saved on disk.
+
+ This will also update `playlist` with the selected value.
+
+* `playlist` (String, RW): Name of existing or new playlist.
+
+* `playlistaction` (String, W): action to perform with `playlist`.
+
+ Possible command options are:
+
+ * `RESTORE`: restore the playlist from `playlist`.
+
+ If the restored playlist contains content from the current server, this content will update the `browse` command option list.
+ Note that playlists can contain a mix of media entries and container references.
+
+ All media in the result list, playable on the current selected `upnprenderer` channel, are automatically queued to the renderer as next media for playback.
+
+ * `SAVE`: save the current `browse` command option list into `playlist`.
+
+ If `playlist` already exists, it will be overwritten.
+
+ * `APPEND`: append the current `browse` command option list to `playlist`.
+
+ If `playlist` does not exist yet, a new playlist will be created.
+
+ * `DELETE`: delete `playlist` from disk and remove from `playlistselect` command option list.
+
+A number of convenience channels replicate the basic control channels from the `upnprenderer` thing for the currently selected renderer on the `upnprenderer` channel.
+These channels are `volume`, `mute` and `control`.
+
+### `upnprenderer`
+
+The `upnprenderer` has the following default channels:
+
+| Channel Type ID | Item Type | Access Mode | Description |
+|--------------------|-------------|-------------|----------------------------------------------------|
+| `volume` | Dimmer | RW | playback master volume |
+| `mute` | Switch | RW | playback master mute |
+| `control` | Player | RW | play, pause, next, previous, fast forward, rewind |
+| `stop` | Switch | W | stop media playback |
+| `repeat` | Switch | RW | continuous play of media queue, restart at end |
+| `shuffle` | Switch | RW | continuous random play of media queue |
+| `onlyplayone` | Switch | RW | only play one media entry from the queue at a time |
+| `uri` | String | RW | URI of currently playing media |
+| `favoriteselect` | String | W | play favorite from list of saved favorites |
+| `favorite` | String | RW | set name for existing of new favorite |
+| `favoriteaction` | String | W | `SAVE` or `DELETE` `favorite` |
+| `playlistselect` | String | W | play playlist from list of saved playlists |
+| `title` | String | R | media title |
+| `album` | String | R | media album |
+| `albumart` | Image | R | image for media album |
+| `creator` | String | R | media creator |
+| `artist` | String | R | media artist |
+| `publisher` | String | R | media publisher |
+| `genre` | String | R | media genre |
+| `tracknumber` | Number | R | track number of current track in album |
+| `trackduration` | Number:Time | R | track duration of current track in album |
+| `trackposition` | Number:Time | RW | current position in track during playback or pause |
+| `reltrackposition` | Dimmer | RW | current position relative to track duration |
+
+A numer of `upnprenderer` audio control channels may be dynamically created depending on the specific renderer capabilities.
+Examples of these are:
+
+| Channel Type ID | Item Type | Access Mode | Description |
+|--------------------|-------------|-------------|----------------------------------------------------|
+| `loudness` | Switch | RW | playback master loudness |
+| `lfvolume` | Dimmer | RW | playback front left volume |
+| `lfmute` | Switch | RW | playback front left mute |
+| `rfvolume` | Dimmer | RW | playback front right volume |
+| `rfmute` | Switch | RW | playback front right mute |
+
## Audio Support
-All configured media renderers are registered as an audio sink.
-`playSound`and `playStream`commands can be used in rules to play back audio fragments or audio streams to a renderer.
+Two audio sinks are registered for each media renderer.
+`playSound` and `playStream` commands can be used in rules to play back audio fragments or audio streams to a renderer.
+
+The first audio sink has the renderer id as a name.
+It is used for normal playback of a sound or stream.
+
+The second audio sink has `-notify` appended to the renderer id for its name, and has a special behavior.
+This audio sink is used to play notifications.
+When setting the volume parameter in the `playSound` command, the volume of the renderer will only change for the duration of playing the notification.
+The `maxNotificationDuration` configuration parameter of the renderer will limit the notification duration the value of the parameter in seconds.
+Normal playing will resume after the notification has played or when the maximum notification duration has been reached, whichever happens first.
+Longer sounds or streams will be cut off.
+
+
+## Managing a Playback Queue
+
+There are multiple ways to serve content to a renderer for playback.
+
+* Directly provide a URI on the `URI` channel or through `playSound` or `playStream` actions:
+
+ Playing will start immediately, interrupting currently playing media.
+ No metadata for the media is available, therefore will be provided in the media channels for metadata (e.g. `title`, `album`, ...).
+
+* Content served from one or multiple `upnpserver` servers:
+
+ This is done on the `upnpserver` thing with the `upnprenderer` set the the renderer for playback.
+ The media at any point in time in the `upnpserver browse` option list (result from browse, search or restoring a playlist), will be queued to the `upnprenderer` for playback.
+ Playback does not start automatically if not yet playing.
+ When already playing a queue, the first entry of the new queue will be playing as the next entry.
+ When playing an URI or media provided through an action, playback will immediately switch to the new queue.
+
+ The `upnprenderer` will use that queue until it is replaced by another queue from the same or another `upnpserver`.
+ Note that querying the content hierarchy on the `upnpserver` will update the `upnpserver browse` option list each time, and therefore the queue on the `upnprenderer` will be updated each time as long as `upnprenderer` is selected on `upnpserver`.
+
+* Selecting a favorite or playlist on the renderer.
+
+ Playback of the favorite or playlist will start immediately.
+
+When playing from a directly provided URI, at the end of the media, the renderer will try to move to the next entry in a queue previously provided by a server.
+Playing will stop when no such entry is available.
+
+Multiple renderers can be sent the same or different playback queue from the same server sequentially.
+Select content on the server and select the first renderer for playback.
+The content queue will be served to the renderer, a play command on the renderer will start playing the queue.
+Select another renderer on the server.
+The same or new (after another content selection) queue will be served to the second renderer.
+Both renderers will keep on playing the full queue they received.
+
+When serving a queue from a server, the renderer can be put in "only play one" mode by putting the `onlyplayone` channel to true.
+A subsequent play command will only play one media entry from the queue while respecting `shuffle` and `repeat`.
+To play the next media from the queue, a new play command will be required after the player stopped.
+An example of usage could be playing a single random sound from a playlist when you are away from home and an intrusion is detected.
+A script could put the player in `shuffle` and `onlyplayone` mode and serve a playlist.
+Only one random sound from the playlist would be played.
+
+### Favorites
+
+Currently playing media can be saved as favorites on the renderer.
+This is especially useful when playing streams, such as online radio, but is valid for any media.
+If the currently playing media has metadata, it will be saved with the favorite.
+
+A favorite only contains one media item.
+Selecting the favorite will only play that one item.
+The favorite will start playing immediately.
+Playing the server queue will resume after playing the favorite.
+
+### Playlists
+
+Playlists provide a way to define lists of server content for playback.
+
+A new playlist can be created on a server thing from the selection in the `upnpserver browse` selection list.
+When restoring a playlist on the server, the media in the playlist from the `upnpserver` thing used for restoring, will be put in the `upnpserver browse` selection list.
+
+The current selection of media playable on the currently selected renderer will automatically be stored as a playlist with name `current`.
+
+A playlist can contain media from different servers.
+Only the media from the current server will be visible in the server when restoring.
+It is possible to append content to a playlist that already contains content from a different server.
+That way, it is possible to combine multiple sources for playback.
+
+When selecting a playlist on a renderer, the playlist will be queued for playback, replacing the current queue.
+Playback will start immediately.
+
+
+## Using Search
+
+Searching content on a media server may take a lot of time, depending on the functionality and the performance of the media server.
+Therefore, it may very well be that media server searches time out.
+
+Rather than searching for individual items, it is therefore often better to search for containers or playlists.
+
+For example:
+
+* `upnp:class derivedfrom "object.item.audioItem.musicTrack" and dc:title contains "Fight For Your Right"` would search for all music tracks with "Fight For Your Right" in the title.
+ This search is potentially slow.
+
+* `dc:title contains "Evening" and upnp:class = "object.container.playlistContainer"` would search for all playlists with "Evening" in the name.
+
+* `dc:title = "Donnie Darko" and upnp:class = "object.container.playlistContainer"` would search for a playlist with a specific name.
+
+With the last example, if the `browseDown` configuration parameter is `true`, the result will not be the playlist, but the content of the playlist.
+This allows immediately starting a play command without having to browse down to the first result of the list (the unique container).
+This is especially useful when doing searches and starting to play in scripts, as the play command can immediately follow the search for a unique container, without a need to browse down to a media ID that is hidden in the browse option list.
+For interactive use through a UI, you may opt to switch the `browseDown` configuration parameter to `false` to see all levels in the browsing hierarchy.
+
+The `searchfromroot` configuration parameter always forces searching to start from the directory root.
+This will also always reset the `browse` channel to the root.
+This option is helpful if you do not want to limit search to a selected container in the directory.
## Limitations
-The current version of BasicUI does not support dynamic refreshing of the selection list in the `upnpserver` channels `renderer` and `browse`.
-A refresh of the browser will be required to show the adjusted selection list.
-The `upnpserver search` channel requires input of a string to trigger a search.
-This cannot be done with BasicUI, but can be achieved with rules.
+BasicUI has a number of limitations that impact the way some of the channels can be used from it:
+
+* BasicUI does not support dynamic refreshing of the selection list in the `upnpserver` channels `renderer`, `browse`, `playlistselect` and in the `upnprenderer` channel `favoriteselect`.
+ A refresh of the browser will be required to show the adjusted selection list.
+
+* The `upnpserver search` channel requires input of a string to trigger a search.
+ The `upnpserver playlist` channel and `upnprenderer favorite` channel require input of a string to set a playlist or favorite.
+ This cannot be done with BasicUI, but can be achieved with rules.
+
+* The player control in BasicUI does not support fast forward or rewind.
+
+None of these are limitations when using the main UI.
## Full Example
.things:
```
-Thing upnpcontrol:upnpserver:mymediaserver [udn="538cf6e8-d188-4aed-8545-73a1b905466e"]
-Thing upnpcontrol:upnprenderer:mymediarenderer [udn="0ec457ae-6c50-4e6e-9012-dee7bb25be2d", filter=true, sortcriteria="+dc:title"]
+Thing upnpcontrol:upnpserver:mymediaserver [udn="0ec457ae-6c50-4e6e-9012-dee7bb25be2d", refresh=120, filter=true, sortCriteria="+dc:title"]
+Thing upnpcontrol:upnprenderer:mymediarenderer [udn="538cf6e8-d188-4aed-8545-73a1b905466e", refresh=600, seekStep=1]
```
.items:
Dimmer Volume "Volume [%.1f %%]" <soundvolume> (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:volume"}
Switch Mute "Mute" <soundvolume_mute> (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:mute"}
+Switch Loudness "Loudness" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:loudness"}
+Dimmer LeftVolume "Volume [%.1f %%]" <soundvolume> (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:lfvolume"}
+Dimmer RightVolume "Volume [%.1f %%]" <soundvolume> (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:rfvolume"}
Player Controls "Controller" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:control"}
Switch Stop "Stop" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:stop"}
+Switch Repeat "Repeat" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:repeat"}
+Switch Shuffle "Shuffle" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:shuffle"}
+String URI "URI" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:uri"}
+String FavoriteSelect "Favorite" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:favoriteselect"}
+String Favorite "Favorite" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:favorite"}
+String FavoriteAction "Favorite Action" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:favoriteaction"}
+String PlaylistPlay "Playlist" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:playlistselect"}
String Title "Now playing [%s]" <text> (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:title"}
String Album "Album" <text> (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:album"}
Image AlbumArt "Album Art" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:albumart"}
Number TrackNumber "Track Number" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:tracknumber"}
Number:Time TrackDuration "Track Duration [%d %unit%]" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:trackduration"}
Number:Time TrackPosition "Track Position [%d %unit%]" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:trackposition"}
+Dimmer RelTrackPosition "Relative Track Position ´[%d %%]" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:reltrackposition"}
String Renderer "Renderer [%s]" <text> (MediaServer) {channel="upnpcontrol:upnpserver:mymediaserver:title"}
-String CurrentId "Current Entry [%s]" <text> (MediaServer) {channel="upnpcontrol:upnpserver:mymediaserver:currentid"}
-String Browse "Browse" (MediaServer) {channel="upnpcontrol:upnpserver:mymediaserver:browse"}
+String CurrentTitle "Current Entry [%s]" <text> (MediaServer) {channel="upnpcontrol:upnpserver:mymediaserver:currenttitle"}
+String Browse "Browse" (MediaServer) {channel="upnpcontrol:upnpserver:mymediaserver:browse"}
+String Search "Search" (MediaServer) {channel="upnpcontrol:upnpserver:mymediaserver:search"}
+String PlaylistSelect "Playlist" (MediaServer) {channel="upnpcontrol:upnpserver:mymediaserver:playlistselect"}
+String Playlist "Playlist" (MediaServer) {channel="upnpcontrol:upnpserver:mymediaserver:playlist"}
+String PlaylistAction "Playlist Action" (MediaServer) {channel="upnpcontrol:upnpserver:mymediaserver:playlistaction"}
```
.sitemap:
```
-Slider item=Volume
-Switch item=Mute
-Default item=Controls
-Switch item=Stop mappings=[ON="STOP"]
-Text item=Title
-Text item=Album
-Default item=AlbumArt
-Text item=Creator
-Text item=Artist
-Text item=Publisher
-Text item=Genre
-Text item=TrackNumber
-Text item=TrackDuration
-Text item=TrackPosition
-
-Text item=Renderer
-Text item=CurrentId
-Text item=Browse
+Slider item=Volume
+Switch item=Mute
+Switch item=Loudness
+Slider item=LeftVolume
+Slider item=RightVolume
+Default item=Controls
+Switch item=Stop mappings=[ON="STOP"]
+Switch item=Repeat
+Switch item=Shuffle
+Text item=URI
+Selection item=FavoriteSelect
+Text item=Favorite
+Switch item=FavoriteAction
+Selection item=PlaylistPlay
+Text item=Title
+Text item=Album
+Default item=AlbumArt
+Text item=Creator
+Text item=Artist
+Text item=Publisher
+Text item=Genre
+Text item=TrackNumber
+Text item=TrackDuration
+Text item=TrackPosition
+Slider item=RelTrackPosition
+
+Selection item=Renderer
+Text item=CurrentTitle
+Selection item=Browse
+Text item=Search
+Selection item=PlaylistSelect
+Text item=Playlist
+Switch item=PlaylistAction
```
Audio sink usage examples in rules:
```
playSound(“doorbell.mp3”)
playStream("upnpcontrol:upnprenderer:mymediarenderer", "http://icecast.vrtcdn.be/stubru_tijdloze-high.mp3”)
+playSound("upnpcontrol:upnprenderer:mymediarenderer-notify", "doorbell.mp3", new PercentType(80))
+
```
+++ /dev/null
-/**
- * Copyright (c) 2010-2020 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.upnpcontrol.internal;
-
-import java.io.IOException;
-import java.util.Locale;
-import java.util.Set;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.upnpcontrol.internal.handler.UpnpRendererHandler;
-import org.openhab.core.audio.AudioFormat;
-import org.openhab.core.audio.AudioHTTPServer;
-import org.openhab.core.audio.AudioSink;
-import org.openhab.core.audio.AudioStream;
-import org.openhab.core.audio.FixedLengthAudioStream;
-import org.openhab.core.audio.URLAudioStream;
-import org.openhab.core.audio.UnsupportedAudioFormatException;
-import org.openhab.core.audio.UnsupportedAudioStreamException;
-import org.openhab.core.library.types.PercentType;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- *
- * @author Mark Herwege - Initial contribution
- */
-@NonNullByDefault
-public class UpnpAudioSink implements AudioSink {
-
- private final Logger logger = LoggerFactory.getLogger(UpnpAudioSink.class);
-
- private static final Set<Class<? extends AudioStream>> SUPPORTED_STREAMS = Stream
- .of(AudioStream.class, FixedLengthAudioStream.class).collect(Collectors.toSet());
- private UpnpRendererHandler handler;
- private AudioHTTPServer audioHTTPServer;
- private String callbackUrl;
-
- public UpnpAudioSink(UpnpRendererHandler handler, AudioHTTPServer audioHTTPServer, String callbackUrl) {
- this.handler = handler;
- this.audioHTTPServer = audioHTTPServer;
- this.callbackUrl = callbackUrl;
- }
-
- @Override
- public String getId() {
- return handler.getThing().getUID().toString();
- }
-
- @Override
- public @Nullable String getLabel(@Nullable Locale locale) {
- return handler.getThing().getLabel();
- }
-
- @Override
- public void process(@Nullable AudioStream audioStream)
- throws UnsupportedAudioFormatException, UnsupportedAudioStreamException {
- if (audioStream == null) {
- stopMedia();
- return;
- }
-
- String url = null;
- if (audioStream instanceof URLAudioStream) {
- URLAudioStream urlAudioStream = (URLAudioStream) audioStream;
- url = urlAudioStream.getURL();
- } else if (!callbackUrl.isEmpty()) {
- String relativeUrl = audioStream instanceof FixedLengthAudioStream
- ? audioHTTPServer.serve((FixedLengthAudioStream) audioStream, 20)
- : audioHTTPServer.serve(audioStream);
- url = String.valueOf(this.callbackUrl) + relativeUrl;
- } else {
- logger.warn("We do not have any callback url, so {} cannot play the audio stream!", handler.getUDN());
- return;
- }
- playMedia(url);
- }
-
- @Override
- public Set<AudioFormat> getSupportedFormats() {
- return handler.getSupportedAudioFormats();
- }
-
- @Override
- public Set<Class<? extends AudioStream>> getSupportedStreams() {
- return SUPPORTED_STREAMS;
- }
-
- @Override
- public PercentType getVolume() throws IOException {
- return handler.getCurrentVolume();
- }
-
- @Override
- public void setVolume(@Nullable PercentType volume) throws IOException {
- if (volume != null) {
- handler.setVolume(handler.getCurrentChannel(), volume);
- }
- }
-
- private void stopMedia() {
- handler.stop();
- }
-
- private void playMedia(String url) {
- stopMedia();
- String newUrl = url;
- if (!url.startsWith("x-") && !url.startsWith("http")) {
- newUrl = "x-file-cifs:" + url;
- }
- handler.setCurrentURI(newUrl, "");
- handler.play();
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2020 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.upnpcontrol.internal;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.openhab.binding.upnpcontrol.internal.handler.UpnpRendererHandler;
-
-/**
- * Interface class to be implemented in {@link UpnpControlHandlerFactory}, allows a {UpnpRendererHandler} to register
- * itself as an audio sink when it supports audio. If it supports audio is only known after the communication with the
- * renderer is established.
- *
- * @author Mark Herwege - Initial contribution
- */
-@NonNullByDefault
-public interface UpnpAudioSinkReg {
-
- /**
- * Implemented method should create a new {@link UpnpAudioSink} and register the handler parameter as an audio sink.
- *
- * @param handler
- */
- void registerAudioSink(UpnpRendererHandler handler);
-}
--- /dev/null
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal;
+
+import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
+
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * This enum contains default openHAB channel configurations for optional channels as defined in the UPnP standard.
+ * Vendor specific channels are not part of this.
+ *
+ * @author Mark Herwege - Initial contribution
+ *
+ */
+@NonNullByDefault
+public enum UpnpChannelName {
+
+ // Volume channels
+ LF_VOLUME("LFvolume", "Left Front Volume", "Left front volume, will be left volume with stereo sound",
+ ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+ RF_VOLUME("RFvolume", "Right Front Volume", "Right front volume, will be left volume with stereo sound",
+ ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+ CF_VOLUME("CFvolume", "Center Front Volume", "Center front volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+ LFE_VOLUME("LFEvolume", "Frequency Enhancement Volume", "Low frequency enhancement volume (subwoofer)",
+ ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+ LS_VOLUME("LSvolume", "Left Surround Volume", "Left surround volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+ RS_VOLUME("RSvolume", "Right Surround Volume", "Right surround volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+ LFC_VOLUME("LFCvolume", "Left of Center Volume", "Left of center (in front) volume", ITEM_TYPE_VOLUME,
+ CHANNEL_TYPE_VOLUME),
+ RFC_VOLUME("RFCvolume", "Right of Center Volume", "Right of center (in front) volume", ITEM_TYPE_VOLUME,
+ CHANNEL_TYPE_VOLUME),
+ SD_VOLUME("SDvolume", "Surround Volume", "Surround (rear) volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+ SL_VOLUME("SLvolume", "Side Left Volume", "Side left (left wall) volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+ SR_VOLUME("SRvolume", "Side Right Volume", "Side right (right wall) volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+ T_VOLUME("Tvolume", "Top Volume", "Top (overhead) volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+ B_VOLUME("Bvolume", "Bottom Volume", "Bottom volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+ BC_VOLUME("BCvolume", "Back Center Volume", "Back center volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+ BL_VOLUME("BLvolume", "Back Left Volume", "Back Left Volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+ BR_VOLUME("BRvolume", "Back Right Volume", "Back right volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+
+ // Mute channels
+ LF_MUTE("LFmute", "Left Front Mute", "Left front mute, will be left mute with stereo sound", ITEM_TYPE_MUTE,
+ CHANNEL_TYPE_MUTE),
+ RF_MUTE("RFmute", "Right Front Mute", "Right front mute, will be left mute with stereo sound", ITEM_TYPE_MUTE,
+ CHANNEL_TYPE_MUTE),
+ CF_MUTE("CFmute", "Center Front Mute", "Center front mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
+ LFE_MUTE("LFEmute", "Frequency Enhancement Mute", "Low frequency enhancement mute (subwoofer)", ITEM_TYPE_MUTE,
+ CHANNEL_TYPE_MUTE),
+ LS_MUTE("LSmute", "Left Surround Mute", "Left surround mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
+ RS_MUTE("RSmute", "Right Surround Mute", "Right surround mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
+ LFC_MUTE("LFCmute", "Left of Center Mute", "Left of center (in front) mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
+ RFC_MUTE("RFCmute", "Right of Center Mute", "Right of center (in front) mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
+ SD_MUTE("SDmute", "Surround Mute", "Surround (rear) mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
+ SL_MUTE("SLmute", "Side Left Mute", "Side left (left wall) mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
+ SR_MUTE("SRmute", "Side Right Mute", "Side right (right wall) mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
+ T_MUTE("Tmute", "Top Mute", "Top (overhead) mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
+ B_MUTE("Bmute", "Bottom Mute", "Bottom mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
+ BC_MUTE("BCmute", "Back Center Mute", "Back center mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
+ BL_MUTE("BLmute", "Back Left Mute", "Back Left Mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
+ BR_MUTE("BRmute", "Back Right Mute", "Back right mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
+
+ // Loudness channels
+ LOUDNESS("loudness", "Loudness", "Master loudness", ITEM_TYPE_LOUDNESS, CHANNEL_TYPE_LOUDNESS),
+ LF_LOUDNESS("LFloudness", "Left Front Loudness", "Left front loudness", ITEM_TYPE_LOUDNESS, CHANNEL_TYPE_LOUDNESS),
+ RF_LOUDNESS("RFloudness", "Right Front Loudness", "Right front loudness", ITEM_TYPE_LOUDNESS,
+ CHANNEL_TYPE_LOUDNESS),
+ CF_LOUDNESS("CFloudness", "Center Front Loudness", "Center front loudness", ITEM_TYPE_LOUDNESS,
+ CHANNEL_TYPE_LOUDNESS),
+ LFE_LOUDNESS("LFEloudness", "Frequency Enhancement Loudness", "Low frequency enhancement loudness (subwoofer)",
+ ITEM_TYPE_LOUDNESS, CHANNEL_TYPE_LOUDNESS),
+ LS_LOUDNESS("LSloudness", "Left Surround Loudness", "Left surround loudness", ITEM_TYPE_LOUDNESS,
+ CHANNEL_TYPE_LOUDNESS),
+ RS_LOUDNESS("RSloudness", "Right Surround Loudness", "Right surround loudness", ITEM_TYPE_LOUDNESS,
+ CHANNEL_TYPE_LOUDNESS),
+ LFC_LOUDNESS("LFCloudness", "Left of Center Loudness", "Left of center (in front) loudness", ITEM_TYPE_LOUDNESS,
+ CHANNEL_TYPE_LOUDNESS),
+ RFC_LOUDNESS("RFCloudness", "Right of Center Loudness", "Right of center (in front) loudness", ITEM_TYPE_LOUDNESS,
+ CHANNEL_TYPE_LOUDNESS),
+ SD_LOUDNESS("SDloudness", "Surround Loudness", "Surround (rear) loudness", ITEM_TYPE_LOUDNESS,
+ CHANNEL_TYPE_LOUDNESS),
+ SL_LOUDNESS("SLloudness", "Side Left Loudness", "Side left (left wall) loudness", ITEM_TYPE_LOUDNESS,
+ CHANNEL_TYPE_LOUDNESS),
+ SR_LOUDNESS("SRloudness", "Side Right Loudness", "Side right (right wall) loudness", ITEM_TYPE_LOUDNESS,
+ CHANNEL_TYPE_LOUDNESS),
+ T_LOUDNESS("Tloudness", "Top Loudness", "Top (overhead) loudness", ITEM_TYPE_LOUDNESS, CHANNEL_TYPE_LOUDNESS),
+ B_LOUDNESS("Bloudness", "Bottom Loudness", "Bottom loudness", ITEM_TYPE_LOUDNESS, CHANNEL_TYPE_LOUDNESS),
+ BC_LOUDNESS("BCloudness", "Back Center Loudness", "Back center loudness", ITEM_TYPE_LOUDNESS,
+ CHANNEL_TYPE_LOUDNESS),
+ BL_LOUDNESS("BLloudness", "Back Left Loudness", "Back Left Loudness", ITEM_TYPE_LOUDNESS, CHANNEL_TYPE_LOUDNESS),
+ BR_LOUDNESS("BRloudness", "Back Right Loudness", "Back right loudness", ITEM_TYPE_LOUDNESS, CHANNEL_TYPE_LOUDNESS);
+
+ private static final Map<String, UpnpChannelName> UPNP_CHANNEL_NAME_MAP = Stream.of(UpnpChannelName.values())
+ .collect(Collectors.toMap(UpnpChannelName::getChannelId, Function.identity()));
+
+ private final String channelId;
+ private final String label;
+ private final String description;
+ private final String itemType;
+ private final String channelType;
+
+ UpnpChannelName(final String channelId, final String label, final String description, final String itemType,
+ final String channelType) {
+ this.channelId = channelId;
+ this.label = label;
+ this.description = description;
+ this.itemType = itemType;
+ this.channelType = channelType;
+ }
+
+ /**
+ * @return The name of the Channel
+ */
+ public String getChannelId() {
+ return channelId;
+ }
+
+ /**
+ * @return The label for the Channel
+ */
+ public String getLabel() {
+ return label;
+ }
+
+ /**
+ * @return The description for the Channel
+ */
+ public String getDescription() {
+ return description;
+ }
+
+ /**
+ * @return The item type for the Channel
+ */
+ public String getItemType() {
+ return itemType;
+ }
+
+ /**
+ * @return The channel type for the Channel
+ */
+ public String getChannelType() {
+ return channelType;
+ }
+
+ /**
+ * Returns the UPnP Channel enum for the given channel id or null if there is no enum available for the given
+ * channel.
+ *
+ * @param channelId Channel to find
+ * @return The UPnP Channel enum or null if there is none.
+ */
+ public static @Nullable UpnpChannelName channelIdToUpnpChannelName(final String channelId) {
+ return UPNP_CHANNEL_NAME_MAP.get(channelId);
+ }
+}
*/
package org.openhab.binding.upnpcontrol.internal;
+import java.io.File;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.OpenHAB;
+import org.openhab.core.thing.DefaultSystemChannelTypeProvider;
import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.type.ChannelTypeUID;
/**
* The {@link UpnpControlBindingConstants} class defines common constants, which are
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Stream.of(THING_TYPE_RENDERER, THING_TYPE_SERVER)
.collect(Collectors.toSet());
- // List of thing parameter names
- public static final String HOST_PARAMETER = "ipAddress";
- public static final String TCP_PORT_PARAMETER = "port";
+ // Binding config parameters
+ public static final String PATH = "path";
+
+ // Thing config parameters
public static final String UDN_PARAMETER = "udn";
- public static final String REFRESH_INTERVAL = "refreshInterval";
+ public static final String REFRESH_INTERVAL = "refresh";
+ public static final String RESPONSE_TIMEOUT = "responsetimeout";
+ // Server thing only config parameters
+ public static final String CONFIG_FILTER = "filter";
+ public static final String SORT_CRITERIA = "sortcriteria";
+ public static final String BROWSE_DOWN = "browsedown";
+ public static final String SEARCH_FROM_ROOT = "searchfromroot";
+ // Renderer thing only config parameters
+ public static final String NOTIFICATION_VOLUME_ADJUSTMENT = "notificationvolumeadjustment";
+ public static final String MAX_NOTIFICATION_DURATION = "maxnotificationduration";
+ public static final String SEEK_STEP = "seekstep";
// List of all Channel ids
public static final String VOLUME = "volume";
public static final String MUTE = "mute";
public static final String CONTROL = "control";
public static final String STOP = "stop";
+ public static final String REPEAT = "repeat";
+ public static final String SHUFFLE = "shuffle";
+ public static final String ONLY_PLAY_ONE = "onlyplayone";
+ public static final String URI = "uri";
+ public static final String FAVORITE_SELECT = "favoriteselect";
+ public static final String FAVORITE = "favorite";
+ public static final String FAVORITE_ACTION = "favoriteaction";
public static final String TITLE = "title";
public static final String ALBUM = "album";
public static final String ALBUM_ART = "albumart";
public static final String TRACK_NUMBER = "tracknumber";
public static final String TRACK_DURATION = "trackduration";
public static final String TRACK_POSITION = "trackposition";
+ public static final String REL_TRACK_POSITION = "reltrackposition";
public static final String UPNPRENDERER = "upnprenderer";
- public static final String CURRENTID = "currentid";
+ public static final String CURRENTTITLE = "currenttitle";
public static final String BROWSE = "browse";
public static final String SEARCH = "search";
public static final String SERVE = "serve";
+ public static final String PLAYLIST_SELECT = "playlistselect";
+ public static final String PLAYLIST = "playlist";
+ public static final String PLAYLIST_ACTION = "playlistaction";
- // Thing config properties
- public static final String CONFIG_FILTER = "filter";
- public static final String SORT_CRITERIA = "sortcriteria";
+ // Type constants for dynamic renderer channels
+ public static final String CHANNEL_TYPE_VOLUME = DefaultSystemChannelTypeProvider.SYSTEM_VOLUME.toString();
+ public static final String CHANNEL_TYPE_MUTE = DefaultSystemChannelTypeProvider.SYSTEM_MUTE.toString();
+ public static final String CHANNEL_TYPE_LOUDNESS = (new ChannelTypeUID(BINDING_ID, "loudness")).toString();
+
+ public static final String ITEM_TYPE_VOLUME = "Dimmer";
+ public static final String ITEM_TYPE_MUTE = "Switch";
+ public static final String ITEM_TYPE_LOUDNESS = "Switch";
+
+ // Command options for playlist and favorite actions
+ public static final String RESTORE = "RESTORE";
+ public static final String SAVE = "SAVE";
+ public static final String APPEND = "APPEND";
+ public static final String DELETE = "DELETE";
+
+ // Channels that are duplicated on server to control current renderer
+ public static final Set<String> SERVER_CONTROL_CHANNELS = Set.of(VOLUME, MUTE, CONTROL, STOP);
+
+ // Master volume and mute identifier
+ public static final String UPNP_MASTER = "Master";
+
+ // Filepath and extension defaults and constants for playlists and favorites
+ public static final String DEFAULT_PATH = OpenHAB.getUserDataFolder() + File.separator + BINDING_ID
+ + File.separator;
+ public static final String PLAYLIST_FILE_EXTENSION = ".lst";
+ public static final String FAVORITE_FILE_EXTENSION = ".fav";
+
+ // Notification audio sink name extension
+ public static final String NOTIFICATION_AUDIOSINK_EXTENSION = "-notify";
}
import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
import java.util.Hashtable;
+import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
+import org.jupnp.UpnpService;
+import org.jupnp.model.meta.LocalDevice;
+import org.jupnp.model.meta.RemoteDevice;
+import org.jupnp.registry.Registry;
+import org.jupnp.registry.RegistryListener;
+import org.openhab.binding.upnpcontrol.internal.audiosink.UpnpAudioSink;
+import org.openhab.binding.upnpcontrol.internal.audiosink.UpnpAudioSinkReg;
+import org.openhab.binding.upnpcontrol.internal.audiosink.UpnpNotificationAudioSink;
+import org.openhab.binding.upnpcontrol.internal.config.UpnpControlBindingConfiguration;
+import org.openhab.binding.upnpcontrol.internal.handler.UpnpHandler;
import org.openhab.binding.upnpcontrol.internal.handler.UpnpRendererHandler;
import org.openhab.binding.upnpcontrol.internal.handler.UpnpServerHandler;
import org.openhab.core.audio.AudioHTTPServer;
import org.openhab.core.audio.AudioSink;
+import org.openhab.core.config.core.Configuration;
import org.openhab.core.io.transport.upnp.UpnpIOService;
import org.openhab.core.net.HttpServiceUtil;
import org.openhab.core.net.NetworkAddressService;
import org.osgi.framework.ServiceRegistration;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Deactivate;
+import org.osgi.service.component.annotations.Modified;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
*/
@Component(service = ThingHandlerFactory.class, configurationPid = "binding.upnpcontrol")
@NonNullByDefault
-public class UpnpControlHandlerFactory extends BaseThingHandlerFactory implements UpnpAudioSinkReg {
+public class UpnpControlHandlerFactory extends BaseThingHandlerFactory implements UpnpAudioSinkReg, RegistryListener {
+ final UpnpControlBindingConfiguration configuration = new UpnpControlBindingConfiguration();
- private final Logger logger = LoggerFactory.getLogger(getClass());
+ private final Logger logger = LoggerFactory.getLogger(UpnpControlHandlerFactory.class);
private ConcurrentMap<String, ServiceRegistration<AudioSink>> audioSinkRegistrations = new ConcurrentHashMap<>();
private ConcurrentMap<String, UpnpRendererHandler> upnpRenderers = new ConcurrentHashMap<>();
private ConcurrentMap<String, UpnpServerHandler> upnpServers = new ConcurrentHashMap<>();
+ private ConcurrentMap<String, UpnpHandler> handlers = new ConcurrentHashMap<>();
+ private ConcurrentMap<String, RemoteDevice> devices = new ConcurrentHashMap<>();
private final UpnpIOService upnpIOService;
+ private final UpnpService upnpService;
private final AudioHTTPServer audioHTTPServer;
private final NetworkAddressService networkAddressService;
private final UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider;
private String callbackUrl = "";
@Activate
- public UpnpControlHandlerFactory(final @Reference UpnpIOService upnpIOService,
+ public UpnpControlHandlerFactory(final @Reference UpnpIOService upnpIOService, @Reference UpnpService upnpService,
final @Reference AudioHTTPServer audioHTTPServer,
final @Reference NetworkAddressService networkAddressService,
final @Reference UpnpDynamicStateDescriptionProvider dynamicStateDescriptionProvider,
- final @Reference UpnpDynamicCommandDescriptionProvider dynamicCommandDescriptionProvider) {
+ final @Reference UpnpDynamicCommandDescriptionProvider dynamicCommandDescriptionProvider,
+ Map<String, Object> config) {
this.upnpIOService = upnpIOService;
+ this.upnpService = upnpService;
this.audioHTTPServer = audioHTTPServer;
this.networkAddressService = networkAddressService;
this.upnpStateDescriptionProvider = dynamicStateDescriptionProvider;
this.upnpCommandDescriptionProvider = dynamicCommandDescriptionProvider;
+
+ upnpService.getRegistry().addListener(this);
+
+ modified(config);
+ }
+
+ @Modified
+ protected void modified(Map<String, Object> config) {
+ // We update instead of replace the configuration object, so that if the user updates the
+ // configuration, the values are automatically available in all handlers. Because they all
+ // share the same instance.
+ configuration.update(new Configuration(config).as(UpnpControlBindingConfiguration.class));
+ logger.debug("Updated binding configuration to {}", configuration);
+ }
+
+ @Deactivate
+ protected void deActivate() {
+ upnpService.getRegistry().removeListener(this);
}
@Override
private UpnpServerHandler addServer(Thing thing) {
UpnpServerHandler handler = new UpnpServerHandler(thing, upnpIOService, upnpRenderers,
- upnpStateDescriptionProvider, upnpCommandDescriptionProvider);
+ upnpStateDescriptionProvider, upnpCommandDescriptionProvider, configuration);
String key = thing.getUID().toString();
upnpServers.put(key, handler);
- logger.debug("Media server handler created for {}", thing.getLabel());
+ logger.debug("Media server handler created for {} with UID {}", thing.getLabel(), thing.getUID());
+
+ String udn = handler.getUDN();
+ if (udn != null) {
+ handlers.put(udn, handler);
+ remoteDeviceUpdated(null, devices.get(udn));
+ }
+
return handler;
}
private UpnpRendererHandler addRenderer(Thing thing) {
callbackUrl = createCallbackUrl();
- UpnpRendererHandler handler = new UpnpRendererHandler(thing, upnpIOService, this);
+ UpnpRendererHandler handler = new UpnpRendererHandler(thing, upnpIOService, this, upnpStateDescriptionProvider,
+ upnpCommandDescriptionProvider, configuration);
String key = thing.getUID().toString();
upnpRenderers.put(key, handler);
upnpServers.forEach((thingId, value) -> value.addRendererOption(key));
- logger.debug("Media renderer handler created for {}", thing.getLabel());
+ logger.debug("Media renderer handler created for {} with UID {}", thing.getLabel(), thing.getUID());
+
+ String udn = handler.getUDN();
+ if (udn != null) {
+ handlers.put(udn, handler);
+ remoteDeviceUpdated(null, devices.get(udn));
+ }
return handler;
}
private void removeServer(String key) {
- logger.debug("Removing media server handler for {}", upnpServers.get(key).getThing().getLabel());
+ UpnpHandler handler = upnpServers.get(key);
+ if (handler == null) {
+ return;
+ }
+ logger.debug("Removing media server handler for {} with UID {}", handler.getThing().getLabel(),
+ handler.getThing().getUID());
+ handlers.remove(handler.getUDN());
upnpServers.remove(key);
}
private void removeRenderer(String key) {
- logger.debug("Removing media renderer handler for {}", upnpRenderers.get(key).getThing().getLabel());
+ UpnpHandler handler = upnpServers.get(key);
+ if (handler == null) {
+ return;
+ }
+ logger.debug("Removing media renderer handler for {} with UID {}", handler.getThing().getLabel(),
+ handler.getThing().getUID());
+
if (audioSinkRegistrations.containsKey(key)) {
- logger.debug("Removing audio sink registration for {}", upnpRenderers.get(key).getThing().getLabel());
+ logger.debug("Removing audio sink registration for {}", handler.getThing().getLabel());
ServiceRegistration<AudioSink> reg = audioSinkRegistrations.get(key);
- reg.unregister();
+ if (reg != null) {
+ reg.unregister();
+ }
audioSinkRegistrations.remove(key);
}
+
+ String notificationKey = key + NOTIFICATION_AUDIOSINK_EXTENSION;
+ if (audioSinkRegistrations.containsKey(notificationKey)) {
+ logger.debug("Removing notification audio sink registration for {}", handler.getThing().getLabel());
+ ServiceRegistration<AudioSink> reg = audioSinkRegistrations.get(notificationKey);
+ if (reg != null) {
+ reg.unregister();
+ }
+ audioSinkRegistrations.remove(notificationKey);
+ }
+
upnpServers.forEach((thingId, value) -> value.removeRendererOption(key));
+ handlers.remove(handler.getUDN());
upnpRenderers.remove(key);
}
Thing thing = handler.getThing();
audioSinkRegistrations.put(thing.getUID().toString(), reg);
logger.debug("Audio sink added for media renderer {}", thing.getLabel());
+
+ UpnpNotificationAudioSink notificationAudioSink = new UpnpNotificationAudioSink(handler, audioHTTPServer,
+ callbackUrl);
+ @SuppressWarnings("unchecked")
+ ServiceRegistration<AudioSink> notificationReg = (ServiceRegistration<AudioSink>) bundleContext
+ .registerService(AudioSink.class.getName(), notificationAudioSink, new Hashtable<String, Object>());
+ audioSinkRegistrations.put(thing.getUID().toString() + NOTIFICATION_AUDIOSINK_EXTENSION, notificationReg);
+ logger.debug("Notification audio sink added for media renderer {}", thing.getLabel());
}
}
}
return "http://" + ipAddress + ":" + port;
}
+
+ @Override
+ public void remoteDeviceDiscoveryStarted(@Nullable Registry registry, @Nullable RemoteDevice device) {
+ }
+
+ @Override
+ public void remoteDeviceDiscoveryFailed(@Nullable Registry registry, @Nullable RemoteDevice device,
+ @Nullable Exception ex) {
+ }
+
+ @Override
+ public void remoteDeviceAdded(@Nullable Registry registry, @Nullable RemoteDevice device) {
+ if (device == null) {
+ return;
+ }
+
+ String udn = device.getIdentity().getUdn().getIdentifierString();
+ if ("MediaServer".equals(device.getType().getType()) || "MediaRenderer".equals(device.getType().getType())) {
+ devices.put(udn, device);
+ }
+
+ if (handlers.containsKey(udn)) {
+ remoteDeviceUpdated(registry, device);
+ }
+ }
+
+ @Override
+ public void remoteDeviceUpdated(@Nullable Registry registry, @Nullable RemoteDevice device) {
+ if (device == null) {
+ return;
+ }
+
+ String udn = device.getIdentity().getUdn().getIdentifierString();
+ UpnpHandler handler = handlers.get(udn);
+ if (handler != null) {
+ handler.updateDeviceConfig(device);
+ }
+ }
+
+ @Override
+ public void remoteDeviceRemoved(@Nullable Registry registry, @Nullable RemoteDevice device) {
+ if (device == null) {
+ return;
+ }
+ devices.remove(device.getIdentity().getUdn().getIdentifierString());
+ }
+
+ @Override
+ public void localDeviceAdded(@Nullable Registry registry, @Nullable LocalDevice device) {
+ }
+
+ @Override
+ public void localDeviceRemoved(@Nullable Registry registry, @Nullable LocalDevice device) {
+ }
+
+ @Override
+ public void beforeShutdown(@Nullable Registry registry) {
+ devices = new ConcurrentHashMap<>();
+ }
+
+ @Override
+ public void afterShutdown() {
+ }
}
+++ /dev/null
-/**
- * Copyright (c) 2010-2020 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.upnpcontrol.internal;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import java.util.stream.Collectors;
-
-import org.apache.commons.lang.StringEscapeUtils;
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-
-/**
- *
- * @author Mark Herwege - Initial contribution
- * @author Karel Goderis - Based on UPnP logic in Sonos binding
- */
-@NonNullByDefault
-public class UpnpEntry {
-
- private static final String DIRECTORY_ROOT = "0";
-
- private static final Pattern CONTAINER_PATTERN = Pattern.compile("object.container");
-
- private String id;
- private String refId;
- private String parentId;
- private String upnpClass;
- private String title = "";
- private List<UpnpEntryRes> resList = new ArrayList<>();
- private String album = "";
- private String albumArtUri = "";
- private String creator = "";
- private String artist = "";
- private String publisher = "";
- private String genre = "";
- private @Nullable Integer originalTrackNumber;
-
- private boolean isContainer;
-
- public UpnpEntry(String id, String refId, String parentId, String upnpClass) {
- this.id = id;
- this.refId = refId;
- this.parentId = parentId;
- this.upnpClass = upnpClass;
-
- Matcher matcher = CONTAINER_PATTERN.matcher(upnpClass);
- isContainer = matcher.find();
- }
-
- public UpnpEntry withTitle(String title) {
- this.title = title;
- return this;
- }
-
- public UpnpEntry withAlbum(String album) {
- this.album = album;
- return this;
- }
-
- public UpnpEntry withAlbumArtUri(String albumArtUri) {
- this.albumArtUri = albumArtUri;
- return this;
- }
-
- public UpnpEntry withCreator(String creator) {
- this.creator = creator;
- return this;
- }
-
- public UpnpEntry withArtist(String artist) {
- this.artist = artist;
- return this;
- }
-
- public UpnpEntry withPublisher(String publisher) {
- this.publisher = publisher;
- return this;
- }
-
- public UpnpEntry withGenre(String genre) {
- this.genre = genre;
- return this;
- }
-
- public UpnpEntry withResList(List<UpnpEntryRes> resList) {
- this.resList = resList;
- return this;
- }
-
- public UpnpEntry withTrackNumber(@Nullable Integer originalTrackNumber) {
- this.originalTrackNumber = originalTrackNumber;
- return this;
- }
-
- /**
- * @return the title of the entry.
- */
- @Override
- public String toString() {
- return title;
- }
-
- /**
- * @return the unique identifier of this entry.
- */
- public String getId() {
- return id;
- }
-
- /**
- * @return the title of the entry.
- */
- public String getTitle() {
- return title;
- }
-
- /**
- * @return the identifier of the entry this reference intry refers to.
- */
- public String getRefId() {
- return refId;
- }
-
- /**
- * @return the unique identifier of the parent of this entry.
- */
- public String getParentId() {
- return parentId.isEmpty() ? DIRECTORY_ROOT : parentId;
- }
-
- /**
- * @return a URI for this entry. Thumbnail resources are not considered.
- */
- public String getRes() {
- return resList.stream().filter(res -> !res.isThumbnailRes()).map(UpnpEntryRes::getRes).findAny().orElse("");
- }
-
- public List<String> getProtocolList() {
- return resList.stream().map(UpnpEntryRes::getProtocolInfo).collect(Collectors.toList());
- }
-
- /**
- * @return the UPnP classname for this entry.
- */
- public String getUpnpClass() {
- return upnpClass;
- }
-
- public boolean isContainer() {
- return isContainer;
- }
-
- /**
- * @return the name of the album.
- */
- public String getAlbum() {
- return album;
- }
-
- /**
- * @return the URI for the album art.
- */
- public String getAlbumArtUri() {
- return StringEscapeUtils.unescapeXml(albumArtUri);
- }
-
- /**
- * @return the name of the artist who created the entry.
- */
- public String getCreator() {
- return creator;
- }
-
- public String getArtist() {
- return artist;
- }
-
- public String getPublisher() {
- return publisher;
- }
-
- public String getGenre() {
- return genre;
- }
-
- public @Nullable Integer getOriginalTrackNumber() {
- return originalTrackNumber;
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2020 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.upnpcontrol.internal;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-
-/**
- *
- * @author Mark Herwege - Initial contribution
- */
-@NonNullByDefault
-class UpnpEntryRes {
-
- private String protocolInfo;
- private @Nullable Long size;
- private String duration;
- private String importUri;
- private String res = "";
-
- UpnpEntryRes(String protocolInfo, @Nullable Long size, @Nullable String duration, @Nullable String importUri) {
- this.protocolInfo = protocolInfo;
- this.size = size;
- this.duration = (duration == null) ? "" : duration;
- this.importUri = (importUri == null) ? "" : importUri;
- }
-
- /**
- * @return the res
- */
- public String getRes() {
- return res;
- }
-
- /**
- * @param res the res to set
- */
- public void setRes(String res) {
- this.res = res;
- }
-
- public String getProtocolInfo() {
- return protocolInfo;
- }
-
- /**
- * @return the size
- */
- public @Nullable Long getSize() {
- return size;
- }
-
- /**
- * @return the duration
- */
- public String getDuration() {
- return duration;
- }
-
- /**
- * @return the importUri
- */
- public String getImportUri() {
- return importUri;
- }
-
- /**
- * @return true if this resource defines a thumbnail as specified in the DLNA specs
- */
- public boolean isThumbnailRes() {
- return getProtocolInfo().toLowerCase().contains("dlna.org_pn=jpeg_tn");
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2020 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.upnpcontrol.internal;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.stream.Collectors;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- *
- * @author Mark Herwege - Initial contribution
- */
-@NonNullByDefault
-public final class UpnpProtocolMatcher {
-
- private static final Logger LOGGER = LoggerFactory.getLogger(UpnpProtocolMatcher.class);
-
- private UpnpProtocolMatcher() {
- }
-
- /**
- * Test if an UPnP protocol matches the object class. This method is used to filter resources for the primary
- * resource.
- *
- * @param protocol format: <protocol>:<network>:<contentFormat>:<additionalInfo>
- * e.g. http-get:*:audio/mpeg:*
- * @param objectClass e.g. object.item.audioItem.musicTrack
- * @return true if protocol matches objectClass
- */
- public static boolean testProtocol(String protocol, String objectClass) {
- String[] protocolDetails = protocol.split(":");
- if (protocolDetails.length < 3) {
- LOGGER.debug("Protocol string {} not valid", protocol);
- return false;
- }
- String protocolType = protocolDetails[2].toLowerCase();
- int index = protocolType.indexOf("/");
- if (index <= 0) {
- LOGGER.debug("Protocol string {} not valid", protocol);
- return false;
- }
- protocolType = protocolType.substring(0, index);
-
- String[] objectClassDetails = objectClass.split("\\.");
- if (objectClassDetails.length < 3) {
- LOGGER.debug("Object class {} not valid", objectClass);
- return false;
- }
- String objectType = objectClassDetails[2].toLowerCase();
-
- LOGGER.debug("Matching protocol type '{}' with object type '{}'", protocolType, objectType);
- return objectType.startsWith(protocolType);
- }
-
- /**
- * Test if a UPnP protocol is in a set of protocols.
- * Ignore vendor specific additionalInfo part in UPnP protocol string.
- * Do all comparisons in lower case.
- *
- * @param protocol format: <protocol>:<network>:<contentFormat>:<additionalInfo>
- * @param protocolSet
- * @return true if protocol in protocolSet
- */
- public static boolean testProtocol(String protocol, List<String> protocolSet) {
- int index = protocol.lastIndexOf(":");
- if (index <= 0) {
- LOGGER.debug("Protocol {} not valid", protocol);
- return false;
- }
- String p = protocol.toLowerCase().substring(0, index);
- List<String> pSet = new ArrayList<>();
- protocolSet.forEach(f -> {
- int i = f.lastIndexOf(":");
- if (i <= 0) {
- LOGGER.debug("Protocol {} from set not valid", f);
- } else {
- pSet.add(f.toLowerCase().substring(0, i));
- }
- });
- LOGGER.trace("Testing {} in {}", p, pSet);
- return pSet.contains(p);
- }
-
- /**
- * Test if any of the UPnP protocols in protocolList can be found in a set of protocols.
- *
- * @param protocolList
- * @param protocolSet
- * @return true if one of the protocols in protocolSet
- */
- public static boolean testProtocolList(List<String> protocolList, List<String> protocolSet) {
- return protocolList.stream().anyMatch(p -> testProtocol(p, protocolSet));
- }
-
- /**
- * Return all UPnP protocols from protocolList that are part of a set of protocols.
- *
- * @param protocolList
- * @param protocolSet
- * @return sublist of protocolList
- */
- public static List<String> getProtocols(List<String> protocolList, List<String> protocolSet) {
- return protocolList.stream().filter(p -> testProtocol(p, protocolSet)).collect(Collectors.toList());
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2020 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.upnpcontrol.internal;
-
-import java.io.IOException;
-import java.io.StringReader;
-import java.text.MessageFormat;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-import javax.xml.parsers.ParserConfigurationException;
-import javax.xml.parsers.SAXParser;
-import javax.xml.parsers.SAXParserFactory;
-
-import org.apache.commons.lang.StringEscapeUtils;
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.xml.sax.Attributes;
-import org.xml.sax.InputSource;
-import org.xml.sax.SAXException;
-import org.xml.sax.helpers.DefaultHandler;
-
-/**
- *
- * @author Mark Herwege - Initial contribution
- * @author Karel Goderis - Based on UPnP logic in Sonos binding
- */
-@NonNullByDefault
-public class UpnpXMLParser {
-
- private static final Logger LOGGER = LoggerFactory.getLogger(UpnpXMLParser.class);
-
- private static final MessageFormat METADATA_FORMAT = new MessageFormat(
- "<DIDL-Lite xmlns:dc=\"http://purl.org/dc/elements/1.1/\" "
- + "xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" "
- + "xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\">"
- + "<item id=\"{0}\" parentID=\"{1}\" restricted=\"true\">" + "<dc:title>{2}</dc:title>"
- + "<upnp:class>{3}</upnp:class>" + "<upnp:album>{4}</upnp:album>"
- + "<upnp:albumArtURI>{5}</upnp:albumArtURI>" + "<dc:creator>{6}</dc:creator>"
- + "<upnp:artist>{7}</upnp:artist>" + "<dc:publisher>{8}</dc:publisher>"
- + "<upnp:genre>{9}</upnp:genre>" + "<upnp:originalTrackNumber>{10}</upnp:originalTrackNumber>"
- + "</item></DIDL-Lite>");
-
- private enum Element {
- TITLE,
- CLASS,
- ALBUM,
- ALBUM_ART_URI,
- CREATOR,
- ARTIST,
- PUBLISHER,
- GENRE,
- TRACK_NUMBER,
- RES
- }
-
- public static Map<String, String> getAVTransportFromXML(String xml) {
- if (xml.isEmpty()) {
- LOGGER.debug("Could not parse AV Transport from empty xml");
- return Collections.emptyMap();
- }
- AVTransportEventHandler handler = new AVTransportEventHandler();
- try {
- SAXParserFactory factory = SAXParserFactory.newInstance();
- SAXParser saxParser = factory.newSAXParser();
- saxParser.parse(new InputSource(new StringReader(xml)), handler);
- } catch (IOException e) {
- // This should never happen - we're not performing I/O!
- LOGGER.error("Could not parse AV Transport from string '{}'", xml, e);
- } catch (SAXException | ParserConfigurationException s) {
- LOGGER.debug("Could not parse AV Transport from string '{}'", xml, s);
- }
- return handler.getChanges();
- }
-
- /**
- * @param xml
- * @return a list of Entries from the given xml string.
- * @throws IOException
- * @throws SAXException
- */
- public static List<UpnpEntry> getEntriesFromXML(String xml) {
- if (xml.isEmpty()) {
- LOGGER.debug("Could not parse Entries from empty xml");
- return Collections.emptyList();
- }
- EntryHandler handler = new EntryHandler();
- try {
- SAXParserFactory factory = SAXParserFactory.newInstance();
- SAXParser saxParser = factory.newSAXParser();
- saxParser.parse(new InputSource(new StringReader(xml)), handler);
- } catch (IOException e) {
- // This should never happen - we're not performing I/O!
- LOGGER.error("Could not parse Entries from string '{}'", xml, e);
- } catch (SAXException | ParserConfigurationException s) {
- LOGGER.debug("Could not parse Entries from string '{}'", xml, s);
- }
- return handler.getEntries();
- }
-
- private static class AVTransportEventHandler extends DefaultHandler {
-
- private final Map<String, String> changes = new HashMap<String, String>();
-
- AVTransportEventHandler() {
- // shouldn't be used outside of this package.
- }
-
- @Override
- public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
- @Nullable Attributes atts) throws SAXException {
- /*
- * The events are all of the form <qName val="value"/> so we can get all
- * the info we need from here.
- */
- if ((qName != null) && (atts != null) && (atts.getValue("val") != null)) {
- changes.put(qName, atts.getValue("val"));
- }
- }
-
- public Map<String, String> getChanges() {
- return changes;
- }
- }
-
- private static class EntryHandler extends DefaultHandler {
-
- // Maintain a set of elements it is not useful to complain about.
- // This list will be initialized on the first failure case.
- private static List<String> ignore = new ArrayList<String>();
-
- private String id = "";
- private String refId = "";
- private String parentId = "";
- private StringBuilder upnpClass = new StringBuilder();
- private List<UpnpEntryRes> resList = new ArrayList<>();
- private StringBuilder res = new StringBuilder();
- private StringBuilder title = new StringBuilder();
- private StringBuilder album = new StringBuilder();
- private StringBuilder albumArtUri = new StringBuilder();
- private StringBuilder creator = new StringBuilder();
- private StringBuilder artist = new StringBuilder();
- private List<String> artistList = new ArrayList<>();
- private StringBuilder publisher = new StringBuilder();
- private StringBuilder genre = new StringBuilder();
- private StringBuilder trackNumber = new StringBuilder();
- private @Nullable Element element = null;
-
- private List<UpnpEntry> entries = new ArrayList<>();
-
- EntryHandler() {
- // shouldn't be used outside of this package.
- }
-
- @Override
- public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
- @Nullable Attributes attributes) throws SAXException {
- if (qName == null) {
- element = null;
- return;
- }
- switch (qName) {
- case "container":
- case "item":
- if (attributes != null) {
- if (attributes.getValue("id") != null) {
- id = attributes.getValue("id");
- }
- if (attributes.getValue("refID") != null) {
- refId = attributes.getValue("refID");
- }
- if (attributes.getValue("parentID") != null) {
- parentId = attributes.getValue("parentID");
- }
- }
- break;
- case "res":
- if (attributes != null) {
- String protocolInfo = attributes.getValue("protocolInfo");
- Long size;
- try {
- size = Long.parseLong(attributes.getValue("size"));
- } catch (NumberFormatException e) {
- size = null;
- }
- String duration = attributes.getValue("duration");
- String importUri = attributes.getValue("importUri");
- resList.add(0, new UpnpEntryRes(protocolInfo, size, duration, importUri));
- element = Element.RES;
- }
- break;
- case "dc:title":
- element = Element.TITLE;
- break;
- case "upnp:class":
- element = Element.CLASS;
- break;
- case "dc:creator":
- element = Element.CREATOR;
- break;
- case "upnp:artist":
- element = Element.ARTIST;
- break;
- case "dc:publisher":
- element = Element.PUBLISHER;
- break;
- case "upnp:genre":
- element = Element.GENRE;
- break;
- case "upnp:album":
- element = Element.ALBUM;
- break;
- case "upnp:albumArtURI":
- element = Element.ALBUM_ART_URI;
- break;
- case "upnp:originalTrackNumber":
- element = Element.TRACK_NUMBER;
- break;
- default:
- if (ignore.isEmpty()) {
- ignore.add("");
- ignore.add("DIDL-Lite");
- ignore.add("type");
- ignore.add("ordinal");
- ignore.add("description");
- ignore.add("writeStatus");
- ignore.add("storageUsed");
- ignore.add("supported");
- ignore.add("pushSource");
- ignore.add("icon");
- ignore.add("playlist");
- ignore.add("date");
- ignore.add("rating");
- ignore.add("userrating");
- ignore.add("episodeSeason");
- ignore.add("childCountContainer");
- ignore.add("modificationTime");
- ignore.add("containerContent");
- }
- if (!ignore.contains(localName)) {
- LOGGER.debug("Did not recognise element named {}", localName);
- }
- element = null;
- }
- }
-
- @Override
- public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
- Element el = element;
- if (el == null || ch == null) {
- return;
- }
- switch (el) {
- case TITLE:
- title.append(ch, start, length);
- break;
- case CLASS:
- upnpClass.append(ch, start, length);
- break;
- case RES:
- res.append(ch, start, length);
- break;
- case ALBUM:
- album.append(ch, start, length);
- break;
- case ALBUM_ART_URI:
- albumArtUri.append(ch, start, length);
- break;
- case CREATOR:
- creator.append(ch, start, length);
- break;
- case ARTIST:
- artist.append(ch, start, length);
- break;
- case PUBLISHER:
- publisher.append(ch, start, length);
- break;
- case GENRE:
- genre.append(ch, start, length);
- break;
- case TRACK_NUMBER:
- trackNumber.append(ch, start, length);
- break;
- }
- }
-
- @Override
- public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
- throws SAXException {
- if ("container".equals(qName) || "item".equals(qName)) {
- element = null;
-
- Integer trackNumberVal;
- try {
- trackNumberVal = Integer.parseInt(trackNumber.toString());
- } catch (NumberFormatException e) {
- trackNumberVal = null;
- }
-
- entries.add(new UpnpEntry(id, refId, parentId, upnpClass.toString()).withTitle(title.toString())
- .withAlbum(album.toString()).withAlbumArtUri(albumArtUri.toString())
- .withCreator(creator.toString())
- .withArtist(artistList.size() > 0 ? artistList.get(0) : artist.toString())
- .withPublisher(publisher.toString()).withGenre(genre.toString()).withTrackNumber(trackNumberVal)
- .withResList(resList));
-
- title = new StringBuilder();
- upnpClass = new StringBuilder();
- resList = new ArrayList<>();
- album = new StringBuilder();
- albumArtUri = new StringBuilder();
- creator = new StringBuilder();
- artistList = new ArrayList<>();
- publisher = new StringBuilder();
- genre = new StringBuilder();
- trackNumber = new StringBuilder();
- } else if ("res".equals(qName)) {
- resList.get(0).setRes(res.toString());
- res = new StringBuilder();
- } else if ("upnp:artist".equals(qName)) {
- artistList.add(artist.toString());
- artist = new StringBuilder();
- }
- }
-
- public List<UpnpEntry> getEntries() {
- return entries;
- }
- }
-
- public static String compileMetadataString(UpnpEntry entry) {
- String id = entry.getId();
- String parentId = entry.getParentId();
- String title = StringEscapeUtils.escapeXml(entry.getTitle());
- String upnpClass = entry.getUpnpClass();
- String album = StringEscapeUtils.escapeXml(entry.getAlbum());
- String albumArtUri = entry.getAlbumArtUri();
- String creator = StringEscapeUtils.escapeXml(entry.getCreator());
- String artist = StringEscapeUtils.escapeXml(entry.getArtist());
- String publisher = StringEscapeUtils.escapeXml(entry.getPublisher());
- String genre = StringEscapeUtils.escapeXml(entry.getGenre());
- Integer trackNumber = entry.getOriginalTrackNumber();
-
- String metadata = METADATA_FORMAT.format(new Object[] { id, parentId, title, upnpClass, album, albumArtUri,
- creator, artist, publisher, genre, trackNumber });
-
- return metadata;
- }
-}
--- /dev/null
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.audiosink;
+
+import java.io.IOException;
+import java.util.Locale;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.upnpcontrol.internal.handler.UpnpRendererHandler;
+import org.openhab.core.audio.AudioFormat;
+import org.openhab.core.audio.AudioHTTPServer;
+import org.openhab.core.audio.AudioSink;
+import org.openhab.core.audio.AudioStream;
+import org.openhab.core.audio.FixedLengthAudioStream;
+import org.openhab.core.audio.URLAudioStream;
+import org.openhab.core.audio.UnsupportedAudioFormatException;
+import org.openhab.core.audio.UnsupportedAudioStreamException;
+import org.openhab.core.library.types.PercentType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@NonNullByDefault
+public class UpnpAudioSink implements AudioSink {
+
+ private final Logger logger = LoggerFactory.getLogger(UpnpAudioSink.class);
+
+ private static final Set<Class<? extends AudioStream>> SUPPORTED_STREAMS = Stream
+ .of(AudioStream.class, FixedLengthAudioStream.class).collect(Collectors.toSet());
+ protected UpnpRendererHandler handler;
+ protected AudioHTTPServer audioHTTPServer;
+ protected String callbackUrl;
+
+ public UpnpAudioSink(UpnpRendererHandler handler, AudioHTTPServer audioHTTPServer, String callbackUrl) {
+ this.handler = handler;
+ this.audioHTTPServer = audioHTTPServer;
+ this.callbackUrl = callbackUrl;
+ }
+
+ @Override
+ public String getId() {
+ return handler.getThing().getUID().toString();
+ }
+
+ @Override
+ public @Nullable String getLabel(@Nullable Locale locale) {
+ return handler.getThing().getLabel();
+ }
+
+ @Override
+ public void process(@Nullable AudioStream audioStream)
+ throws UnsupportedAudioFormatException, UnsupportedAudioStreamException {
+ if (audioStream == null) {
+ stopMedia();
+ return;
+ }
+
+ String url = null;
+ if (audioStream instanceof URLAudioStream) {
+ URLAudioStream urlAudioStream = (URLAudioStream) audioStream;
+ url = urlAudioStream.getURL();
+ } else if (!callbackUrl.isEmpty()) {
+ String relativeUrl = audioStream instanceof FixedLengthAudioStream
+ ? audioHTTPServer.serve((FixedLengthAudioStream) audioStream, 20)
+ : audioHTTPServer.serve(audioStream);
+ url = String.valueOf(this.callbackUrl) + relativeUrl;
+ } else {
+ logger.warn("We do not have any callback url, so {} cannot play the audio stream!", handler.getUDN());
+ return;
+ }
+ playMedia(url);
+ }
+
+ @Override
+ public Set<AudioFormat> getSupportedFormats() {
+ return handler.getSupportedAudioFormats();
+ }
+
+ @Override
+ public Set<Class<? extends AudioStream>> getSupportedStreams() {
+ return SUPPORTED_STREAMS;
+ }
+
+ @Override
+ public PercentType getVolume() throws IOException {
+ return handler.getCurrentVolume();
+ }
+
+ @Override
+ public void setVolume(@Nullable PercentType volume) throws IOException {
+ if (volume != null) {
+ handler.setVolume(volume);
+ }
+ }
+
+ protected void stopMedia() {
+ handler.stop();
+ }
+
+ protected void playMedia(String url) {
+ String newUrl = url;
+ if (!url.startsWith("x-") && !url.startsWith("http")) {
+ newUrl = "x-file-cifs:" + url;
+ }
+ handler.setCurrentURI(newUrl, "");
+ handler.play();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.audiosink;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.upnpcontrol.internal.UpnpControlHandlerFactory;
+import org.openhab.binding.upnpcontrol.internal.handler.UpnpRendererHandler;
+
+/**
+ * Interface class to be implemented in {@link UpnpControlHandlerFactory}, allows a {UpnpRendererHandler} to register
+ * itself as an audio sink when it supports audio. If it supports audio is only known after the communication with the
+ * renderer is established.
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@NonNullByDefault
+public interface UpnpAudioSinkReg {
+
+ /**
+ * Implemented method should create a new {@link UpnpAudioSink} and register the handler parameter as an audio sink.
+ *
+ * @param handler
+ */
+ void registerAudioSink(UpnpRendererHandler handler);
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.audiosink;
+
+import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.NOTIFICATION_AUDIOSINK_EXTENSION;
+
+import java.io.IOException;
+import java.util.Locale;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.upnpcontrol.internal.handler.UpnpRendererHandler;
+import org.openhab.core.audio.AudioHTTPServer;
+import org.openhab.core.library.types.PercentType;
+
+/**
+ *
+ * This class works as a standard audio sink for openHAB, but with specific behavior for the audio players. It is only
+ * meant to be used for playing notifications. When sending audio through this sink, the previously playing media will
+ * be interrupted and will automatically resume after playing the notification. If no volume is specified, the
+ * notification volume will be controlled by the media player notification volume configuration.
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@NonNullByDefault
+public class UpnpNotificationAudioSink extends UpnpAudioSink {
+
+ public UpnpNotificationAudioSink(UpnpRendererHandler handler, AudioHTTPServer audioHTTPServer, String callbackUrl) {
+ super(handler, audioHTTPServer, callbackUrl);
+ }
+
+ @Override
+ public String getId() {
+ return handler.getThing().getUID().toString() + NOTIFICATION_AUDIOSINK_EXTENSION;
+ }
+
+ @Override
+ public @Nullable String getLabel(@Nullable Locale locale) {
+ return handler.getThing().getLabel() + NOTIFICATION_AUDIOSINK_EXTENSION;
+ }
+
+ @Override
+ public void setVolume(@Nullable PercentType volume) throws IOException {
+ if (volume != null) {
+ handler.setNotificationVolume(volume);
+ }
+ }
+
+ @Override
+ protected void playMedia(String url) {
+ String newUrl = url;
+ if (!url.startsWith("x-") && !url.startsWith("http")) {
+ newUrl = "x-file-cifs:" + url;
+ }
+ handler.playNotification(newUrl);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.config;
+
+import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.DEFAULT_PATH;
+
+import java.io.File;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.upnpcontrol.internal.util.UpnpControlUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Class containing the binding configuration parameters. Some helper methods take care of updating the relevant classes
+ * with parameter changes.
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@NonNullByDefault
+public class UpnpControlBindingConfiguration {
+ private final Logger logger = LoggerFactory.getLogger(UpnpControlBindingConfiguration.class);
+
+ public String path = DEFAULT_PATH;
+
+ public void update(UpnpControlBindingConfiguration newConfig) {
+ String newPath = newConfig.path;
+
+ if (newPath.isEmpty()) {
+ path = DEFAULT_PATH;
+ } else {
+ File file = new File(newPath);
+ if (!file.isDirectory()) {
+ file = file.getParentFile();
+ }
+ if (file.exists()) {
+ if (!(newPath.endsWith(File.separator) || newPath.endsWith("/"))) {
+ newPath = newPath + File.separator;
+ }
+ path = newPath;
+ } else {
+ path = DEFAULT_PATH;
+ }
+ }
+
+ logger.debug("Storage path updated to {}", path);
+
+ UpnpControlUtil.bindingConfigurationChanged(path);
+ }
+}
@NonNullByDefault
public class UpnpControlConfiguration {
public @Nullable String udn;
+ public int refresh = 60;
+ public int responseTimeout = 2500;
}
--- /dev/null
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@NonNullByDefault
+public class UpnpControlRendererConfiguration extends UpnpControlConfiguration {
+ public int notificationVolumeAdjustment = 10;
+ public int maxNotificationDuration = 15;
+ public int seekStep = 5;
+}
@NonNullByDefault
public class UpnpControlServerConfiguration extends UpnpControlConfiguration {
public boolean filter = false;
- public String sortcriteria = "+dc:title";
+ public String sortCriteria = "+dc:title";
+ public boolean browseDown = true;
+ public boolean searchFromRoot = false;
}
import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
+import java.net.URL;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.jupnp.model.meta.RemoteDevice;
+import org.jupnp.model.meta.RemoteService;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.config.discovery.upnp.UpnpDiscoveryParticipant;
String label = device.getDetails().getFriendlyName().isEmpty() ? device.getDisplayString()
: device.getDetails().getFriendlyName();
Map<String, Object> properties = new HashMap<>();
- properties.put("ipAddress", device.getIdentity().getDescriptorURL().getHost());
+ URL descriptorURL = device.getIdentity().getDescriptorURL();
+ properties.put("ipAddress", descriptorURL.getHost());
properties.put("udn", device.getIdentity().getUdn().getIdentifierString());
+ properties.put("deviceDescrURL", descriptorURL.toString());
+ URL baseURL = device.getDetails().getBaseURL();
+ if (baseURL != null) {
+ properties.put("baseURL", device.getDetails().getBaseURL().toString());
+ }
+ for (RemoteService service : device.getServices()) {
+ properties.put(service.getServiceType().getType() + "DescrURI", service.getDescriptorURI().toString());
+ }
result = DiscoveryResultBuilder.create(thingUid).withLabel(label).withProperties(properties)
.withRepresentationProperty("udn").build();
}
String manufacturer = device.getDetails().getManufacturerDetails().getManufacturer();
String model = device.getDetails().getModelDetails().getModelName();
String serialNumber = device.getDetails().getSerialNumber();
+ String udn = device.getIdentity().getUdn().getIdentifierString();
- logger.debug("Device type {}, manufacturer {}, model {}, SN# {}", deviceType, manufacturer, model,
- serialNumber);
+ logger.debug("Device type {}, manufacturer {}, model {}, SN# {}, UDN {}", deviceType, manufacturer, model,
+ serialNumber, udn);
if (deviceType.equalsIgnoreCase("MediaRenderer")) {
this.logger.debug("Media renderer found: {}, {}", manufacturer, model);
*/
package org.openhab.binding.upnpcontrol.internal.handler;
+import java.util.ArrayList;
+import java.util.Collections;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
+import org.jupnp.model.meta.RemoteDevice;
+import org.jupnp.registry.RegistryListener;
+import org.openhab.binding.upnpcontrol.internal.UpnpChannelName;
+import org.openhab.binding.upnpcontrol.internal.UpnpDynamicCommandDescriptionProvider;
+import org.openhab.binding.upnpcontrol.internal.UpnpDynamicStateDescriptionProvider;
+import org.openhab.binding.upnpcontrol.internal.config.UpnpControlBindingConfiguration;
import org.openhab.binding.upnpcontrol.internal.config.UpnpControlConfiguration;
+import org.openhab.binding.upnpcontrol.internal.queue.UpnpPlaylistsListener;
+import org.openhab.binding.upnpcontrol.internal.util.UpnpControlUtil;
+import org.openhab.core.common.ThreadPoolManager;
import org.openhab.core.io.transport.upnp.UpnpIOParticipant;
import org.openhab.core.io.transport.upnp.UpnpIOService;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.thing.binding.builder.ChannelBuilder;
+import org.openhab.core.thing.binding.builder.ThingBuilder;
+import org.openhab.core.thing.type.ChannelTypeUID;
+import org.openhab.core.types.CommandDescription;
+import org.openhab.core.types.CommandDescriptionBuilder;
+import org.openhab.core.types.CommandOption;
+import org.openhab.core.types.StateDescription;
+import org.openhab.core.types.StateDescriptionFragmentBuilder;
+import org.openhab.core.types.StateOption;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
- * The {@link UpnpHandler} is the base class for {@link UpnpRendererHandler} and {@link UpnpServerHandler}.
+ * The {@link UpnpHandler} is the base class for {@link UpnpRendererHandler} and {@link UpnpServerHandler}. The base
+ * class implements UPnPConnectionManager service actions.
*
* @author Mark Herwege - Initial contribution
* @author Karel Goderis - Based on UPnP logic in Sonos binding
*/
@NonNullByDefault
-public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOParticipant {
+public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOParticipant, UpnpPlaylistsListener {
private final Logger logger = LoggerFactory.getLogger(UpnpHandler.class);
- protected UpnpIOService service;
- protected volatile String transportState = "";
- protected volatile int connectionId;
- protected volatile int avTransportId;
- protected volatile int rcsId;
- protected @NonNullByDefault({}) UpnpControlConfiguration config;
+ // UPnP constants
+ static final String CONNECTION_MANAGER = "ConnectionManager";
+ static final String CONNECTION_ID = "ConnectionID";
+ static final String AV_TRANSPORT_ID = "AVTransportID";
+ static final String RCS_ID = "RcsID";
+ static final Pattern PROTOCOL_PATTERN = Pattern.compile("(?:.*):(?:.*):(.*):(?:.*)");
- public UpnpHandler(Thing thing, UpnpIOService upnpIOService) {
+ protected UpnpIOService upnpIOService;
+
+ protected volatile @Nullable RemoteDevice device;
+
+ // The handlers can potentially create an important number of tasks, therefore put them in a separate thread pool
+ protected ScheduledExecutorService upnpScheduler = ThreadPoolManager.getScheduledPool("binding-upnpcontrol");
+
+ private boolean updateChannels;
+ private final List<Channel> updatedChannels = new ArrayList<>();
+ private final List<ChannelUID> updatedChannelUIDs = new ArrayList<>();
+
+ protected volatile int connectionId = 0; // UPnP Connection Id
+ protected volatile int avTransportId = 0; // UPnP AVTtransport Id
+ protected volatile int rcsId = 0; // UPnP Rendering Control Id
+
+ protected UpnpControlBindingConfiguration bindingConfig;
+ protected UpnpControlConfiguration config;
+
+ protected final Object invokeActionLock = new Object();
+
+ protected @Nullable ScheduledFuture<?> pollingJob;
+ protected final Object jobLock = new Object();
+
+ protected volatile @Nullable CompletableFuture<Boolean> isConnectionIdSet;
+ protected volatile @Nullable CompletableFuture<Boolean> isAvTransportIdSet;
+ protected volatile @Nullable CompletableFuture<Boolean> isRcsIdSet;
+
+ protected static final int SUBSCRIPTION_DURATION_SECONDS = 3600;
+ protected List<String> serviceSubscriptions = new ArrayList<>();
+ protected volatile @Nullable ScheduledFuture<?> subscriptionRefreshJob;
+ protected final Runnable subscriptionRefresh = () -> {
+ for (String subscription : serviceSubscriptions) {
+ removeSubscription(subscription);
+ addSubscription(subscription, SUBSCRIPTION_DURATION_SECONDS);
+ }
+ };
+ protected volatile boolean upnpSubscribed;
+
+ protected UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider;
+ protected UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider;
+
+ public UpnpHandler(Thing thing, UpnpIOService upnpIOService, UpnpControlBindingConfiguration configuration,
+ UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider,
+ UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider) {
super(thing);
- upnpIOService.registerParticipant(this);
- this.service = upnpIOService;
+ this.upnpIOService = upnpIOService;
+
+ this.bindingConfig = configuration;
+
+ this.upnpStateDescriptionProvider = upnpStateDescriptionProvider;
+ this.upnpCommandDescriptionProvider = upnpCommandDescriptionProvider;
+
+ // Get this in constructor, so the UDN is immediately available from the config. The concrete classes should
+ // update the config from the initialize method.
+ config = getConfigAs(UpnpControlConfiguration.class);
}
@Override
public void initialize() {
config = getConfigAs(UpnpControlConfiguration.class);
- service.registerParticipant(this);
+
+ upnpIOService.registerParticipant(this);
+
+ UpnpControlUtil.updatePlaylistsList(bindingConfig.path);
+ UpnpControlUtil.playlistsSubscribe(this);
}
@Override
public void dispose() {
- service.unregisterParticipant(this);
+ cancelPollingJob();
+ removeSubscriptions();
+
+ UpnpControlUtil.playlistsUnsubscribe(this);
+
+ CompletableFuture<Boolean> connectionIdFuture = isConnectionIdSet;
+ if (connectionIdFuture != null) {
+ connectionIdFuture.complete(false);
+ isConnectionIdSet = null;
+ }
+ CompletableFuture<Boolean> avTransportIdFuture = isAvTransportIdSet;
+ if (avTransportIdFuture != null) {
+ avTransportIdFuture.complete(false);
+ isAvTransportIdSet = null;
+ }
+ CompletableFuture<Boolean> rcsIdFuture = isRcsIdSet;
+ if (rcsIdFuture != null) {
+ rcsIdFuture.complete(false);
+ isRcsIdSet = null;
+ }
+
+ updateChannels = false;
+ updatedChannels.clear();
+ updatedChannelUIDs.clear();
+
+ upnpIOService.removeStatusListener(this);
+ upnpIOService.unregisterParticipant(this);
+ }
+
+ private void cancelPollingJob() {
+ ScheduledFuture<?> job = pollingJob;
+
+ if (job != null) {
+ job.cancel(true);
+ }
+ pollingJob = null;
+ }
+
+ /**
+ * To be called from implementing classes when initializing the device, to start initialization refresh
+ */
+ protected void initDevice() {
+ String udn = getUDN();
+ if ((udn != null) && !udn.isEmpty()) {
+ if (config.refresh == 0) {
+ upnpScheduler.submit(this::initJob);
+ } else {
+ pollingJob = upnpScheduler.scheduleWithFixedDelay(this::initJob, 0, config.refresh, TimeUnit.SECONDS);
+ }
+ } else {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "No UDN configured for " + thing.getLabel());
+ }
+ }
+
+ /**
+ * Job to be executed in an asynchronous process when initializing a device. This checks if the connection id's are
+ * correctly set up for the connection. It can also be called from a polling job to get the thing back online when
+ * connection is lost.
+ */
+ protected abstract void initJob();
+
+ @Override
+ protected void updateStatus(ThingStatus status) {
+ ThingStatus currentStatus = thing.getStatus();
+
+ super.updateStatus(status);
+
+ // When status changes to ThingStatus.ONLINE, make sure to refresh all linked channels
+ if (!status.equals(currentStatus) && status.equals(ThingStatus.ONLINE)) {
+ thing.getChannels().forEach(channel -> {
+ if (isLinked(channel.getUID())) {
+ channelLinked(channel.getUID());
+ }
+ });
+ }
+ }
+
+ /**
+ * Method called when a the remote device represented by the thing for this handler is added to the jupnp
+ * {@link RegistryListener} or is updated. Configuration info can be retrieved from the {@link RemoteDevice}.
+ *
+ * @param device
+ */
+ public void updateDeviceConfig(RemoteDevice device) {
+ this.device = device;
+ };
+
+ protected void updateStateDescription(ChannelUID channelUID, List<StateOption> stateOptionList) {
+ StateDescription stateDescription = StateDescriptionFragmentBuilder.create().withReadOnly(false)
+ .withOptions(stateOptionList).build().toStateDescription();
+ upnpStateDescriptionProvider.setDescription(channelUID, stateDescription);
+ }
+
+ protected void updateCommandDescription(ChannelUID channelUID, List<CommandOption> commandOptionList) {
+ CommandDescription commandDescription = CommandDescriptionBuilder.create().withCommandOptions(commandOptionList)
+ .build();
+ upnpCommandDescriptionProvider.setDescription(channelUID, commandDescription);
+ }
+
+ protected void createChannel(@Nullable UpnpChannelName upnpChannelName) {
+ if ((upnpChannelName != null)) {
+ createChannel(upnpChannelName.getChannelId(), upnpChannelName.getLabel(), upnpChannelName.getDescription(),
+ upnpChannelName.getItemType(), upnpChannelName.getChannelType());
+ }
+ }
+
+ protected void createChannel(String channelId, String label, String description, String itemType,
+ String channelType) {
+ ChannelUID channelUID = new ChannelUID(thing.getUID(), channelId);
+
+ if (thing.getChannel(channelUID) != null) {
+ // channel already exists
+ logger.trace("UPnP device {}, channel {} already exists", thing.getLabel(), channelId);
+ return;
+ }
+
+ ChannelTypeUID channelTypeUID = new ChannelTypeUID(channelType);
+ Channel channel = ChannelBuilder.create(channelUID).withLabel(label).withDescription(description)
+ .withAcceptedItemType(itemType).withType(channelTypeUID).build();
+
+ logger.debug("UPnP device {}, created channel {}", thing.getLabel(), channelId);
+
+ updatedChannels.add(channel);
+ updatedChannelUIDs.add(channelUID);
+ updateChannels = true;
+ }
+
+ protected void updateChannels() {
+ if (updateChannels) {
+ List<Channel> channels = thing.getChannels().stream().filter(c -> !updatedChannelUIDs.contains(c.getUID()))
+ .collect(Collectors.toList());
+ channels.addAll(updatedChannels);
+ final ThingBuilder thingBuilder = editThing();
+ thingBuilder.withChannels(channels);
+ updateThing(thingBuilder.build());
+ }
+ updatedChannels.clear();
+ updatedChannelUIDs.clear();
+ updateChannels = false;
}
/**
*/
protected void prepareForConnection(String remoteProtocolInfo, String peerConnectionManager, int peerConnectionId,
String direction) {
+ CompletableFuture<Boolean> settingConnection = isConnectionIdSet;
+ CompletableFuture<Boolean> settingAVTransport = isAvTransportIdSet;
+ CompletableFuture<Boolean> settingRcs = isRcsIdSet;
+ if (settingConnection != null) {
+ settingConnection.complete(false);
+ }
+ if (settingAVTransport != null) {
+ settingAVTransport.complete(false);
+ }
+ if (settingRcs != null) {
+ settingRcs.complete(false);
+ }
+
+ // Set new futures, so we don't try to use service when connection id's are not known yet
+ isConnectionIdSet = new CompletableFuture<Boolean>();
+ isAvTransportIdSet = new CompletableFuture<Boolean>();
+ isRcsIdSet = new CompletableFuture<Boolean>();
+
HashMap<String, String> inputs = new HashMap<String, String>();
inputs.put("RemoteProtocolInfo", remoteProtocolInfo);
inputs.put("PeerConnectionManager", peerConnectionManager);
inputs.put("PeerConnectionID", Integer.toString(peerConnectionId));
inputs.put("Direction", direction);
- invokeAction("ConnectionManager", "PrepareForConnection", inputs);
+ invokeAction(CONNECTION_MANAGER, "PrepareForConnection", inputs);
}
/**
* Invoke ConnectionComplete on UPnP Connection Manager.
- *
- * @param connectionId
*/
- protected void connectionComplete(int connectionId) {
- HashMap<String, String> inputs = new HashMap<String, String>();
- inputs.put("ConnectionID", String.valueOf(connectionId));
+ protected void connectionComplete() {
+ Map<String, String> inputs = Collections.singletonMap(CONNECTION_ID, Integer.toString(connectionId));
- invokeAction("ConnectionManager", "ConnectionComplete", inputs);
+ invokeAction(CONNECTION_MANAGER, "ConnectionComplete", inputs);
}
/**
- * Invoke GetTransportState on UPnP AV Transport.
+ * Invoke GetCurrentConnectionIDs on the UPnP Connection Manager.
* Result is received in {@link onValueReceived}.
*/
- protected void getTransportState() {
- HashMap<String, String> inputs = new HashMap<String, String>();
- inputs.put("InstanceID", Integer.toString(avTransportId));
+ protected void getCurrentConnectionIDs() {
+ Map<String, String> inputs = Collections.emptyMap();
+
+ invokeAction(CONNECTION_MANAGER, "GetCurrentConnectionIDs", inputs);
+ }
+
+ /**
+ * Invoke GetCurrentConnectionInfo on the UPnP Connection Manager.
+ * Result is received in {@link onValueReceived}.
+ */
+ protected void getCurrentConnectionInfo() {
+ CompletableFuture<Boolean> settingAVTransport = isAvTransportIdSet;
+ CompletableFuture<Boolean> settingRcs = isRcsIdSet;
+ if (settingAVTransport != null) {
+ settingAVTransport.complete(false);
+ }
+ if (settingRcs != null) {
+ settingRcs.complete(false);
+ }
+
+ // Set new futures, so we don't try to use service when connection id's are not known yet
+ isAvTransportIdSet = new CompletableFuture<Boolean>();
+ isRcsIdSet = new CompletableFuture<Boolean>();
- invokeAction("AVTransport", "GetTransportInfo", inputs);
+ // ConnectionID will default to 0 if not set through prepareForConnection method
+ Map<String, String> inputs = Collections.singletonMap(CONNECTION_ID, Integer.toString(connectionId));
+
+ invokeAction(CONNECTION_MANAGER, "GetCurrentConnectionInfo", inputs);
+ }
+
+ /**
+ * Invoke GetFeatureList on the UPnP Connection Manager.
+ * Result is received in {@link onValueReceived}.
+ */
+ protected void getFeatureList() {
+ Map<String, String> inputs = Collections.emptyMap();
+
+ invokeAction(CONNECTION_MANAGER, "GetFeatureList", inputs);
}
/**
* Result is received in {@link onValueReceived}.
*/
protected void getProtocolInfo() {
- Map<String, String> inputs = new HashMap<>();
+ Map<String, String> inputs = Collections.emptyMap();
- invokeAction("ConnectionManager", "GetProtocolInfo", inputs);
+ invokeAction(CONNECTION_MANAGER, "GetProtocolInfo", inputs);
}
@Override
public void onServiceSubscribed(@Nullable String service, boolean succeeded) {
- logger.debug("Upnp device {} received subscription reply {} from service {}", thing.getLabel(), succeeded,
+ logger.debug("UPnP device {} received subscription reply {} from service {}", thing.getLabel(), succeeded,
service);
+ if (!succeeded) {
+ upnpSubscribed = false;
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ "Could not subscribe to service " + service + "for" + thing.getLabel());
+ }
}
@Override
public void onStatusChanged(boolean status) {
+ logger.debug("UPnP device {} received status update {}", thing.getLabel(), status);
if (status) {
- updateStatus(ThingStatus.ONLINE);
+ initJob();
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"Communication lost with " + thing.getLabel());
}
}
- @Override
- public @Nullable String getUDN() {
- return config.udn;
- }
-
/**
* This method wraps {@link org.openhab.core.io.transport.upnp.UpnpIOService.invokeAction}. It schedules and
* submits the call and calls {@link onValueReceived} upon completion. All state updates or other actions depending
* @param inputs
*/
protected void invokeAction(String serviceId, String actionId, Map<String, String> inputs) {
- scheduler.submit(() -> {
- Map<String, String> result = service.invokeAction(this, serviceId, actionId, inputs);
- if (logger.isDebugEnabled() && !"GetPositionInfo".equals(actionId)) {
- // don't log position info refresh every second
- logger.debug("Upnp device {} invoke upnp action {} on service {} with inputs {}", thing.getLabel(),
- actionId, serviceId, inputs);
- logger.debug("Upnp device {} invoke upnp action {} on service {} reply {}", thing.getLabel(), actionId,
- serviceId, result);
+ upnpScheduler.submit(() -> {
+ Map<String, @Nullable String> result;
+ synchronized (invokeActionLock) {
+ if (logger.isDebugEnabled() && !"GetPositionInfo".equals(actionId)) {
+ // don't log position info refresh every second
+ logger.debug("UPnP device {} invoke upnp action {} on service {} with inputs {}", thing.getLabel(),
+ actionId, serviceId, inputs);
+ }
+ result = upnpIOService.invokeAction(this, serviceId, actionId, inputs);
+ if (logger.isDebugEnabled() && !"GetPositionInfo".equals(actionId)) {
+ // don't log position info refresh every second
+ logger.debug("UPnP device {} invoke upnp action {} on service {} reply {}", thing.getLabel(),
+ actionId, serviceId, result);
+ }
+
+ if (!result.isEmpty()) {
+ // We can be sure a non-empty result means the device is online.
+ // An empty result could be expected for certain actions, but could also be hiding an exception.
+ updateStatus(ThingStatus.ONLINE);
+ }
+
+ result = preProcessInvokeActionResult(inputs, serviceId, actionId, result);
}
for (String variable : result.keySet()) {
onValueReceived(variable, result.get(variable), serviceId);
});
}
+ /**
+ * Some received values need info on inputs of action. Therefore we allow to pre-process in a separate step. The
+ * method will return an adjusted result list. The default implementation will copy over the received result without
+ * additional processing. Derived classes can add additional logic.
+ *
+ * @param inputs
+ * @param service
+ * @param result
+ * @return
+ */
+ protected Map<String, @Nullable String> preProcessInvokeActionResult(Map<String, String> inputs,
+ @Nullable String service, @Nullable String action, Map<String, @Nullable String> result) {
+ Map<String, @Nullable String> newResult = new HashMap<>();
+ for (String variable : result.keySet()) {
+ String newVariable = preProcessValueReceived(inputs, variable, result.get(variable), service, action);
+ if (newVariable != null) {
+ newResult.put(newVariable, result.get(variable));
+ }
+ }
+ return newResult;
+ }
+
+ /**
+ * Some received values need info on inputs of action. Therefore we allow to pre-process in a separate step. The
+ * default implementation will return the original value. Derived classes can implement additional logic.
+ *
+ * @param inputs
+ * @param variable
+ * @param value
+ * @param service
+ * @return
+ */
+ protected @Nullable String preProcessValueReceived(Map<String, String> inputs, @Nullable String variable,
+ @Nullable String value, @Nullable String service, @Nullable String action) {
+ return variable;
+ }
+
@Override
public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
if (variable == null || value == null) {
return;
}
switch (variable) {
- case "CurrentTransportState":
- if (!value.isEmpty()) {
- transportState = value;
- }
+ case CONNECTION_ID:
+ onValueReceivedConnectionId(value);
break;
- case "ConnectionID":
- connectionId = Integer.parseInt(value);
+ case AV_TRANSPORT_ID:
+ onValueReceivedAVTransportId(value);
break;
- case "AVTransportID":
- avTransportId = Integer.parseInt(value);
+ case RCS_ID:
+ onValueReceivedRcsId(value);
break;
- case "RcsID":
- rcsId = Integer.parseInt(value);
+ case "Source":
+ case "Sink":
+ if (!value.isEmpty()) {
+ updateProtocolInfo(value);
+ }
break;
default:
break;
}
}
+ private void onValueReceivedConnectionId(@Nullable String value) {
+ try {
+ connectionId = (value == null) ? 0 : Integer.parseInt(value);
+ } catch (NumberFormatException e) {
+ connectionId = 0;
+ }
+ CompletableFuture<Boolean> connectionIdFuture = isConnectionIdSet;
+ if (connectionIdFuture != null) {
+ connectionIdFuture.complete(true);
+ }
+ }
+
+ private void onValueReceivedAVTransportId(@Nullable String value) {
+ try {
+ avTransportId = (value == null) ? 0 : Integer.parseInt(value);
+ } catch (NumberFormatException e) {
+ avTransportId = 0;
+ }
+ CompletableFuture<Boolean> avTransportIdFuture = isAvTransportIdSet;
+ if (avTransportIdFuture != null) {
+ avTransportIdFuture.complete(true);
+ }
+ }
+
+ private void onValueReceivedRcsId(@Nullable String value) {
+ try {
+ rcsId = (value == null) ? 0 : Integer.parseInt(value);
+ } catch (NumberFormatException e) {
+ rcsId = 0;
+ }
+ CompletableFuture<Boolean> rcsIdFuture = isRcsIdSet;
+ if (rcsIdFuture != null) {
+ rcsIdFuture.complete(true);
+ }
+ }
+
+ @Override
+ public @Nullable String getUDN() {
+ return config.udn;
+ }
+
+ protected boolean checkForConnectionIds() {
+ return checkForConnectionId(isConnectionIdSet) & checkForConnectionId(isAvTransportIdSet)
+ & checkForConnectionId(isRcsIdSet);
+ }
+
+ private boolean checkForConnectionId(@Nullable CompletableFuture<Boolean> future) {
+ try {
+ if (future != null) {
+ return future.get(config.responseTimeout, TimeUnit.MILLISECONDS);
+ }
+ } catch (InterruptedException | ExecutionException | TimeoutException e) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Update internal representation of supported protocols, needs to be implemented in derived classes.
+ *
+ * @param value
+ */
+ protected abstract void updateProtocolInfo(String value);
+
/**
* Subscribe this handler as a participant to a GENA subscription.
*
* @param duration
*/
protected void addSubscription(String serviceId, int duration) {
- logger.debug("Upnp device {} add upnp subscription on {}", thing.getLabel(), serviceId);
- service.addSubscription(this, serviceId, duration);
+ if (upnpIOService.isRegistered(this)) {
+ logger.debug("UPnP device {} add upnp subscription on {}", thing.getLabel(), serviceId);
+ upnpIOService.addSubscription(this, serviceId, duration);
+ }
}
/**
* @param serviceId
*/
protected void removeSubscription(String serviceId) {
- if (service.isRegistered(this)) {
- service.removeSubscription(this, serviceId);
+ if (upnpIOService.isRegistered(this)) {
+ upnpIOService.removeSubscription(this, serviceId);
}
}
+
+ protected void addSubscriptions() {
+ upnpSubscribed = true;
+
+ for (String subscription : serviceSubscriptions) {
+ addSubscription(subscription, SUBSCRIPTION_DURATION_SECONDS);
+ }
+ subscriptionRefreshJob = upnpScheduler.scheduleWithFixedDelay(subscriptionRefresh,
+ SUBSCRIPTION_DURATION_SECONDS / 2, SUBSCRIPTION_DURATION_SECONDS / 2, TimeUnit.SECONDS);
+
+ // This action should exist on all media devices and return a result, so a good candidate for testing the
+ // connection.
+ upnpIOService.addStatusListener(this, CONNECTION_MANAGER, "GetCurrentConnectionIDs", config.refresh);
+ }
+
+ protected void removeSubscriptions() {
+ cancelSubscriptionRefreshJob();
+
+ for (String subscription : serviceSubscriptions) {
+ removeSubscription(subscription);
+ }
+
+ upnpIOService.removeStatusListener(this);
+
+ upnpSubscribed = false;
+ }
+
+ private void cancelSubscriptionRefreshJob() {
+ ScheduledFuture<?> refreshJob = subscriptionRefreshJob;
+
+ if (refreshJob != null) {
+ refreshJob.cancel(true);
+ }
+ subscriptionRefreshJob = null;
+ }
+
+ @Override
+ public abstract void playlistsListChanged();
+
+ /**
+ * Get access to all device info through the UPnP {@link RemoteDevice}.
+ *
+ * @return UPnP RemoteDevice
+ */
+ protected @Nullable RemoteDevice getDevice() {
+ return device;
+ }
}
import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
-import java.util.ListIterator;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
+import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.upnpcontrol.internal.UpnpAudioSink;
-import org.openhab.binding.upnpcontrol.internal.UpnpAudioSinkReg;
-import org.openhab.binding.upnpcontrol.internal.UpnpEntry;
-import org.openhab.binding.upnpcontrol.internal.UpnpXMLParser;
+import org.jupnp.model.meta.RemoteDevice;
+import org.openhab.binding.upnpcontrol.internal.UpnpChannelName;
+import org.openhab.binding.upnpcontrol.internal.UpnpDynamicCommandDescriptionProvider;
+import org.openhab.binding.upnpcontrol.internal.UpnpDynamicStateDescriptionProvider;
+import org.openhab.binding.upnpcontrol.internal.audiosink.UpnpAudioSink;
+import org.openhab.binding.upnpcontrol.internal.audiosink.UpnpAudioSinkReg;
+import org.openhab.binding.upnpcontrol.internal.config.UpnpControlBindingConfiguration;
+import org.openhab.binding.upnpcontrol.internal.config.UpnpControlRendererConfiguration;
+import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntry;
+import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntryQueue;
+import org.openhab.binding.upnpcontrol.internal.queue.UpnpFavorite;
+import org.openhab.binding.upnpcontrol.internal.services.UpnpRenderingControlConfiguration;
+import org.openhab.binding.upnpcontrol.internal.util.UpnpControlUtil;
+import org.openhab.binding.upnpcontrol.internal.util.UpnpXMLParser;
import org.openhab.core.audio.AudioFormat;
import org.openhab.core.io.net.http.HttpUtil;
import org.openhab.core.io.transport.upnp.UpnpIOService;
import org.openhab.core.library.types.RewindFastforwardType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.library.unit.SmartHomeUnits;
+import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.types.Command;
+import org.openhab.core.types.CommandOption;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
/**
* The {@link UpnpRendererHandler} is responsible for handling commands sent to the UPnP Renderer. It extends
- * {@link UpnpHandler} with UPnP renderer specific logic.
+ * {@link UpnpHandler} with UPnP renderer specific logic. It implements UPnP AVTransport and RenderingControl service
+ * actions.
*
* @author Mark Herwege - Initial contribution
* @author Karel Goderis - Based on UPnP logic in Sonos binding
private final Logger logger = LoggerFactory.getLogger(UpnpRendererHandler.class);
- private static final int SUBSCRIPTION_DURATION_SECONDS = 3600;
-
- // UPnP protocol pattern
- private static final Pattern PROTOCOL_PATTERN = Pattern.compile("(?:.*):(?:.*):(.*):(?:.*)");
+ // UPnP constants
+ static final String RENDERING_CONTROL = "RenderingControl";
+ static final String AV_TRANSPORT = "AVTransport";
+ static final String INSTANCE_ID = "InstanceID";
private volatile boolean audioSupport;
protected volatile Set<AudioFormat> supportedAudioFormats = new HashSet<>();
private volatile UpnpAudioSinkReg audioSinkReg;
- private volatile boolean upnpSubscribed;
+ private volatile Set<UpnpServerHandler> serverHandlers = ConcurrentHashMap.newKeySet();
+
+ protected @NonNullByDefault({}) UpnpControlRendererConfiguration config;
+ private UpnpRenderingControlConfiguration renderingControlConfiguration = new UpnpRenderingControlConfiguration();
+
+ private volatile List<CommandOption> favoriteCommandOptionList = List.of();
+ private volatile List<CommandOption> playlistCommandOptionList = List.of();
- private static final String UPNP_CHANNEL = "Master";
+ private @NonNullByDefault({}) ChannelUID favoriteSelectChannelUID;
+ private @NonNullByDefault({}) ChannelUID playlistSelectChannelUID;
- private volatile OnOffType soundMute = OnOffType.OFF;
private volatile PercentType soundVolume = new PercentType();
+ private @Nullable volatile PercentType notificationVolume;
private volatile List<String> sink = new ArrayList<>();
- private volatile ArrayList<UpnpEntry> currentQueue = new ArrayList<>();
- private volatile UpnpIterator<UpnpEntry> queueIterator = new UpnpIterator<>(currentQueue.listIterator());
- private volatile @Nullable UpnpEntry currentEntry = null;
- private volatile @Nullable UpnpEntry nextEntry = null;
- private volatile boolean playerStopped;
- private volatile boolean playing;
- private volatile @Nullable CompletableFuture<Boolean> isSettingURI;
+ private volatile String favoriteName = ""; // Currently selected favorite
+
+ private volatile boolean repeat;
+ private volatile boolean shuffle;
+ private volatile boolean onlyplayone; // Set to true if we only want to play one at a time
+
+ // Queue as received from server and current and next media entries for playback
+ private volatile UpnpEntryQueue currentQueue = new UpnpEntryQueue();
+ volatile @Nullable UpnpEntry currentEntry = null;
+ volatile @Nullable UpnpEntry nextEntry = null;
+
+ // Group of fields representing current state of player
+ private volatile String nowPlayingUri = ""; // Used to block waiting for setting URI when it is the same as current
+ // as some players will not send URI update when it is the same as
+ // previous
+ private volatile String transportState = ""; // Current transportState to be able to refresh the control
+ volatile boolean playerStopped; // Set if the player is stopped from OH command or code, allows to identify
+ // if STOP came from other source when receiving STOP state from GENA event
+ volatile boolean playing; // Set to false when a STOP is received, so we can filter two consecutive STOPs
+ // and not play next entry second time
+ private volatile @Nullable ScheduledFuture<?> paused; // Set when a pause command is given, to compensate for
+ // renderers that cannot pause playback
+ private volatile @Nullable CompletableFuture<Boolean> isSettingURI; // Set to wait for setting URI before starting
+ // to play or seeking
+ private volatile @Nullable CompletableFuture<Boolean> isStopping; // Set when stopping to be able to wait for stop
+ // confirmation for subsequent actions that need
+ // the player to be stopped
+ volatile boolean registeredQueue; // Set when registering a new queue. This allows to decide if we just
+ // need to play URI, or serve the first entry in a queue when a play
+ // command is given.
+ volatile boolean playingQueue; // Identifies if we are playing a queue received from a server. If so, a new
+ // queue received will be played after the currently playing entry
+ private volatile boolean oneplayed; // Set to true when the one entry is being played, allows to check if stop is
+ // needed when only playing one
+ volatile boolean playingNotification; // Set when playing a notification
+ private volatile @Nullable ScheduledFuture<?> playingNotificationFuture; // Set when playing a notification, allows
+ // timing out notification
+ private volatile String notificationUri = ""; // Used to check if the received URI is from the notification
+ private final Object notificationLock = new Object();
+
+ // Track position and duration fields
private volatile int trackDuration = 0;
private volatile int trackPosition = 0;
+ private volatile long expectedTrackend = 0;
private volatile @Nullable ScheduledFuture<?> trackPositionRefresh;
+ private volatile int posAtNotificationStart = 0;
- private volatile @Nullable ScheduledFuture<?> subscriptionRefreshJob;
- private final Runnable subscriptionRefresh = () -> {
- removeSubscription("AVTransport");
- addSubscription("AVTransport", SUBSCRIPTION_DURATION_SECONDS);
- };
-
- /**
- * The {@link ListIterator} class does not keep a cursor position and therefore will not give the previous element
- * when next was called before, or give the next element when previous was called before. This iterator will always
- * go to previous/next.
- */
- private static class UpnpIterator<T> {
- private final ListIterator<T> listIterator;
-
- private boolean nextWasCalled = false;
- private boolean previousWasCalled = false;
-
- public UpnpIterator(ListIterator<T> listIterator) {
- this.listIterator = listIterator;
- }
-
- public T next() {
- if (previousWasCalled) {
- previousWasCalled = false;
- listIterator.next();
- }
- nextWasCalled = true;
- return listIterator.next();
- }
-
- public T previous() {
- if (nextWasCalled) {
- nextWasCalled = false;
- listIterator.previous();
- }
- previousWasCalled = true;
- return listIterator.previous();
- }
-
- public boolean hasNext() {
- if (previousWasCalled) {
- return true;
- } else {
- return listIterator.hasNext();
- }
- }
-
- public boolean hasPrevious() {
- if (previousIndex() < 0) {
- return false;
- } else if (nextWasCalled) {
- return true;
- } else {
- return listIterator.hasPrevious();
- }
- }
+ public UpnpRendererHandler(Thing thing, UpnpIOService upnpIOService, UpnpAudioSinkReg audioSinkReg,
+ UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider,
+ UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider,
+ UpnpControlBindingConfiguration configuration) {
+ super(thing, upnpIOService, configuration, upnpStateDescriptionProvider, upnpCommandDescriptionProvider);
- public int nextIndex() {
- if (previousWasCalled) {
- return listIterator.nextIndex() + 1;
- } else {
- return listIterator.nextIndex();
- }
- }
-
- public int previousIndex() {
- if (nextWasCalled) {
- return listIterator.previousIndex() - 1;
- } else {
- return listIterator.previousIndex();
- }
- }
- }
-
- public UpnpRendererHandler(Thing thing, UpnpIOService upnpIOService, UpnpAudioSinkReg audioSinkReg) {
- super(thing, upnpIOService);
+ serviceSubscriptions.add(AV_TRANSPORT);
+ serviceSubscriptions.add(RENDERING_CONTROL);
this.audioSinkReg = audioSinkReg;
}
@Override
public void initialize() {
super.initialize();
-
+ config = getConfigAs(UpnpControlRendererConfiguration.class);
+ if (config.seekStep < 1) {
+ config.seekStep = 1;
+ }
logger.debug("Initializing handler for media renderer device {}", thing.getLabel());
- if (config.udn != null) {
- if (service.isRegistered(this)) {
- initRenderer();
- } else {
- updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
- "Communication cannot be established with " + thing.getLabel());
- }
+ Channel favoriteSelectChannel = thing.getChannel(FAVORITE_SELECT);
+ if (favoriteSelectChannel != null) {
+ favoriteSelectChannelUID = favoriteSelectChannel.getUID();
+ } else {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "Channel " + FAVORITE_SELECT + " not defined");
+ return;
+ }
+ Channel playlistSelectChannel = thing.getChannel(PLAYLIST_SELECT);
+ if (playlistSelectChannel != null) {
+ playlistSelectChannelUID = playlistSelectChannel.getUID();
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
- "No UDN configured for " + thing.getLabel());
+ "Channel " + PLAYLIST_SELECT + " not defined");
+ return;
}
+
+ initDevice();
}
@Override
public void dispose() {
- cancelSubscriptionRefreshJob();
- removeSubscription("AVTransport");
+ logger.debug("Disposing handler for media renderer device {}", thing.getLabel());
cancelTrackPositionRefresh();
+ resetPaused();
+ CompletableFuture<Boolean> settingURI = isSettingURI;
+ if (settingURI != null) {
+ settingURI.complete(false);
+ }
super.dispose();
}
- private void cancelSubscriptionRefreshJob() {
- ScheduledFuture<?> refreshJob = subscriptionRefreshJob;
+ @Override
+ protected void initJob() {
+ synchronized (jobLock) {
+ if (!upnpIOService.isRegistered(this)) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ "UPnP device with UDN " + getUDN() + " not yet registered");
+ return;
+ }
- if (refreshJob != null) {
- refreshJob.cancel(true);
- }
- subscriptionRefreshJob = null;
+ if (!ThingStatus.ONLINE.equals(thing.getStatus())) {
+ getProtocolInfo();
+
+ getCurrentConnectionInfo();
+ if (!checkForConnectionIds()) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ "No connection Id's set for UPnP device with UDN " + getUDN());
+ return;
+ }
+
+ getTransportState();
+
+ updateFavoritesList();
+ playlistsListChanged();
+
+ RemoteDevice device = getDevice();
+ if (device != null) { // The handler factory will update the device config later when it has not been
+ // set yet
+ updateDeviceConfig(device);
+ }
+
+ updateStatus(ThingStatus.ONLINE);
+ }
- upnpSubscribed = false;
+ if (!upnpSubscribed) {
+ addSubscriptions();
+ }
+ }
}
- private void initRenderer() {
- if (!upnpSubscribed) {
- addSubscription("AVTransport", SUBSCRIPTION_DURATION_SECONDS);
- upnpSubscribed = true;
+ @Override
+ public void updateDeviceConfig(RemoteDevice device) {
+ super.updateDeviceConfig(device);
- subscriptionRefreshJob = scheduler.scheduleWithFixedDelay(subscriptionRefresh,
- SUBSCRIPTION_DURATION_SECONDS / 2, SUBSCRIPTION_DURATION_SECONDS / 2, TimeUnit.SECONDS);
+ UpnpRenderingControlConfiguration config = new UpnpRenderingControlConfiguration(device);
+ renderingControlConfiguration = config;
+ for (String audioChannel : config.audioChannels) {
+ createAudioChannels(audioChannel);
}
- getProtocolInfo();
- getTransportState();
- updateStatus(ThingStatus.ONLINE);
+ updateChannels();
+ }
+
+ private void createAudioChannels(String audioChannel) {
+ UpnpRenderingControlConfiguration config = renderingControlConfiguration;
+ if (config.volume && !UPNP_MASTER.equals(audioChannel)) {
+ String name = audioChannel + "volume";
+ if (UpnpChannelName.channelIdToUpnpChannelName(name) != null) {
+ createChannel(UpnpChannelName.channelIdToUpnpChannelName(name));
+ } else {
+ createChannel(name, name, "Vendor specific UPnP volume channel", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME);
+ }
+ }
+ if (config.mute && !UPNP_MASTER.equals(audioChannel)) {
+ String name = audioChannel + "mute";
+ if (UpnpChannelName.channelIdToUpnpChannelName(name) != null) {
+ createChannel(UpnpChannelName.channelIdToUpnpChannelName(name));
+ } else {
+ createChannel(name, name, "Vendor specific UPnP mute channel", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE);
+ }
+ }
+ if (config.loudness) {
+ String name = (UPNP_MASTER.equals(audioChannel) ? "" : audioChannel) + "loudness";
+ if (UpnpChannelName.channelIdToUpnpChannelName(name) != null) {
+ createChannel(UpnpChannelName.channelIdToUpnpChannelName(name));
+ } else {
+ createChannel(name, name, "Vendor specific UPnP loudness channel", ITEM_TYPE_LOUDNESS,
+ CHANNEL_TYPE_LOUDNESS);
+ }
+ }
}
/**
* Invoke Stop on UPnP AV Transport.
*/
public void stop() {
- Map<String, String> inputs = Collections.singletonMap("InstanceID", Integer.toString(avTransportId));
+ playerStopped = true;
+
+ if (playing) {
+ CompletableFuture<Boolean> stopping = isStopping;
+ if (stopping != null) {
+ stopping.complete(false);
+ }
+ isStopping = new CompletableFuture<Boolean>(); // set this so we can check if stop confirmation has been
+ // received
+ }
+
+ Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
- invokeAction("AVTransport", "Stop", inputs);
+ invokeAction(AV_TRANSPORT, "Stop", inputs);
}
/**
* Invoke Play on UPnP AV Transport.
*/
public void play() {
- CompletableFuture<Boolean> setting = isSettingURI;
+ CompletableFuture<Boolean> settingURI = isSettingURI;
+ boolean uriSet = true;
try {
- if ((setting == null) || (setting.get(2500, TimeUnit.MILLISECONDS))) {
+ if (settingURI != null) {
// wait for maximum 2.5s until the media URI is set before playing
- Map<String, String> inputs = new HashMap<>();
- inputs.put("InstanceID", Integer.toString(avTransportId));
- inputs.put("Speed", "1");
-
- invokeAction("AVTransport", "Play", inputs);
- } else {
- logger.debug("Cannot play, cancelled setting URI in the renderer");
+ uriSet = settingURI.get(config.responseTimeout, TimeUnit.MILLISECONDS);
}
} catch (InterruptedException | ExecutionException | TimeoutException e) {
- logger.debug("Cannot play, media URI not yet set in the renderer");
+ logger.debug("Timeout exception, media URI not yet set in renderer {}, trying to play anyway",
+ thing.getLabel());
+ }
+
+ if (uriSet) {
+ Map<String, String> inputs = new HashMap<>();
+ inputs.put(INSTANCE_ID, Integer.toString(avTransportId));
+ inputs.put("Speed", "1");
+
+ invokeAction(AV_TRANSPORT, "Play", inputs);
+ } else {
+ logger.debug("Cannot play, cancelled setting URI in the renderer {}", thing.getLabel());
}
}
/**
* Invoke Pause on UPnP AV Transport.
*/
- public void pause() {
- Map<String, String> inputs = Collections.singletonMap("InstanceID", Integer.toString(avTransportId));
+ protected void pause() {
+ Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
- invokeAction("AVTransport", "Pause", inputs);
+ invokeAction(AV_TRANSPORT, "Pause", inputs);
}
/**
* Invoke Next on UPnP AV Transport.
*/
protected void next() {
- Map<String, String> inputs = Collections.singletonMap("InstanceID", Integer.toString(avTransportId));
+ Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
- invokeAction("AVTransport", "Next", inputs);
+ invokeAction(AV_TRANSPORT, "Next", inputs);
}
/**
* Invoke Previous on UPnP AV Transport.
*/
protected void previous() {
- Map<String, String> inputs = Collections.singletonMap("InstanceID", Integer.toString(avTransportId));
+ Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
+
+ invokeAction(AV_TRANSPORT, "Previous", inputs);
+ }
- invokeAction("AVTransport", "Previous", inputs);
+ /**
+ * Invoke Seek on UPnP AV Transport.
+ *
+ * @param seekTarget relative position in current track, format HH:mm:ss
+ */
+ protected void seek(String seekTarget) {
+ CompletableFuture<Boolean> settingURI = isSettingURI;
+ boolean uriSet = true;
+ try {
+ if (settingURI != null) {
+ // wait for maximum 2.5s until the media URI is set before seeking
+ uriSet = settingURI.get(config.responseTimeout, TimeUnit.MILLISECONDS);
+ }
+ } catch (InterruptedException | ExecutionException | TimeoutException e) {
+ logger.debug("Timeout exception, media URI not yet set in renderer {}, skipping seek", thing.getLabel());
+ return;
+ }
+
+ if (uriSet) {
+ Map<String, String> inputs = new HashMap<>();
+ inputs.put(INSTANCE_ID, Integer.toString(avTransportId));
+ inputs.put("Unit", "REL_TIME");
+ inputs.put("Target", seekTarget);
+
+ invokeAction(AV_TRANSPORT, "Seek", inputs);
+ } else {
+ logger.debug("Cannot seek, cancelled setting URI in the renderer {}", thing.getLabel());
+ }
}
/**
* @param URIMetaData
*/
public void setCurrentURI(String URI, String URIMetaData) {
- CompletableFuture<Boolean> setting = isSettingURI;
- if (setting != null) {
- setting.complete(false);
+ String uri = "";
+ try {
+ uri = URLDecoder.decode(URI.trim(), StandardCharsets.UTF_8.name());
+ // Some renderers don't send a URI Last Changed event when the same URI is requested, so don't wait for it
+ // before starting to play
+ if (!uri.equals(nowPlayingUri) && !playingNotification) {
+ CompletableFuture<Boolean> settingURI = isSettingURI;
+ if (settingURI != null) {
+ settingURI.complete(false);
+ }
+ isSettingURI = new CompletableFuture<Boolean>(); // set this so we don't start playing when not finished
+ // setting URI
+ } else {
+ logger.debug("New URI {} is same as previous on renderer {}", nowPlayingUri, thing.getLabel());
+ }
+ } catch (UnsupportedEncodingException ignore) {
+ uri = URI;
}
- isSettingURI = new CompletableFuture<Boolean>(); // set this so we don't start playing when not finished setting
- // URI
+
Map<String, String> inputs = new HashMap<>();
- try {
- inputs.put("InstanceID", Integer.toString(avTransportId));
- inputs.put("CurrentURI", URI);
- inputs.put("CurrentURIMetaData", URIMetaData);
+ inputs.put(INSTANCE_ID, Integer.toString(avTransportId));
+ inputs.put("CurrentURI", uri);
+ inputs.put("CurrentURIMetaData", URIMetaData);
- invokeAction("AVTransport", "SetAVTransportURI", inputs);
- } catch (NumberFormatException ex) {
- logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
- }
+ invokeAction(AV_TRANSPORT, "SetAVTransportURI", inputs);
}
/**
* @param nextURI
* @param nextURIMetaData
*/
- public void setNextURI(String nextURI, String nextURIMetaData) {
+ protected void setNextURI(String nextURI, String nextURIMetaData) {
Map<String, String> inputs = new HashMap<>();
- try {
- inputs.put("InstanceID", Integer.toString(avTransportId));
- inputs.put("NextURI", nextURI);
- inputs.put("NextURIMetaData", nextURIMetaData);
+ inputs.put(INSTANCE_ID, Integer.toString(avTransportId));
+ inputs.put("NextURI", nextURI);
+ inputs.put("NextURIMetaData", nextURIMetaData);
- invokeAction("AVTransport", "SetNextAVTransportURI", inputs);
- } catch (NumberFormatException ex) {
- logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
- }
+ invokeAction(AV_TRANSPORT, "SetNextAVTransportURI", inputs);
}
/**
- * Retrieves the current audio channel ('Master' by default).
- *
- * @return current audio channel
+ * Invoke GetTransportState on UPnP AV Transport.
+ * Result is received in {@link onValueReceived}.
*/
- public String getCurrentChannel() {
- return UPNP_CHANNEL;
+ protected void getTransportState() {
+ Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
+
+ invokeAction(AV_TRANSPORT, "GetTransportInfo", inputs);
+ }
+
+ /**
+ * Invoke getPositionInfo on UPnP AV Transport.
+ * Result is received in {@link onValueReceived}.
+ */
+ protected void getPositionInfo() {
+ Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
+
+ invokeAction(AV_TRANSPORT, "GetPositionInfo", inputs);
+ }
+
+ /**
+ * Invoke GetMediaInfo on UPnP AV Transport.
+ * Result is received in {@link onValueReceived}.
+ */
+ protected void getMediaInfo() {
+ Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
+
+ invokeAction(AV_TRANSPORT, "smarthome:audio stream http://icecast.vrtcdn.be/stubru_tijdloze-high.mp3", inputs);
}
/**
*/
protected void getVolume(String channel) {
Map<String, String> inputs = new HashMap<>();
- inputs.put("InstanceID", Integer.toString(rcsId));
+ inputs.put(INSTANCE_ID, Integer.toString(rcsId));
inputs.put("Channel", channel);
- invokeAction("RenderingControl", "GetVolume", inputs);
+ invokeAction(RENDERING_CONTROL, "GetVolume", inputs);
}
/**
* @param channel
* @param volume
*/
- public void setVolume(String channel, PercentType volume) {
+ protected void setVolume(String channel, PercentType volume) {
+ UpnpRenderingControlConfiguration config = renderingControlConfiguration;
+
+ long newVolume = volume.intValue() * config.maxvolume / 100;
Map<String, String> inputs = new HashMap<>();
- inputs.put("InstanceID", Integer.toString(rcsId));
+ inputs.put(INSTANCE_ID, Integer.toString(rcsId));
inputs.put("Channel", channel);
- inputs.put("DesiredVolume", String.valueOf(volume.intValue()));
+ inputs.put("DesiredVolume", String.valueOf(newVolume));
- invokeAction("RenderingControl", "SetVolume", inputs);
+ invokeAction(RENDERING_CONTROL, "SetVolume", inputs);
+ }
+
+ /**
+ * Invoke SetVolume for Master channel on UPnP Rendering Control.
+ *
+ * @param volume
+ */
+ public void setVolume(PercentType volume) {
+ setVolume(UPNP_MASTER, volume);
}
/**
*/
protected void getMute(String channel) {
Map<String, String> inputs = new HashMap<>();
- inputs.put("InstanceID", Integer.toString(rcsId));
+ inputs.put(INSTANCE_ID, Integer.toString(rcsId));
inputs.put("Channel", channel);
- invokeAction("RenderingControl", "GetMute", inputs);
+ invokeAction(RENDERING_CONTROL, "GetMute", inputs);
}
/**
*/
protected void setMute(String channel, OnOffType mute) {
Map<String, String> inputs = new HashMap<>();
- inputs.put("InstanceID", Integer.toString(rcsId));
+ inputs.put(INSTANCE_ID, Integer.toString(rcsId));
inputs.put("Channel", channel);
inputs.put("DesiredMute", mute == OnOffType.ON ? "1" : "0");
- invokeAction("RenderingControl", "SetMute", inputs);
+ invokeAction(RENDERING_CONTROL, "SetMute", inputs);
+ }
+
+ /**
+ * Invoke getMute on UPnP Rendering Control.
+ * Result is received in {@link onValueReceived}.
+ *
+ * @param channel
+ */
+ protected void getLoudness(String channel) {
+ Map<String, String> inputs = new HashMap<>();
+ inputs.put(INSTANCE_ID, Integer.toString(rcsId));
+ inputs.put("Channel", channel);
+
+ invokeAction(RENDERING_CONTROL, "GetLoudness", inputs);
+ }
+
+ /**
+ * Invoke SetMute on UPnP Rendering Control.
+ *
+ * @param channel
+ * @param mute
+ */
+ protected void setLoudness(String channel, OnOffType mute) {
+ Map<String, String> inputs = new HashMap<>();
+ inputs.put(INSTANCE_ID, Integer.toString(rcsId));
+ inputs.put("Channel", channel);
+ inputs.put("DesiredLoudness", mute == OnOffType.ON ? "1" : "0");
+
+ invokeAction(RENDERING_CONTROL, "SetLoudness", inputs);
+ }
+
+ /**
+ * Called from server handler for renderer to be able to send back status to server handler
+ *
+ * @param handler
+ */
+ protected void setServerHandler(UpnpServerHandler handler) {
+ logger.debug("Set server handler {} on renderer {}", handler.getThing().getLabel(), thing.getLabel());
+ serverHandlers.add(handler);
+ }
+
+ /**
+ * Should be called from server handler when server stops serving this renderer
+ */
+ protected void unsetServerHandler() {
+ logger.debug("Unset server handler on renderer {}", thing.getLabel());
+ for (UpnpServerHandler handler : serverHandlers) {
+ Thing serverThing = handler.getThing();
+ Channel serverChannel;
+ for (String channel : SERVER_CONTROL_CHANNELS) {
+ if ((serverChannel = serverThing.getChannel(channel)) != null) {
+ handler.updateServerState(serverChannel.getUID(), UnDefType.UNDEF);
+ }
+ }
+
+ serverHandlers.remove(handler);
+ }
+ }
+
+ @Override
+ protected void updateState(ChannelUID channelUID, State state) {
+ // override to be able to propagate channel state updates to corresponding channels on the server
+ if (SERVER_CONTROL_CHANNELS.contains(channelUID.getId())) {
+ for (UpnpServerHandler handler : serverHandlers) {
+ Thing serverThing = handler.getThing();
+ Channel serverChannel = serverThing.getChannel(channelUID.getId());
+ if (serverChannel != null) {
+ logger.debug("Update server {} channel {} with state {} from renderer {}", serverThing.getLabel(),
+ state, channelUID, thing.getLabel());
+ handler.updateServerState(serverChannel.getUID(), state);
+ }
+ }
+ }
+ super.updateState(channelUID, state);
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ logger.debug("Handle command {} for channel {} on renderer {}", command, channelUID, thing.getLabel());
+
+ String id = channelUID.getId();
+
+ if (id.endsWith("volume")) {
+ handleCommandVolume(command, id);
+ } else if (id.endsWith("mute")) {
+ handleCommandMute(command, id);
+ } else if (id.endsWith("loudness")) {
+ handleCommandLoudness(command, id);
+ } else {
+ switch (id) {
+ case STOP:
+ handleCommandStop(command);
+ break;
+ case CONTROL:
+ handleCommandControl(channelUID, command);
+ break;
+ case REPEAT:
+ handleCommandRepeat(channelUID, command);
+ break;
+ case SHUFFLE:
+ handleCommandShuffle(channelUID, command);
+ case ONLY_PLAY_ONE:
+ handleCommandOnlyPlayOne(channelUID, command);
+ break;
+ case URI:
+ handleCommandUri(channelUID, command);
+ break;
+ case FAVORITE_SELECT:
+ handleCommandFavoriteSelect(command);
+ break;
+ case FAVORITE:
+ handleCommandFavorite(channelUID, command);
+ break;
+ case FAVORITE_ACTION:
+ handleCommandFavoriteAction(command);
+ break;
+ case PLAYLIST_SELECT:
+ handleCommandPlaylistSelect(command);
+ break;
+ case TRACK_POSITION:
+ handleCommandTrackPosition(channelUID, command);
+ break;
+ case REL_TRACK_POSITION:
+ handleCommandRelTrackPosition(channelUID, command);
+ break;
+ default:
+ break;
+ }
+ }
+ }
+
+ private void handleCommandVolume(Command command, String id) {
+ if (command instanceof RefreshType) {
+ getVolume("volume".equals(id) ? UPNP_MASTER : id.replace("volume", ""));
+ } else if (command instanceof PercentType) {
+ setVolume("volume".equals(id) ? UPNP_MASTER : id.replace("volume", ""), (PercentType) command);
+ }
+ }
+
+ private void handleCommandMute(Command command, String id) {
+ if (command instanceof RefreshType) {
+ getMute("mute".equals(id) ? UPNP_MASTER : id.replace("mute", ""));
+ } else if (command instanceof OnOffType) {
+ setMute("mute".equals(id) ? UPNP_MASTER : id.replace("mute", ""), (OnOffType) command);
+ }
+ }
+
+ private void handleCommandLoudness(Command command, String id) {
+ if (command instanceof RefreshType) {
+ getLoudness("loudness".equals(id) ? UPNP_MASTER : id.replace("loudness", ""));
+ } else if (command instanceof OnOffType) {
+ setLoudness("loudness".equals(id) ? UPNP_MASTER : id.replace("loudness", ""), (OnOffType) command);
+ }
+ }
+
+ private void handleCommandStop(Command command) {
+ if (OnOffType.ON.equals(command)) {
+ updateState(CONTROL, PlayPauseType.PAUSE);
+ stop();
+ updateState(TRACK_POSITION, new QuantityType<>(0, SmartHomeUnits.SECOND));
+ }
+ }
+
+ private void handleCommandControl(ChannelUID channelUID, Command command) {
+ String state;
+ if (command instanceof RefreshType) {
+ state = transportState;
+ State newState = UnDefType.UNDEF;
+ if ("PLAYING".equals(state)) {
+ newState = PlayPauseType.PLAY;
+ } else if ("STOPPED".equals(state)) {
+ newState = PlayPauseType.PAUSE;
+ } else if ("PAUSED_PLAYBACK".equals(state)) {
+ newState = PlayPauseType.PAUSE;
+ }
+ updateState(channelUID, newState);
+ } else if (command instanceof PlayPauseType) {
+ if (PlayPauseType.PLAY.equals(command)) {
+ if (registeredQueue) {
+ registeredQueue = false;
+ playingQueue = true;
+ oneplayed = false;
+ serve();
+ } else {
+ play();
+ }
+ } else if (PlayPauseType.PAUSE.equals(command)) {
+ checkPaused();
+ pause();
+ }
+ } else if (command instanceof NextPreviousType) {
+ if (NextPreviousType.NEXT.equals(command)) {
+ serveNext();
+ } else if (NextPreviousType.PREVIOUS.equals(command)) {
+ servePrevious();
+ }
+ } else if (command instanceof RewindFastforwardType) {
+ int pos = 0;
+ if (RewindFastforwardType.FASTFORWARD.equals(command)) {
+ pos = Integer.min(trackDuration, trackPosition + config.seekStep);
+ } else if (command == RewindFastforwardType.REWIND) {
+ pos = Integer.max(0, trackPosition - config.seekStep);
+ }
+ seek(String.format("%02d:%02d:%02d", pos / 3600, (pos % 3600) / 60, pos % 60));
+ }
+ }
+
+ private void handleCommandRepeat(ChannelUID channelUID, Command command) {
+ if (command instanceof RefreshType) {
+ updateState(channelUID, OnOffType.from(repeat));
+ } else {
+ repeat = (OnOffType.ON.equals(command));
+ currentQueue.setRepeat(repeat);
+ updateState(channelUID, OnOffType.from(repeat));
+ logger.debug("Repeat set to {} for {}", repeat, thing.getLabel());
+ }
+ }
+
+ private void handleCommandShuffle(ChannelUID channelUID, Command command) {
+ if (command instanceof RefreshType) {
+ updateState(channelUID, OnOffType.from(shuffle));
+ } else {
+ shuffle = (OnOffType.ON.equals(command));
+ currentQueue.setShuffle(shuffle);
+ if (!playing) {
+ resetToStartQueue();
+ }
+ updateState(channelUID, OnOffType.from(shuffle));
+ logger.debug("Shuffle set to {} for {}", shuffle, thing.getLabel());
+ }
+ }
+
+ private void handleCommandOnlyPlayOne(ChannelUID channelUID, Command command) {
+ if (command instanceof RefreshType) {
+ updateState(channelUID, OnOffType.from(onlyplayone));
+ } else {
+ onlyplayone = (OnOffType.ON.equals(command));
+ oneplayed = (onlyplayone && playing) ? true : false;
+ if (oneplayed) {
+ setNextURI("", "");
+ } else {
+ UpnpEntry next = nextEntry;
+ if (next != null) {
+ setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
+ }
+ }
+ updateState(channelUID, OnOffType.from(onlyplayone));
+ logger.debug("OnlyPlayOne set to {} for {}", onlyplayone, thing.getLabel());
+ }
+ }
+
+ private void handleCommandUri(ChannelUID channelUID, Command command) {
+ if (command instanceof RefreshType) {
+ updateState(channelUID, StringType.valueOf(nowPlayingUri));
+ } else if (command instanceof StringType) {
+ setCurrentURI(command.toString(), "");
+ play();
+ }
+ }
+
+ private void handleCommandFavoriteSelect(Command command) {
+ if (command instanceof StringType) {
+ favoriteName = command.toString();
+ updateState(FAVORITE, StringType.valueOf(favoriteName));
+ playFavorite();
+ }
+ }
+
+ private void handleCommandFavorite(ChannelUID channelUID, Command command) {
+ if (command instanceof StringType) {
+ favoriteName = command.toString();
+ if (favoriteCommandOptionList.contains(new CommandOption(favoriteName, favoriteName))) {
+ playFavorite();
+ }
+ }
+ updateState(channelUID, StringType.valueOf(favoriteName));
+ }
+
+ private void handleCommandFavoriteAction(Command command) {
+ if (command instanceof StringType) {
+ switch (command.toString()) {
+ case SAVE:
+ handleCommandFavoriteSave();
+ break;
+ case DELETE:
+ handleCommandFavoriteDelete();
+ break;
+ }
+ }
+ }
+
+ private void handleCommandFavoriteSave() {
+ if (!favoriteName.isEmpty()) {
+ UpnpFavorite favorite = new UpnpFavorite(favoriteName, nowPlayingUri, currentEntry);
+ favorite.saveFavorite(favoriteName, bindingConfig.path);
+ updateFavoritesList();
+ }
+ }
+
+ private void handleCommandFavoriteDelete() {
+ if (!favoriteName.isEmpty()) {
+ UpnpControlUtil.deleteFavorite(favoriteName, bindingConfig.path);
+ updateFavoritesList();
+ updateState(FAVORITE, UnDefType.UNDEF);
+ }
+ }
+
+ private void handleCommandPlaylistSelect(Command command) {
+ if (command instanceof StringType) {
+ String playlistName = command.toString();
+ UpnpEntryQueue queue = new UpnpEntryQueue();
+ queue.restoreQueue(playlistName, null, bindingConfig.path);
+ registerQueue(queue);
+ resetToStartQueue();
+ playingQueue = true;
+ serve();
+ }
+ }
+
+ private void handleCommandTrackPosition(ChannelUID channelUID, Command command) {
+ if (command instanceof RefreshType) {
+ updateState(channelUID, new QuantityType<>(trackPosition, SmartHomeUnits.SECOND));
+ } else if (command instanceof QuantityType<?>) {
+ QuantityType<?> position = ((QuantityType<?>) command).toUnit(SmartHomeUnits.SECOND);
+ if (position != null) {
+ int pos = Integer.min(trackDuration, position.intValue());
+ seek(String.format("%02d:%02d:%02d", pos / 3600, (pos % 3600) / 60, pos % 60));
+ }
+ }
+ }
+
+ private void handleCommandRelTrackPosition(ChannelUID channelUID, Command command) {
+ if (command instanceof RefreshType) {
+ int relPosition = (trackDuration != 0) ? (trackPosition * 100) / trackDuration : 0;
+ updateState(channelUID, new PercentType(relPosition));
+ } else if (command instanceof PercentType) {
+ int pos = ((PercentType) command).intValue() * trackDuration / 100;
+ seek(String.format("%02d:%02d:%02d", pos / 3600, (pos % 3600) / 60, pos % 60));
+ }
+ }
+
+ /**
+ * Set the volume for notifications.
+ *
+ * @param volume
+ */
+ public void setNotificationVolume(PercentType volume) {
+ notificationVolume = volume;
+ }
+
+ /**
+ * Play a notification. Previous state of the renderer will resume at the end of the notification, or after the
+ * maximum notification duration as defined in the renderer parameters.
+ *
+ * @param URI for notification sound
+ */
+ public void playNotification(String URI) {
+ synchronized (notificationLock) {
+ if (URI.isEmpty()) {
+ logger.debug("UPnP device {} received empty notification URI", thing.getLabel());
+ return;
+ }
+
+ notificationUri = URI;
+
+ logger.debug("UPnP device {} playing notification {}", thing.getLabel(), URI);
+
+ cancelTrackPositionRefresh();
+ getPositionInfo();
+
+ cancelPlayingNotificationFuture();
+
+ if (config.maxNotificationDuration > 0) {
+ playingNotificationFuture = upnpScheduler.schedule(this::stop, config.maxNotificationDuration,
+ TimeUnit.SECONDS);
+ }
+ playingNotification = true;
+
+ setCurrentURI(URI, "");
+ setNextURI("", "");
+ PercentType volume = notificationVolume;
+ setVolume(volume == null
+ ? new PercentType(Math.min(100,
+ Math.max(0, (100 + config.notificationVolumeAdjustment) * soundVolume.intValue() / 100)))
+ : volume);
+
+ CompletableFuture<Boolean> stopping = isStopping;
+ try {
+ if (stopping != null) {
+ // wait for maximum 2.5s until the renderer stopped before playing
+ stopping.get(config.responseTimeout, TimeUnit.MILLISECONDS);
+ }
+ } catch (InterruptedException | ExecutionException | TimeoutException e) {
+ logger.debug("Timeout exception, renderer {} didn't stop yet, trying to play anyway", thing.getLabel());
+ }
+ play();
+ }
+ }
+
+ private void cancelPlayingNotificationFuture() {
+ ScheduledFuture<?> future = playingNotificationFuture;
+ if (future != null) {
+ future.cancel(true);
+ playingNotificationFuture = null;
+ }
}
- /**
- * Invoke getPositionInfo on UPnP Rendering Control.
- * Result is received in {@link onValueReceived}.
- */
- protected void getPositionInfo() {
- Map<String, String> inputs = Collections.singletonMap("InstanceID", Integer.toString(rcsId));
+ private void resumeAfterNotification() {
+ synchronized (notificationLock) {
+ logger.debug("UPnP device {} resume after playing notification", thing.getLabel());
- invokeAction("AVTransport", "GetPositionInfo", inputs);
- }
+ setCurrentURI(nowPlayingUri, "");
+ setVolume(soundVolume);
- @Override
- public void handleCommand(ChannelUID channelUID, Command command) {
- logger.debug("Handle command {} for channel {} on renderer {}", command, channelUID, thing.getLabel());
+ cancelPlayingNotificationFuture();
- String transportState;
- if (command instanceof RefreshType) {
- switch (channelUID.getId()) {
- case VOLUME:
- getVolume(getCurrentChannel());
- break;
- case MUTE:
- getMute(getCurrentChannel());
- break;
- case CONTROL:
- transportState = this.transportState;
- State newState = UnDefType.UNDEF;
- if ("PLAYING".equals(transportState)) {
- newState = PlayPauseType.PLAY;
- } else if ("STOPPED".equals(transportState)) {
- newState = PlayPauseType.PAUSE;
- } else if ("PAUSED_PLAYBACK".equals(transportState)) {
- newState = PlayPauseType.PAUSE;
- }
- updateState(channelUID, newState);
- break;
- }
- return;
- } else {
- switch (channelUID.getId()) {
- case VOLUME:
- setVolume(getCurrentChannel(), (PercentType) command);
- break;
- case MUTE:
- setMute(getCurrentChannel(), (OnOffType) command);
- break;
- case STOP:
- if (command == OnOffType.ON) {
- updateState(CONTROL, PlayPauseType.PAUSE);
- playerStopped = true;
- stop();
- updateState(TRACK_POSITION, new QuantityType<>(0, SmartHomeUnits.SECOND));
- }
- break;
- case CONTROL:
- playerStopped = false;
- if (command instanceof PlayPauseType) {
- if (command == PlayPauseType.PLAY) {
- play();
- } else if (command == PlayPauseType.PAUSE) {
- pause();
- }
- } else if (command instanceof NextPreviousType) {
- if (command == NextPreviousType.NEXT) {
- playerStopped = true;
- serveNext();
- } else if (command == NextPreviousType.PREVIOUS) {
- playerStopped = true;
- servePrevious();
- }
- } else if (command instanceof RewindFastforwardType) {
- }
- break;
+ playingNotification = false;
+ notificationVolume = null;
+ notificationUri = "";
+
+ if (playing) {
+ int pos = posAtNotificationStart;
+ seek(String.format("%02d:%02d:%02d", pos / 3600, (pos % 3600) / 60, pos % 60));
+ play();
}
+ posAtNotificationStart = 0;
+ }
+ }
- return;
+ private void playFavorite() {
+ UpnpFavorite favorite = new UpnpFavorite(favoriteName, bindingConfig.path);
+ String uri = favorite.getUri();
+ UpnpEntry entry = favorite.getUpnpEntry();
+ if (!uri.isEmpty()) {
+ String metadata = "";
+ if (entry != null) {
+ metadata = UpnpXMLParser.compileMetadataString(entry);
+ }
+ setCurrentURI(uri, metadata);
+ play();
}
}
+ void updateFavoritesList() {
+ favoriteCommandOptionList = UpnpControlUtil.favorites(bindingConfig.path).stream()
+ .map(p -> (new CommandOption(p, p))).collect(Collectors.toList());
+ updateCommandDescription(favoriteSelectChannelUID, favoriteCommandOptionList);
+ }
+
+ @Override
+ public void playlistsListChanged() {
+ playlistCommandOptionList = UpnpControlUtil.playlists().stream().map(p -> (new CommandOption(p, p)))
+ .collect(Collectors.toList());
+ updateCommandDescription(playlistSelectChannelUID, playlistCommandOptionList);
+ }
+
@Override
public void onStatusChanged(boolean status) {
- logger.debug("Renderer status changed to {}", status);
- if (status) {
- initRenderer();
- } else {
- cancelSubscriptionRefreshJob();
+ if (!status) {
+ removeSubscriptions();
updateState(CONTROL, PlayPauseType.PAUSE);
cancelTrackPositionRefresh();
-
- updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
- "Communication lost with " + thing.getLabel());
}
super.onStatusChanged(status);
}
+ @Override
+ protected @Nullable String preProcessValueReceived(Map<String, String> inputs, @Nullable String variable,
+ @Nullable String value, @Nullable String service, @Nullable String action) {
+ if (variable == null) {
+ return null;
+ } else {
+ switch (variable) {
+ case "CurrentVolume":
+ return (inputs.containsKey("Channel") ? inputs.get("Channel") : UPNP_MASTER) + "Volume";
+ case "CurrentMute":
+ return (inputs.containsKey("Channel") ? inputs.get("Channel") : UPNP_MASTER) + "Mute";
+ case "CurrentLoudness":
+ return (inputs.containsKey("Channel") ? inputs.get("Channel") : UPNP_MASTER) + "Loudness";
+ default:
+ return variable;
+ }
+ }
+ }
+
@Override
public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
if (logger.isTraceEnabled()) {
- logger.trace("Upnp device {} received variable {} with value {} from service {}", thing.getLabel(),
+ logger.trace("UPnP device {} received variable {} with value {} from service {}", thing.getLabel(),
variable, value, service);
} else {
if (logger.isDebugEnabled() && !("AbsTime".equals(variable) || "RelCount".equals(variable)
|| "RelTime".equals(variable) || "AbsCount".equals(variable) || "Track".equals(variable)
|| "TrackDuration".equals(variable))) {
// don't log all variables received when updating the track position every second
- logger.debug("Upnp device {} received variable {} with value {} from service {}", thing.getLabel(),
+ logger.debug("UPnP device {} received variable {} with value {} from service {}", thing.getLabel(),
variable, value, service);
}
}
return;
}
- switch (variable) {
- case "CurrentMute":
- if (!((value == null) || (value.isEmpty()))) {
- soundMute = OnOffType.from(Boolean.parseBoolean(value));
- updateState(MUTE, soundMute);
- }
- break;
- case "CurrentVolume":
- if (!((value == null) || (value.isEmpty()))) {
- soundVolume = PercentType.valueOf(value);
- updateState(VOLUME, soundVolume);
- }
- break;
- case "Sink":
- if (!((value == null) || (value.isEmpty()))) {
- updateProtocolInfo(value);
- }
- break;
- case "LastChange":
- // pre-process some variables, eg XML processing
- if (!((value == null) || value.isEmpty())) {
- if ("AVTransport".equals(service)) {
- Map<String, String> parsedValues = UpnpXMLParser.getAVTransportFromXML(value);
- for (Map.Entry<String, String> entrySet : parsedValues.entrySet()) {
- // Update the transport state after the update of the media information
- // to not break the notification mechanism
- if (!"TransportState".equals(entrySet.getKey())) {
- onValueReceived(entrySet.getKey(), entrySet.getValue(), service);
- }
- if ("AVTransportURI".equals(entrySet.getKey())) {
+ if (variable.endsWith("Volume")) {
+ onValueReceivedVolume(variable, value);
+ } else if (variable.endsWith("Mute")) {
+ onValueReceivedMute(variable, value);
+ } else if (variable.endsWith("Loudness")) {
+ onValueReceivedLoudness(variable, value);
+ } else {
+ switch (variable) {
+ case "LastChange":
+ onValueReceivedLastChange(value, service);
+ break;
+ case "CurrentTransportState":
+ case "TransportState":
+ onValueReceivedTransportState(value);
+ break;
+ case "CurrentTrackURI":
+ case "CurrentURI":
+ onValueReceivedCurrentURI(value);
+ break;
+ case "CurrentTrackMetaData":
+ case "CurrentURIMetaData":
+ onValueReceivedCurrentMetaData(value);
+ break;
+ case "NextAVTransportURIMetaData":
+ case "NextURIMetaData":
+ onValueReceivedNextMetaData(value);
+ break;
+ case "CurrentTrackDuration":
+ case "TrackDuration":
+ onValueReceivedDuration(value);
+ break;
+ case "RelTime":
+ onValueReceivedRelTime(value);
+ break;
+ default:
+ super.onValueReceived(variable, value, service);
+ break;
+ }
+ }
+ }
+
+ private void onValueReceivedVolume(String variable, @Nullable String value) {
+ if (value != null && !value.isEmpty()) {
+ UpnpRenderingControlConfiguration config = renderingControlConfiguration;
+
+ long volume = Long.valueOf(value);
+ volume = volume * 100 / config.maxvolume;
+
+ String upnpChannel = variable.replace("Volume", "volume").replace("Master", "");
+ updateState(upnpChannel, new PercentType((int) volume));
+
+ if (!playingNotification && "volume".equals(upnpChannel)) {
+ soundVolume = new PercentType((int) volume);
+ }
+ }
+ }
+
+ private void onValueReceivedMute(String variable, @Nullable String value) {
+ if (value != null && !value.isEmpty()) {
+ String upnpChannel = variable.replace("Mute", "mute").replace("Master", "");
+ updateState(upnpChannel,
+ ("1".equals(value) || "true".equals(value.toLowerCase())) ? OnOffType.ON : OnOffType.OFF);
+ }
+ }
+
+ private void onValueReceivedLoudness(String variable, @Nullable String value) {
+ if (value != null && !value.isEmpty()) {
+ String upnpChannel = variable.replace("Loudness", "loudness").replace("Master", "");
+ updateState(upnpChannel,
+ ("1".equals(value) || "true".equals(value.toLowerCase())) ? OnOffType.ON : OnOffType.OFF);
+ }
+ }
+
+ private void onValueReceivedLastChange(@Nullable String value, @Nullable String service) {
+ // This is returned from a GENA subscription. The jupnp library does not allow receiving new GENA subscription
+ // messages as long as this thread has not finished. As we may trigger long running processes based on this
+ // result, we run it in a separate thread.
+ upnpScheduler.submit(() -> {
+ // pre-process some variables, eg XML processing
+ if (value != null && !value.isEmpty()) {
+ if (AV_TRANSPORT.equals(service)) {
+ Map<String, String> parsedValues = UpnpXMLParser.getAVTransportFromXML(value);
+ for (Map.Entry<String, String> entrySet : parsedValues.entrySet()) {
+ switch (entrySet.getKey()) {
+ case "TransportState":
+ // Update the transport state after the update of the media information
+ // to not break the notification mechanism
+ break;
+ case "AVTransportURI":
onValueReceived("CurrentTrackURI", entrySet.getValue(), service);
- } else if ("AVTransportURIMetaData".equals(entrySet.getKey())) {
+ break;
+ case "AVTransportURIMetaData":
onValueReceived("CurrentTrackMetaData", entrySet.getValue(), service);
- }
- }
- if (parsedValues.containsKey("TransportState")) {
- onValueReceived("TransportState", parsedValues.get("TransportState"), service);
+ break;
+ default:
+ onValueReceived(entrySet.getKey(), entrySet.getValue(), service);
}
}
- }
- break;
- case "TransportState":
- transportState = (value == null) ? "" : value;
- if ("STOPPED".equals(value)) {
- updateState(CONTROL, PlayPauseType.PAUSE);
- cancelTrackPositionRefresh();
- // playerStopped is true if stop came from openHAB. This allows us to identify if we played to the
- // end of an entry. We should then move to the next entry if the queue is not at the end already.
- if (playing && !playerStopped) {
- // Only go to next for first STOP command, then wait until we received PLAYING before moving
- // to next (avoids issues with renderers sending multiple stop states)
- playing = false;
- serveNext();
- } else {
- currentEntry = nextEntry; // Try to get the metadata for the next entry if controlled by an
- // external control point
- playing = false;
- }
- } else if ("PLAYING".equals(value)) {
- playerStopped = false;
- playing = true;
- updateState(CONTROL, PlayPauseType.PLAY);
- scheduleTrackPositionRefresh();
- } else if ("PAUSED_PLAYBACK".equals(value)) {
- updateState(CONTROL, PlayPauseType.PAUSE);
- }
- break;
- case "CurrentTrackURI":
- UpnpEntry current = currentEntry;
- if (queueIterator.hasNext() && (current != null) && !current.getRes().equals(value)
- && currentQueue.get(queueIterator.nextIndex()).getRes().equals(value)) {
- // Renderer advanced to next entry independent of openHAB UPnP control point.
- // Advance in the queue to keep proper position status.
- // Make the next entry available to renderers that support it.
- updateMetaDataState(currentQueue.get(queueIterator.nextIndex()));
- logger.trace("Renderer moved from '{}' to next entry '{}' in queue", currentEntry,
- currentQueue.get(queueIterator.nextIndex()));
- currentEntry = queueIterator.next();
- if (queueIterator.hasNext()) {
- UpnpEntry next = currentQueue.get(queueIterator.nextIndex());
- setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
+ if (parsedValues.containsKey("TransportState")) {
+ onValueReceived("TransportState", parsedValues.get("TransportState"), service);
}
- }
- if (isSettingURI != null) {
- isSettingURI.complete(true); // We have received current URI, so can allow play to start
- }
- break;
- case "CurrentTrackMetaData":
- if (!((value == null) || (value.isEmpty()))) {
- List<UpnpEntry> list = UpnpXMLParser.getEntriesFromXML(value);
- if (!list.isEmpty()) {
- updateMetaDataState(list.get(0));
+ } else if (RENDERING_CONTROL.equals(service)) {
+ Map<String, @Nullable String> parsedValues = UpnpXMLParser.getRenderingControlFromXML(value);
+ for (String parsedValue : parsedValues.keySet()) {
+ onValueReceived(parsedValue, parsedValues.get(parsedValue), RENDERING_CONTROL);
}
}
- break;
- case "NextAVTransportURIMetaData":
- if (!((value == null) || (value.isEmpty() || "NOT_IMPLEMENTED".equals(value)))) {
- List<UpnpEntry> list = UpnpXMLParser.getEntriesFromXML(value);
- if (!list.isEmpty()) {
- nextEntry = list.get(0);
+ }
+ });
+ }
+
+ private void onValueReceivedTransportState(@Nullable String value) {
+ transportState = (value == null) ? "" : value;
+
+ if ("STOPPED".equals(value)) {
+ CompletableFuture<Boolean> stopping = isStopping;
+ if (stopping != null) {
+ stopping.complete(true); // We have received stop confirmation
+ isStopping = null;
+ }
+
+ if (playingNotification) {
+ resumeAfterNotification();
+ return;
+ }
+
+ cancelCheckPaused();
+ updateState(CONTROL, PlayPauseType.PAUSE);
+ cancelTrackPositionRefresh();
+ // Only go to next for first STOP command, then wait until we received PLAYING before moving
+ // to next (avoids issues with renderers sending multiple stop states)
+ if (playing) {
+ playing = false;
+
+ // playerStopped is true if stop came from openHAB. This allows us to identify if we played to the
+ // end of an entry, because STOP would come from the player and not from openHAB. We should then
+ // move to the next entry if the queue is not at the end already.
+ if (!playerStopped) {
+ if (Instant.now().toEpochMilli() >= expectedTrackend) {
+ // If we are receiving track duration info, we know when the track is expected to end. If we
+ // received STOP before track end, and it is not coming from openHAB, it must have been stopped
+ // from the renderer directly, and we do not want to play the next entry.
+ if (playingQueue) {
+ serveNext();
+ }
}
+ } else if (playingQueue) {
+ playingQueue = false;
}
- break;
- case "CurrentTrackDuration":
- case "TrackDuration":
- // track duration and track position have format H+:MM:SS[.F+] or H+:MM:SS[.F0/F1]. We are not
- // interested in the fractional seconds, so drop everything after . and calculate in seconds.
- if ((value == null) || ("NOT_IMPLEMENTED".equals(value))) {
- trackDuration = 0;
- updateState(TRACK_DURATION, UnDefType.UNDEF);
- } else {
- trackDuration = Arrays.stream(value.split("\\.")[0].split(":")).mapToInt(n -> Integer.parseInt(n))
- .reduce(0, (n, m) -> n * 60 + m);
- updateState(TRACK_DURATION, new QuantityType<>(trackDuration, SmartHomeUnits.SECOND));
- }
- break;
- case "RelTime":
- if ((value == null) || ("NOT_IMPLEMENTED".equals(value))) {
- trackPosition = 0;
- updateState(TRACK_POSITION, UnDefType.UNDEF);
- } else {
- trackPosition = Arrays.stream(value.split("\\.")[0].split(":")).mapToInt(n -> Integer.parseInt(n))
- .reduce(0, (n, m) -> n * 60 + m);
- updateState(TRACK_POSITION, new QuantityType<>(trackPosition, SmartHomeUnits.SECOND));
+ }
+ } else if ("PLAYING".equals(value)) {
+ if (playingNotification) {
+ return;
+ }
+
+ playerStopped = false;
+ playing = true;
+ registeredQueue = false; // reset queue registration flag as we are playing something
+ updateState(CONTROL, PlayPauseType.PLAY);
+ scheduleTrackPositionRefresh();
+ } else if ("PAUSED_PLAYBACK".equals(value)) {
+ cancelCheckPaused();
+ updateState(CONTROL, PlayPauseType.PAUSE);
+ } else if ("NO_MEDIA_PRESENT".equals(value)) {
+ updateState(CONTROL, UnDefType.UNDEF);
+ }
+ }
+
+ private void onValueReceivedCurrentURI(@Nullable String value) {
+ CompletableFuture<Boolean> settingURI = isSettingURI;
+ if (settingURI != null) {
+ settingURI.complete(true); // We have received current URI, so can allow play to start
+ }
+
+ UpnpEntry current = currentEntry;
+ UpnpEntry next = nextEntry;
+
+ String uri = "";
+ String currentUri = "";
+ String nextUri = "";
+ try {
+ if (value != null) {
+ uri = URLDecoder.decode(value.trim(), StandardCharsets.UTF_8.name());
+ }
+ if (current != null) {
+ currentUri = URLDecoder.decode(current.getRes().trim(), StandardCharsets.UTF_8.name());
+ }
+ if (next != null) {
+ nextUri = URLDecoder.decode(next.getRes(), StandardCharsets.UTF_8.name());
+ }
+ } catch (UnsupportedEncodingException ignore) {
+ // If not valid current URI, we assume there is none
+ }
+
+ if (playingNotification && uri.equals(notificationUri)) {
+ // No need to update anything more if this is for playing a notification
+ return;
+ }
+
+ nowPlayingUri = uri;
+ updateState(URI, StringType.valueOf(uri));
+
+ logger.trace("Renderer {} received URI: {}", thing.getLabel(), uri);
+ logger.trace("Renderer {} current URI: {}, equal to received URI {}", thing.getLabel(), currentUri,
+ uri.equals(currentUri));
+ logger.trace("Renderer {} next URI: {}", thing.getLabel(), nextUri);
+
+ if (!uri.equals(currentUri)) {
+ if ((next != null) && uri.equals(nextUri)) {
+ // Renderer advanced to next entry independent of openHAB UPnP control point.
+ // Advance in the queue to keep proper position status.
+ // Make the next entry available to renderers that support it.
+ logger.trace("Renderer {} moved from '{}' to next entry '{}' in queue", thing.getLabel(), current,
+ next);
+ currentEntry = currentQueue.next();
+ nextEntry = currentQueue.get(currentQueue.nextIndex());
+ logger.trace("Renderer {} auto move forward, current queue index: {}", thing.getLabel(),
+ currentQueue.index());
+
+ updateMetaDataState(next);
+
+ // look one further to get next entry for next URI
+ next = nextEntry;
+ if ((next != null) && !onlyplayone) {
+ setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
}
- break;
- default:
- super.onValueReceived(variable, value, service);
- break;
+ } else {
+ // A new entry is being served that does not match the next entry in the queue. This can be because a
+ // sound or stream is being played through an action, or another control point started a new entry.
+ // We should clear the metadata in this case and wait for new metadata to arrive.
+ clearMetaDataState();
+ }
+ }
+ }
+
+ private void onValueReceivedCurrentMetaData(@Nullable String value) {
+ if (playingNotification) {
+ // Don't update metadata when playing notification
+ return;
+ }
+
+ if (value != null && !value.isEmpty()) {
+ List<UpnpEntry> list = UpnpXMLParser.getEntriesFromXML(value);
+ if (!list.isEmpty()) {
+ updateMetaDataState(list.get(0));
+ return;
+ }
+ }
+ clearMetaDataState();
+ }
+
+ private void onValueReceivedNextMetaData(@Nullable String value) {
+ if (value != null && !value.isEmpty() && !"NOT_IMPLEMENTED".equals(value)) {
+ List<UpnpEntry> list = UpnpXMLParser.getEntriesFromXML(value);
+ if (!list.isEmpty()) {
+ nextEntry = list.get(0);
+ }
+ }
+ }
+
+ private void onValueReceivedDuration(@Nullable String value) {
+ // track duration and track position have format H+:MM:SS[.F+] or H+:MM:SS[.F0/F1]. We are not
+ // interested in the fractional seconds, so drop everything after . and calculate in seconds.
+ if (value == null || "NOT_IMPLEMENTED".equals(value)) {
+ trackDuration = 0;
+ updateState(TRACK_DURATION, UnDefType.UNDEF);
+ updateState(REL_TRACK_POSITION, UnDefType.UNDEF);
+ } else {
+ try {
+ trackDuration = Arrays.stream(value.split("\\.")[0].split(":")).mapToInt(n -> Integer.parseInt(n))
+ .reduce(0, (n, m) -> n * 60 + m);
+ updateState(TRACK_DURATION, new QuantityType<>(trackDuration, SmartHomeUnits.SECOND));
+ } catch (NumberFormatException e) {
+ logger.debug("Illegal format for track duration {}", value);
+ return;
+ }
+ }
+ setExpectedTrackend();
+ }
+
+ private void onValueReceivedRelTime(@Nullable String value) {
+ if (value == null || "NOT_IMPLEMENTED".equals(value)) {
+ trackPosition = 0;
+ updateState(TRACK_POSITION, UnDefType.UNDEF);
+ updateState(REL_TRACK_POSITION, UnDefType.UNDEF);
+ } else {
+ try {
+ trackPosition = Arrays.stream(value.split("\\.")[0].split(":")).mapToInt(n -> Integer.parseInt(n))
+ .reduce(0, (n, m) -> n * 60 + m);
+ updateState(TRACK_POSITION, new QuantityType<>(trackPosition, SmartHomeUnits.SECOND));
+ int relPosition = (trackDuration != 0) ? trackPosition * 100 / trackDuration : 0;
+ updateState(REL_TRACK_POSITION, new PercentType(relPosition));
+ } catch (NumberFormatException e) {
+ logger.trace("Illegal format for track position {}", value);
+ return;
+ }
+ }
+
+ if (playingNotification) {
+ posAtNotificationStart = trackPosition;
}
+
+ setExpectedTrackend();
}
- private void updateProtocolInfo(String value) {
+ @Override
+ protected void updateProtocolInfo(String value) {
sink.clear();
supportedAudioFormats.clear();
audioSupport = false;
}
if (audioSupport) {
- logger.debug("Device {} supports audio", thing.getLabel());
+ logger.debug("Renderer {} supports audio", thing.getLabel());
registerAudioSink();
}
}
- private void registerAudioSink() {
- if (audioSinkRegistered) {
- logger.debug("Audio Sink already registered for renderer {}", thing.getLabel());
- return;
- } else if (!service.isRegistered(this)) {
- logger.debug("Audio Sink registration for renderer {} failed, no service", thing.getLabel());
- return;
- }
- logger.debug("Registering Audio Sink for renderer {}", thing.getLabel());
- audioSinkReg.registerAudioSink(this);
- audioSinkRegistered = true;
- }
-
private void clearCurrentEntry() {
- updateState(TITLE, UnDefType.UNDEF);
- updateState(ALBUM, UnDefType.UNDEF);
- updateState(ALBUM_ART, UnDefType.UNDEF);
- updateState(CREATOR, UnDefType.UNDEF);
- updateState(ARTIST, UnDefType.UNDEF);
- updateState(PUBLISHER, UnDefType.UNDEF);
- updateState(GENRE, UnDefType.UNDEF);
- updateState(TRACK_NUMBER, UnDefType.UNDEF);
+ clearMetaDataState();
+
trackDuration = 0;
updateState(TRACK_DURATION, UnDefType.UNDEF);
trackPosition = 0;
updateState(TRACK_POSITION, UnDefType.UNDEF);
+ updateState(REL_TRACK_POSITION, UnDefType.UNDEF);
currentEntry = null;
}
*
* @param queue
*/
- public void registerQueue(ArrayList<UpnpEntry> queue) {
+ protected void registerQueue(UpnpEntryQueue queue) {
+ if (currentQueue.equals(queue)) {
+ // We get the same queue, so do nothing
+ return;
+ }
+
logger.debug("Registering queue on renderer {}", thing.getLabel());
+
+ registeredQueue = true;
currentQueue = queue;
- queueIterator = new UpnpIterator<>(currentQueue.listIterator());
- if (playing) {
- if (queueIterator.hasNext()) {
+ currentQueue.setRepeat(repeat);
+ currentQueue.setShuffle(shuffle);
+ if (playingQueue) {
+ nextEntry = currentQueue.get(currentQueue.nextIndex());
+ UpnpEntry next = nextEntry;
+ if ((next != null) && !onlyplayone) {
// make the next entry available to renderers that support it
- logger.trace("Still playing, set new queue as next entry");
- UpnpEntry next = currentQueue.get(queueIterator.nextIndex());
+ logger.trace("Renderer {} still playing, set new queue as next entry", thing.getLabel());
setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
}
} else {
- if (queueIterator.hasNext()) {
- UpnpEntry entry = queueIterator.next();
- updateMetaDataState(entry);
- setCurrentURI(entry.getRes(), UpnpXMLParser.compileMetadataString(entry));
- currentEntry = entry;
- } else {
- clearCurrentEntry();
- }
+ resetToStartQueue();
}
}
* Move to next position in queue and start playing.
*/
private void serveNext() {
- if (queueIterator.hasNext()) {
- currentEntry = queueIterator.next();
+ if (currentQueue.hasNext()) {
+ currentEntry = currentQueue.next();
+ nextEntry = currentQueue.get(currentQueue.nextIndex());
logger.debug("Serve next media '{}' from queue on renderer {}", currentEntry, thing.getLabel());
+ logger.trace("Serve next, current queue index: {}", currentQueue.index());
+
serve();
} else {
logger.debug("Cannot serve next, end of queue on renderer {}", thing.getLabel());
- cancelTrackPositionRefresh();
- stop();
- queueIterator = new UpnpIterator<>(currentQueue.listIterator()); // reset to beginning of queue
- if (currentQueue.isEmpty()) {
- clearCurrentEntry();
- } else {
- updateMetaDataState(currentQueue.get(queueIterator.nextIndex()));
- UpnpEntry entry = queueIterator.next();
- setCurrentURI(entry.getRes(), UpnpXMLParser.compileMetadataString(entry));
- currentEntry = entry;
- }
+ resetToStartQueue();
}
}
* Move to previous position in queue and start playing.
*/
private void servePrevious() {
- if (queueIterator.hasPrevious()) {
- currentEntry = queueIterator.previous();
+ if (currentQueue.hasPrevious()) {
+ currentEntry = currentQueue.previous();
+ nextEntry = currentQueue.get(currentQueue.nextIndex());
logger.debug("Serve previous media '{}' from queue on renderer {}", currentEntry, thing.getLabel());
+ logger.trace("Serve previous, current queue index: {}", currentQueue.index());
+
serve();
} else {
logger.debug("Cannot serve previous, already at start of queue on renderer {}", thing.getLabel());
- cancelTrackPositionRefresh();
- stop();
- queueIterator = new UpnpIterator<>(currentQueue.listIterator()); // reset to beginning of queue
- if (currentQueue.isEmpty()) {
- clearCurrentEntry();
- } else {
- updateMetaDataState(currentQueue.get(queueIterator.nextIndex()));
- UpnpEntry entry = queueIterator.next();
- setCurrentURI(entry.getRes(), UpnpXMLParser.compileMetadataString(entry));
- currentEntry = entry;
- }
+ resetToStartQueue();
+ }
+ }
+
+ private void resetToStartQueue() {
+ logger.trace("Reset to start queue on renderer {}", thing.getLabel());
+
+ playingQueue = false;
+ registeredQueue = true;
+
+ stop();
+
+ currentQueue.resetIndex(); // reset to beginning of queue
+ currentEntry = currentQueue.next();
+ nextEntry = currentQueue.get(currentQueue.nextIndex());
+ logger.trace("Reset queue, current queue index: {}", currentQueue.index());
+ UpnpEntry current = currentEntry;
+ if (current != null) {
+ clearMetaDataState();
+ updateMetaDataState(current);
+ setCurrentURI(current.getRes(), UpnpXMLParser.compileMetadataString(current));
+ } else {
+ clearCurrentEntry();
+ }
+
+ UpnpEntry next = nextEntry;
+ if (onlyplayone) {
+ setNextURI("", "");
+ } else if (next != null) {
+ setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
}
}
/**
- * Play media.
+ * Serve media from a queue and play immediately when already playing.
*
* @param media
*/
private void serve() {
+ logger.trace("Serve media on renderer {}", thing.getLabel());
+
UpnpEntry entry = currentEntry;
if (entry != null) {
- logger.trace("Ready to play '{}' from queue", currentEntry);
- updateMetaDataState(entry);
+ clearMetaDataState();
String res = entry.getRes();
if (res.isEmpty()) {
- logger.debug("Cannot serve media '{}', no URI", currentEntry);
+ logger.debug("Renderer {} cannot serve media '{}', no URI", thing.getLabel(), currentEntry);
+ playingQueue = false;
return;
}
+ updateMetaDataState(entry);
setCurrentURI(res, UpnpXMLParser.compileMetadataString(entry));
- play();
+
+ if ((playingQueue || playing) && !(onlyplayone && oneplayed)) {
+ logger.trace("Ready to play '{}' from queue", currentEntry);
+
+ trackDuration = 0;
+ trackPosition = 0;
+ expectedTrackend = 0;
+ play();
+
+ oneplayed = true;
+ playingQueue = true;
+ }
// make the next entry available to renderers that support it
- if (queueIterator.hasNext()) {
- UpnpEntry next = currentQueue.get(queueIterator.nextIndex());
- setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
+ if (!onlyplayone) {
+ UpnpEntry next = nextEntry;
+ if (next != null) {
+ setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
+ }
}
}
}
+ /**
+ * Called before handling a pause CONTROL command. If we do not received PAUSED_PLAYBACK or STOPPED back within
+ * timeout, we will revert to playing state. This takes care of renderers that cannot pause playback.
+ */
+ private void checkPaused() {
+ paused = upnpScheduler.schedule(this::resetPaused, config.responseTimeout, TimeUnit.MILLISECONDS);
+ }
+
+ private void resetPaused() {
+ updateState(CONTROL, PlayPauseType.PLAY);
+ }
+
+ private void cancelCheckPaused() {
+ ScheduledFuture<?> future = paused;
+ if (future != null) {
+ future.cancel(true);
+ paused = null;
+ }
+ }
+
+ private void setExpectedTrackend() {
+ expectedTrackend = Instant.now().toEpochMilli() + (trackDuration - trackPosition) * 1000
+ - config.responseTimeout;
+ }
+
/**
* Update the current track position every second if the channel is linked.
*/
private void scheduleTrackPositionRefresh() {
- cancelTrackPositionRefresh();
- if (!isLinked(TRACK_POSITION)) {
+ if (playingNotification) {
return;
}
- if (trackPositionRefresh == null) {
- trackPositionRefresh = scheduler.scheduleWithFixedDelay(this::getPositionInfo, 1, 1, TimeUnit.SECONDS);
+
+ cancelTrackPositionRefresh();
+ if (!(isLinked(TRACK_POSITION) || isLinked(REL_TRACK_POSITION))) {
+ // only get it once, so we can use the track end to correctly identify STOP pressed directly on renderer
+ getPositionInfo();
+ } else {
+ if (trackPositionRefresh == null) {
+ trackPositionRefresh = upnpScheduler.scheduleWithFixedDelay(this::getPositionInfo, 1, 1,
+ TimeUnit.SECONDS);
+ }
}
}
trackPosition = 0;
updateState(TRACK_POSITION, new QuantityType<>(trackPosition, SmartHomeUnits.SECOND));
+ int relPosition = (trackDuration != 0) ? trackPosition / trackDuration : 0;
+ updateState(REL_TRACK_POSITION, new PercentType(relPosition));
}
/**
* @param media
*/
private void updateMetaDataState(UpnpEntry media) {
- // The AVTransport passes the URI resource in the ID.
- // We don't want to update metadata if the metadata from the AVTransport is empty for the current entry.
- boolean isCurrent;
- UpnpEntry entry = currentEntry;
- if (entry == null) {
- entry = new UpnpEntry(media.getId(), media.getId(), "", "object.item");
- currentEntry = entry;
- isCurrent = false;
+ // We don't want to update metadata if the metadata from the AVTransport is less complete than in the current
+ // entry.
+ boolean isCurrent = false;
+ UpnpEntry entry = null;
+ if (playingQueue) {
+ entry = currentEntry;
+ }
+
+ logger.trace("Renderer {}, received media ID: {}", thing.getLabel(), media.getId());
+
+ if ((entry != null) && entry.getId().equals(media.getId())) {
+ logger.trace("Current ID: {}", entry.getId());
+
+ isCurrent = true;
} else {
- isCurrent = media.getId().equals(entry.getRes());
+ // Sometimes we receive the media URL without the ID, then compare on URL
+ String mediaRes = media.getRes().trim();
+ String entryRes = (entry != null) ? entry.getRes().trim() : "";
+
+ try {
+ String mediaUrl = URLDecoder.decode(mediaRes, StandardCharsets.UTF_8.name());
+ String entryUrl = URLDecoder.decode(entryRes, StandardCharsets.UTF_8.name());
+ isCurrent = mediaUrl.equals(entryUrl);
+ } catch (UnsupportedEncodingException e) {
+ logger.debug("Renderer {} unsupported encoding for new {} or current {} res URL, trying string compare",
+ thing.getLabel(), mediaRes, entryRes);
+ isCurrent = mediaRes.equals(entryRes);
+ }
+
+ logger.trace("Current queue res: {}", entryRes);
+ logger.trace("Updated media res: {}", mediaRes);
}
- logger.trace("Media ID: {}", media.getId());
- logger.trace("Current queue res: {}", entry.getRes());
- logger.trace("Updating current entry: {}", isCurrent);
+
+ logger.trace("Received meta data is for current entry: {}", isCurrent);
if (!(isCurrent && media.getTitle().isEmpty())) {
updateState(TITLE, StringType.valueOf(media.getTitle()));
}
}
+ private void clearMetaDataState() {
+ updateState(TITLE, UnDefType.UNDEF);
+ updateState(ALBUM, UnDefType.UNDEF);
+ updateState(ALBUM_ART, UnDefType.UNDEF);
+ updateState(CREATOR, UnDefType.UNDEF);
+ updateState(ARTIST, UnDefType.UNDEF);
+ updateState(PUBLISHER, UnDefType.UNDEF);
+ updateState(GENRE, UnDefType.UNDEF);
+ updateState(TRACK_NUMBER, UnDefType.UNDEF);
+ }
+
/**
* @return Audio formats supported by the renderer.
*/
return supportedAudioFormats;
}
+ private void registerAudioSink() {
+ if (audioSinkRegistered) {
+ logger.debug("Audio Sink already registered for renderer {}", thing.getLabel());
+ return;
+ } else if (!upnpIOService.isRegistered(this)) {
+ logger.debug("Audio Sink registration for renderer {} failed, no service", thing.getLabel());
+ return;
+ }
+ logger.debug("Registering Audio Sink for renderer {}", thing.getLabel());
+ audioSinkReg.registerAudioSink(this);
+ audioSinkRegistered = true;
+ }
+
/**
* @return UPnP sink definitions supported by the renderer.
*/
import java.util.Map;
import java.util.Optional;
import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.upnpcontrol.internal.UpnpControlHandlerFactory;
import org.openhab.binding.upnpcontrol.internal.UpnpDynamicCommandDescriptionProvider;
import org.openhab.binding.upnpcontrol.internal.UpnpDynamicStateDescriptionProvider;
-import org.openhab.binding.upnpcontrol.internal.UpnpEntry;
-import org.openhab.binding.upnpcontrol.internal.UpnpProtocolMatcher;
-import org.openhab.binding.upnpcontrol.internal.UpnpXMLParser;
+import org.openhab.binding.upnpcontrol.internal.config.UpnpControlBindingConfiguration;
import org.openhab.binding.upnpcontrol.internal.config.UpnpControlServerConfiguration;
+import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntry;
+import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntryQueue;
+import org.openhab.binding.upnpcontrol.internal.util.UpnpControlUtil;
+import org.openhab.binding.upnpcontrol.internal.util.UpnpProtocolMatcher;
+import org.openhab.binding.upnpcontrol.internal.util.UpnpXMLParser;
import org.openhab.core.io.transport.upnp.UpnpIOService;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.types.Command;
-import org.openhab.core.types.CommandDescription;
-import org.openhab.core.types.CommandDescriptionBuilder;
import org.openhab.core.types.CommandOption;
import org.openhab.core.types.RefreshType;
-import org.openhab.core.types.StateDescription;
-import org.openhab.core.types.StateDescriptionFragmentBuilder;
+import org.openhab.core.types.State;
import org.openhab.core.types.StateOption;
import org.openhab.core.types.UnDefType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
- * The {@link UpnpServerHandler} is responsible for handling commands sent to the UPnP Server.
+ * The {@link UpnpServerHandler} is responsible for handling commands sent to the UPnP Server. It implements UPnP
+ * ContentDirectory service actions.
*
* @author Mark Herwege - Initial contribution
* @author Karel Goderis - Based on UPnP logic in Sonos binding
@NonNullByDefault
public class UpnpServerHandler extends UpnpHandler {
- private static final String DIRECTORY_ROOT = "0";
- private static final String UP = "..";
-
private final Logger logger = LoggerFactory.getLogger(UpnpServerHandler.class);
- private ConcurrentMap<String, UpnpRendererHandler> upnpRenderers;
+ // UPnP constants
+ static final String CONTENT_DIRECTORY = "ContentDirectory";
+ static final String DIRECTORY_ROOT = "0";
+ static final String UP = "..";
+
+ ConcurrentMap<String, UpnpRendererHandler> upnpRenderers;
private volatile @Nullable UpnpRendererHandler currentRendererHandler;
private volatile List<StateOption> rendererStateOptionList = Collections.synchronizedList(new ArrayList<>());
+ private volatile List<CommandOption> playlistCommandOptionList = List.of();
+
private @NonNullByDefault({}) ChannelUID rendererChannelUID;
private @NonNullByDefault({}) ChannelUID currentSelectionChannelUID;
+ private @NonNullByDefault({}) ChannelUID playlistSelectChannelUID;
+
+ private volatile @Nullable CompletableFuture<Boolean> isBrowsing;
+ private volatile boolean browseUp = false; // used to avoid automatically going down a level if only one container
+ // entry found when going up in the hierarchy
- private volatile UpnpEntry currentEntry = new UpnpEntry(DIRECTORY_ROOT, DIRECTORY_ROOT, DIRECTORY_ROOT,
+ private static final UpnpEntry ROOT_ENTRY = new UpnpEntry(DIRECTORY_ROOT, DIRECTORY_ROOT, DIRECTORY_ROOT,
"object.container");
- private volatile List<UpnpEntry> entries = Collections.synchronizedList(new ArrayList<>()); // current entry list in
- // selection
- private volatile Map<String, UpnpEntry> parentMap = new HashMap<>(); // store parents in hierarchy separately to be
- // able to move up in directory structure
+ volatile UpnpEntry currentEntry = ROOT_ENTRY;
+ // current entry list in selection
+ List<UpnpEntry> entries = Collections.synchronizedList(new ArrayList<>());
+ // store parents in hierarchy separately to be able to move up in directory structure
+ private ConcurrentMap<String, UpnpEntry> parentMap = new ConcurrentHashMap<>();
- private UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider;
- private UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider;
+ private volatile String playlistName = "";
protected @NonNullByDefault({}) UpnpControlServerConfiguration config;
public UpnpServerHandler(Thing thing, UpnpIOService upnpIOService,
ConcurrentMap<String, UpnpRendererHandler> upnpRenderers,
UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider,
- UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider) {
- super(thing, upnpIOService);
+ UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider,
+ UpnpControlBindingConfiguration configuration) {
+ super(thing, upnpIOService, configuration, upnpStateDescriptionProvider, upnpCommandDescriptionProvider);
this.upnpRenderers = upnpRenderers;
- this.upnpStateDescriptionProvider = upnpStateDescriptionProvider;
- this.upnpCommandDescriptionProvider = upnpCommandDescriptionProvider;
// put root as highest level in parent map
- parentMap.put(currentEntry.getId(), currentEntry);
+ parentMap.put(ROOT_ENTRY.getId(), ROOT_ENTRY);
}
@Override
"Channel " + BROWSE + " not defined");
return;
}
- if (config.udn != null) {
- if (service.isRegistered(this)) {
- initServer();
- } else {
+ Channel playlistSelectChannel = thing.getChannel(PLAYLIST_SELECT);
+ if (playlistSelectChannel != null) {
+ playlistSelectChannelUID = playlistSelectChannel.getUID();
+ } else {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "Channel " + PLAYLIST_SELECT + " not defined");
+ return;
+ }
+
+ initDevice();
+ }
+
+ @Override
+ public void dispose() {
+ logger.debug("Disposing handler for media server device {}", thing.getLabel());
+
+ CompletableFuture<Boolean> browsingFuture = isBrowsing;
+ if (browsingFuture != null) {
+ browsingFuture.complete(false);
+ isBrowsing = null;
+ }
+
+ super.dispose();
+ }
+
+ @Override
+ protected void initJob() {
+ synchronized (jobLock) {
+ if (!upnpIOService.isRegistered(this)) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
- "Communication cannot be established with " + thing.getLabel());
+ "UPnP device with UDN " + getUDN() + " not yet registered");
+ return;
+ }
+
+ if (!ThingStatus.ONLINE.equals(thing.getStatus())) {
+ rendererStateOptionList = Collections.synchronizedList(new ArrayList<>());
+ synchronized (rendererStateOptionList) {
+ upnpRenderers.forEach((key, value) -> {
+ StateOption stateOption = new StateOption(key, value.getThing().getLabel());
+ rendererStateOptionList.add(stateOption);
+ });
+ }
+ updateStateDescription(rendererChannelUID, rendererStateOptionList);
+ getProtocolInfo();
+ browse(currentEntry.getId(), "BrowseDirectChildren", "*", "0", "0", config.sortCriteria);
+ playlistsListChanged();
+ updateStatus(ThingStatus.ONLINE);
+ }
+
+ if (!upnpSubscribed) {
+ addSubscriptions();
+ }
+ }
+ }
+
+ /**
+ * Method that does a UPnP browse on a content directory. Results will be retrieved in the {@link onValueReceived}
+ * method.
+ *
+ * @param objectID content directory object
+ * @param browseFlag BrowseMetaData or BrowseDirectChildren
+ * @param filter properties to be returned
+ * @param startingIndex starting index of objects to return
+ * @param requestedCount number of objects to return, 0 for all
+ * @param sortCriteria sort criteria, example: +dc:title
+ */
+ protected void browse(String objectID, String browseFlag, String filter, String startingIndex,
+ String requestedCount, String sortCriteria) {
+ CompletableFuture<Boolean> browsing = isBrowsing;
+ boolean browsed = true;
+ try {
+ if (browsing != null) {
+ // wait for maximum 2.5s until browsing is finished
+ browsed = browsing.get(config.responseTimeout, TimeUnit.MILLISECONDS);
}
+ } catch (InterruptedException | ExecutionException | TimeoutException e) {
+ logger.debug("Exception, previous server query on {} interrupted or timed out, trying new browse anyway",
+ thing.getLabel());
+ }
+
+ if (browsed) {
+ isBrowsing = new CompletableFuture<Boolean>();
+
+ Map<String, String> inputs = new HashMap<>();
+ inputs.put("ObjectID", objectID);
+ inputs.put("BrowseFlag", browseFlag);
+ inputs.put("Filter", filter);
+ inputs.put("StartingIndex", startingIndex);
+ inputs.put("RequestedCount", requestedCount);
+ inputs.put("SortCriteria", sortCriteria);
+
+ invokeAction(CONTENT_DIRECTORY, "Browse", inputs);
} else {
- updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
- "No UDN configured for " + thing.getLabel());
+ logger.debug("Cannot browse, cancelled querying server {}", thing.getLabel());
}
}
- private void initServer() {
- rendererStateOptionList = Collections.synchronizedList(new ArrayList<>());
- synchronized (rendererStateOptionList) {
- upnpRenderers.forEach((key, value) -> {
- StateOption stateOption = new StateOption(key, value.getThing().getLabel());
- rendererStateOptionList.add(stateOption);
- });
+ /**
+ * Method that does a UPnP search on a content directory. Results will be retrieved in the {@link onValueReceived}
+ * method.
+ *
+ * @param containerID content directory container
+ * @param searchCriteria search criteria, examples:
+ * dc:title contains "song"
+ * dc:creator contains "Springsteen"
+ * upnp:class = "object.item.audioItem"
+ * upnp:album contains "Born in"
+ * @param filter properties to be returned
+ * @param startingIndex starting index of objects to return
+ * @param requestedCount number of objects to return, 0 for all
+ * @param sortCriteria sort criteria, example: +dc:title
+ */
+ protected void search(String containerID, String searchCriteria, String filter, String startingIndex,
+ String requestedCount, String sortCriteria) {
+ CompletableFuture<Boolean> browsing = isBrowsing;
+ boolean browsed = true;
+ try {
+ if (browsing != null) {
+ // wait for maximum 2.5s until browsing is finished
+ browsed = browsing.get(config.responseTimeout, TimeUnit.MILLISECONDS);
+ }
+ } catch (InterruptedException | ExecutionException | TimeoutException e) {
+ logger.debug("Exception, previous server query on {} interrupted or timed out, trying new search anyway",
+ thing.getLabel());
}
- updateStateDescription(rendererChannelUID, rendererStateOptionList);
- getProtocolInfo();
+ if (browsed) {
+ isBrowsing = new CompletableFuture<Boolean>();
- browse(currentEntry.getId(), "BrowseDirectChildren", "*", "0", "0", config.sortcriteria);
+ Map<String, String> inputs = new HashMap<>();
+ inputs.put("ContainerID", containerID);
+ inputs.put("SearchCriteria", searchCriteria);
+ inputs.put("Filter", filter);
+ inputs.put("StartingIndex", startingIndex);
+ inputs.put("RequestedCount", requestedCount);
+ inputs.put("SortCriteria", sortCriteria);
- updateStatus(ThingStatus.ONLINE);
+ invokeAction(CONTENT_DIRECTORY, "Search", inputs);
+ } else {
+ logger.debug("Cannot search, cancelled querying server {}", thing.getLabel());
+ }
+ }
+
+ protected void updateServerState(ChannelUID channelUID, State state) {
+ updateState(channelUID, state);
}
@Override
switch (channelUID.getId()) {
case UPNPRENDERER:
- if (command instanceof StringType) {
- currentRendererHandler = (upnpRenderers.get(((StringType) command).toString()));
- if (config.filter) {
- // only refresh title list if filtering by renderer capabilities
- browse(currentEntry.getId(), "BrowseDirectChildren", "*", "0", "0", config.sortcriteria);
- }
- } else if (command instanceof RefreshType) {
- UpnpRendererHandler renderer = currentRendererHandler;
- if (renderer != null) {
- updateState(channelUID, StringType.valueOf(renderer.getThing().getLabel()));
- }
- }
+ handleCommandUpnpRenderer(channelUID, command);
+ break;
+ case CURRENTTITLE:
+ handleCommandCurrentTitle(channelUID, command);
break;
- case CURRENTID:
- String currentId = "";
- if (command instanceof StringType) {
- currentId = String.valueOf(command);
- } else if (command instanceof RefreshType) {
- currentId = currentEntry.getId();
- updateState(channelUID, StringType.valueOf(currentId));
- }
- logger.debug("Setting currentId to {}", currentId);
- if (!currentId.isEmpty()) {
- browse(currentId, "BrowseDirectChildren", "*", "0", "0", config.sortcriteria);
- }
case BROWSE:
- if (command instanceof StringType) {
- String browseTarget = command.toString();
- if (browseTarget != null) {
- if (!UP.equals(browseTarget)) {
- final String target = browseTarget;
- synchronized (entries) {
- Optional<UpnpEntry> current = entries.stream()
- .filter(entry -> target.equals(entry.getId())).findFirst();
- if (current.isPresent()) {
- currentEntry = current.get();
- } else {
- logger.info("Trying to browse invalid target {}", browseTarget);
- browseTarget = UP; // move up on invalid target
- }
- }
- }
- if (UP.equals(browseTarget)) {
- // Move up in tree
- browseTarget = currentEntry.getParentId();
- if (browseTarget.isEmpty()) {
- // No parent found, so make it the root directory
- browseTarget = DIRECTORY_ROOT;
- }
- UpnpEntry entry = parentMap.get(browseTarget);
- if (entry == null) {
- logger.info("Browse target not found. Exiting.");
- return;
- }
- currentEntry = entry;
+ handleCommandBrowse(channelUID, command);
+ break;
+ case SEARCH:
+ handleCommandSearch(command);
+ break;
+ case PLAYLIST_SELECT:
+ handleCommandPlaylistSelect(channelUID, command);
+ break;
+ case PLAYLIST:
+ handleCommandPlaylist(channelUID, command);
+ break;
+ case PLAYLIST_ACTION:
+ handleCommandPlaylistAction(command);
+ break;
+ case VOLUME:
+ case MUTE:
+ case CONTROL:
+ case STOP:
+ // Pass these on to the media renderer thing if one is selected
+ handleCommandInRenderer(channelUID, command);
+ break;
+ }
+ }
- }
- updateState(CURRENTID, StringType.valueOf(currentEntry.getId()));
- logger.debug("Browse target {}", browseTarget);
- browse(browseTarget, "BrowseDirectChildren", "*", "0", "0", config.sortcriteria);
+ private void handleCommandUpnpRenderer(ChannelUID channelUID, Command command) {
+ UpnpRendererHandler renderer = null;
+ UpnpRendererHandler previousRenderer = currentRendererHandler;
+ if (command instanceof StringType) {
+ renderer = (upnpRenderers.get(((StringType) command).toString()));
+ currentRendererHandler = renderer;
+ if (config.filter) {
+ // only refresh title list if filtering by renderer capabilities
+ browse(currentEntry.getId(), "BrowseDirectChildren", "*", "0", "0", config.sortCriteria);
+ } else {
+ serveMedia();
+ }
+ }
+
+ if ((renderer != null) && !renderer.equals(previousRenderer)) {
+ if (previousRenderer != null) {
+ previousRenderer.unsetServerHandler();
+ }
+ renderer.setServerHandler(this);
+
+ Channel channel;
+ if ((channel = thing.getChannel(VOLUME)) != null) {
+ handleCommand(channel.getUID(), RefreshType.REFRESH);
+ }
+ if ((channel = thing.getChannel(MUTE)) != null) {
+ handleCommand(channel.getUID(), RefreshType.REFRESH);
+ }
+ if ((channel = thing.getChannel(CONTROL)) != null) {
+ handleCommand(channel.getUID(), RefreshType.REFRESH);
+ }
+ }
+
+ if ((renderer = currentRendererHandler) != null) {
+ updateState(channelUID, StringType.valueOf(renderer.getThing().getUID().toString()));
+ } else {
+ updateState(channelUID, UnDefType.UNDEF);
+ }
+ }
+
+ private void handleCommandCurrentTitle(ChannelUID channelUID, Command command) {
+ if (command instanceof RefreshType) {
+ updateState(channelUID, StringType.valueOf(currentEntry.getTitle()));
+ }
+ }
+
+ private void handleCommandBrowse(ChannelUID channelUID, Command command) {
+ String browseTarget = "";
+ if (command instanceof StringType) {
+ browseTarget = command.toString();
+ if (!browseTarget.isEmpty()) {
+ if (UP.equals(browseTarget)) {
+ // Move up in tree
+ browseTarget = currentEntry.getParentId();
+ if (browseTarget.isEmpty()) {
+ // No parent found, so make it the root directory
+ browseTarget = DIRECTORY_ROOT;
}
+ browseUp = true;
}
- break;
- case SEARCH:
- if (command instanceof StringType) {
- String criteria = command.toString();
- if (criteria != null) {
- String searchContainer = "";
- if (currentEntry.isContainer()) {
- searchContainer = currentEntry.getId();
+ UpnpEntry entry = parentMap.get(browseTarget);
+ if (entry != null) {
+ currentEntry = entry;
+ } else {
+ final String target = browseTarget;
+ synchronized (entries) {
+ Optional<UpnpEntry> current = entries.stream().filter(e -> target.equals(e.getId()))
+ .findFirst();
+ if (current.isPresent()) {
+ currentEntry = current.get();
} else {
- searchContainer = currentEntry.getParentId();
- }
- if (searchContainer.isEmpty()) {
- // No parent found, so make it the root directory
- searchContainer = DIRECTORY_ROOT;
+ // The real entry is not in the parentMap or options list yet, so construct a default one
+ currentEntry = new UpnpEntry(browseTarget, browseTarget, DIRECTORY_ROOT,
+ "object.container");
}
- updateState(CURRENTID, StringType.valueOf(currentEntry.getId()));
- logger.debug("Search container {} for {}", searchContainer, criteria);
- search(searchContainer, criteria, "*", "0", "0", config.sortcriteria);
}
}
- break;
+
+ logger.debug("Browse target {}", browseTarget);
+ logger.debug("Navigating to node {} on server {}", currentEntry.getId(), thing.getLabel());
+ updateState(channelUID, StringType.valueOf(browseTarget));
+ updateState(CURRENTTITLE, StringType.valueOf(currentEntry.getTitle()));
+ browse(browseTarget, "BrowseDirectChildren", "*", "0", "0", config.sortCriteria);
+ }
+ } else if (command instanceof RefreshType) {
+ browseTarget = currentEntry.getId();
+ updateState(channelUID, StringType.valueOf(browseTarget));
+ }
+ }
+
+ private void handleCommandSearch(Command command) {
+ if (command instanceof StringType) {
+ String criteria = command.toString();
+ if (!criteria.isEmpty()) {
+ String searchContainer = "";
+ if (currentEntry.isContainer()) {
+ searchContainer = currentEntry.getId();
+ } else {
+ searchContainer = currentEntry.getParentId();
+ }
+ if (config.searchFromRoot || searchContainer.isEmpty()) {
+ // Config option search from root or no parent found, so make it the root directory
+ searchContainer = DIRECTORY_ROOT;
+ }
+ UpnpEntry entry = parentMap.get(searchContainer);
+ if (entry != null) {
+ currentEntry = entry;
+ } else {
+ // The real entry is not in the parentMap yet, so construct a default one
+ currentEntry = new UpnpEntry(searchContainer, searchContainer, DIRECTORY_ROOT, "object.container");
+ }
+
+ logger.debug("Navigating to node {} on server {}", searchContainer, thing.getLabel());
+ updateState(BROWSE, StringType.valueOf(currentEntry.getId()));
+ logger.debug("Search container {} for {}", searchContainer, criteria);
+ search(searchContainer, criteria, "*", "0", "0", config.sortCriteria);
+ }
+ }
+ }
+
+ private void handleCommandPlaylistSelect(ChannelUID channelUID, Command command) {
+ if (command instanceof StringType) {
+ playlistName = command.toString();
+ updateState(PLAYLIST, StringType.valueOf(playlistName));
+ }
+ }
+
+ private void handleCommandPlaylist(ChannelUID channelUID, Command command) {
+ if (command instanceof StringType) {
+ playlistName = command.toString();
+ }
+ updateState(channelUID, StringType.valueOf(playlistName));
+ }
+
+ private void handleCommandPlaylistAction(Command command) {
+ if (command instanceof StringType) {
+ switch (command.toString()) {
+ case RESTORE:
+ handleCommandPlaylistRestore();
+ break;
+ case SAVE:
+ handleCommandPlaylistSave(false);
+ break;
+ case APPEND:
+ handleCommandPlaylistSave(true);
+ break;
+ case DELETE:
+ handleCommandPlaylistDelete();
+ break;
+ }
+ }
+ }
+
+ private void handleCommandPlaylistRestore() {
+ if (!playlistName.isEmpty()) {
+ // Don't immediately restore a playlist if a browse or search is still underway, or it could get overwritten
+ CompletableFuture<Boolean> browsing = isBrowsing;
+ try {
+ if (browsing != null) {
+ // wait for maximum 2.5s until browsing is finished
+ browsing.get(config.responseTimeout, TimeUnit.MILLISECONDS);
+ }
+ } catch (InterruptedException | ExecutionException | TimeoutException e) {
+ logger.debug(
+ "Exception, previous server on {} query interrupted or timed out, restoring playlist anyway",
+ thing.getLabel());
+ }
+
+ UpnpEntryQueue queue = new UpnpEntryQueue();
+ queue.restoreQueue(playlistName, config.udn, bindingConfig.path);
+ updateTitleSelection(queue.getEntryList());
+
+ String parentId;
+ UpnpEntry current = queue.get(0);
+ if (current != null) {
+ parentId = current.getParentId();
+ UpnpEntry entry = parentMap.get(parentId);
+ if (entry != null) {
+ currentEntry = entry;
+ } else {
+ // The real entry is not in the parentMap yet, so construct a default one
+ currentEntry = new UpnpEntry(parentId, parentId, DIRECTORY_ROOT, "object.container");
+ }
+ } else {
+ parentId = DIRECTORY_ROOT;
+ currentEntry = ROOT_ENTRY;
+ }
+
+ logger.debug("Restoring playlist to node {} on server {}", parentId, thing.getLabel());
+ }
+ }
+
+ private void handleCommandPlaylistSave(boolean append) {
+ if (!playlistName.isEmpty()) {
+ List<UpnpEntry> mediaQueue = new ArrayList<>();
+ mediaQueue.addAll(entries);
+ if (mediaQueue.isEmpty() && !currentEntry.isContainer()) {
+ mediaQueue.add(currentEntry);
+ }
+ UpnpEntryQueue queue = new UpnpEntryQueue(mediaQueue, config.udn);
+ queue.persistQueue(playlistName, append, bindingConfig.path);
+ UpnpControlUtil.updatePlaylistsList(bindingConfig.path);
+ }
+ }
+
+ private void handleCommandPlaylistDelete() {
+ if (!playlistName.isEmpty()) {
+ UpnpControlUtil.deletePlaylist(playlistName, bindingConfig.path);
+ UpnpControlUtil.updatePlaylistsList(bindingConfig.path);
+ updateState(PLAYLIST, UnDefType.UNDEF);
+ }
+ }
+
+ private void handleCommandInRenderer(ChannelUID channelUID, Command command) {
+ String channelId = channelUID.getId();
+ UpnpRendererHandler handler = currentRendererHandler;
+ Channel channel;
+ if ((handler != null) && (channel = handler.getThing().getChannel(channelId)) != null) {
+ handler.handleCommand(channel.getUID(), command);
+ } else if (!STOP.equals(channelId)) {
+ updateState(channelId, UnDefType.UNDEF);
}
}
*/
public void addRendererOption(String key) {
synchronized (rendererStateOptionList) {
- rendererStateOptionList.add(new StateOption(key, upnpRenderers.get(key).getThing().getLabel()));
+ UpnpRendererHandler handler = upnpRenderers.get(key);
+ if (handler != null) {
+ rendererStateOptionList.add(new StateOption(key, handler.getThing().getLabel()));
+ }
}
updateStateDescription(rendererChannelUID, rendererStateOptionList);
logger.debug("Renderer option {} added to {}", key, thing.getLabel());
logger.debug("Renderer option {} removed from {}", key, thing.getLabel());
}
- private void updateTitleSelection(List<UpnpEntry> titleList) {
- logger.debug("Navigating to node {} on server {}", currentEntry.getId(), thing.getLabel());
+ @Override
+ public void playlistsListChanged() {
+ playlistCommandOptionList = UpnpControlUtil.playlists().stream().map(p -> (new CommandOption(p, p)))
+ .collect(Collectors.toList());
+ updateCommandDescription(playlistSelectChannelUID, playlistCommandOptionList);
+ }
+ private void updateTitleSelection(List<UpnpEntry> titleList) {
// Optionally, filter only items that can be played on the renderer
logger.debug("Filtering content on server {}: {}", thing.getLabel(), config.filter);
List<UpnpEntry> resultList = config.filter ? filterEntries(titleList, true) : titleList;
- List<CommandOption> commandOptionList = new ArrayList<>();
+ List<StateOption> stateOptionList = new ArrayList<>();
// Add a directory up selector if not in the directory root
if ((!resultList.isEmpty() && !(DIRECTORY_ROOT.equals(resultList.get(0).getParentId())))
|| (resultList.isEmpty() && !DIRECTORY_ROOT.equals(currentEntry.getId()))) {
- CommandOption commandOption = new CommandOption(UP, UP);
- commandOptionList.add(commandOption);
+ StateOption stateOption = new StateOption(UP, UP);
+ stateOptionList.add(stateOption);
logger.debug("UP added to selection list on server {}", thing.getLabel());
}
synchronized (entries) {
entries.clear(); // always only keep the current selection in the entry map to keep memory usage down
resultList.forEach((value) -> {
- CommandOption commandOption = new CommandOption(value.getId(), value.getTitle());
- commandOptionList.add(commandOption);
+ StateOption stateOption = new StateOption(value.getId(), value.getTitle());
+ stateOptionList.add(stateOption);
logger.trace("{} added to selection list on server {}", value.getId(), thing.getLabel());
// Keep the entries in a map so we can find the parent and container for the current selection to go
});
}
- // Set the currentId to the parent of the first entry in the list
- if (!resultList.isEmpty()) {
- updateState(CURRENTID, StringType.valueOf(resultList.get(0).getId()));
- }
-
- logger.debug("{} entries added to selection list on server {}", commandOptionList.size(), thing.getLabel());
- updateCommandDescription(currentSelectionChannelUID, commandOptionList);
+ logger.debug("{} entries added to selection list on server {}", stateOptionList.size(), thing.getLabel());
+ updateStateDescription(currentSelectionChannelUID, stateOptionList);
+ updateState(BROWSE, StringType.valueOf(currentEntry.getId()));
+ updateState(CURRENTTITLE, StringType.valueOf(currentEntry.getTitle()));
serveMedia();
}
/**
- * Filter a list of media and only keep the media that are playable on the currently selected renderer.
+ * Filter a list of media and only keep the media that are playable on the currently selected renderer. Return all
+ * if no renderer is selected.
*
* @param resultList
* @param includeContainers
* @return
*/
private List<UpnpEntry> filterEntries(List<UpnpEntry> resultList, boolean includeContainers) {
- logger.debug("Raw result list {}", resultList);
- List<UpnpEntry> list = new ArrayList<>();
- UpnpRendererHandler handler = currentRendererHandler;
- if (handler != null) {
- List<String> sink = handler.getSink();
- list = resultList.stream()
- .filter(entry -> (includeContainers && entry.isContainer())
- || UpnpProtocolMatcher.testProtocolList(entry.getProtocolList(), sink))
- .collect(Collectors.toList());
- }
- logger.debug("Filtered result list {}", list);
- return list;
- }
-
- private void updateStateDescription(ChannelUID channelUID, List<StateOption> stateOptionList) {
- StateDescription stateDescription = StateDescriptionFragmentBuilder.create().withReadOnly(false)
- .withOptions(stateOptionList).build().toStateDescription();
- upnpStateDescriptionProvider.setDescription(channelUID, stateDescription);
- }
-
- private void updateCommandDescription(ChannelUID channelUID, List<CommandOption> commandOptionList) {
- CommandDescription commandDescription = CommandDescriptionBuilder.create().withCommandOptions(commandOptionList)
- .build();
- upnpCommandDescriptionProvider.setDescription(channelUID, commandDescription);
- }
+ logger.debug("Server {}, raw result list {}", thing.getLabel(), resultList);
- /**
- * Method that does a UPnP browse on a content directory. Results will be retrieved in the
- * {@link #onValueReceived(String, String, String)} method.
- *
- * @param objectID content directory object
- * @param browseFlag BrowseMetaData or BrowseDirectChildren
- * @param filter properties to be returned
- * @param startingIndex starting index of objects to return
- * @param requestedCount number of objects to return, 0 for all
- * @param sortCriteria sort criteria, example: +dc:title
- */
- public void browse(String objectID, String browseFlag, String filter, String startingIndex, String requestedCount,
- String sortCriteria) {
- Map<String, String> inputs = new HashMap<>();
- inputs.put("ObjectID", objectID);
- inputs.put("BrowseFlag", browseFlag);
- inputs.put("Filter", filter);
- inputs.put("StartingIndex", startingIndex);
- inputs.put("RequestedCount", requestedCount);
- inputs.put("SortCriteria", sortCriteria);
-
- invokeAction("ContentDirectory", "Browse", inputs);
- }
-
- /**
- * Method that does a UPnP search on a content directory. Results will be retrieved in the
- * {@link #onValueReceived(String, String, String)} method.
- *
- * @param containerID content directory container
- * @param searchCriteria search criteria, examples:
- * dc:title contains "song"
- * dc:creator contains "Springsteen"
- * upnp:class = "object.item.audioItem"
- * upnp:album contains "Born in"
- * @param filter properties to be returned
- * @param startingIndex starting index of objects to return
- * @param requestedCount number of objects to return, 0 for all
- * @param sortCriteria sort criteria, example: +dc:title
- */
- public void search(String containerID, String searchCriteria, String filter, String startingIndex,
- String requestedCount, String sortCriteria) {
- Map<String, String> inputs = new HashMap<>();
- inputs.put("ContainerID", containerID);
- inputs.put("SearchCriteria", searchCriteria);
- inputs.put("Filter", filter);
- inputs.put("StartingIndex", startingIndex);
- inputs.put("RequestedCount", requestedCount);
- inputs.put("SortCriteria", sortCriteria);
-
- invokeAction("ContentDirectory", "Search", inputs);
- }
+ UpnpRendererHandler handler = currentRendererHandler;
+ List<String> sink = (handler != null) ? handler.getSink() : null;
+ List<UpnpEntry> list = resultList.stream()
+ .filter(entry -> ((includeContainers && entry.isContainer()) || (sink == null) && !entry.isContainer())
+ || ((sink != null) && UpnpProtocolMatcher.testProtocolList(entry.getProtocolList(), sink)))
+ .collect(Collectors.toList());
- @Override
- public void onStatusChanged(boolean status) {
- logger.debug("Server status changed to {}", status);
- if (status) {
- initServer();
- } else {
- updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
- "Communication lost with " + thing.getLabel());
- }
- super.onStatusChanged(status);
+ logger.debug("Server {}, filtered result list {}", thing.getLabel(), list);
+ return list;
}
@Override
public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
- logger.debug("Upnp device {} received variable {} with value {} from service {}", thing.getLabel(), variable,
+ logger.debug("UPnP device {} received variable {} with value {} from service {}", thing.getLabel(), variable,
value, service);
if (variable == null) {
return;
}
switch (variable) {
case "Result":
- if (!((value == null) || (value.isEmpty()))) {
- updateTitleSelection(removeDuplicates(UpnpXMLParser.getEntriesFromXML(value)));
- } else {
- updateTitleSelection(new ArrayList<UpnpEntry>());
- }
+ onValueReceivedResult(value);
break;
- case "Source":
case "NumberReturned":
case "TotalMatches":
case "UpdateID":
}
}
+ private void onValueReceivedResult(@Nullable String value) {
+ CompletableFuture<Boolean> browsing = isBrowsing;
+ if (!((value == null) || (value.isEmpty()))) {
+ List<UpnpEntry> list = UpnpXMLParser.getEntriesFromXML(value);
+ if (config.browseDown && (list.size() == 1) && list.get(0).isContainer() && !browseUp) {
+ // We only received one container entry, so we immediately browse to the next level if config.browsedown
+ // = true
+ if (browsing != null) {
+ browsing.complete(true); // Clear previous browse flag before starting new browse
+ }
+ currentEntry = list.get(0);
+ String browseTarget = currentEntry.getId();
+ parentMap.put(browseTarget, currentEntry);
+ logger.debug("Server {}, browsing down one level to the unique container result {}", thing.getLabel(),
+ browseTarget);
+ browse(browseTarget, "BrowseDirectChildren", "*", "0", "0", config.sortCriteria);
+ } else {
+ updateTitleSelection(removeDuplicates(list));
+ }
+ } else {
+ updateTitleSelection(new ArrayList<UpnpEntry>());
+ }
+ browseUp = false;
+ if (browsing != null) {
+ browsing.complete(true); // We have received browse or search results, so can launch new browse or
+ // search
+ }
+ }
+
+ @Override
+ protected void updateProtocolInfo(String value) {
+ }
+
/**
* Remove double entries by checking the refId if it exists as Id in the list and only keeping the original entry if
* available. If the original entry is not in the list, only keep one referring entry.
private List<UpnpEntry> removeDuplicates(List<UpnpEntry> list) {
List<UpnpEntry> newList = new ArrayList<>();
Set<String> refIdSet = new HashSet<>();
- final Set<String> idSet = list.stream().map(UpnpEntry::getId).collect(Collectors.toSet());
list.forEach(entry -> {
String refId = entry.getRefId();
- if (refId.isEmpty() || (!idSet.contains(refId)) && !refIdSet.contains(refId)) {
+ if (refId.isEmpty() || !refIdSet.contains(refId)) {
newList.add(entry);
- }
- if (!refId.isEmpty()) {
refIdSet.add(refId);
}
});
private void serveMedia() {
UpnpRendererHandler handler = currentRendererHandler;
if (handler != null) {
- ArrayList<UpnpEntry> mediaQueue = new ArrayList<>();
+ List<UpnpEntry> mediaQueue = new ArrayList<>();
mediaQueue.addAll(filterEntries(entries, false));
if (mediaQueue.isEmpty() && !currentEntry.isContainer()) {
mediaQueue.add(currentEntry);
logger.debug("Nothing to serve from server {} to renderer {}", thing.getLabel(),
handler.getThing().getLabel());
} else {
- handler.registerQueue(mediaQueue);
+ UpnpEntryQueue queue = new UpnpEntryQueue(mediaQueue, getUDN());
+ handler.registerQueue(queue);
logger.debug("Serving media queue {} from server {} to renderer {}", mediaQueue, thing.getLabel(),
handler.getThing().getLabel());
+
+ // always keep a copy of current list that is being served
+ queue.persistQueue(bindingConfig.path);
+ UpnpControlUtil.updatePlaylistsList(bindingConfig.path);
}
} else {
logger.warn("Cannot serve media from server {}, no renderer selected", thing.getLabel());
--- /dev/null
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.queue;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import org.apache.commons.lang.StringEscapeUtils;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ *
+ * @author Mark Herwege - Initial contribution
+ * @author Karel Goderis - Based on UPnP logic in Sonos binding
+ */
+@NonNullByDefault
+public class UpnpEntry {
+
+ private static final String DIRECTORY_ROOT = "0";
+
+ private static final Pattern CONTAINER_PATTERN = Pattern.compile("object.container");
+
+ private String id;
+ private String refId;
+ private String parentId;
+ private String upnpClass;
+ private String title = "";
+ private List<UpnpEntryRes> resList = new ArrayList<>();
+ private String album = "";
+ private String albumArtUri = "";
+ private String creator = "";
+ private String artist = "";
+ private String publisher = "";
+ private String genre = "";
+ private @Nullable Integer originalTrackNumber;
+
+ private boolean isContainer;
+
+ public UpnpEntry() {
+ this("", "", "", "");
+ }
+
+ public UpnpEntry(String id, String refId, String parentId, String upnpClass) {
+ this.id = id;
+ this.refId = refId;
+ this.parentId = parentId;
+ this.upnpClass = upnpClass;
+
+ Matcher matcher = CONTAINER_PATTERN.matcher(upnpClass);
+ isContainer = matcher.find();
+ }
+
+ public UpnpEntry withTitle(String title) {
+ this.title = title;
+ return this;
+ }
+
+ public UpnpEntry withAlbum(String album) {
+ this.album = album;
+ return this;
+ }
+
+ public UpnpEntry withAlbumArtUri(String albumArtUri) {
+ this.albumArtUri = albumArtUri;
+ return this;
+ }
+
+ public UpnpEntry withCreator(String creator) {
+ this.creator = creator;
+ return this;
+ }
+
+ public UpnpEntry withArtist(String artist) {
+ this.artist = artist;
+ return this;
+ }
+
+ public UpnpEntry withPublisher(String publisher) {
+ this.publisher = publisher;
+ return this;
+ }
+
+ public UpnpEntry withGenre(String genre) {
+ this.genre = genre;
+ return this;
+ }
+
+ public UpnpEntry withResList(List<UpnpEntryRes> resList) {
+ this.resList = resList;
+ return this;
+ }
+
+ public UpnpEntry withTrackNumber(@Nullable Integer originalTrackNumber) {
+ this.originalTrackNumber = originalTrackNumber;
+ return this;
+ }
+
+ /**
+ * @return the title of the entry.
+ */
+ @Override
+ public String toString() {
+ return title;
+ }
+
+ /**
+ * @return the unique identifier of this entry.
+ */
+ public String getId() {
+ return id;
+ }
+
+ /**
+ * @return the title of the entry.
+ */
+ public String getTitle() {
+ return title;
+ }
+
+ /**
+ * @return the identifier of the entry this reference intry refers to.
+ */
+ public String getRefId() {
+ return refId;
+ }
+
+ /**
+ * @return the unique identifier of the parent of this entry.
+ */
+ public String getParentId() {
+ return parentId.isEmpty() ? DIRECTORY_ROOT : parentId;
+ }
+
+ /**
+ * @return a URI for this entry. Thumbnail resources are not considered.
+ */
+ public String getRes() {
+ return resList.stream().filter(res -> !res.isThumbnailRes()).map(UpnpEntryRes::getRes).findAny().orElse("");
+ }
+
+ public List<String> getProtocolList() {
+ return resList.stream().map(UpnpEntryRes::getProtocolInfo).collect(Collectors.toList());
+ }
+
+ /**
+ * @return the UPnP classname for this entry.
+ */
+ public String getUpnpClass() {
+ return upnpClass;
+ }
+
+ public boolean isContainer() {
+ return isContainer;
+ }
+
+ /**
+ * @return the name of the album.
+ */
+ public String getAlbum() {
+ return album;
+ }
+
+ /**
+ * @return the URI for the album art.
+ */
+ public String getAlbumArtUri() {
+ return StringEscapeUtils.unescapeXml(albumArtUri);
+ }
+
+ /**
+ * @return the name of the artist who created the entry.
+ */
+ public String getCreator() {
+ return creator;
+ }
+
+ public String getArtist() {
+ return artist;
+ }
+
+ public String getPublisher() {
+ return publisher;
+ }
+
+ public String getGenre() {
+ return genre;
+ }
+
+ public @Nullable Integer getOriginalTrackNumber() {
+ return originalTrackNumber;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.queue;
+
+import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.PLAYLIST_FILE_EXTENSION;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonParseException;
+
+/**
+ * The class {@link UpnpEntryQueue} represents a queue of UPnP media entries to be played on a renderer. It keeps track
+ * of a current index in the queue. It has convenience methods to play previous/next entries, whereby the queue can be
+ * organized to play from first to last (with no repetition), to restart at the start when the end is reached (in a
+ * continuous loop), or to random shuffle the entries. Repeat and shuffle are off by default, but can be set using the
+ * {@link setRepeat} and {@link setShuffle} methods.
+ *
+ * @author Mark Herwege - Initial contribution
+ *
+ */
+@NonNullByDefault
+public class UpnpEntryQueue {
+
+ private final Logger logger = LoggerFactory.getLogger(UpnpEntryQueue.class);
+
+ private volatile boolean repeat = false;
+ private volatile boolean shuffle = false;
+
+ private volatile int currentIndex = -1;
+
+ private class Playlist {
+ @SuppressWarnings("unused")
+ String name; // Used in serialization
+ volatile Map<String, List<UpnpEntry>> masterList;
+
+ Playlist(String name, Map<String, List<UpnpEntry>> masterList) {
+ this.name = name;
+ this.masterList = masterList;
+ }
+ }
+
+ private volatile Playlist playlist;
+
+ private volatile List<UpnpEntry> currentQueue;
+ private volatile List<UpnpEntry> shuffledQueue = Collections.emptyList();
+
+ private final Gson gson = new Gson();
+
+ public UpnpEntryQueue() {
+ this(Collections.emptyList());
+ }
+
+ /**
+ * @param queue
+ */
+ public UpnpEntryQueue(List<UpnpEntry> queue) {
+ this(queue, "");
+ }
+
+ /**
+ * @param queue
+ * @param udn Defines the UPnP media server source of the queue, could be used to re-query the server if URL
+ * resources are out of date.
+ */
+ public UpnpEntryQueue(List<UpnpEntry> queue, @Nullable String udn) {
+ String serverUdn = (udn != null) ? udn : "";
+ Map<String, List<UpnpEntry>> masterList = Collections.synchronizedMap(new HashMap<>());
+ List<UpnpEntry> localQueue = new ArrayList<>(queue);
+ masterList.put(serverUdn, localQueue);
+ playlist = new Playlist("", masterList);
+ currentQueue = localQueue.stream().filter(e -> !e.isContainer()).collect(Collectors.toList());
+ }
+
+ /**
+ * Switch on/off repeat mode.
+ *
+ * @param repeat
+ */
+ public void setRepeat(boolean repeat) {
+ this.repeat = repeat;
+ }
+
+ /**
+ * Switch on/off shuffle mode.
+ *
+ * @param shuffle
+ */
+ public synchronized void setShuffle(boolean shuffle) {
+ if (shuffle) {
+ shuffle();
+ } else {
+ int index = currentIndex;
+ if (index != -1) {
+ currentIndex = currentQueue.indexOf(shuffledQueue.get(index));
+ }
+ this.shuffle = false;
+ }
+ }
+
+ private synchronized void shuffle() {
+ UpnpEntry current = null;
+ int index = currentIndex;
+ if (index != -1) {
+ current = this.shuffle ? shuffledQueue.get(index) : currentQueue.get(index);
+ }
+
+ // Shuffle the queue again
+ shuffledQueue = new ArrayList<UpnpEntry>(currentQueue);
+ Collections.shuffle(shuffledQueue);
+ if (current != null) {
+ // Put the current entry at the beginning of the shuffled queue
+ shuffledQueue.remove(current);
+ shuffledQueue.add(0, current);
+ currentIndex = 0;
+ }
+
+ this.shuffle = true;
+ }
+
+ /**
+ * @return will return the next element in the queue, or null when the end of the queue has been reached. With
+ * repeat set, will restart at the beginning of the queue when the end has been reached. The method will
+ * return null if the queue is empty.
+ */
+ public synchronized @Nullable UpnpEntry next() {
+ currentIndex++;
+ if (currentIndex >= size()) {
+ if (shuffle && repeat) {
+ currentIndex = -1;
+ shuffle();
+ }
+ currentIndex = repeat ? 0 : -1;
+ }
+ return currentIndex >= 0 ? get(currentIndex) : null;
+ }
+
+ /**
+ * @return will return the previous element in the queue, or null when the start of the queue has been reached. With
+ * repeat set, will restart at the end of the queue when the start has been reached. The method will return
+ * null if the queue is empty.
+ */
+ public synchronized @Nullable UpnpEntry previous() {
+ currentIndex--;
+ if (currentIndex < 0) {
+ if (shuffle && repeat) {
+ currentIndex = -1;
+ shuffle();
+ }
+ currentIndex = repeat ? (size() - 1) : -1;
+ }
+ return currentIndex >= 0 ? get(currentIndex) : null;
+ }
+
+ /**
+ * @return the index of the current element in the queue.
+ */
+ public int index() {
+ return currentIndex;
+ }
+
+ /**
+ * @return the index of the next element in the queue that will be served if {@link next} is called, or -1 if
+ * nothing to serve for next.
+ */
+ public synchronized int nextIndex() {
+ int index = currentIndex + 1;
+ if (index >= size()) {
+ index = repeat ? 0 : -1;
+ }
+ return index;
+ }
+
+ /**
+ * @return the index of the previous element in the queue that will be served if {@link previous} is called, or -1
+ * if nothing to serve for next.
+ */
+ public synchronized int previousIndex() {
+ int index = currentIndex - 1;
+ if (index < 0) {
+ index = repeat ? (size() - 1) : -1;
+ }
+ return index;
+ }
+
+ /**
+ * @return true if there is an element to server when calling {@link next}.
+ */
+ public synchronized boolean hasNext() {
+ int size = currentQueue.size();
+ if (repeat && (size > 0)) {
+ return true;
+ }
+ return (currentIndex < (size - 1));
+ }
+
+ /**
+ * @return true if there is an element to server when calling {@link previous}.
+ */
+ public synchronized boolean hasPrevious() {
+ int size = currentQueue.size();
+ if (repeat && (size > 0)) {
+ return true;
+ }
+ return (currentIndex > 0);
+ }
+
+ /**
+ * @param index
+ * @return the UpnpEntry at the index position in the queue, or null when none can be retrieved.
+ */
+ public @Nullable synchronized UpnpEntry get(int index) {
+ if ((index >= 0) && (index < size())) {
+ if (shuffle) {
+ return shuffledQueue.get(index);
+ } else {
+ return currentQueue.get(index);
+ }
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Reset the queue position to before the start of the queue (-1).
+ */
+ public synchronized void resetIndex() {
+ currentIndex = -1;
+ if (shuffle) {
+ shuffle();
+ }
+ }
+
+ /**
+ * @return number of element in the queue.
+ */
+ public synchronized int size() {
+ return currentQueue.size();
+ }
+
+ /**
+ * @return true if the queue is empty.
+ */
+ public synchronized boolean isEmpty() {
+ return currentQueue.isEmpty();
+ }
+
+ /**
+ * Persist queue as a playlist with name "current"
+ *
+ * @param path of playlist directory
+ */
+ public void persistQueue(String path) {
+ persistQueue("current", false, path);
+ }
+
+ /**
+ * Persist the queue as a playlist.
+ *
+ * @param name of the playlist
+ * @param append to the playlist if it already exists
+ * @param path of playlist directory
+ */
+ public synchronized void persistQueue(String name, boolean append, String path) {
+ String fileName = path + name + PLAYLIST_FILE_EXTENSION;
+ File file = new File(fileName);
+
+ String json;
+
+ try {
+ // ensure full path exists
+ file.getParentFile().mkdirs();
+
+ if (append && file.exists()) {
+ try {
+ logger.debug("Reading contents of {} for appending", file.getAbsolutePath());
+ final byte[] contents = Files.readAllBytes(file.toPath());
+ json = new String(contents, StandardCharsets.UTF_8);
+ Playlist appendList = gson.fromJson(json, Playlist.class);
+ if (appendList == null) {
+ // empty playlist file, so just overwrite
+ playlist.name = name;
+ json = gson.toJson(playlist);
+ } else {
+ // Merging masterList with persistList, overwriting persistList UpnpEntry objects with same id
+ playlist.masterList.forEach((u, list) -> appendList.masterList.merge(u, list,
+ (oldlist,
+ newlist) -> new ArrayList<>(Stream.of(oldlist, newlist).flatMap(List::stream)
+ .collect(Collectors.toMap(UpnpEntry::getId, entry -> entry,
+ (UpnpEntry oldentry, UpnpEntry newentry) -> newentry))
+ .values())));
+
+ json = gson.toJson(new Playlist(name, appendList.masterList));
+ }
+ } catch (JsonParseException | UnsupportedOperationException e) {
+ logger.debug("Could not append, JsonParseException reading {}: {}", file.toPath(), e.getMessage(),
+ e);
+ return;
+ } catch (IOException e) {
+ logger.debug("Could not append, IOException reading playlist {} from {}", name, file.toPath());
+ return;
+ }
+ } else {
+ playlist.name = name;
+ json = gson.toJson(playlist);
+ }
+
+ final byte[] contents = json.getBytes(StandardCharsets.UTF_8);
+ Files.write(file.toPath(), contents);
+ } catch (IOException e) {
+ logger.debug("IOException writing playlist {} to {}", name, file.toPath());
+ }
+ }
+
+ /**
+ * Replace the current queue with the playlist name and reset the queue index.
+ *
+ * @param name
+ * @param path directory containing playlist to restore
+ */
+ public void restoreQueue(String name, @Nullable String path) {
+ restoreQueue(name, null, path);
+ }
+
+ /**
+ * Replace the current queue with the playlist name and reset the queue index. Filter the content of the playlist on
+ * the server udn.
+ *
+ * @param name
+ * @param udn of the server the playlist entries were created on, all entries when null
+ * @param path of playlist directory
+ */
+ public synchronized void restoreQueue(String name, @Nullable String udn, @Nullable String path) {
+ if (path == null) {
+ return;
+ }
+
+ String fileName = path + name + PLAYLIST_FILE_EXTENSION;
+ File file = new File(fileName);
+
+ if (file.exists()) {
+ try {
+ logger.debug("Reading contents of {}", file.getAbsolutePath());
+ final byte[] contents = Files.readAllBytes(file.toPath());
+ final String json = new String(contents, StandardCharsets.UTF_8);
+
+ Playlist list = gson.fromJson(json, Playlist.class);
+ if (list == null) {
+ logger.debug("Empty playlist file {}", file.getAbsolutePath());
+ return;
+ }
+
+ playlist = list;
+
+ Stream<Entry<String, List<UpnpEntry>>> stream = playlist.masterList.entrySet().stream();
+ if (udn != null) {
+ stream = stream.filter(u -> u.getKey().equals(udn));
+ }
+ currentQueue = stream.map(p -> p.getValue()).flatMap(List::stream).filter(e -> !e.isContainer())
+ .collect(Collectors.toList());
+ resetIndex();
+ } catch (JsonParseException | UnsupportedOperationException e) {
+ logger.debug("JsonParseException reading {}: {}", file.toPath(), e.getMessage(), e);
+ } catch (IOException e) {
+ logger.debug("IOException reading playlist {} from {}", name, file.toPath());
+ }
+ }
+ }
+
+ /**
+ * @return list of all UpnpEntries in the queue.
+ */
+ public List<UpnpEntry> getEntryList() {
+ return currentQueue;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.queue;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@NonNullByDefault
+public class UpnpEntryRes {
+
+ private String protocolInfo;
+ private @Nullable Long size;
+ private String duration;
+ private String importUri;
+ private String res = "";
+
+ public UpnpEntryRes(String protocolInfo, @Nullable Long size, @Nullable String duration,
+ @Nullable String importUri) {
+ this.protocolInfo = protocolInfo.trim();
+ this.size = size;
+ this.duration = (duration == null) ? "" : duration.trim();
+ this.importUri = (importUri == null) ? "" : importUri.trim();
+ }
+
+ /**
+ * @return the res
+ */
+ public String getRes() {
+ return res;
+ }
+
+ /**
+ * @param res the res to set
+ */
+ public void setRes(String res) {
+ this.res = res.trim();
+ }
+
+ public String getProtocolInfo() {
+ return protocolInfo;
+ }
+
+ /**
+ * @return the size
+ */
+ public @Nullable Long getSize() {
+ return size;
+ }
+
+ /**
+ * @return the duration
+ */
+ public String getDuration() {
+ return duration;
+ }
+
+ /**
+ * @return the importUri
+ */
+ public String getImportUri() {
+ return importUri;
+ }
+
+ /**
+ * @return true if this resource defines a thumbnail as specified in the DLNA specs
+ */
+ public boolean isThumbnailRes() {
+ return getProtocolInfo().toLowerCase().contains("dlna.org_pn=jpeg_tn");
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.queue;
+
+import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.FAVORITE_FILE_EXTENSION;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonParseException;
+
+/**
+ * Class used to model favorites, with and without full meta data. If metadata exists, it will be in UpnpEntry.
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@NonNullByDefault
+public class UpnpFavorite {
+
+ private final Logger logger = LoggerFactory.getLogger(UpnpFavorite.class);
+
+ /**
+ * Inner class used for streaming a favorite to disk as a json object.
+ *
+ */
+ private class Favorite {
+ String name;
+ String uri;
+ @Nullable
+ UpnpEntry entry;
+
+ Favorite(String name, String uri, @Nullable UpnpEntry entry) {
+ this.name = name;
+ this.uri = uri;
+ this.entry = entry;
+ }
+ }
+
+ private volatile Favorite favorite;
+
+ private final Gson gson = new Gson();
+
+ /**
+ * Create a new favorite from provide URI and details. If {@link UpnpEntry} entry is null, no metadata will be
+ * available with the favorite.
+ *
+ * @param name
+ * @param uri
+ * @param entry
+ */
+ public UpnpFavorite(String name, String uri, @Nullable UpnpEntry entry) {
+ favorite = new Favorite(name, uri, entry);
+ }
+
+ /**
+ * Create a new favorite from a file copy stored on disk. If the favorite cannot be read from disk, an empty
+ * favorite is created.
+ *
+ * @param name
+ * @param path
+ */
+ public UpnpFavorite(String name, @Nullable String path) {
+ String fileName = path + name + FAVORITE_FILE_EXTENSION;
+ File file = new File(fileName);
+
+ Favorite fav = null;
+
+ if ((path != null) && file.exists()) {
+ try {
+ logger.debug("Reading contents of {}", file.getAbsolutePath());
+ final byte[] contents = Files.readAllBytes(file.toPath());
+ final String json = new String(contents, StandardCharsets.UTF_8);
+
+ fav = gson.fromJson(json, Favorite.class);
+ } catch (JsonParseException | UnsupportedOperationException e) {
+ logger.debug("JsonParseException reading {}: {}", file.toPath(), e.getMessage(), e);
+ } catch (IOException e) {
+ logger.debug("IOException reading favorite {} from {}", name, file.toPath());
+ }
+ }
+
+ favorite = (fav != null) ? fav : new Favorite(name, "", null);
+ }
+
+ /**
+ * @return name of favorite
+ */
+ public String getName() {
+ return favorite.name;
+ }
+
+ /**
+ * @return URI of favorite
+ */
+ public String getUri() {
+ return favorite.uri;
+ }
+
+ /**
+ * @return {@link UpnpEntry} known details of favorite
+ */
+ @Nullable
+ public UpnpEntry getUpnpEntry() {
+ return favorite.entry;
+ }
+
+ /**
+ * Save the favorite to disk.
+ *
+ * @param name
+ * @param path
+ */
+ public void saveFavorite(String name, @Nullable String path) {
+ if (path == null) {
+ return;
+ }
+
+ String fileName = path + name + FAVORITE_FILE_EXTENSION;
+ File file = new File(fileName);
+
+ try {
+ // ensure full path exists
+ file.getParentFile().mkdirs();
+
+ String json = gson.toJson(favorite);
+ final byte[] contents = json.getBytes(StandardCharsets.UTF_8);
+ Files.write(file.toPath(), contents);
+ } catch (IOException e) {
+ logger.debug("IOException writing favorite {} to {}", name, file.toPath());
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.queue;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Interface for updating playlists list in multiple handlers.
+ *
+ * @author Mark Herwege - Initial contribution
+ *
+ */
+@NonNullByDefault
+public interface UpnpPlaylistsListener {
+
+ public void playlistsListChanged();
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.services;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.jupnp.model.meta.RemoteDevice;
+import org.jupnp.model.meta.RemoteService;
+import org.jupnp.model.types.ServiceId;
+
+/**
+ * Class representing the configuration of the renderer. Instantiation will get configuration parameters from UPnP
+ * {@link RemoteDevice}.
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@NonNullByDefault
+public class UpnpRenderingControlConfiguration {
+ protected static final String UPNP_RENDERING_CONTROL_SCHEMA = "urn:schemas-upnp-org:service:RenderingControl";
+
+ public Set<String> audioChannels = Collections.emptySet();
+
+ public boolean volume;
+ public boolean mute;
+ public boolean loudness;
+
+ public long maxvolume = 100;
+
+ public UpnpRenderingControlConfiguration() {
+ }
+
+ public UpnpRenderingControlConfiguration(@Nullable RemoteDevice device) {
+ if (device == null) {
+ return;
+ }
+
+ RemoteService rcService = device.findService(ServiceId.valueOf(UPNP_RENDERING_CONTROL_SCHEMA));
+ if (rcService != null) {
+ volume = (rcService.getStateVariable("Volume") != null);
+ if (volume) {
+ maxvolume = rcService.getStateVariable("Volume").getTypeDetails().getAllowedValueRange().getMaximum();
+ }
+ mute = (rcService.getStateVariable("Mute") != null);
+ loudness = (rcService.getStateVariable("Loudness") != null);
+ if (rcService.getStateVariable("A_ARG_TYPE_Channel") != null) {
+ audioChannels = new HashSet<String>(Arrays
+ .asList(rcService.getStateVariable("A_ARG_TYPE_Channel").getTypeDetails().getAllowedValues()));
+ }
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.util;
+
+import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArraySet;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.upnpcontrol.internal.queue.UpnpPlaylistsListener;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Class with some static utility methods for the upnpcontrol binding.
+ *
+ * @author Mark Herwege - Initial contribution
+ *
+ */
+@NonNullByDefault
+public final class UpnpControlUtil {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(UpnpControlUtil.class);
+
+ private static volatile List<String> playlistList = new ArrayList<>();
+ private static final Set<UpnpPlaylistsListener> PLAYLIST_SUBSCRIPTIONS = new CopyOnWriteArraySet<>();
+
+ public static void updatePlaylistsList(@Nullable String path) {
+ playlistList = list(path, PLAYLIST_FILE_EXTENSION);
+ PLAYLIST_SUBSCRIPTIONS.forEach(UpnpPlaylistsListener::playlistsListChanged);
+ }
+
+ public static void playlistsSubscribe(UpnpPlaylistsListener listener) {
+ PLAYLIST_SUBSCRIPTIONS.add(listener);
+ }
+
+ public static void playlistsUnsubscribe(UpnpPlaylistsListener listener) {
+ PLAYLIST_SUBSCRIPTIONS.remove(listener);
+ }
+
+ public static void bindingConfigurationChanged(@Nullable String path) {
+ updatePlaylistsList(path);
+ }
+
+ /**
+ * Get names of saved playlists.
+ *
+ * @return playlists
+ */
+ public static List<String> playlists() {
+ return playlistList;
+ }
+
+ /**
+ * Delete a saved playlist.
+ *
+ * @param name of playlist to delete
+ * @param path of playlist directory
+ */
+ public static void deletePlaylist(String name, @Nullable String path) {
+ delete(name, path, PLAYLIST_FILE_EXTENSION);
+ }
+
+ /**
+ * Get names of saved favorites.
+ *
+ * @param path of favorite directory
+ * @return favorites
+ */
+ public static List<String> favorites(@Nullable String path) {
+ return list(path, FAVORITE_FILE_EXTENSION);
+ }
+
+ /**
+ * Delete a saved favorite.
+ *
+ * @param name of favorite to delete
+ * @param path of favorite directory
+ */
+ public static void deleteFavorite(String name, @Nullable String path) {
+ delete(name, path, FAVORITE_FILE_EXTENSION);
+ }
+
+ private static List<String> list(@Nullable String path, String extension) {
+ if (path == null) {
+ LOGGER.debug("No path set for {} files", extension);
+ return Collections.emptyList();
+ }
+
+ File directory = new File(path);
+ File[] files = directory.listFiles((dir, name) -> name.toLowerCase().endsWith(extension));
+
+ if (files == null) {
+ LOGGER.debug("No {} files in {}", extension, path);
+ return Collections.emptyList();
+ }
+
+ List<String> result = (Arrays.asList(files)).stream().map(p -> p.getName().replace(extension, ""))
+ .collect(Collectors.toList());
+ return result;
+ }
+
+ private static void delete(String name, @Nullable String path, String extension) {
+ if (path == null) {
+ return;
+ }
+
+ File file = new File(path + name + extension);
+ file.delete();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.util;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@NonNullByDefault
+public final class UpnpProtocolMatcher {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(UpnpProtocolMatcher.class);
+
+ private UpnpProtocolMatcher() {
+ }
+
+ /**
+ * Test if an UPnP protocol matches the object class. This method is used to filter resources for the primary
+ * resource.
+ *
+ * @param protocol format: <protocol>:<network>:<contentFormat>:<additionalInfo>
+ * e.g. http-get:*:audio/mpeg:*
+ * @param objectClass e.g. object.item.audioItem.musicTrack
+ * @return true if protocol matches objectClass
+ */
+ public static boolean testProtocol(String protocol, String objectClass) {
+ String[] protocolDetails = protocol.split(":");
+ if (protocolDetails.length < 3) {
+ LOGGER.debug("Protocol string {} not valid", protocol);
+ return false;
+ }
+ String protocolType = protocolDetails[2].toLowerCase();
+ int index = protocolType.indexOf("/");
+ if (index <= 0) {
+ LOGGER.debug("Protocol string {} not valid", protocol);
+ return false;
+ }
+ protocolType = protocolType.substring(0, index);
+
+ String[] objectClassDetails = objectClass.split("\\.");
+ if (objectClassDetails.length < 3) {
+ LOGGER.debug("Object class {} not valid", objectClass);
+ return false;
+ }
+ String objectType = objectClassDetails[2].toLowerCase();
+
+ LOGGER.debug("Matching protocol type '{}' with object type '{}'", protocolType, objectType);
+ return objectType.startsWith(protocolType);
+ }
+
+ /**
+ * Test if a UPnP protocol is in a set of protocols.
+ * Ignore vendor specific additionalInfo part in UPnP protocol string.
+ * Do all comparisons in lower case.
+ *
+ * @param protocol format: <protocol>:<network>:<contentFormat>:<additionalInfo>
+ * @param protocolSet
+ * @return true if protocol in protocolSet
+ */
+ public static boolean testProtocol(String protocol, List<String> protocolSet) {
+ int index = protocol.lastIndexOf(":");
+ if (index <= 0) {
+ LOGGER.debug("Protocol {} not valid", protocol);
+ return false;
+ }
+ String p = protocol.toLowerCase().substring(0, index);
+ List<String> pSet = new ArrayList<>();
+ protocolSet.forEach(f -> {
+ int i = f.lastIndexOf(":");
+ if (i <= 0) {
+ LOGGER.debug("Protocol {} from set not valid", f);
+ } else {
+ pSet.add(f.toLowerCase().substring(0, i));
+ }
+ });
+ LOGGER.trace("Testing {} in {}", p, pSet);
+ return pSet.contains(p);
+ }
+
+ /**
+ * Test if any of the UPnP protocols in protocolList can be found in a set of protocols.
+ *
+ * @param protocolList
+ * @param protocolSet
+ * @return true if one of the protocols in protocolSet
+ */
+ public static boolean testProtocolList(List<String> protocolList, List<String> protocolSet) {
+ return protocolList.stream().anyMatch(p -> testProtocol(p, protocolSet));
+ }
+
+ /**
+ * Return all UPnP protocols from protocolList that are part of a set of protocols.
+ *
+ * @param protocolList
+ * @param protocolSet
+ * @return sublist of protocolList
+ */
+ public static List<String> getProtocols(List<String> protocolList, List<String> protocolSet) {
+ return protocolList.stream().filter(p -> testProtocol(p, protocolSet)).collect(Collectors.toList());
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.util;
+
+import java.io.IOException;
+import java.io.StringReader;
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.parsers.SAXParser;
+import javax.xml.parsers.SAXParserFactory;
+
+import org.apache.commons.lang.StringEscapeUtils;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntry;
+import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntryRes;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.xml.sax.Attributes;
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.DefaultHandler;
+
+/**
+ *
+ * @author Mark Herwege - Initial contribution
+ * @author Karel Goderis - Based on UPnP logic in Sonos binding
+ */
+@NonNullByDefault
+public class UpnpXMLParser {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(UpnpXMLParser.class);
+
+ private static final String METADATA_PATTERN = "<DIDL-Lite xmlns:dc=\"http://purl.org/dc/elements/1.1/\" "
+ + "xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" "
+ + "xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\">"
+ + "<item id=\"{0}\" parentID=\"{1}\" restricted=\"true\"><dc:title>{2}</dc:title>"
+ + "<upnp:class>{3}</upnp:class><upnp:album>{4}</upnp:album>"
+ + "<upnp:albumArtURI>{5}</upnp:albumArtURI><dc:creator>{6}</dc:creator>"
+ + "<upnp:artist>{7}</upnp:artist><dc:publisher>{8}</dc:publisher>"
+ + "<upnp:genre>{9}</upnp:genre><upnp:originalTrackNumber>{10}</upnp:originalTrackNumber>"
+ + "</item></DIDL-Lite>";
+
+ private enum Element {
+ TITLE,
+ CLASS,
+ ALBUM,
+ ALBUM_ART_URI,
+ CREATOR,
+ ARTIST,
+ PUBLISHER,
+ GENRE,
+ TRACK_NUMBER,
+ RES
+ }
+
+ public static Map<String, @Nullable String> getRenderingControlFromXML(String xml) {
+ if (xml.isEmpty()) {
+ LOGGER.debug("Could not parse Rendering Control from empty xml");
+ return Collections.emptyMap();
+ }
+ RenderingControlEventHandler handler = new RenderingControlEventHandler();
+ try {
+ SAXParserFactory factory = SAXParserFactory.newInstance();
+ SAXParser saxParser = factory.newSAXParser();
+ saxParser.parse(new InputSource(new StringReader(xml)), handler);
+ } catch (IOException e) {
+ // This should never happen - we're not performing I/O!
+ LOGGER.error("Could not parse Rendering Control from string '{}'", xml);
+ } catch (SAXException | ParserConfigurationException s) {
+ LOGGER.error("Could not parse Rendering Control from string '{}'", xml);
+ }
+ return handler.getChanges();
+ }
+
+ private static class RenderingControlEventHandler extends DefaultHandler {
+
+ private final Map<String, @Nullable String> changes = new HashMap<>();
+
+ RenderingControlEventHandler() {
+ // shouldn't be used outside of this package.
+ }
+
+ @Override
+ public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
+ @Nullable Attributes attributes) throws SAXException {
+ if (qName == null) {
+ return;
+ }
+ switch (qName) {
+ case "Volume":
+ case "Mute":
+ case "Loudness":
+ String channel = attributes == null ? null : attributes.getValue("channel");
+ String val = attributes == null ? null : attributes.getValue("val");
+ if (channel != null && val != null) {
+ changes.put(channel + qName, val);
+ }
+ break;
+ default:
+ if ((attributes != null) && (attributes.getValue("val") != null)) {
+ changes.put(qName, attributes.getValue("val"));
+ }
+ break;
+ }
+ }
+
+ public Map<String, @Nullable String> getChanges() {
+ return changes;
+ }
+ }
+
+ public static Map<String, String> getAVTransportFromXML(String xml) {
+ if (xml.isEmpty()) {
+ LOGGER.debug("Could not parse AV Transport from empty xml");
+ return Collections.emptyMap();
+ }
+ AVTransportEventHandler handler = new AVTransportEventHandler();
+ try {
+ SAXParserFactory factory = SAXParserFactory.newInstance();
+ SAXParser saxParser = factory.newSAXParser();
+ saxParser.parse(new InputSource(new StringReader(xml)), handler);
+ } catch (IOException e) {
+ // This should never happen - we're not performing I/O!
+ LOGGER.error("Could not parse AV Transport from string '{}'", xml, e);
+ } catch (SAXException | ParserConfigurationException s) {
+ LOGGER.debug("Could not parse AV Transport from string '{}'", xml, s);
+ }
+ return handler.getChanges();
+ }
+
+ private static class AVTransportEventHandler extends DefaultHandler {
+
+ private final Map<String, String> changes = new HashMap<String, String>();
+
+ AVTransportEventHandler() {
+ // shouldn't be used outside of this package.
+ }
+
+ @Override
+ public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
+ @Nullable Attributes attributes) throws SAXException {
+ /*
+ * The events are all of the form <qName val="value"/> so we can get all
+ * the info we need from here.
+ */
+ if ((qName != null) && (attributes != null) && (attributes.getValue("val") != null)) {
+ changes.put(qName, attributes.getValue("val"));
+ }
+ }
+
+ public Map<String, String> getChanges() {
+ return changes;
+ }
+ }
+
+ public static List<UpnpEntry> getEntriesFromXML(String xml) {
+ if (xml.isEmpty()) {
+ LOGGER.debug("Could not parse Entries from empty xml");
+ return Collections.emptyList();
+ }
+ EntryHandler handler = new EntryHandler();
+ try {
+ SAXParserFactory factory = SAXParserFactory.newInstance();
+ SAXParser saxParser = factory.newSAXParser();
+ saxParser.parse(new InputSource(new StringReader(xml)), handler);
+ } catch (IOException e) {
+ // This should never happen - we're not performing I/O!
+ LOGGER.error("Could not parse Entries from string '{}'", xml, e);
+ } catch (SAXException | ParserConfigurationException s) {
+ LOGGER.debug("Could not parse Entries from string '{}'", xml, s);
+ }
+ return handler.getEntries();
+ }
+
+ private static class EntryHandler extends DefaultHandler {
+
+ // Maintain a set of elements it is not useful to complain about.
+ // This list will be initialized on the first failure case.
+ private static List<String> ignore = new ArrayList<String>();
+
+ private String id = "";
+ private String refId = "";
+ private String parentId = "";
+ private StringBuilder upnpClass = new StringBuilder();
+ private List<UpnpEntryRes> resList = new ArrayList<>();
+ private StringBuilder res = new StringBuilder();
+ private StringBuilder title = new StringBuilder();
+ private StringBuilder album = new StringBuilder();
+ private StringBuilder albumArtUri = new StringBuilder();
+ private StringBuilder creator = new StringBuilder();
+ private StringBuilder artist = new StringBuilder();
+ private List<String> artistList = new ArrayList<>();
+ private StringBuilder publisher = new StringBuilder();
+ private StringBuilder genre = new StringBuilder();
+ private StringBuilder trackNumber = new StringBuilder();
+ private @Nullable Element element = null;
+
+ private List<UpnpEntry> entries = new ArrayList<>();
+
+ EntryHandler() {
+ // shouldn't be used outside of this package.
+ }
+
+ @Override
+ public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
+ @Nullable Attributes attributes) throws SAXException {
+ if (qName == null) {
+ element = null;
+ return;
+ }
+ switch (qName) {
+ case "container":
+ case "item":
+ if (attributes != null) {
+ if (attributes.getValue("id") != null) {
+ id = attributes.getValue("id");
+ }
+ if (attributes.getValue("refID") != null) {
+ refId = attributes.getValue("refID");
+ }
+ if (attributes.getValue("parentID") != null) {
+ parentId = attributes.getValue("parentID");
+ }
+ }
+ break;
+ case "res":
+ if (attributes != null) {
+ String protocolInfo = attributes.getValue("protocolInfo");
+ Long size;
+ try {
+ size = Long.parseLong(attributes.getValue("size"));
+ } catch (NumberFormatException e) {
+ size = null;
+ }
+ String duration = attributes.getValue("duration");
+ String importUri = attributes.getValue("importUri");
+ resList.add(0, new UpnpEntryRes(protocolInfo, size, duration, importUri));
+ element = Element.RES;
+ }
+ break;
+ case "dc:title":
+ element = Element.TITLE;
+ break;
+ case "upnp:class":
+ element = Element.CLASS;
+ break;
+ case "dc:creator":
+ element = Element.CREATOR;
+ break;
+ case "upnp:artist":
+ element = Element.ARTIST;
+ break;
+ case "dc:publisher":
+ element = Element.PUBLISHER;
+ break;
+ case "upnp:genre":
+ element = Element.GENRE;
+ break;
+ case "upnp:album":
+ element = Element.ALBUM;
+ break;
+ case "upnp:albumArtURI":
+ element = Element.ALBUM_ART_URI;
+ break;
+ case "upnp:originalTrackNumber":
+ element = Element.TRACK_NUMBER;
+ break;
+ default:
+ if (ignore.isEmpty()) {
+ ignore.add("");
+ ignore.add("DIDL-Lite");
+ ignore.add("type");
+ ignore.add("ordinal");
+ ignore.add("description");
+ ignore.add("writeStatus");
+ ignore.add("storageUsed");
+ ignore.add("supported");
+ ignore.add("pushSource");
+ ignore.add("icon");
+ ignore.add("playlist");
+ ignore.add("date");
+ ignore.add("rating");
+ ignore.add("userrating");
+ ignore.add("episodeSeason");
+ ignore.add("childCountContainer");
+ ignore.add("modificationTime");
+ ignore.add("containerContent");
+ }
+ if (!ignore.contains(localName)) {
+ LOGGER.debug("Did not recognise element named {}", localName);
+ }
+ element = null;
+ }
+ }
+
+ @Override
+ public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
+ Element el = element;
+ if (el == null || ch == null) {
+ return;
+ }
+ switch (el) {
+ case TITLE:
+ title.append(ch, start, length);
+ break;
+ case CLASS:
+ upnpClass.append(ch, start, length);
+ break;
+ case RES:
+ res.append(ch, start, length);
+ break;
+ case ALBUM:
+ album.append(ch, start, length);
+ break;
+ case ALBUM_ART_URI:
+ albumArtUri.append(ch, start, length);
+ break;
+ case CREATOR:
+ creator.append(ch, start, length);
+ break;
+ case ARTIST:
+ artist.append(ch, start, length);
+ break;
+ case PUBLISHER:
+ publisher.append(ch, start, length);
+ break;
+ case GENRE:
+ genre.append(ch, start, length);
+ break;
+ case TRACK_NUMBER:
+ trackNumber.append(ch, start, length);
+ break;
+ }
+ }
+
+ @Override
+ public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
+ throws SAXException {
+ if ("container".equals(qName) || "item".equals(qName)) {
+ element = null;
+
+ Integer trackNumberVal;
+ try {
+ trackNumberVal = Integer.parseInt(trackNumber.toString());
+ } catch (NumberFormatException e) {
+ trackNumberVal = null;
+ }
+
+ entries.add(new UpnpEntry(id, refId, parentId, upnpClass.toString()).withTitle(title.toString())
+ .withAlbum(album.toString()).withAlbumArtUri(albumArtUri.toString())
+ .withCreator(creator.toString())
+ .withArtist(artistList.size() > 0 ? artistList.get(0) : artist.toString())
+ .withPublisher(publisher.toString()).withGenre(genre.toString()).withTrackNumber(trackNumberVal)
+ .withResList(resList));
+
+ title = new StringBuilder();
+ upnpClass = new StringBuilder();
+ resList = new ArrayList<>();
+ album = new StringBuilder();
+ albumArtUri = new StringBuilder();
+ creator = new StringBuilder();
+ artistList = new ArrayList<>();
+ publisher = new StringBuilder();
+ genre = new StringBuilder();
+ trackNumber = new StringBuilder();
+ } else if ("res".equals(qName)) {
+ resList.get(0).setRes(res.toString());
+ res = new StringBuilder();
+ } else if ("upnp:artist".equals(qName)) {
+ artistList.add(artist.toString());
+ artist = new StringBuilder();
+ }
+ }
+
+ public List<UpnpEntry> getEntries() {
+ return entries;
+ }
+ }
+
+ public static String compileMetadataString(UpnpEntry entry) {
+ String id = entry.getId();
+ String parentId = entry.getParentId();
+ String title = StringEscapeUtils.escapeXml(entry.getTitle());
+ String upnpClass = entry.getUpnpClass();
+ String album = StringEscapeUtils.escapeXml(entry.getAlbum());
+ String albumArtUri = entry.getAlbumArtUri();
+ String creator = StringEscapeUtils.escapeXml(entry.getCreator());
+ String artist = StringEscapeUtils.escapeXml(entry.getArtist());
+ String publisher = StringEscapeUtils.escapeXml(entry.getPublisher());
+ String genre = StringEscapeUtils.escapeXml(entry.getGenre());
+ Integer trackNumber = entry.getOriginalTrackNumber();
+
+ final MessageFormat messageFormat = new MessageFormat(METADATA_PATTERN);
+ String metadata = messageFormat.format(new Object[] { id, parentId, title, upnpClass, album, albumArtUri,
+ creator, artist, publisher, genre, trackNumber });
+
+ return metadata;
+ }
+}
<name>UPnP Control Binding</name>
<description>This binding acts as a UPnP Control Point that can query media server content directories and serve
content to media renderers.</description>
-
+ <config-description>
+ <parameter name="path" type="text">
+ <label>Storage Path</label>
+ <description>Folder path for playlists and favourites. If not set, it will default to $OPENHAB_USERDATA/upnpcontrol.
+ The folder will be created on first use when it does not exist.</description>
+ </parameter>
+ </config-description>
</binding:binding>
<channels>
<channel id="volume" typeId="system.volume"/>
<channel id="mute" typeId="system.mute"/>
+
<channel id="control" typeId="system.media-control"/>
<channel id="stop" typeId="stop"/>
+
+ <channel id="repeat" typeId="repeat"/>
+ <channel id="shuffle" typeId="shuffle"/>
+ <channel id="onlyplayone" typeId="onlyplayone"/>
+
+ <channel id="uri" typeId="uri"/>
+ <channel id="favoriteselect" typeId="favoriteselect"/>
+ <channel id="favorite" typeId="favorite"/>
+ <channel id="favoriteaction" typeId="favoriteaction"/>
+
+ <channel id="playlistselect" typeId="playlistselect"/>
+
<channel id="title" typeId="system.media-title"/>
<channel id="album" typeId="album"/>
<channel id="albumart" typeId="albumart"/>
<channel id="tracknumber" typeId="tracknumber"/>
<channel id="trackduration" typeId="trackduration"/>
<channel id="trackposition" typeId="trackposition"/>
+ <channel id="reltrackposition" typeId="reltrackposition"/>
</channels>
+ <representation-property>udn</representation-property>
<config-description>
<parameter name="udn" type="text" required="true">
<label>Unique Device Name</label>
<description>The UDN identifies the UPnP Renderer</description>
</parameter>
+ <parameter name="refresh" type="integer" unit="s">
+ <label>Refresh Interval</label>
+ <description>Specifies the refresh interval in seconds</description>
+ <default>60</default>
+ </parameter>
+ <parameter name="notificationVolumeAdjustment" type="integer" min="-100" max="100" step="1" unit="%">
+ <label>Notification Sound Volume Adjustment</label>
+ <description>Specifies the percentage adjustment to the current sound volume when playing notifications</description>
+ <default>10</default>
+ </parameter>
+ <parameter name="maxNotificationDuration" type="integer" unit="s">
+ <label>Maximum Notification Duration</label>
+ <description>Specifies the maximum duration for notifications, longer notification sounds will be interrupted. O
+ represents no maximum duration</description>
+ <default>15</default>
+ </parameter>
+ <parameter name="seekStep" type="integer" min="1">
+ <label>Fast Forward/Rewind Step</label>
+ <description>Step in seconds for fast forward rewind</description>
+ <default>5</default>
+ </parameter>
+ <parameter name="responseTimeout" type="integer" unit="ms">
+ <label>UPnP Response Timeout</label>
+ <description>Specifies the timeout in milliseconds when waiting for responses on UPnP actions</description>
+ <default>2500</default>
+ <advanced>true</advanced>
+ </parameter>
</config-description>
-
</thing-type>
+
<thing-type id="upnpserver">
<label>UPnPServer</label>
<description>UPnP AV Server</description>
<channels>
<channel id="upnprenderer" typeId="upnprenderer"/>
- <channel id="currentid" typeId="currentid"/>
+ <channel id="currenttitle" typeId="system.media-title"/>
<channel id="browse" typeId="browse"/>
<channel id="search" typeId="search"/>
+
+ <channel id="playlistselect" typeId="playlistselect"/>
+ <channel id="playlist" typeId="playlist"/>
+ <channel id="playlistaction" typeId="playlistaction"/>
+
+ <channel id="volume" typeId="system.volume"/>
+ <channel id="mute" typeId="system.mute"/>
+ <channel id="control" typeId="system.media-control"/>
+ <channel id="stop" typeId="stop"/>
+
</channels>
+ <representation-property>udn</representation-property>
<config-description>
<parameter name="udn" type="text" required="true">
<label>Unique Device Name</label>
<description>The UDN identifies the UPnP Media Server</description>
</parameter>
- <parameter name="filter" type="boolean" required="false">
+ <parameter name="refresh" type="integer" unit="s">
+ <label>Refresh Interval</label>
+ <description>Specifies the refresh interval in seconds</description>
+ <default>60</default>
+ </parameter>
+ <parameter name="filter" type="boolean">
<label>Filter Content</label>
<description>Only list content which is playable on the selected renderer</description>
<default>false</default>
- <advanced>false</advanced>
</parameter>
- <parameter name="sortcriteria" type="text" required="false">
+ <parameter name="sortCriteria" type="text">
<label>Sort Criteria</label>
<description>Sort criteria for the titles in the selection list and when sending for playing to a renderer. The
criteria are defined in UPnP sort criteria format. Examples: +dc:title, -dc:creator, +upnp:album. Supported sort
criteria will depend on the media server</description>
<default>+dc:title</default>
</parameter>
+ <parameter name="browseDown" type="boolean">
+ <label>Auto Browse Down</label>
+ <description>When browse or search results in exactly one container entry, iteratively browse down until the
+ result
+ contains multiple container entries or at least one media entry</description>
+ <default>true</default>
+ </parameter>
+ <parameter name="searchFromRoot" type="boolean">
+ <label>Search From Root</label>
+ <description>Always search from the root directory</description>
+ <default>false</default>
+ </parameter>
+ <parameter name="responseTimeout" type="integer" unit="ms">
+ <label>UPnP Response Timeout</label>
+ <description>Specifies the timeout in milliseconds when waiting for responses on UPnP actions</description>
+ <default>2500</default>
+ <advanced>true</advanced>
+ </parameter>
</config-description>
</thing-type>
<!-- Channel Types -->
+ <channel-type id="loudness">
+ <item-type>Switch</item-type>
+ <label>Loudness</label>
+ <description>Loudness</description>
+ <category>SoundVolume</category>
+ </channel-type>
<channel-type id="stop">
<item-type>Switch</item-type>
<label>Stop</label>
<description>Stop the player</description>
<autoUpdatePolicy>veto</autoUpdatePolicy>
</channel-type>
+ <channel-type id="repeat">
+ <item-type>Switch</item-type>
+ <label>Repeat</label>
+ <description>Repeat the selection</description>
+ </channel-type>
+ <channel-type id="shuffle">
+ <item-type>Switch</item-type>
+ <label>Shuffle</label>
+ <description>Random shuffle the selection</description>
+ </channel-type>
+ <channel-type id="onlyplayone">
+ <item-type>Switch</item-type>
+ <label>Only Play One</label>
+ <description>Stop playback after playing one media entry from queue</description>
+ </channel-type>
+ <channel-type id="uri">
+ <item-type>String</item-type>
+ <label>URI</label>
+ <description>Now playing URI</description>
+ </channel-type>
+ <channel-type id="favoriteselect">
+ <item-type>String</item-type>
+ <label>Select Favorite</label>
+ <description>Select favorite to play</description>
+ <autoUpdatePolicy>veto</autoUpdatePolicy>
+ </channel-type>
+ <channel-type id="favorite">
+ <item-type>String</item-type>
+ <label>Favorite</label>
+ <description>Favorite name</description>
+ </channel-type>
+ <channel-type id="favoriteaction">
+ <item-type>String</item-type>
+ <label>Favorite Action</label>
+ <description>Favorite action</description>
+ <command>
+ <options>
+ <option value="SAVE">Save</option>
+ <option value="DELETE">Delete</option>
+ </options>
+ </command>
+ <autoUpdatePolicy>veto</autoUpdatePolicy>
+ </channel-type>
<channel-type id="album">
<item-type>String</item-type>
<label>Album</label>
<item-type>Number:Time</item-type>
<label>Track Position</label>
<description>Now playing track position</description>
- <state readOnly="true" pattern="%d %unit%"/>
+ <state pattern="%d %unit%"/>
+ </channel-type>
+ <channel-type id="reltrackposition">
+ <item-type>Dimmer</item-type>
+ <label>Relative Track Position</label>
+ <description>Track position as percentage of track duration</description>
+ <category>MediaControl</category>
</channel-type>
<channel-type id="upnprenderer">
<label>Renderer</label>
<description>Select AV renderer</description>
</channel-type>
- <channel-type id="currentid">
- <item-type>String</item-type>
- <label>Current Media Id</label>
- <description>Current id of media entry or container</description>
- </channel-type>
<channel-type id="browse">
<item-type>String</item-type>
- <label>Browse Selection</label>
- <description>Browse selection for playing</description>
+ <label>Current Media Id</label>
+ <description>Current id of media entry or container, option list to browse hierarchy</description>
</channel-type>
<channel-type id="search">
<item-type>String</item-type>
Examples: dc:title contains "song", dc:creator contains "SpringSteen", unp:class = "object.item.audioItem",
upnp:album contains "Born in"</description>
</channel-type>
+ <channel-type id="playlistselect">
+ <item-type>String</item-type>
+ <label>Select Playlist</label>
+ <description>Playlist for selection</description>
+ <autoUpdatePolicy>veto</autoUpdatePolicy>
+ </channel-type>
+ <channel-type id="playlist">
+ <item-type>String</item-type>
+ <label>Playlist</label>
+ <description>Playlist name</description>
+ </channel-type>
+ <channel-type id="playlistaction">
+ <item-type>String</item-type>
+ <label>Playlist Action</label>
+ <description>Playlist action</description>
+ <command>
+ <options>
+ <option value="RESTORE">Restore</option>
+ <option value="SAVE">Save</option>
+ <option value="APPEND">Append</option>
+ <option value="DELETE">Delete</option>
+ </options>
+ </command>
+ <autoUpdatePolicy>veto</autoUpdatePolicy>
+ </channel-type>
</thing:thing-descriptions>
--- /dev/null
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.handler;
+
+import static org.eclipse.jdt.annotation.Checks.requireNonNull;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.openhab.binding.upnpcontrol.internal.UpnpDynamicCommandDescriptionProvider;
+import org.openhab.binding.upnpcontrol.internal.UpnpDynamicStateDescriptionProvider;
+import org.openhab.binding.upnpcontrol.internal.config.UpnpControlBindingConfiguration;
+import org.openhab.binding.upnpcontrol.internal.config.UpnpControlConfiguration;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.io.transport.upnp.UpnpIOService;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.binding.ThingHandlerCallback;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Base class for {@link UpnpServerHandlerTest} and {@link UpnpRendererHandlerTest}.
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@SuppressWarnings({ "null" })
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.WARN)
+@NonNullByDefault
+public class UpnpHandlerTest {
+
+ private final Logger logger = LoggerFactory.getLogger(UpnpHandlerTest.class);
+
+ private static final ScheduledExecutorService SCHEDULER = Executors.newScheduledThreadPool(1);
+
+ protected @Nullable UpnpHandler handler;
+
+ @Mock
+ protected @Nullable Thing thing;
+
+ @Mock
+ protected @Nullable UpnpIOService upnpIOService;
+
+ @Mock
+ protected @Nullable UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider;
+
+ @Mock
+ protected @Nullable UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider;
+
+ protected UpnpControlBindingConfiguration configuration = new UpnpControlBindingConfiguration();
+
+ @Mock
+ protected @Nullable Configuration config;
+
+ // Use temporary folder for favorites and playlists testing
+ @TempDir
+ public @Nullable Path tempFolder;
+
+ @Mock
+ @Nullable
+ protected ScheduledExecutorService scheduler;
+
+ @Mock
+ protected @Nullable ThingHandlerCallback callback;
+
+ public void setUp() {
+ // don't test for multi-threading, so avoid using extra threads
+ implementAsDirectExecutor(requireNonNull(scheduler));
+
+ String path = tempFolder.toString();
+ if (!(path.endsWith(File.separator) || path.endsWith("/"))) {
+ path = path + File.separator;
+ }
+ configuration.path = path;
+
+ // stub thing methods
+ when(thing.getConfiguration()).thenReturn(requireNonNull(config));
+ when(thing.getStatus()).thenReturn(ThingStatus.OFFLINE);
+
+ // stub upnpIOService methods for initialize
+ when(upnpIOService.isRegistered(any())).thenReturn(true);
+
+ Map<String, String> result = new HashMap<>();
+ result.put("ConnectionID", "0");
+ result.put("AVTransportID", "0");
+ result.put("RcsID", "0");
+ when(upnpIOService.invokeAction(any(), eq("ConnectionManager"), eq("GetCurrentConnectionInfo"), anyMap()))
+ .thenReturn(result);
+
+ // stub config for initialize
+ when(config.as(UpnpControlConfiguration.class)).thenReturn(new UpnpControlConfiguration());
+ }
+
+ protected void initHandler(UpnpHandler handler) {
+ handler.setCallback(callback);
+ handler.upnpScheduler = requireNonNull(scheduler);
+
+ // No timeouts for responses, as we don't actually communicate with a UPnP device
+ handler.config.responseTimeout = 0;
+
+ doReturn("12345").when(handler).getUDN();
+ }
+
+ /**
+ * Mock the {@link ScheduledExecutorService}, so all testing is done in the current thread. We do not test
+ * request/response with a real media server, so do not need the executor to avoid long running processes.
+ * As an exception, we will schedule one off futures with 500ms delay, as this is related to internal
+ * synchronization
+ * logic.
+ *
+ * @param executor
+ */
+ private void implementAsDirectExecutor(ScheduledExecutorService executor) {
+ doAnswer(invocation -> {
+ ((Runnable) invocation.getArguments()[0]).run();
+ return null;
+ }).when(executor).submit(any(Runnable.class));
+ doAnswer(invocation -> {
+ ((Runnable) invocation.getArguments()[0]).run();
+ return null;
+ }).when(executor).scheduleWithFixedDelay(any(Runnable.class), eq(0L), anyLong(), any(TimeUnit.class));
+ doAnswer(invocation -> {
+ return SCHEDULER.schedule((Runnable) invocation.getArguments()[0], 500, TimeUnit.MILLISECONDS);
+ }).when(executor).schedule(any(Runnable.class), anyLong(), any(TimeUnit.class));
+ }
+
+ public void tearDown() {
+ logger.info("-----------------------------------------------------------------------------------");
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.handler;
+
+import static org.eclipse.jdt.annotation.Checks.requireNonNull;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.openhab.binding.upnpcontrol.internal.audiosink.UpnpAudioSinkReg;
+import org.openhab.binding.upnpcontrol.internal.config.UpnpControlRendererConfiguration;
+import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntry;
+import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntryQueue;
+import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntryRes;
+import org.openhab.binding.upnpcontrol.internal.util.UpnpXMLParser;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.NextPreviousType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.library.types.PlayPauseType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.unit.SmartHomeUnits;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.builder.ChannelBuilder;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.CommandOption;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Unit tests for {@link UpnpRendererHandler}.
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@SuppressWarnings({ "null", "unchecked" })
+@NonNullByDefault
+public class UpnpRendererHandlerTest extends UpnpHandlerTest {
+
+ private final Logger logger = LoggerFactory.getLogger(UpnpRendererHandlerTest.class);
+
+ private static final String THING_TYPE_UID = "upnpcontrol:upnprenderer";
+ private static final String THING_UID = THING_TYPE_UID + ":mockrenderer";
+
+ private static final String LAST_CHANGE_HEADER = "<Event xmlns=\"urn:schemas-upnp-org:metadata-1-0/AVT/\">"
+ + "<InstanceID val=\"0\">";
+ private static final String LAST_CHANGE_FOOTER = "</InstanceID></Event>";
+ private static final String AV_TRANSPORT_URI = "<AVTransportURI val=\"";
+ private static final String AV_TRANSPORT_URI_METADATA = "<AVTransportURIMetaData val=\"";
+ private static final String CURRENT_TRACK_URI = "<CurrentTrackURI val=\"";
+ private static final String CURRENT_TRACK_METADATA = "<CurrentTrackMetaData val=\"";
+ private static final String TRANSPORT_STATE = "<TransportState val=\"";
+ private static final String CLOSE = "\"/>";
+
+ protected @Nullable UpnpRendererHandler handler;
+
+ private @Nullable UpnpEntryQueue upnpEntryQueue;
+
+ private ChannelUID volumeChannelUID = new ChannelUID(THING_UID + ":" + VOLUME);
+ private Channel volumeChannel = ChannelBuilder.create(volumeChannelUID, "Dimmer").build();
+
+ private ChannelUID muteChannelUID = new ChannelUID(THING_UID + ":" + MUTE);
+ private Channel muteChannel = ChannelBuilder.create(muteChannelUID, "Switch").build();
+
+ private ChannelUID stopChannelUID = new ChannelUID(THING_UID + ":" + STOP);
+ private Channel stopChannel = ChannelBuilder.create(stopChannelUID, "Switch").build();
+
+ private ChannelUID controlChannelUID = new ChannelUID(THING_UID + ":" + CONTROL);
+ private Channel controlChannel = ChannelBuilder.create(controlChannelUID, "Player").build();
+
+ private ChannelUID repeatChannelUID = new ChannelUID(THING_UID + ":" + REPEAT);
+ private Channel repeatChannel = ChannelBuilder.create(repeatChannelUID, "Switch").build();
+
+ private ChannelUID shuffleChannelUID = new ChannelUID(THING_UID + ":" + SHUFFLE);
+ private Channel shuffleChannel = ChannelBuilder.create(shuffleChannelUID, "Switch").build();
+
+ private ChannelUID onlyPlayOneChannelUID = new ChannelUID(THING_UID + ":" + ONLY_PLAY_ONE);
+ private Channel onlyPlayOneChannel = ChannelBuilder.create(onlyPlayOneChannelUID, "Switch").build();
+
+ private ChannelUID uriChannelUID = new ChannelUID(THING_UID + ":" + URI);
+ private Channel uriChannel = ChannelBuilder.create(uriChannelUID, "String").build();
+
+ private ChannelUID favoriteSelectChannelUID = new ChannelUID(THING_UID + ":" + FAVORITE_SELECT);
+ private Channel favoriteSelectChannel = ChannelBuilder.create(favoriteSelectChannelUID, "String").build();
+
+ private ChannelUID favoriteChannelUID = new ChannelUID(THING_UID + ":" + FAVORITE);
+ private Channel favoriteChannel = ChannelBuilder.create(favoriteChannelUID, "String").build();
+
+ private ChannelUID favoriteActionChannelUID = new ChannelUID(THING_UID + ":" + FAVORITE_ACTION);
+ private Channel favoriteActionChannel = ChannelBuilder.create(favoriteActionChannelUID, "String").build();
+
+ private ChannelUID playlistSelectChannelUID = new ChannelUID(THING_UID + ":" + PLAYLIST_SELECT);
+ private Channel playlistSelectChannel = ChannelBuilder.create(playlistSelectChannelUID, "String").build();
+
+ private ChannelUID titleChannelUID = new ChannelUID(THING_UID + ":" + TITLE);
+ private Channel titleChannel = ChannelBuilder.create(titleChannelUID, "String").build();
+
+ private ChannelUID albumChannelUID = new ChannelUID(THING_UID + ":" + ALBUM);
+ private Channel albumChannel = ChannelBuilder.create(albumChannelUID, "String").build();
+
+ private ChannelUID albumArtChannelUID = new ChannelUID(THING_UID + ":" + ALBUM_ART);
+ private Channel albumArtChannel = ChannelBuilder.create(albumArtChannelUID, "Image").build();
+
+ private ChannelUID creatorChannelUID = new ChannelUID(THING_UID + ":" + CREATOR);
+ private Channel creatorChannel = ChannelBuilder.create(creatorChannelUID, "String").build();
+
+ private ChannelUID artistChannelUID = new ChannelUID(THING_UID + ":" + ARTIST);
+ private Channel artistChannel = ChannelBuilder.create(artistChannelUID, "String").build();
+
+ private ChannelUID publisherChannelUID = new ChannelUID(THING_UID + ":" + PUBLISHER);
+ private Channel publisherChannel = ChannelBuilder.create(publisherChannelUID, "String").build();
+
+ private ChannelUID genreChannelUID = new ChannelUID(THING_UID + ":" + GENRE);
+ private Channel genreChannel = ChannelBuilder.create(genreChannelUID, "String").build();
+
+ private ChannelUID trackNumberChannelUID = new ChannelUID(THING_UID + ":" + TRACK_NUMBER);
+ private Channel trackNumberChannel = ChannelBuilder.create(trackNumberChannelUID, "Number").build();
+
+ private ChannelUID trackDurationChannelUID = new ChannelUID(THING_UID + ":" + TRACK_DURATION);
+ private Channel trackDurationChannel = ChannelBuilder.create(trackDurationChannelUID, "Number:Time").build();
+
+ private ChannelUID trackPositionChannelUID = new ChannelUID(THING_UID + ":" + TRACK_POSITION);
+ private Channel trackPositionChannel = ChannelBuilder.create(trackPositionChannelUID, "Number:Time").build();
+
+ private ChannelUID relTrackPositionChannelUID = new ChannelUID(THING_UID + ":" + REL_TRACK_POSITION);
+ private Channel relTrackPositionChannel = ChannelBuilder.create(relTrackPositionChannelUID, "Dimmer").build();
+
+ @Mock
+ private @Nullable UpnpAudioSinkReg audioSinkReg;
+
+ @Override
+ @BeforeEach
+ public void setUp() {
+ super.setUp();
+
+ // stub thing methods
+ when(thing.getUID()).thenReturn(new ThingUID("upnpcontrol", "upnprenderer", "mockrenderer"));
+ when(thing.getLabel()).thenReturn("MockRenderer");
+ when(thing.getStatus()).thenReturn(ThingStatus.OFFLINE);
+
+ // stub channels
+ when(thing.getChannel(VOLUME)).thenReturn(volumeChannel);
+ when(thing.getChannel(MUTE)).thenReturn(muteChannel);
+ when(thing.getChannel(STOP)).thenReturn(stopChannel);
+ when(thing.getChannel(CONTROL)).thenReturn(controlChannel);
+ when(thing.getChannel(REPEAT)).thenReturn(repeatChannel);
+ when(thing.getChannel(SHUFFLE)).thenReturn(shuffleChannel);
+ when(thing.getChannel(ONLY_PLAY_ONE)).thenReturn(onlyPlayOneChannel);
+ when(thing.getChannel(URI)).thenReturn(uriChannel);
+ when(thing.getChannel(FAVORITE_SELECT)).thenReturn(favoriteSelectChannel);
+ when(thing.getChannel(FAVORITE)).thenReturn(favoriteChannel);
+ when(thing.getChannel(FAVORITE_ACTION)).thenReturn(favoriteActionChannel);
+ when(thing.getChannel(PLAYLIST_SELECT)).thenReturn(playlistSelectChannel);
+ when(thing.getChannel(TITLE)).thenReturn(titleChannel);
+ when(thing.getChannel(ALBUM)).thenReturn(albumChannel);
+ when(thing.getChannel(ALBUM_ART)).thenReturn(albumArtChannel);
+ when(thing.getChannel(CREATOR)).thenReturn(creatorChannel);
+ when(thing.getChannel(ARTIST)).thenReturn(artistChannel);
+ when(thing.getChannel(PUBLISHER)).thenReturn(publisherChannel);
+ when(thing.getChannel(GENRE)).thenReturn(genreChannel);
+ when(thing.getChannel(TRACK_NUMBER)).thenReturn(trackNumberChannel);
+ when(thing.getChannel(TRACK_DURATION)).thenReturn(trackDurationChannel);
+ when(thing.getChannel(TRACK_POSITION)).thenReturn(trackPositionChannel);
+ when(thing.getChannel(REL_TRACK_POSITION)).thenReturn(relTrackPositionChannel);
+
+ // stub config for initialize
+ when(config.as(UpnpControlRendererConfiguration.class)).thenReturn(new UpnpControlRendererConfiguration());
+
+ // create a media queue for playing
+ List<UpnpEntry> entries = createUpnpEntries();
+ upnpEntryQueue = new UpnpEntryQueue(entries, "54321");
+
+ handler = spy(new UpnpRendererHandler(requireNonNull(thing), requireNonNull(upnpIOService),
+ requireNonNull(audioSinkReg), requireNonNull(upnpStateDescriptionProvider),
+ requireNonNull(upnpCommandDescriptionProvider), configuration));
+
+ initHandler(requireNonNull(handler));
+
+ handler.initialize();
+
+ expectLastChangeOnStop(true);
+ expectLastChangeOnPlay(true);
+ expectLastChangeOnPause(true);
+ }
+
+ private List<UpnpEntry> createUpnpEntries() {
+ List<UpnpEntry> entries = new ArrayList<>();
+ UpnpEntry entry;
+ List<UpnpEntryRes> resList;
+ UpnpEntryRes res;
+ resList = new ArrayList<>();
+ res = new UpnpEntryRes("http-get:*:audio/mpeg:*", 8054458L, "10", "http://MediaServerContent_0/1/M0/");
+ res.setRes("http://MediaServerContent_0/1/M0/Test_0.mp3");
+ resList.add(res);
+ entry = new UpnpEntry("M0", "M0", "C11", "object.item.audioItem").withTitle("Music_00").withResList(resList)
+ .withAlbum("My Music 0").withCreator("Creator_0").withArtist("Artist_0").withGenre("Morning")
+ .withPublisher("myself 0").withAlbumArtUri("").withTrackNumber(1);
+ entries.add(entry);
+ resList = new ArrayList<>();
+ res = new UpnpEntryRes("http-get:*:audio/wav:*", 1156598L, "6", "http://MediaServerContent_0/1/M1/");
+ res.setRes("http://MediaServerContent_0/1/M1/Test_1.wav");
+ resList.add(res);
+ entry = new UpnpEntry("M1", "M1", "C11", "object.item.audioItem").withTitle("Music_01").withResList(resList)
+ .withAlbum("My Music 0").withCreator("Creator_1").withArtist("Artist_1").withGenre("Morning")
+ .withPublisher("myself 1").withAlbumArtUri("").withTrackNumber(2);
+ entries.add(entry);
+ resList = new ArrayList<>();
+ res = new UpnpEntryRes("http-get:*:audio/mpeg:*", 1156598L, "40", "http://MediaServerContent_0/1/M2/");
+ res.setRes("http://MediaServerContent_0/1/M2/Test_2.mp3");
+ resList.add(res);
+ entry = new UpnpEntry("M2", "M2", "C12", "object.item.audioItem").withTitle("Music_02").withResList(resList)
+ .withAlbum("My Music 2").withCreator("Creator_2").withArtist("Artist_2").withGenre("Evening")
+ .withPublisher("myself 2").withAlbumArtUri("").withTrackNumber(1);
+ entries.add(entry);
+ return entries;
+ }
+
+ @Override
+ @AfterEach
+ public void tearDown() {
+ handler.dispose();
+
+ super.tearDown();
+ }
+
+ @Test
+ public void testRegisterQueue() {
+ logger.info("testRegisterQueue");
+
+ // Register a media queue
+ expectLastChangeOnSetAVTransportURI(true, 0);
+ handler.registerQueue(requireNonNull(upnpEntryQueue));
+
+ checkInternalState(0, 1, true, false, true, false);
+ checkControlChannel(PlayPauseType.PAUSE);
+ checkSetURI(0, 1);
+ checkMetadataChannels(0);
+ }
+
+ @Test
+ public void testPlayQueue() {
+ logger.info("testPlayQueue");
+
+ // Register a media queue
+ expectLastChangeOnSetAVTransportURI(true, 0);
+ handler.registerQueue(requireNonNull(upnpEntryQueue));
+
+ // Play media
+ handler.handleCommand(controlChannelUID, PlayPauseType.PLAY);
+
+ checkInternalState(0, 1, false, true, false, true);
+ checkControlChannel(PlayPauseType.PLAY);
+ checkSetURI(0, 1);
+ checkMetadataChannels(0);
+ }
+
+ @Test
+ public void testStop() {
+ logger.info("testStop");
+
+ // Register a media queue
+ expectLastChangeOnSetAVTransportURI(true, 0);
+ handler.registerQueue(requireNonNull(upnpEntryQueue));
+
+ // Play media
+ handler.handleCommand(controlChannelUID, PlayPauseType.PLAY);
+
+ // Stop playback
+ handler.handleCommand(stopChannelUID, OnOffType.ON);
+
+ checkInternalState(0, 1, true, false, false, false);
+ checkControlChannel(PlayPauseType.PAUSE);
+ checkSetURI(0, 1);
+ checkMetadataChannels(0);
+ }
+
+ @Test
+ public void testPause() {
+ logger.info("testPause");
+
+ // Register a media queue
+ expectLastChangeOnSetAVTransportURI(true, 0);
+ handler.registerQueue(requireNonNull(upnpEntryQueue));
+
+ // Play media
+ handler.handleCommand(controlChannelUID, PlayPauseType.PLAY);
+
+ // Pause media
+ handler.handleCommand(controlChannelUID, PlayPauseType.PAUSE);
+
+ checkControlChannel(PlayPauseType.PAUSE);
+
+ // Continue playing
+ handler.handleCommand(controlChannelUID, PlayPauseType.PLAY);
+
+ checkControlChannel(PlayPauseType.PLAY);
+ }
+
+ @Test
+ public void testPauseNotSupported() {
+ logger.info("testPauseNotSupported");
+
+ // Some players don't support pause and just continue playing.
+ // Test if we properly switch back to playing state if no confirmation of pause received.
+
+ // Register a media queue
+ expectLastChangeOnSetAVTransportURI(true, 0);
+ handler.registerQueue(requireNonNull(upnpEntryQueue));
+
+ // Play media
+ handler.handleCommand(controlChannelUID, PlayPauseType.PLAY);
+
+ // Pause media
+ // Do not receive a PAUSED_PLAYBACK response
+ expectLastChangeOnPause(false);
+ handler.handleCommand(controlChannelUID, PlayPauseType.PAUSE);
+
+ // Wait long enough for status to turn back to PLAYING.
+ // All timeouts in test are set to 1s.
+ try {
+ TimeUnit.SECONDS.sleep(1);
+ } catch (InterruptedException ignore) {
+ }
+
+ checkControlChannel(PlayPauseType.PLAY);
+ }
+
+ @Test
+ public void testRegisterQueueWhilePlaying() {
+ logger.info("testRegisterQueueWhilePlaying");
+
+ // Register a media queue
+ expectLastChangeOnSetAVTransportURI(true, 2);
+ List<UpnpEntry> startList = new ArrayList<UpnpEntry>();
+ startList.add(requireNonNull(upnpEntryQueue.get(2)));
+ UpnpEntryQueue startQueue = new UpnpEntryQueue(startList, "54321");
+ handler.registerQueue(requireNonNull(startQueue));
+
+ // Play media
+ handler.handleCommand(controlChannelUID, PlayPauseType.PLAY);
+
+ // Register a new media queue
+ expectLastChangeOnSetAVTransportURI(true, 0);
+ handler.registerQueue(requireNonNull(upnpEntryQueue));
+
+ checkInternalState(2, 0, false, true, true, true);
+ checkControlChannel(PlayPauseType.PLAY);
+ checkSetURI(null, 0);
+ checkMetadataChannels(2);
+ }
+
+ @Test
+ public void testNext() {
+ logger.info("testNext");
+
+ testNext(false, false);
+ }
+
+ @Test
+ public void testNextRepeat() {
+ logger.info("testNextRepeat");
+
+ testNext(false, true);
+ }
+
+ @Test
+ public void testNextWhilePlaying() {
+ logger.info("testNextWhilePlaying");
+
+ testNext(true, false);
+ }
+
+ @Test
+ public void testNextWhilePlayingRepeat() {
+ logger.info("testNextWhilePlayingRepeat");
+
+ testNext(true, true);
+ }
+
+ private void testNext(boolean play, boolean repeat) {
+ // Register a media queue
+ expectLastChangeOnSetAVTransportURI(true, 0);
+ handler.registerQueue(requireNonNull(upnpEntryQueue));
+
+ if (repeat) {
+ handler.handleCommand(repeatChannelUID, OnOffType.ON);
+ }
+
+ if (play) {
+ // Play media
+ handler.handleCommand(controlChannelUID, PlayPauseType.PLAY);
+ }
+
+ // Next media
+ expectLastChangeOnSetAVTransportURI(true, 1);
+ handler.handleCommand(controlChannelUID, NextPreviousType.NEXT);
+
+ checkInternalState(1, 2, play ? false : true, play ? true : false, play ? false : true, play ? true : false);
+ checkControlChannel(play ? PlayPauseType.PLAY : PlayPauseType.PAUSE);
+ checkSetURI(1, 2);
+ checkMetadataChannels(1);
+
+ // Next media
+ expectLastChangeOnSetAVTransportURI(true, 2);
+ handler.handleCommand(controlChannelUID, NextPreviousType.NEXT);
+
+ checkInternalState(2, repeat ? 0 : null, play ? false : true, play ? true : false, play ? false : true,
+ play ? true : false);
+ checkControlChannel(play ? PlayPauseType.PLAY : PlayPauseType.PAUSE);
+ checkSetURI(2, repeat ? 0 : null);
+ checkMetadataChannels(2);
+
+ // Next media
+ expectLastChangeOnSetAVTransportURI(true, 0);
+ handler.handleCommand(controlChannelUID, NextPreviousType.NEXT);
+
+ checkInternalState(0, 1, (play && repeat) ? false : true, (play && repeat) ? true : false,
+ (play && repeat) ? false : true, (play && repeat) ? true : false);
+ checkControlChannel((play && repeat) ? PlayPauseType.PLAY : PlayPauseType.PAUSE);
+ checkSetURI(0, 1);
+ checkMetadataChannels(0);
+ }
+
+ @Test
+ public void testPrevious() {
+ logger.info("testPrevious");
+
+ testPrevious(false, false);
+ }
+
+ @Test
+ public void testPreviousRepeat() {
+ logger.info("testPreviousRepeat");
+
+ testPrevious(false, true);
+ }
+
+ @Test
+ public void testPreviousWhilePlaying() {
+ logger.info("testPreviousWhilePlaying");
+
+ testPrevious(true, false);
+ }
+
+ @Test
+ public void testPreviousWhilePlayingRepeat() {
+ logger.info("testPreviousWhilePlayingRepeat");
+
+ testPrevious(true, true);
+ }
+
+ public void testPrevious(boolean play, boolean repeat) {
+ // Register a media queue
+ expectLastChangeOnSetAVTransportURI(true, 0);
+ handler.registerQueue(requireNonNull(upnpEntryQueue));
+
+ if (repeat) {
+ handler.handleCommand(repeatChannelUID, OnOffType.ON);
+ }
+
+ if (play) {
+ // Play media
+ handler.handleCommand(controlChannelUID, PlayPauseType.PLAY);
+ }
+
+ // Next media
+ expectLastChangeOnSetAVTransportURI(true, 1);
+ handler.handleCommand(controlChannelUID, NextPreviousType.NEXT);
+
+ // Previous media
+ expectLastChangeOnSetAVTransportURI(true, 2);
+ handler.handleCommand(controlChannelUID, NextPreviousType.PREVIOUS);
+
+ checkInternalState(0, 1, play ? false : true, play ? true : false, play ? false : true, play ? true : false);
+ checkControlChannel(play ? PlayPauseType.PLAY : PlayPauseType.PAUSE);
+ checkSetURI(0, 1);
+ checkMetadataChannels(0);
+
+ // Previous media
+ expectLastChangeOnSetAVTransportURI(true, 0);
+ handler.handleCommand(controlChannelUID, NextPreviousType.PREVIOUS);
+
+ checkInternalState(repeat ? 2 : 0, repeat ? 0 : 1, (play && repeat) ? false : true,
+ (play && repeat) ? true : false, (play && repeat) ? false : true, (play && repeat) ? true : false);
+ checkControlChannel((play && repeat) ? PlayPauseType.PLAY : PlayPauseType.PAUSE);
+ checkSetURI(repeat ? 2 : 0, repeat ? 0 : 1);
+ checkMetadataChannels(repeat ? 2 : 0);
+ }
+
+ @Test
+ public void testAutoPlayNextInQueue() {
+ logger.info("testAutoPlayNextInQueue");
+
+ // Register a media queue
+ expectLastChangeOnSetAVTransportURI(true, 0);
+ handler.registerQueue(requireNonNull(upnpEntryQueue));
+
+ // Play media
+ handler.handleCommand(controlChannelUID, PlayPauseType.PLAY);
+
+ // We expect GENA LastChange event with new metadata when the renderer starts to play next entry
+ expectLastChangeOnSetAVTransportURI(true, 1);
+
+ // At the end of the media, we will get GENA LastChange STOP event, renderer should move to next media and play
+ // Force this STOP event for test
+ String lastChange = LAST_CHANGE_HEADER + TRANSPORT_STATE + "STOPPED" + CLOSE + LAST_CHANGE_FOOTER;
+ handler.onValueReceived("LastChange", lastChange, "AVTransport");
+
+ checkInternalState(1, 2, false, true, false, true);
+ checkControlChannel(PlayPauseType.PLAY);
+ checkSetURI(1, 2);
+ checkMetadataChannels(1);
+ }
+
+ @Test
+ public void testAutoPlayNextInQueueGapless() {
+ logger.info("testAutoPlayNextInQueueGapless");
+
+ // Register a media queue
+ expectLastChangeOnSetAVTransportURI(true, 0);
+ handler.registerQueue(requireNonNull(upnpEntryQueue));
+
+ // Play media
+ handler.handleCommand(controlChannelUID, PlayPauseType.PLAY);
+
+ // We expect GENA LastChange event with new metadata when the renderer starts to play next entry
+ expectLastChangeOnSetAVTransportURI(true, 1);
+
+ // At the end of the media, we will get GENA event with new URI and metadata
+ String lastChange = LAST_CHANGE_HEADER + AV_TRANSPORT_URI + upnpEntryQueue.get(1).getRes() + CLOSE
+ + AV_TRANSPORT_URI_METADATA + UpnpXMLParser.compileMetadataString(requireNonNull(upnpEntryQueue.get(0)))
+ + CLOSE + CURRENT_TRACK_URI + upnpEntryQueue.get(1).getRes() + CLOSE + CURRENT_TRACK_METADATA
+ + UpnpXMLParser.compileMetadataString(requireNonNull(upnpEntryQueue.get(1))) + CLOSE
+ + LAST_CHANGE_FOOTER;
+ handler.onValueReceived("LastChange", lastChange, "AVTransport");
+
+ checkInternalState(1, 2, false, true, false, true);
+ checkControlChannel(PlayPauseType.PLAY);
+ checkSetURI(null, 2);
+ checkMetadataChannels(1);
+ }
+
+ @Test
+ public void testOnlyPlayOne() {
+ logger.info("testOnlyPlayOne");
+
+ handler.handleCommand(onlyPlayOneChannelUID, OnOffType.ON);
+
+ // Register a media queue
+ expectLastChangeOnSetAVTransportURI(true, 0);
+ handler.registerQueue(requireNonNull(upnpEntryQueue));
+
+ // Play media
+ handler.handleCommand(controlChannelUID, PlayPauseType.PLAY);
+
+ checkInternalState(0, 1, false, true, false, true);
+ checkSetURI(0, null);
+ checkMetadataChannels(0);
+
+ // We expect GENA LastChange event with new metadata when the renderer has finished playing
+ expectLastChangeOnSetAVTransportURI(true, 1);
+
+ // At the end of the media, we will get GENA LastChange STOP event, renderer should stop
+ // Force this STOP event for test
+ String lastChange = LAST_CHANGE_HEADER + TRANSPORT_STATE + "STOPPED" + CLOSE + LAST_CHANGE_FOOTER;
+ handler.onValueReceived("LastChange", lastChange, "AVTransport");
+
+ checkInternalState(1, 2, false, false, false, true);
+ checkControlChannel(PlayPauseType.PAUSE);
+ checkSetURI(1, null);
+ checkMetadataChannels(1);
+ }
+
+ @Test
+ public void testPlayUri() {
+ logger.info("testPlayUri");
+
+ expectLastChangeOnSetAVTransportURI(true, false, 0);
+ handler.handleCommand(uriChannelUID, StringType.valueOf(upnpEntryQueue.get(0).getRes()));
+
+ // Play media
+ handler.handleCommand(controlChannelUID, PlayPauseType.PLAY);
+
+ checkInternalState(null, null, false, true, false, false);
+ checkControlChannel(PlayPauseType.PLAY);
+ checkSetURI(0, null, false);
+ checkMetadataChannels(0, true);
+ }
+
+ @Test
+ public void testPlayAction() {
+ logger.info("testPlayAction");
+
+ expectLastChangeOnSetAVTransportURI(true, false, 0);
+
+ // Methods called in sequence by audio sink
+ handler.setCurrentURI(upnpEntryQueue.get(0).getRes(), "");
+ handler.play();
+
+ checkInternalState(null, null, false, true, false, false);
+ checkControlChannel(PlayPauseType.PLAY);
+ checkSetURI(0, null, false);
+ checkMetadataChannels(0, true);
+ }
+
+ @Test
+ public void testPlayNotification() {
+ logger.info("testPlayNotification");
+
+ // Register a media queue
+ expectLastChangeOnSetAVTransportURI(true, 0);
+ handler.registerQueue(requireNonNull(upnpEntryQueue));
+
+ // Set volume
+ expectLastChangeOnSetVolume(true, 50);
+ handler.setVolume(new PercentType(50));
+
+ checkInternalState(0, 1, true, false, true, false);
+ checkSetURI(0, 1, true);
+ checkMetadataChannels(0, false);
+
+ // Play notification, at standard 10% volume above current volume level
+ expectLastChangeOnSetAVTransportURI(true, false, 2);
+ expectLastChangeOnGetPositionInfo(true, "00:00:00");
+ handler.playNotification(upnpEntryQueue.get(2).getRes());
+
+ checkInternalState(0, 1, true, false, true, false);
+ checkSetURI(2, null, false);
+ checkMetadataChannels(0, false);
+ verify(handler).setVolume(new PercentType(55));
+
+ // At the end of the notification, we will get GENA LastChange STOP event
+ // Force this STOP event for test
+ expectLastChangeOnSetAVTransportURI(true, false, 0);
+ String lastChange = LAST_CHANGE_HEADER + TRANSPORT_STATE + "STOPPED" + CLOSE + LAST_CHANGE_FOOTER;
+ handler.onValueReceived("LastChange", lastChange, "AVTransport");
+
+ checkInternalState(0, 1, true, false, true, false);
+ checkMetadataChannels(0, false);
+ verify(handler, times(2)).setVolume(new PercentType(50));
+
+ // Play media and move to position
+ handler.handleCommand(controlChannelUID, PlayPauseType.PLAY);
+
+ checkInternalState(0, 1, false, true, false, true); //
+ checkSetURI(0, 1, true);
+ checkMetadataChannels(0, false);
+
+ // Play notification again, while simulating the current playing media is at 10s position
+ // Play at volume level provided by audiSink action
+ expectLastChangeOnSetAVTransportURI(true, false, 2);
+ expectLastChangeOnGetPositionInfo(true, "00:00:10");
+ handler.setNotificationVolume(new PercentType(70));
+ handler.playNotification(upnpEntryQueue.get(2).getRes());
+
+ checkInternalState(0, 1, false, true, false, true);
+ checkSetURI(2, null, false);
+ checkMetadataChannels(0, false);
+ verify(handler).setVolume(new PercentType(70));
+
+ // Wait long enough for max notification duration to be reached.
+ // In the test, we have enforced 500ms delay through schedule mock.
+ expectLastChangeOnSetAVTransportURI(true, false, 0);
+ try {
+ TimeUnit.SECONDS.sleep(1);
+ logger.info("Test playing {}, stopped {}", handler.playing, handler.playerStopped);
+ } catch (InterruptedException ignore) {
+ }
+
+ checkInternalState(0, 1, false, true, false, true);
+ checkSetURI(0, null, false);
+ checkMetadataChannels(0, false);
+ verify(handler, times(3)).setVolume(new PercentType(50));
+ verify(callback, times(2)).stateUpdated(trackPositionChannelUID, new QuantityType<>(10, SmartHomeUnits.SECOND));
+ }
+
+ @Test
+ public void testFavorite() {
+ logger.info("testFavorite");
+
+ // Check already called in initialize
+ verify(handler).updateFavoritesList();
+
+ // First set URI
+ expectLastChangeOnSetAVTransportURI(true, false, 0);
+ handler.handleCommand(uriChannelUID, StringType.valueOf(upnpEntryQueue.get(0).getRes()));
+
+ // Save favorite
+ handler.handleCommand(favoriteChannelUID, StringType.valueOf("Test_Favorite"));
+ handler.handleCommand(favoriteActionChannelUID, StringType.valueOf("SAVE"));
+
+ // Check called after saving favorite
+ verify(handler, times(2)).updateFavoritesList();
+
+ // Check that FAVORITE_SELECT channel now has the favorite as a state option
+ ArgumentCaptor<List<CommandOption>> commandOptionListCaptor = ArgumentCaptor.forClass(List.class);
+ verify(handler, atLeastOnce()).updateCommandDescription(eq(thing.getChannel(FAVORITE_SELECT).getUID()),
+ commandOptionListCaptor.capture());
+ assertThat(commandOptionListCaptor.getValue().size(), is(1));
+ assertThat(commandOptionListCaptor.getValue().get(0).getCommand(), is("Test_Favorite"));
+ assertThat(commandOptionListCaptor.getValue().get(0).getLabel(), is("Test_Favorite"));
+
+ // Clear FAVORITE channel
+ handler.handleCommand(favoriteChannelUID, StringType.valueOf(""));
+
+ // Set another URI
+ expectLastChangeOnSetAVTransportURI(true, false, 2);
+ handler.handleCommand(uriChannelUID, StringType.valueOf(upnpEntryQueue.get(2).getRes()));
+
+ checkInternalState(null, null, false, true, false, false);
+ checkSetURI(2, null, false);
+ checkMetadataChannels(2, true);
+
+ // Restore favorite
+ expectLastChangeOnSetAVTransportURI(true, false, 0);
+ handler.handleCommand(favoriteSelectChannelUID, StringType.valueOf("Test_Favorite"));
+
+ checkInternalState(null, null, false, true, false, false);
+ checkControlChannel(PlayPauseType.PLAY);
+ checkSetURI(0, null, false);
+ checkMetadataChannels(0, true);
+
+ // Delete favorite
+ handler.handleCommand(favoriteSelectChannelUID, StringType.valueOf("Test_Favorite"));
+ handler.handleCommand(favoriteActionChannelUID, StringType.valueOf("DELETE"));
+
+ // Check called after deleting favorite
+ verify(handler, times(3)).updateFavoritesList();
+
+ // Check that FAVORITE_SELECT channel option list is empty again
+ commandOptionListCaptor = ArgumentCaptor.forClass(List.class);
+ verify(handler, atLeastOnce()).updateCommandDescription(eq(thing.getChannel(FAVORITE_SELECT).getUID()),
+ commandOptionListCaptor.capture());
+ assertThat(commandOptionListCaptor.getValue().size(), is(0));
+ }
+
+ private void expectLastChangeOnStop(boolean respond) {
+ String value = LAST_CHANGE_HEADER + TRANSPORT_STATE + "STOPPED" + CLOSE + LAST_CHANGE_FOOTER;
+ doAnswer(invocation -> {
+ if (respond) {
+ handler.onValueReceived("LastChange", value, "AVTransport");
+ }
+ return Collections.emptyMap();
+ }).when(upnpIOService).invokeAction(eq(handler), eq("AVTransport"), eq("Stop"), anyMap());
+ }
+
+ private void expectLastChangeOnPlay(boolean respond) {
+ String value = LAST_CHANGE_HEADER + TRANSPORT_STATE + "PLAYING" + CLOSE + LAST_CHANGE_FOOTER;
+ doAnswer(invocation -> {
+ if (respond) {
+ handler.onValueReceived("LastChange", value, "AVTransport");
+ }
+ return Collections.emptyMap();
+ }).when(upnpIOService).invokeAction(eq(handler), eq("AVTransport"), eq("Play"), anyMap());
+ }
+
+ private void expectLastChangeOnPause(boolean respond) {
+ String value = LAST_CHANGE_HEADER + TRANSPORT_STATE + "PAUSED_PLAYBACK" + CLOSE + LAST_CHANGE_FOOTER;
+ doAnswer(invocation -> {
+ if (respond) {
+ handler.onValueReceived("LastChange", value, "AVTransport");
+ }
+ return Collections.emptyMap();
+ }).when(upnpIOService).invokeAction(eq(handler), eq("AVTransport"), eq("Pause"), anyMap());
+ }
+
+ private void expectLastChangeOnSetVolume(boolean respond, long volume) {
+ Map<String, String> inputs = new HashMap<>();
+ inputs.put("InstanceID", "0");
+ inputs.put("Channel", UPNP_MASTER);
+ inputs.put("DesiredVolume", String.valueOf(volume));
+ doAnswer(invocation -> {
+ if (respond) {
+ handler.onValueReceived(UPNP_MASTER + "Volume", String.valueOf(volume), "RenderingControl");
+ }
+ return Collections.emptyMap();
+ }).when(upnpIOService).invokeAction(eq(handler), eq("RenderingControl"), eq("SetVolume"), eq(inputs));
+ }
+
+ private void expectLastChangeOnGetPositionInfo(boolean respond, String seekTarget) {
+ Map<String, String> inputs = new HashMap<>();
+ inputs.put("InstanceID", "0");
+ doAnswer(invocation -> {
+ if (respond) {
+ handler.onValueReceived("RelTime", seekTarget, "AVTransport");
+ }
+ return Collections.emptyMap();
+ }).when(upnpIOService).invokeAction(eq(handler), eq("AVTransport"), eq("GetPositionInfo"), eq(inputs));
+ }
+
+ private void expectLastChangeOnSetAVTransportURI(boolean respond, int mediaId) {
+ expectLastChangeOnSetAVTransportURI(respond, true, mediaId);
+ }
+
+ private void expectLastChangeOnSetAVTransportURI(boolean respond, boolean withMetadata, int mediaId) {
+ String uri = upnpEntryQueue.get(mediaId).getRes();
+ String metadata = UpnpXMLParser.compileMetadataString(requireNonNull(upnpEntryQueue.get(mediaId)));
+ Map<String, String> inputs = new HashMap<>();
+ inputs.put("InstanceID", "0");
+ inputs.put("CurrentURI", uri);
+ inputs.put("CurrentURIMetaData", withMetadata ? metadata : "");
+ String value = LAST_CHANGE_HEADER + AV_TRANSPORT_URI + uri + CLOSE + AV_TRANSPORT_URI_METADATA + metadata
+ + CLOSE + CURRENT_TRACK_URI + uri + CLOSE + CURRENT_TRACK_METADATA + metadata + CLOSE
+ + LAST_CHANGE_FOOTER;
+ doAnswer(invocation -> {
+ if (respond) {
+ handler.onValueReceived("LastChange", value, "AVTransport");
+ }
+ return Collections.emptyMap();
+ }).when(upnpIOService).invokeAction(eq(handler), eq("AVTransport"), eq("SetAVTransportURI"), eq(inputs));
+ }
+
+ private void checkInternalState(@Nullable Integer currentEntry, @Nullable Integer nextEntry, boolean playerStopped,
+ boolean playing, boolean registeredQueue, boolean playingQueue) {
+ if (currentEntry == null) {
+ assertNull(handler.currentEntry);
+ } else {
+ assertThat(handler.currentEntry, is(upnpEntryQueue.get(currentEntry)));
+ }
+ if (nextEntry == null) {
+ assertNull(handler.nextEntry);
+ } else {
+ assertThat(handler.nextEntry, is(upnpEntryQueue.get(nextEntry)));
+ }
+ assertThat(handler.playerStopped, is(playerStopped));
+ assertThat(handler.playing, is(playing));
+ assertThat(handler.registeredQueue, is(registeredQueue));
+ assertThat(handler.playingQueue, is(playingQueue));
+ }
+
+ private void checkControlChannel(Command command) {
+ ArgumentCaptor<PlayPauseType> captor = ArgumentCaptor.forClass(PlayPauseType.class);
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(CONTROL).getUID()), captor.capture());
+ assertThat(captor.getValue(), is(command));
+ }
+
+ private void checkSetURI(@Nullable Integer current, @Nullable Integer next) {
+ checkSetURI(current, next, true);
+ }
+
+ private void checkSetURI(@Nullable Integer current, @Nullable Integer next, boolean withMetadata) {
+ ArgumentCaptor<String> uriCaptor = ArgumentCaptor.forClass(String.class);
+ ArgumentCaptor<String> metadataCaptor = ArgumentCaptor.forClass(String.class);
+ if (current != null) {
+ verify(handler, atLeastOnce()).setCurrentURI(uriCaptor.capture(), metadataCaptor.capture());
+ assertThat(uriCaptor.getValue(), is(upnpEntryQueue.get(current).getRes()));
+ if (withMetadata) {
+ assertThat(metadataCaptor.getValue(),
+ is(UpnpXMLParser.compileMetadataString(requireNonNull(upnpEntryQueue.get(current)))));
+ }
+ }
+ if (next != null) {
+ verify(handler, atLeastOnce()).setNextURI(uriCaptor.capture(), metadataCaptor.capture());
+ assertThat(uriCaptor.getValue(), is(upnpEntryQueue.get(next).getRes()));
+ if (withMetadata) {
+ assertThat(metadataCaptor.getValue(),
+ is(UpnpXMLParser.compileMetadataString(requireNonNull(upnpEntryQueue.get(next)))));
+ }
+ }
+ }
+
+ private void checkMetadataChannels(int mediaId) {
+ checkMetadataChannels(mediaId, false);
+ }
+
+ private void checkMetadataChannels(int mediaId, boolean cleared) {
+ ArgumentCaptor<State> stateCaptor = ArgumentCaptor.forClass(State.class);
+
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(URI).getUID()), stateCaptor.capture());
+ assertThat(stateCaptor.getValue(), is(StringType.valueOf(upnpEntryQueue.get(mediaId).getRes())));
+
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(TITLE).getUID()), stateCaptor.capture());
+ assertThat(stateCaptor.getValue(),
+ is(cleared ? UnDefType.UNDEF : StringType.valueOf(upnpEntryQueue.get(mediaId).getTitle())));
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(ALBUM).getUID()), stateCaptor.capture());
+ assertThat(stateCaptor.getValue(),
+ is(cleared ? UnDefType.UNDEF : StringType.valueOf(upnpEntryQueue.get(mediaId).getAlbum())));
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(CREATOR).getUID()), stateCaptor.capture());
+ assertThat(stateCaptor.getValue(),
+ is(cleared ? UnDefType.UNDEF : StringType.valueOf(upnpEntryQueue.get(mediaId).getCreator())));
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(ARTIST).getUID()), stateCaptor.capture());
+ assertThat(stateCaptor.getValue(),
+ is(cleared ? UnDefType.UNDEF : StringType.valueOf(upnpEntryQueue.get(mediaId).getArtist())));
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(PUBLISHER).getUID()), stateCaptor.capture());
+ assertThat(stateCaptor.getValue(),
+ is(cleared ? UnDefType.UNDEF : StringType.valueOf(upnpEntryQueue.get(mediaId).getPublisher())));
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(GENRE).getUID()), stateCaptor.capture());
+ assertThat(stateCaptor.getValue(),
+ is(cleared ? UnDefType.UNDEF : StringType.valueOf(upnpEntryQueue.get(mediaId).getGenre())));
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(TRACK_NUMBER).getUID()),
+ stateCaptor.capture());
+ assertThat(stateCaptor.getValue(),
+ is(cleared ? UnDefType.UNDEF : new DecimalType(upnpEntryQueue.get(mediaId).getOriginalTrackNumber())));
+ is(new DecimalType(upnpEntryQueue.get(mediaId).getOriginalTrackNumber()));
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.handler;
+
+import static org.eclipse.jdt.annotation.Checks.requireNonNull;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.openhab.binding.upnpcontrol.internal.config.UpnpControlServerConfiguration;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.builder.ChannelBuilder;
+import org.openhab.core.types.CommandOption;
+import org.openhab.core.types.StateOption;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Unit tests for {@link UpnpServerHandler}.
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@SuppressWarnings({ "null", "unchecked" })
+@NonNullByDefault
+public class UpnpServerHandlerTest extends UpnpHandlerTest {
+
+ private final Logger logger = LoggerFactory.getLogger(UpnpServerHandlerTest.class);
+
+ private static final String THING_TYPE_UID = "upnpcontrol:upnpserver";
+ private static final String THING_UID = THING_TYPE_UID + ":mockserver";
+
+ private static final String RESPONSE_HEADER = "<DIDL-Lite xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\" "
+ + "xmlns:dc=\"http://purl.org/dc/elements/1.1/\" "
+ + "xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\">";
+ private static final String RESPONSE_FOOTER = "</DIDL-Lite>";
+
+ private static final String BASE_CONTAINER = RESPONSE_HEADER
+ + "<container id=\"C1\" searchable=\"0\" parentID=\"0\" restricted=\"1\" childCount=\"2\">"
+ + "<dc:title>All Audio Items</dc:title><upnp:class>object.container</upnp:class>"
+ + "<upnp:writeStatus>UNKNOWN</upnp:writeStatus></container>"
+ + "<container id=\"C2\" searchable=\"0\" parentID=\"0\" restricted=\"1\" childCount=\"0\">"
+ + "<dc:title>All Image Items</dc:title><upnp:class>object.container</upnp:class>"
+ + "<upnp:writeStatus>UNKNOWN</upnp:writeStatus></container>" + RESPONSE_FOOTER;
+
+ private static final String SINGLE_CONTAINER = RESPONSE_HEADER
+ + "<container id=\"C11\" searchable=\"0\" parentID=\"C1\" restricted=\"1\" childCount=\"2\">"
+ + "<dc:title>Morning Music</dc:title><upnp:class>object.container</upnp:class>"
+ + "<upnp:writeStatus>UNKNOWN</upnp:writeStatus></container>" + RESPONSE_FOOTER;
+
+ private static final String DOUBLE_CONTAINER = RESPONSE_HEADER
+ + "<container id=\"C11\" searchable=\"0\" parentID=\"C1\" restricted=\"1\" childCount=\"2\">"
+ + "<dc:title>Morning Music</dc:title><upnp:class>object.container</upnp:class>"
+ + "<upnp:writeStatus>UNKNOWN</upnp:writeStatus></container>"
+ + "<container id=\"C12\" searchable=\"0\" parentID=\"C1\" restricted=\"1\" childCount=\"1\">"
+ + "<dc:title>Evening Music</dc:title><upnp:class>object.container</upnp:class>"
+ + "<upnp:writeStatus>UNKNOWN</upnp:writeStatus></container>" + RESPONSE_FOOTER;
+
+ private static final String DOUBLE_MEDIA = RESPONSE_HEADER + "<item id=\"M1\" parentID=\"C11\" restricted=\"1\">"
+ + "<dc:title>Music_01</dc:title><upnp:class>object.item.audioItem</upnp:class>"
+ + "<dc:creator>Creator_1</dc:creator>"
+ + "<res protocolInfo=\"http-get:*:audio/mpeg:*\" size=\"8054458\" importUri=\"http://MediaServerContent_0/1/M1/\">http://MediaServerContent_0/1/M1/Test_1.mp3</res>"
+ + "<upnp:writeStatus>UNKNOWN</upnp:writeStatus></item>"
+ + "<item id=\"M2\" parentID=\"C11\" restricted=\"1\">"
+ + "<dc:title>Music_02</dc:title><upnp:class>object.item.audioItem</upnp:class>"
+ + "<dc:creator>Creator_2</dc:creator>"
+ + "<res protocolInfo=\"http-get:*:audio/wav:*\" size=\"1156598\" importUri=\"http://MediaServerContent_0/3/M2/\">http://MediaServerContent_0/3/M2/Test_2.wav</res>"
+ + "<upnp:writeStatus>UNKNOWN</upnp:writeStatus></item>" + RESPONSE_FOOTER;
+
+ private static final String EXTRA_MEDIA = RESPONSE_HEADER + "<item id=\"M3\" parentID=\"C12\" restricted=\"1\">"
+ + "<dc:title>Extra_01</dc:title><upnp:class>object.item.audioItem</upnp:class>"
+ + "<dc:creator>Creator_3</dc:creator>"
+ + "<res protocolInfo=\"http-get:*:audio/mpeg:*\" size=\"8054458\" importUri=\"http://MediaServerContent_0/1/M3/\">http://MediaServerContent_0/1/M3/Test_3.mp3</res>"
+ + "<upnp:writeStatus>UNKNOWN</upnp:writeStatus></item>" + RESPONSE_FOOTER;
+
+ protected @Nullable UpnpServerHandler handler;
+
+ private ChannelUID rendererChannelUID = new ChannelUID(THING_UID + ":" + UPNPRENDERER);
+ private Channel rendererChannel = ChannelBuilder.create(rendererChannelUID, "String").build();
+
+ private ChannelUID browseChannelUID = new ChannelUID(THING_UID + ":" + BROWSE);
+ private Channel browseChannel = ChannelBuilder.create(browseChannelUID, "String").build();
+
+ private ChannelUID currentTitleChannelUID = new ChannelUID(THING_UID + ":" + CURRENTTITLE);
+ private Channel currentTitleChannel = ChannelBuilder.create(currentTitleChannelUID, "String").build();
+
+ private ChannelUID searchChannelUID = new ChannelUID(THING_UID + ":" + SEARCH);
+ private Channel searchChannel = ChannelBuilder.create(searchChannelUID, "String").build();
+
+ private ChannelUID playlistSelectChannelUID = new ChannelUID(THING_UID + ":" + PLAYLIST_SELECT);
+ private Channel playlistSelectChannel = ChannelBuilder.create(playlistSelectChannelUID, "String").build();
+
+ private ChannelUID playlistChannelUID = new ChannelUID(THING_UID + ":" + PLAYLIST);
+ private Channel playlistChannel = ChannelBuilder.create(playlistChannelUID, "String").build();
+
+ private ChannelUID playlistActionChannelUID = new ChannelUID(THING_UID + ":" + PLAYLIST_ACTION);
+ private Channel playlistActionChannel = ChannelBuilder.create(playlistActionChannelUID, "String").build();
+
+ private ConcurrentMap<String, UpnpRendererHandler> upnpRenderers = new ConcurrentHashMap<>();
+
+ @Mock
+ private @Nullable UpnpRendererHandler rendererHandler;
+ @Mock
+ private @Nullable Thing rendererThing;
+
+ @Override
+ @BeforeEach
+ public void setUp() {
+ super.setUp();
+
+ // stub thing methods
+ when(thing.getUID()).thenReturn(new ThingUID("upnpcontrol", "upnpserver", "mockserver"));
+ when(thing.getLabel()).thenReturn("MockServer");
+ when(thing.getStatus()).thenReturn(ThingStatus.OFFLINE);
+
+ // stub upnpIOService methods for initialize
+ Map<String, String> result = new HashMap<>();
+ result.put("Result", BASE_CONTAINER);
+ when(upnpIOService.invokeAction(any(), eq("ContentDirectory"), eq("Browse"), anyMap())).thenReturn(result);
+
+ // stub rendererHandler, so that only one protocol is supported and results should be filtered when filter true
+ when(rendererHandler.getSink()).thenReturn(Arrays.asList("http-get:*:audio/mpeg:*"));
+ when(rendererHandler.getThing()).thenReturn(requireNonNull(rendererThing));
+ when(rendererThing.getUID()).thenReturn(new ThingUID("upnpcontrol", "upnprenderer", "mockrenderer"));
+ when(rendererThing.getLabel()).thenReturn("MockRenderer");
+ upnpRenderers.put(rendererThing.getUID().toString(), requireNonNull(rendererHandler));
+
+ // stub channels
+ when(thing.getChannel(UPNPRENDERER)).thenReturn(rendererChannel);
+ when(thing.getChannel(BROWSE)).thenReturn(browseChannel);
+ when(thing.getChannel(CURRENTTITLE)).thenReturn(currentTitleChannel);
+ when(thing.getChannel(SEARCH)).thenReturn(searchChannel);
+ when(thing.getChannel(PLAYLIST_SELECT)).thenReturn(playlistSelectChannel);
+ when(thing.getChannel(PLAYLIST)).thenReturn(playlistChannel);
+ when(thing.getChannel(PLAYLIST_ACTION)).thenReturn(playlistActionChannel);
+
+ // stub config for initialize
+ when(config.as(UpnpControlServerConfiguration.class)).thenReturn(new UpnpControlServerConfiguration());
+
+ handler = spy(new UpnpServerHandler(requireNonNull(thing), requireNonNull(upnpIOService),
+ requireNonNull(upnpRenderers), requireNonNull(upnpStateDescriptionProvider),
+ requireNonNull(upnpCommandDescriptionProvider), configuration));
+
+ initHandler(requireNonNull(handler));
+
+ handler.initialize();
+ }
+
+ @Override
+ @AfterEach
+ public void tearDown() {
+ handler.dispose();
+
+ super.tearDown();
+ }
+
+ @Test
+ public void testBase() {
+ logger.info("testBase");
+
+ handler.config.filter = false;
+ handler.config.browseDown = false;
+ handler.config.searchFromRoot = false;
+
+ // Check currentEntry
+ assertThat(handler.currentEntry.getId(), is(UpnpServerHandler.DIRECTORY_ROOT));
+
+ // Check BROWSE
+ ArgumentCaptor<StringType> stringCaptor = ArgumentCaptor.forClass(StringType.class);
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(BROWSE).getUID()), stringCaptor.capture());
+ assertThat(stringCaptor.getValue(), is(StringType.valueOf("0")));
+
+ // Check CURRENTTITLE
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(CURRENTTITLE).getUID()),
+ stringCaptor.capture());
+ assertThat(stringCaptor.getValue(), is(StringType.valueOf("")));
+
+ // Check entries
+ assertThat(handler.entries.size(), is(2));
+ assertThat(handler.entries.get(0).getId(), is("C1"));
+ assertThat(handler.entries.get(0).getTitle(), is("All Audio Items"));
+ assertThat(handler.entries.get(1).getId(), is("C2"));
+ assertThat(handler.entries.get(1).getTitle(), is("All Image Items"));
+
+ // Check that BROWSE channel gets the correct command options, no UP should be added
+ ArgumentCaptor<List<StateOption>> commandOptionListCaptor = ArgumentCaptor.forClass(List.class);
+ verify(handler, atLeastOnce()).updateStateDescription(eq(thing.getChannel(BROWSE).getUID()),
+ commandOptionListCaptor.capture());
+ assertThat(commandOptionListCaptor.getValue().size(), is(2));
+ assertThat(commandOptionListCaptor.getValue().get(0).getValue(), is("C1"));
+ assertThat(commandOptionListCaptor.getValue().get(0).getLabel(), is("All Audio Items"));
+ assertThat(commandOptionListCaptor.getValue().get(1).getValue(), is("C2"));
+ assertThat(commandOptionListCaptor.getValue().get(1).getLabel(), is("All Image Items"));
+
+ // Check media queue serving
+ verify(rendererHandler, times(0)).registerQueue(any());
+ }
+
+ @Test
+ public void testSetBrowse() {
+ logger.info("testSetBrowse");
+
+ handler.config.filter = false;
+ handler.config.browseDown = false;
+ handler.config.searchFromRoot = false;
+
+ Map<String, String> result = new HashMap<>();
+ result.put("Result", DOUBLE_MEDIA);
+ doReturn(result).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Browse"), anyMap());
+
+ handler.handleCommand(browseChannelUID, StringType.valueOf("C11"));
+
+ // Check currentEntry
+ assertThat(handler.currentEntry.getId(), is("C11"));
+
+ // Check BROWSE
+ ArgumentCaptor<StringType> stringCaptor = ArgumentCaptor.forClass(StringType.class);
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(BROWSE).getUID()), stringCaptor.capture());
+ assertThat(stringCaptor.getValue(), is(StringType.valueOf("C11")));
+
+ // Check CURRENTTITLE
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(CURRENTTITLE).getUID()),
+ stringCaptor.capture());
+ assertThat(stringCaptor.getValue(), is(StringType.valueOf("")));
+
+ // Check entries
+ assertThat(handler.entries.size(), is(2));
+ assertThat(handler.entries.get(0).getId(), is("M1"));
+ assertThat(handler.entries.get(0).getTitle(), is("Music_01"));
+ assertThat(handler.entries.get(1).getId(), is("M2"));
+ assertThat(handler.entries.get(1).getTitle(), is("Music_02"));
+
+ // Check that BROWSE channel gets the correct state options
+ ArgumentCaptor<List<StateOption>> stateOptionListCaptor = ArgumentCaptor.forClass(List.class);
+ verify(handler, atLeastOnce()).updateStateDescription(eq(thing.getChannel(BROWSE).getUID()),
+ stateOptionListCaptor.capture());
+ assertThat(stateOptionListCaptor.getValue().size(), is(3));
+ assertThat(stateOptionListCaptor.getValue().get(0).getValue(), is(".."));
+ assertThat(stateOptionListCaptor.getValue().get(0).getLabel(), is(".."));
+ assertThat(stateOptionListCaptor.getValue().get(1).getValue(), is("M1"));
+ assertThat(stateOptionListCaptor.getValue().get(1).getLabel(), is("Music_01"));
+ assertThat(stateOptionListCaptor.getValue().get(2).getValue(), is("M2"));
+ assertThat(stateOptionListCaptor.getValue().get(2).getLabel(), is("Music_02"));
+
+ // Check media queue serving
+ verify(rendererHandler, times(0)).registerQueue(any());
+ }
+
+ @Test
+ public void testSetBrowseRendererFilter() {
+ logger.info("testSetBrowseRendererFilter");
+
+ handler.config.filter = true;
+ handler.config.browseDown = false;
+ handler.config.searchFromRoot = false;
+
+ handler.handleCommand(rendererChannelUID, StringType.valueOf(rendererThing.getUID().toString()));
+
+ Map<String, String> result = new HashMap<>();
+ result.put("Result", DOUBLE_MEDIA);
+ doReturn(result).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Browse"), anyMap());
+
+ handler.handleCommand(browseChannelUID, StringType.valueOf("C11"));
+
+ // Check currentEntry
+ assertThat(handler.currentEntry.getId(), is("C11"));
+
+ // Check BROWSE
+ ArgumentCaptor<StringType> stringCaptor = ArgumentCaptor.forClass(StringType.class);
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(BROWSE).getUID()), stringCaptor.capture());
+ assertThat(stringCaptor.getValue(), is(StringType.valueOf("C11")));
+
+ // Check CURRENTTITLE
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(CURRENTTITLE).getUID()),
+ stringCaptor.capture());
+ assertThat(stringCaptor.getValue(), is(StringType.valueOf("")));
+
+ // Check entries
+ assertThat(handler.entries.size(), is(1));
+ assertThat(handler.entries.get(0).getId(), is("M1"));
+ assertThat(handler.entries.get(0).getTitle(), is("Music_01"));
+
+ // Check that BROWSE channel gets the correct state options
+ ArgumentCaptor<List<StateOption>> stateOptionListCaptor = ArgumentCaptor.forClass(List.class);
+ verify(handler, atLeastOnce()).updateStateDescription(eq(thing.getChannel(BROWSE).getUID()),
+ stateOptionListCaptor.capture());
+ assertThat(stateOptionListCaptor.getValue().size(), is(2));
+ assertThat(stateOptionListCaptor.getValue().get(0).getValue(), is(".."));
+ assertThat(stateOptionListCaptor.getValue().get(0).getLabel(), is(".."));
+ assertThat(stateOptionListCaptor.getValue().get(1).getValue(), is("M1"));
+ assertThat(stateOptionListCaptor.getValue().get(1).getLabel(), is("Music_01"));
+
+ // Check media queue serving
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(UPNPRENDERER).getUID()),
+ stringCaptor.capture());
+ assertThat(stringCaptor.getValue(), is(StringType.valueOf(rendererThing.getUID().toString())));
+
+ // Check media queue serving
+ verify(rendererHandler).registerQueue(any());
+ }
+
+ @Test
+ public void testBrowseContainers() {
+ logger.info("testBrowseContainers");
+
+ handler.config.filter = false;
+ handler.config.browseDown = false;
+ handler.config.searchFromRoot = false;
+
+ Map<String, String> result = new HashMap<>();
+ result.put("Result", DOUBLE_CONTAINER);
+ doReturn(result).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Browse"), anyMap());
+
+ handler.handleCommand(browseChannelUID, StringType.valueOf("C1"));
+
+ // Check currentEntry
+ assertThat(handler.currentEntry.getId(), is("C1"));
+
+ // Check BROWSE
+ ArgumentCaptor<StringType> stringCaptor = ArgumentCaptor.forClass(StringType.class);
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(BROWSE).getUID()), stringCaptor.capture());
+ assertThat(stringCaptor.getValue(), is(StringType.valueOf("C1")));
+
+ // Check CURRENTTITLE
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(CURRENTTITLE).getUID()),
+ stringCaptor.capture());
+ assertThat(stringCaptor.getValue(), is(StringType.valueOf("All Audio Items")));
+
+ // Check entries
+ assertThat(handler.entries.size(), is(2));
+ assertThat(handler.entries.get(0).getId(), is("C11"));
+ assertThat(handler.entries.get(0).getTitle(), is("Morning Music"));
+ assertThat(handler.entries.get(1).getId(), is("C12"));
+ assertThat(handler.entries.get(1).getTitle(), is("Evening Music"));
+
+ // Check that BROWSE channel gets the correct state options
+ ArgumentCaptor<List<StateOption>> stateOptionListCaptor = ArgumentCaptor.forClass(List.class);
+ verify(handler, atLeastOnce()).updateStateDescription(eq(thing.getChannel(BROWSE).getUID()),
+ stateOptionListCaptor.capture());
+ assertThat(stateOptionListCaptor.getValue().size(), is(3));
+ assertThat(stateOptionListCaptor.getValue().get(0).getValue(), is(".."));
+ assertThat(stateOptionListCaptor.getValue().get(0).getLabel(), is(".."));
+ assertThat(stateOptionListCaptor.getValue().get(1).getValue(), is("C11"));
+ assertThat(stateOptionListCaptor.getValue().get(1).getLabel(), is("Morning Music"));
+ assertThat(stateOptionListCaptor.getValue().get(2).getValue(), is("C12"));
+ assertThat(stateOptionListCaptor.getValue().get(2).getLabel(), is("Evening Music"));
+
+ // Check media queue serving
+ verify(rendererHandler, times(0)).registerQueue(any());
+ }
+
+ @Test
+ public void testBrowseOneContainerNoBrowseDown() {
+ logger.info("testBrowseOneContainerNoBrowseDown");
+
+ handler.config.filter = false;
+ handler.config.browseDown = false;
+ handler.config.searchFromRoot = false;
+
+ Map<String, String> resultContainer = new HashMap<>();
+ resultContainer.put("Result", SINGLE_CONTAINER);
+ Map<String, String> resultMedia = new HashMap<>();
+ resultMedia.put("Result", DOUBLE_MEDIA);
+ doReturn(resultContainer).doReturn(resultMedia).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"),
+ eq("Browse"), anyMap());
+
+ handler.handleCommand(browseChannelUID, StringType.valueOf("C1"));
+
+ // Check currentEntry
+ assertThat(handler.currentEntry.getId(), is("C1"));
+
+ // Check BROWSE
+ ArgumentCaptor<StringType> stringCaptor = ArgumentCaptor.forClass(StringType.class);
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(BROWSE).getUID()), stringCaptor.capture());
+ assertThat(stringCaptor.getValue(), is(StringType.valueOf("C1")));
+
+ // Check CURRENTTITLE
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(CURRENTTITLE).getUID()),
+ stringCaptor.capture());
+ assertThat(stringCaptor.getValue(), is(StringType.valueOf("All Audio Items")));
+
+ // Check entries
+ assertThat(handler.entries.size(), is(1));
+ assertThat(handler.entries.get(0).getId(), is("C11"));
+ assertThat(handler.entries.get(0).getTitle(), is("Morning Music"));
+
+ // Check that BROWSE channel gets the correct state options
+ ArgumentCaptor<List<StateOption>> stateOptionListCaptor = ArgumentCaptor.forClass(List.class);
+ verify(handler, atLeastOnce()).updateStateDescription(eq(thing.getChannel(BROWSE).getUID()),
+ stateOptionListCaptor.capture());
+ assertThat(stateOptionListCaptor.getValue().size(), is(2));
+ assertThat(stateOptionListCaptor.getValue().get(0).getValue(), is(".."));
+ assertThat(stateOptionListCaptor.getValue().get(0).getLabel(), is(".."));
+ assertThat(stateOptionListCaptor.getValue().get(1).getValue(), is("C11"));
+ assertThat(stateOptionListCaptor.getValue().get(1).getLabel(), is("Morning Music"));
+
+ // Check that a no media queue is being served as there is no renderer selected
+ verify(rendererHandler, times(0)).registerQueue(any());
+ }
+
+ @Test
+ public void testBrowseOneContainerBrowseDown() {
+ logger.info("testBrowseOneContainerBrowseDown");
+
+ handler.config.filter = false;
+ handler.config.browseDown = true;
+ handler.config.searchFromRoot = false;
+
+ Map<String, String> resultContainer = new HashMap<>();
+ resultContainer.put("Result", SINGLE_CONTAINER);
+ Map<String, String> resultMedia = new HashMap<>();
+ resultMedia.put("Result", DOUBLE_MEDIA);
+ doReturn(resultContainer).doReturn(resultMedia).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"),
+ eq("Browse"), anyMap());
+
+ handler.handleCommand(browseChannelUID, StringType.valueOf("C1"));
+
+ // Check currentEntry
+ assertThat(handler.currentEntry.getId(), is("C11"));
+
+ // Check BROWSE
+ ArgumentCaptor<StringType> stringCaptor = ArgumentCaptor.forClass(StringType.class);
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(BROWSE).getUID()), stringCaptor.capture());
+ assertThat(stringCaptor.getValue(), is(StringType.valueOf("C11")));
+
+ // Check CURRENTTITLE
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(CURRENTTITLE).getUID()),
+ stringCaptor.capture());
+ assertThat(stringCaptor.getValue(), is(StringType.valueOf("Morning Music")));
+
+ // Check entries
+ assertThat(handler.entries.size(), is(2));
+ assertThat(handler.entries.get(0).getId(), is("M1"));
+ assertThat(handler.entries.get(0).getTitle(), is("Music_01"));
+ assertThat(handler.entries.get(1).getId(), is("M2"));
+ assertThat(handler.entries.get(1).getTitle(), is("Music_02"));
+
+ // Check that BROWSE channel gets the correct state options
+ ArgumentCaptor<List<StateOption>> stateOptionListCaptor = ArgumentCaptor.forClass(List.class);
+ verify(handler, atLeastOnce()).updateStateDescription(eq(thing.getChannel(BROWSE).getUID()),
+ stateOptionListCaptor.capture());
+ assertThat(stateOptionListCaptor.getValue().size(), is(3));
+ assertThat(stateOptionListCaptor.getValue().get(0).getValue(), is(".."));
+ assertThat(stateOptionListCaptor.getValue().get(0).getLabel(), is(".."));
+ assertThat(stateOptionListCaptor.getValue().get(1).getValue(), is("M1"));
+ assertThat(stateOptionListCaptor.getValue().get(1).getLabel(), is("Music_01"));
+ assertThat(stateOptionListCaptor.getValue().get(2).getValue(), is("M2"));
+ assertThat(stateOptionListCaptor.getValue().get(2).getLabel(), is("Music_02"));
+
+ // Check media queue serving
+ verify(rendererHandler, times(0)).registerQueue(any());
+ }
+
+ @Test
+ public void testSearchOneContainerNotFromRootNoBrowseDown() {
+ logger.info("testSearchOneContainerNotFromRootNoBrowseDown");
+
+ handler.config.filter = false;
+ handler.config.browseDown = false;
+ handler.config.searchFromRoot = false;
+
+ // First navigate away from root
+ Map<String, String> result = new HashMap<>();
+ result.put("Result", DOUBLE_CONTAINER);
+ doReturn(result).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Browse"), anyMap());
+ handler.handleCommand(browseChannelUID, StringType.valueOf("C1"));
+
+ Map<String, String> resultContainer = new HashMap<>();
+ resultContainer.put("Result", SINGLE_CONTAINER);
+ Map<String, String> resultMedia = new HashMap<>();
+ resultMedia.put("Result", DOUBLE_MEDIA);
+ doReturn(resultContainer).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Search"),
+ anyMap());
+ doReturn(resultMedia).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Browse"), anyMap());
+
+ String searchString = "dc:title contains \"Morning\" and upnp:class derivedfrom \"object.container\"";
+ handler.handleCommand(searchChannelUID, StringType.valueOf(searchString));
+
+ // Check currentEntry
+ assertThat(handler.currentEntry.getId(), is("C1"));
+
+ // Check BROWSE
+ ArgumentCaptor<StringType> stringCaptor = ArgumentCaptor.forClass(StringType.class);
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(BROWSE).getUID()), stringCaptor.capture());
+ assertThat(stringCaptor.getValue(), is(StringType.valueOf("C1")));
+
+ // Check CURRENTTITLE
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(CURRENTTITLE).getUID()),
+ stringCaptor.capture());
+ assertThat(stringCaptor.getValue(), is(StringType.valueOf("All Audio Items")));
+
+ // Check entries
+ assertThat(handler.entries.size(), is(1));
+ assertThat(handler.entries.get(0).getId(), is("C11"));
+ assertThat(handler.entries.get(0).getTitle(), is("Morning Music"));
+
+ // Check that BROWSE channel gets the correct state options
+ ArgumentCaptor<List<StateOption>> stateOptionListCaptor = ArgumentCaptor.forClass(List.class);
+ verify(handler, atLeastOnce()).updateStateDescription(eq(thing.getChannel(BROWSE).getUID()),
+ stateOptionListCaptor.capture());
+ assertThat(stateOptionListCaptor.getValue().size(), is(2));
+ assertThat(stateOptionListCaptor.getValue().get(0).getValue(), is(".."));
+ assertThat(stateOptionListCaptor.getValue().get(0).getLabel(), is(".."));
+ assertThat(stateOptionListCaptor.getValue().get(1).getValue(), is("C11"));
+ assertThat(stateOptionListCaptor.getValue().get(1).getLabel(), is("Morning Music"));
+
+ // Check that a no media queue is being served as there is no renderer selected
+ verify(rendererHandler, times(0)).registerQueue(any());
+ }
+
+ @Test
+ public void testSearchOneContainerNotFromRootBrowseDown() {
+ logger.info("testSearchOneContainerNotFromRootBrowseDown");
+
+ handler.config.filter = false;
+ handler.config.browseDown = true;
+ handler.config.searchFromRoot = false;
+
+ // First navigate away from root
+ Map<String, String> result = new HashMap<>();
+ result.put("Result", DOUBLE_CONTAINER);
+ doReturn(result).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Browse"), anyMap());
+ handler.handleCommand(browseChannelUID, StringType.valueOf("C1"));
+
+ Map<String, String> resultContainer = new HashMap<>();
+ resultContainer.put("Result", SINGLE_CONTAINER);
+ Map<String, String> resultMedia = new HashMap<>();
+ resultMedia.put("Result", DOUBLE_MEDIA);
+ doReturn(resultContainer).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Search"),
+ anyMap());
+ doReturn(resultMedia).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Browse"), anyMap());
+
+ String searchString = "dc:title contains \"Morning\" and upnp:class derivedfrom \"object.container\"";
+ handler.handleCommand(searchChannelUID, StringType.valueOf(searchString));
+
+ // Check currentEntry
+ assertThat(handler.currentEntry.getId(), is("C11"));
+
+ // Check BROWSE
+ ArgumentCaptor<StringType> stringCaptor = ArgumentCaptor.forClass(StringType.class);
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(BROWSE).getUID()), stringCaptor.capture());
+ assertThat(stringCaptor.getValue(), is(StringType.valueOf("C11")));
+
+ // Check CURRENTTITLE
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(CURRENTTITLE).getUID()),
+ stringCaptor.capture());
+ assertThat(stringCaptor.getValue(), is(StringType.valueOf("Morning Music")));
+
+ // Check entries
+ assertThat(handler.entries.size(), is(2));
+ assertThat(handler.entries.get(0).getId(), is("M1"));
+ assertThat(handler.entries.get(0).getTitle(), is("Music_01"));
+ assertThat(handler.entries.get(1).getId(), is("M2"));
+ assertThat(handler.entries.get(1).getTitle(), is("Music_02"));
+
+ // Check that BROWSE channel gets the correct state options
+ ArgumentCaptor<List<StateOption>> stateOptionListCaptor = ArgumentCaptor.forClass(List.class);
+ verify(handler, atLeastOnce()).updateStateDescription(eq(thing.getChannel(BROWSE).getUID()),
+ stateOptionListCaptor.capture());
+ assertThat(stateOptionListCaptor.getValue().size(), is(3));
+ assertThat(stateOptionListCaptor.getValue().get(0).getValue(), is(".."));
+ assertThat(stateOptionListCaptor.getValue().get(0).getLabel(), is(".."));
+ assertThat(stateOptionListCaptor.getValue().get(1).getValue(), is("M1"));
+ assertThat(stateOptionListCaptor.getValue().get(1).getLabel(), is("Music_01"));
+ assertThat(stateOptionListCaptor.getValue().get(2).getValue(), is("M2"));
+ assertThat(stateOptionListCaptor.getValue().get(2).getLabel(), is("Music_02"));
+
+ // Check that a no media queue is being served as there is no renderer selected
+ verify(rendererHandler, times(0)).registerQueue(any());
+ }
+
+ @Test
+ public void testSearchOneContainerFromRootNoBrowseDown() {
+ logger.info("testSearchOneContainerFromRootNoBrowseDown");
+
+ handler.config.filter = false;
+ handler.config.browseDown = false;
+ handler.config.searchFromRoot = true;
+
+ // First navigate away from root
+ Map<String, String> result = new HashMap<>();
+ result.put("Result", DOUBLE_CONTAINER);
+ doReturn(result).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Browse"), anyMap());
+ handler.handleCommand(browseChannelUID, StringType.valueOf("C1"));
+
+ Map<String, String> resultContainer = new HashMap<>();
+ resultContainer.put("Result", SINGLE_CONTAINER);
+ Map<String, String> resultMedia = new HashMap<>();
+ resultMedia.put("Result", DOUBLE_MEDIA);
+ doReturn(resultContainer).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Search"),
+ anyMap());
+ doReturn(resultMedia).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Browse"), anyMap());
+
+ String searchString = "dc:title contains \"Morning\" and upnp:class derivedfrom \"object.container\"";
+ handler.handleCommand(searchChannelUID, StringType.valueOf(searchString));
+
+ // Check currentEntry
+ assertThat(handler.currentEntry.getId(), is("0"));
+
+ // Check BROWSE
+ ArgumentCaptor<StringType> stringCaptor = ArgumentCaptor.forClass(StringType.class);
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(BROWSE).getUID()), stringCaptor.capture());
+ assertThat(stringCaptor.getValue(), is(StringType.valueOf("0")));
+
+ // Check CURRENTTITLE
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(CURRENTTITLE).getUID()),
+ stringCaptor.capture());
+ assertThat(stringCaptor.getValue(), is(StringType.valueOf("")));
+
+ // Check entries
+ assertThat(handler.entries.size(), is(1));
+ assertThat(handler.entries.get(0).getId(), is("C11"));
+ assertThat(handler.entries.get(0).getTitle(), is("Morning Music"));
+
+ // Check that BROWSE channel gets the correct state options
+ ArgumentCaptor<List<StateOption>> stateOptionListCaptor = ArgumentCaptor.forClass(List.class);
+ verify(handler, atLeastOnce()).updateStateDescription(eq(thing.getChannel(BROWSE).getUID()),
+ stateOptionListCaptor.capture());
+ assertThat(stateOptionListCaptor.getValue().size(), is(2));
+ assertThat(stateOptionListCaptor.getValue().get(0).getValue(), is(".."));
+ assertThat(stateOptionListCaptor.getValue().get(0).getLabel(), is(".."));
+ assertThat(stateOptionListCaptor.getValue().get(1).getValue(), is("C11"));
+ assertThat(stateOptionListCaptor.getValue().get(1).getLabel(), is("Morning Music"));
+
+ // Check that a no media queue is being served as there is no renderer selected
+ verify(rendererHandler, times(0)).registerQueue(any());
+ }
+
+ @Test
+ public void testSearchOneContainerFromRootBrowseDown() {
+ logger.info("testSearchOneContainerFromRootBrowseDown");
+
+ handler.config.filter = false;
+ handler.config.browseDown = true;
+ handler.config.searchFromRoot = true;
+
+ // First navigate away from root
+ Map<String, String> result = new HashMap<>();
+ result.put("Result", DOUBLE_CONTAINER);
+ doReturn(result).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Browse"), anyMap());
+ handler.handleCommand(browseChannelUID, StringType.valueOf("C1"));
+
+ Map<String, String> resultContainer = new HashMap<>();
+ resultContainer.put("Result", SINGLE_CONTAINER);
+ Map<String, String> resultMedia = new HashMap<>();
+ resultMedia.put("Result", DOUBLE_MEDIA);
+ doReturn(resultContainer).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Search"),
+ anyMap());
+ doReturn(resultMedia).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Browse"), anyMap());
+
+ String searchString = "dc:title contains \"Morning\" and upnp:class derivedfrom \"object.container\"";
+ handler.handleCommand(searchChannelUID, StringType.valueOf(searchString));
+
+ // Check currentEntry
+ assertThat(handler.currentEntry.getId(), is("C11"));
+
+ // Check BROWSE
+ ArgumentCaptor<StringType> stringCaptor = ArgumentCaptor.forClass(StringType.class);
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(BROWSE).getUID()), stringCaptor.capture());
+ assertThat(stringCaptor.getValue(), is(StringType.valueOf("C11")));
+
+ // Check CURRENTTITLE
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(CURRENTTITLE).getUID()),
+ stringCaptor.capture());
+ assertThat(stringCaptor.getValue(), is(StringType.valueOf("Morning Music")));
+
+ // Check entries
+ assertThat(handler.entries.size(), is(2));
+ assertThat(handler.entries.get(0).getId(), is("M1"));
+ assertThat(handler.entries.get(0).getTitle(), is("Music_01"));
+ assertThat(handler.entries.get(1).getId(), is("M2"));
+ assertThat(handler.entries.get(1).getTitle(), is("Music_02"));
+
+ // Check that BROWSE channel gets the correct state options
+ ArgumentCaptor<List<StateOption>> stateOptionListCaptor = ArgumentCaptor.forClass(List.class);
+ verify(handler, atLeastOnce()).updateStateDescription(eq(thing.getChannel(BROWSE).getUID()),
+ stateOptionListCaptor.capture());
+ assertThat(stateOptionListCaptor.getValue().size(), is(3));
+ assertThat(stateOptionListCaptor.getValue().get(0).getValue(), is(".."));
+ assertThat(stateOptionListCaptor.getValue().get(0).getLabel(), is(".."));
+ assertThat(stateOptionListCaptor.getValue().get(1).getValue(), is("M1"));
+ assertThat(stateOptionListCaptor.getValue().get(1).getLabel(), is("Music_01"));
+ assertThat(stateOptionListCaptor.getValue().get(2).getValue(), is("M2"));
+ assertThat(stateOptionListCaptor.getValue().get(2).getLabel(), is("Music_02"));
+
+ // Check that a no media queue is being served as there is no renderer selected
+ verify(rendererHandler, times(0)).registerQueue(any());
+ }
+
+ @Test
+ public void testSearchMediaFromRootBrowseDownFilter() {
+ logger.info("testSearchMediaFromRootBrowseDownFilter");
+
+ handler.config.filter = true;
+ handler.config.browseDown = true;
+ handler.config.searchFromRoot = true;
+
+ // First navigate away from root
+ Map<String, String> result = new HashMap<>();
+ result.put("Result", DOUBLE_CONTAINER);
+ doReturn(result).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Browse"), anyMap());
+ handler.handleCommand(browseChannelUID, StringType.valueOf("C1"));
+
+ Map<String, String> resultMedia = new HashMap<>();
+ resultMedia.put("Result", DOUBLE_MEDIA);
+ doReturn(resultMedia).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Search"), anyMap());
+
+ String searchString = "dc:title contains \"Music\"";
+ handler.handleCommand(searchChannelUID, StringType.valueOf(searchString));
+
+ // Check currentEntry
+ assertThat(handler.currentEntry.getId(), is("0"));
+
+ // Check BROWSE
+ ArgumentCaptor<StringType> stringCaptor = ArgumentCaptor.forClass(StringType.class);
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(BROWSE).getUID()), stringCaptor.capture());
+ assertThat(stringCaptor.getValue(), is(StringType.valueOf("0")));
+
+ // Check CURRENTTITLE
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(CURRENTTITLE).getUID()),
+ stringCaptor.capture());
+ assertThat(stringCaptor.getValue(), is(StringType.valueOf("")));
+
+ // Check entries
+ assertThat(handler.entries.size(), is(2));
+ assertThat(handler.entries.get(0).getId(), is("M1"));
+ assertThat(handler.entries.get(0).getTitle(), is("Music_01"));
+ assertThat(handler.entries.get(1).getId(), is("M2"));
+ assertThat(handler.entries.get(1).getTitle(), is("Music_02"));
+
+ // Check that BROWSE channel gets the correct state options
+ ArgumentCaptor<List<StateOption>> stateOptionListCaptor = ArgumentCaptor.forClass(List.class);
+ verify(handler, atLeastOnce()).updateStateDescription(eq(thing.getChannel(BROWSE).getUID()),
+ stateOptionListCaptor.capture());
+ assertThat(stateOptionListCaptor.getValue().size(), is(3));
+ assertThat(stateOptionListCaptor.getValue().get(0).getValue(), is(".."));
+ assertThat(stateOptionListCaptor.getValue().get(0).getLabel(), is(".."));
+ assertThat(stateOptionListCaptor.getValue().get(1).getValue(), is("M1"));
+ assertThat(stateOptionListCaptor.getValue().get(1).getLabel(), is("Music_01"));
+ assertThat(stateOptionListCaptor.getValue().get(2).getValue(), is("M2"));
+ assertThat(stateOptionListCaptor.getValue().get(2).getLabel(), is("Music_02"));
+
+ // Check that a no media queue is being served as there is no renderer selected
+ verify(rendererHandler, times(0)).registerQueue(any());
+ }
+
+ @Test
+ public void testPlaylist() {
+ logger.info("testPlaylist");
+
+ handler.config.filter = false;
+ handler.config.browseDown = false;
+ handler.config.searchFromRoot = true;
+
+ // Check already called in initialize
+ verify(handler).playlistsListChanged();
+
+ // First search for media
+ Map<String, String> resultMedia = new HashMap<>();
+ resultMedia.put("Result", DOUBLE_MEDIA);
+ doReturn(resultMedia).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Search"), anyMap());
+ String searchString = "dc:title contains \"Music\"";
+ handler.handleCommand(searchChannelUID, StringType.valueOf(searchString));
+
+ // Save playlist
+ handler.handleCommand(playlistChannelUID, StringType.valueOf("Test_Playlist"));
+ handler.handleCommand(playlistActionChannelUID, StringType.valueOf("SAVE"));
+
+ // Check called after saving playlist
+ verify(handler, times(2)).playlistsListChanged();
+
+ // Check that PLAYLIST_SELECT channel now has the playlist as a state option
+ ArgumentCaptor<List<CommandOption>> commandOptionListCaptor = ArgumentCaptor.forClass(List.class);
+ verify(handler, atLeastOnce()).updateCommandDescription(eq(thing.getChannel(PLAYLIST_SELECT).getUID()),
+ commandOptionListCaptor.capture());
+ assertThat(commandOptionListCaptor.getValue().size(), is(1));
+ assertThat(commandOptionListCaptor.getValue().get(0).getCommand(), is("Test_Playlist"));
+ assertThat(commandOptionListCaptor.getValue().get(0).getLabel(), is("Test_Playlist"));
+
+ // Clear PLAYLIST channel
+ handler.handleCommand(playlistChannelUID, StringType.valueOf(""));
+
+ // Search for some extra media
+ resultMedia = new HashMap<>();
+ resultMedia.put("Result", EXTRA_MEDIA);
+ doReturn(resultMedia).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Search"), anyMap());
+ searchString = "dc:title contains \"Extra\"";
+ handler.handleCommand(searchChannelUID, StringType.valueOf(searchString));
+
+ // Append to playlist
+ handler.handleCommand(playlistSelectChannelUID, StringType.valueOf("Test_Playlist"));
+ handler.handleCommand(playlistActionChannelUID, StringType.valueOf("APPEND"));
+
+ // Check called after appending to playlist
+ verify(handler, times(3)).playlistsListChanged();
+
+ // Check that PLAYLIST channel received "Test_Playlist"
+ ArgumentCaptor<StringType> stringCaptor = ArgumentCaptor.forClass(StringType.class);
+ verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(PLAYLIST).getUID()), stringCaptor.capture());
+ assertThat(stringCaptor.getValue(), is(StringType.valueOf("Test_Playlist")));
+
+ // Clear PLAYLIST channel
+ handler.handleCommand(playlistChannelUID, StringType.valueOf(""));
+
+ // Restore playlist
+ handler.handleCommand(playlistSelectChannelUID, StringType.valueOf("Test_Playlist"));
+ handler.handleCommand(playlistActionChannelUID, StringType.valueOf("RESTORE"));
+
+ // Check currentEntry
+ assertThat(handler.currentEntry.getId(), is("C11"));
+
+ // Check entries
+ assertThat(handler.entries.size(), is(3));
+ assertThat(handler.entries.get(0).getId(), is("M1"));
+ assertThat(handler.entries.get(0).getTitle(), is("Music_01"));
+ assertThat(handler.entries.get(1).getId(), is("M2"));
+ assertThat(handler.entries.get(1).getTitle(), is("Music_02"));
+ assertThat(handler.entries.get(2).getId(), is("M3"));
+ assertThat(handler.entries.get(2).getTitle(), is("Extra_01"));
+
+ // Delete playlist
+ handler.handleCommand(playlistSelectChannelUID, StringType.valueOf("Test_Playlist"));
+ handler.handleCommand(playlistActionChannelUID, StringType.valueOf("DELETE"));
+
+ // Check called after deleting playlist
+ verify(handler, times(4)).playlistsListChanged();
+
+ // Check that PLAYLIST_SELECT channel is empty again
+ commandOptionListCaptor = ArgumentCaptor.forClass(List.class);
+ verify(handler, atLeastOnce()).updateCommandDescription(eq(thing.getChannel(PLAYLIST_SELECT).getUID()),
+ commandOptionListCaptor.capture());
+ assertThat(commandOptionListCaptor.getValue().size(), is(0));
+
+ // select a renderer, so we expect the "current" playlist to be created
+ handler.handleCommand(rendererChannelUID, StringType.valueOf(rendererThing.getUID().toString()));
+
+ // Check called after selecting renderer
+ verify(handler, times(5)).playlistsListChanged();
+
+ // Check that PLAYLIST_SELECT channel received "current" playlist
+ verify(handler, atLeastOnce()).updateCommandDescription(eq(thing.getChannel(PLAYLIST_SELECT).getUID()),
+ commandOptionListCaptor.capture());
+ assertThat(commandOptionListCaptor.getValue().size(), is(1));
+ assertThat(commandOptionListCaptor.getValue().get(0).getCommand(), is("current"));
+ assertThat(commandOptionListCaptor.getValue().get(0).getLabel(), is("current"));
+ }
+}