[upnpcontrol] Rework and extension of binding. (#9081)

Signed-off-by: Mark Herwege <mark.herwege@telenet.be>
This commit is contained in:
Mark Herwege 2020-11-28 13:38:44 +01:00 committed by GitHub
parent 6b2d217e08
commit 821a84067a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 6176 additions and 934 deletions

View File

@ -8,10 +8,13 @@ UPnP AV media renderers take care of playback of the content.
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`.
@ -22,6 +25,13 @@ It complies with part of the UPnP AV Media standard, but has not been verified t
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.
@ -33,79 +43,288 @@ Both the `upnprenderer` and `upnpserver` thing require a configuration paramete
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:
### `upnpserver`
* `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 `upnpserver` has the following channels (item type and access mode indicated in brackets):
The `upnprenderer` has the following channels:
* `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 |
| 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 |
## 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:
@ -116,8 +335,18 @@ Group MediaRenderer <player>
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"}
@ -128,33 +357,53 @@ String Genre "Genre" <text> (MediaRenderer) {channel=
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
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
Text item=Renderer
Text item=CurrentId
Text item=Browse
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:
@ -162,4 +411,6 @@ 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))
```

View File

@ -0,0 +1,172 @@
/**
* 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);
}
}

View File

@ -12,12 +12,16 @@
*/
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
@ -36,17 +40,35 @@ public class UpnpControlBindingConstants {
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";
@ -57,14 +79,44 @@ public class UpnpControlBindingConstants {
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";
}

View File

@ -15,15 +15,27 @@ package org.openhab.binding.upnpcontrol.internal;
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;
@ -35,6 +47,8 @@ import org.openhab.core.thing.binding.ThingHandlerFactory;
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;
@ -47,15 +61,19 @@ 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;
@ -64,16 +82,36 @@ public class UpnpControlHandlerFactory extends BaseThingHandlerFactory implement
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
@ -108,38 +146,78 @@ public class UpnpControlHandlerFactory extends BaseThingHandlerFactory implement
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);
}
@ -153,6 +231,14 @@ public class UpnpControlHandlerFactory extends BaseThingHandlerFactory implement
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());
}
}
@ -173,4 +259,67 @@ public class UpnpControlHandlerFactory extends BaseThingHandlerFactory implement
}
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() {
}
}

View File

@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal;
package org.openhab.binding.upnpcontrol.internal.audiosink;
import java.io.IOException;
import java.util.Locale;
@ -44,9 +44,9 @@ public class UpnpAudioSink implements AudioSink {
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;
protected UpnpRendererHandler handler;
protected AudioHTTPServer audioHTTPServer;
protected String callbackUrl;
public UpnpAudioSink(UpnpRendererHandler handler, AudioHTTPServer audioHTTPServer, String callbackUrl) {
this.handler = handler;
@ -106,16 +106,15 @@ public class UpnpAudioSink implements AudioSink {
@Override
public void setVolume(@Nullable PercentType volume) throws IOException {
if (volume != null) {
handler.setVolume(handler.getCurrentChannel(), volume);
handler.setVolume(volume);
}
}
private void stopMedia() {
protected void stopMedia() {
handler.stop();
}
private void playMedia(String url) {
stopMedia();
protected void playMedia(String url) {
String newUrl = url;
if (!url.startsWith("x-") && !url.startsWith("http")) {
newUrl = "x-file-cifs:" + url;

View File

@ -10,9 +10,10 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal;
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;
/**

View File

@ -0,0 +1,67 @@
/**
* 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);
}
}

View File

@ -0,0 +1,60 @@
/**
* 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);
}
}

View File

@ -23,4 +23,6 @@ import org.eclipse.jdt.annotation.Nullable;
@NonNullByDefault
public class UpnpControlConfiguration {
public @Nullable String udn;
public int refresh = 60;
public int responseTimeout = 2500;
}

View File

@ -0,0 +1,26 @@
/**
* 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;
}

View File

@ -21,5 +21,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
@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;
}

View File

@ -14,6 +14,7 @@ package org.openhab.binding.upnpcontrol.internal.discovery;
import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
import java.net.URL;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
@ -21,6 +22,7 @@ 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;
@ -53,8 +55,17 @@ public class UpnpControlDiscoveryParticipant implements 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();
}
@ -68,9 +79,10 @@ public class UpnpControlDiscoveryParticipant implements UpnpDiscoveryParticipant
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);

View File

@ -12,55 +12,281 @@
*/
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;
}
/**
@ -74,36 +300,84 @@ public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOPart
*/
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("AVTransport", "GetTransportInfo", inputs);
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>();
// 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);
}
/**
@ -111,30 +385,31 @@ public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOPart
* 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);
}
@Override
public void onStatusChanged(boolean status) {
if (status) {
updateStatus(ThingStatus.ONLINE);
} else {
if (!succeeded) {
upnpSubscribed = false;
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"Communication lost with " + thing.getLabel());
"Could not subscribe to service " + service + "for" + thing.getLabel());
}
}
@Override
public @Nullable String getUDN() {
return config.udn;
public void onStatusChanged(boolean status) {
logger.debug("UPnP device {} received status update {}", thing.getLabel(), status);
if (status) {
initJob();
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"Communication lost with " + thing.getLabel());
}
}
/**
@ -148,14 +423,28 @@ public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOPart
* @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);
@ -163,31 +452,133 @@ public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOPart
});
}
/**
* 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":
case CONNECTION_ID:
onValueReceivedConnectionId(value);
break;
case AV_TRANSPORT_ID:
onValueReceivedAVTransportId(value);
break;
case RCS_ID:
onValueReceivedRcsId(value);
break;
case "Source":
case "Sink":
if (!value.isEmpty()) {
transportState = value;
updateProtocolInfo(value);
}
break;
case "ConnectionID":
connectionId = Integer.parseInt(value);
break;
case "AVTransportID":
avTransportId = Integer.parseInt(value);
break;
case "RcsID":
rcsId = Integer.parseInt(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.
*
@ -195,8 +586,10 @@ public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOPart
* @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);
}
}
/**
@ -205,8 +598,55 @@ public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOPart
* @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;
}
}

View File

@ -22,7 +22,12 @@ import java.util.List;
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;
@ -30,10 +35,13 @@ import org.eclipse.jdt.annotation.Nullable;
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;
@ -42,19 +50,17 @@ 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.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
@ -62,41 +68,49 @@ import org.slf4j.LoggerFactory;
@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 UpnpEntry currentEntry = new UpnpEntry(DIRECTORY_ROOT, DIRECTORY_ROOT, DIRECTORY_ROOT,
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 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
@ -122,34 +136,151 @@ public class UpnpServerHandler extends UpnpHandler {
"Channel " + BROWSE + " not defined");
return;
}
if (config.udn != null) {
if (service.isRegistered(this)) {
initServer();
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"Communication cannot be established with " + thing.getLabel());
}
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() {
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,
"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();
}
}
}
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 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());
}
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("ObjectID", objectID);
inputs.put("BrowseFlag", browseFlag);
inputs.put("Filter", filter);
inputs.put("StartingIndex", startingIndex);
inputs.put("RequestedCount", requestedCount);
inputs.put("SortCriteria", sortCriteria);
updateStatus(ThingStatus.ONLINE);
invokeAction(CONTENT_DIRECTORY, "Browse", inputs);
} else {
logger.debug("Cannot browse, cancelled querying server {}", thing.getLabel());
}
}
/**
* 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());
}
if (browsed) {
isBrowsing = new CompletableFuture<Boolean>();
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(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
@ -158,89 +289,256 @@ public class UpnpServerHandler extends UpnpHandler {
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;
}
updateState(CURRENTID, StringType.valueOf(currentEntry.getId()));
logger.debug("Browse target {}", browseTarget);
browse(browseTarget, "BrowseDirectChildren", "*", "0", "0", config.sortcriteria);
}
}
handleCommandBrowse(channelUID, command);
break;
case SEARCH:
if (command instanceof StringType) {
String criteria = command.toString();
if (criteria != null) {
String searchContainer = "";
if (currentEntry.isContainer()) {
searchContainer = currentEntry.getId();
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;
}
}
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;
}
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();
// 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");
}
if (searchContainer.isEmpty()) {
// No parent found, so make it the root directory
searchContainer = DIRECTORY_ROOT;
}
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);
}
}
@ -252,7 +550,10 @@ public class UpnpServerHandler extends UpnpHandler {
*/
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());
@ -277,27 +578,32 @@ public class UpnpServerHandler extends UpnpHandler {
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
@ -309,131 +615,47 @@ public class UpnpServerHandler extends UpnpHandler {
});
}
// 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<>();
logger.debug("Server {}, raw result list {}", thing.getLabel(), resultList);
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);
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());
logger.debug("Server {}, filtered result list {}", thing.getLabel(), 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);
}
/**
* 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);
}
@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);
}
@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":
@ -444,6 +666,39 @@ public class UpnpServerHandler extends UpnpHandler {
}
}
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.
@ -454,13 +709,10 @@ public class UpnpServerHandler extends UpnpHandler {
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);
}
});
@ -470,7 +722,7 @@ public class UpnpServerHandler extends UpnpHandler {
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);
@ -479,9 +731,14 @@ public class UpnpServerHandler extends UpnpHandler {
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());

View File

@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal;
package org.openhab.binding.upnpcontrol.internal.queue;
import java.util.ArrayList;
import java.util.List;
@ -50,6 +50,10 @@ public class UpnpEntry {
private boolean isContainer;
public UpnpEntry() {
this("", "", "", "");
}
public UpnpEntry(String id, String refId, String parentId, String upnpClass) {
this.id = id;
this.refId = refId;

View File

@ -0,0 +1,402 @@
/**
* 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;
}
}

View File

@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal;
package org.openhab.binding.upnpcontrol.internal.queue;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
@ -20,7 +20,7 @@ import org.eclipse.jdt.annotation.Nullable;
* @author Mark Herwege - Initial contribution
*/
@NonNullByDefault
class UpnpEntryRes {
public class UpnpEntryRes {
private String protocolInfo;
private @Nullable Long size;
@ -28,11 +28,12 @@ class UpnpEntryRes {
private String importUri;
private String res = "";
UpnpEntryRes(String protocolInfo, @Nullable Long size, @Nullable String duration, @Nullable String importUri) {
this.protocolInfo = protocolInfo;
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;
this.importUri = (importUri == null) ? "" : importUri;
this.duration = (duration == null) ? "" : duration.trim();
this.importUri = (importUri == null) ? "" : importUri.trim();
}
/**
@ -46,7 +47,7 @@ class UpnpEntryRes {
* @param res the res to set
*/
public void setRes(String res) {
this.res = res;
this.res = res.trim();
}
public String getProtocolInfo() {

View File

@ -0,0 +1,150 @@
/**
* 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());
}
}
}

View File

@ -0,0 +1,27 @@
/**
* 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();
}

View File

@ -0,0 +1,66 @@
/**
* 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()));
}
}
}
}

View File

@ -0,0 +1,129 @@
/**
* 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();
}
}

View File

@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal;
package org.openhab.binding.upnpcontrol.internal.util;
import java.util.ArrayList;
import java.util.List;

View File

@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal;
package org.openhab.binding.upnpcontrol.internal.util;
import java.io.IOException;
import java.io.StringReader;
@ -28,6 +28,8 @@ 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;
@ -45,16 +47,15 @@ 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 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,
@ -69,6 +70,62 @@ public class UpnpXMLParser {
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");
@ -88,12 +145,31 @@ public class UpnpXMLParser {
return handler.getChanges();
}
/**
* @param xml
* @return a list of Entries from the given xml string.
* @throws IOException
* @throws SAXException
*/
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");
@ -113,31 +189,6 @@ public class UpnpXMLParser {
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.
@ -356,7 +407,8 @@ public class UpnpXMLParser {
String genre = StringEscapeUtils.escapeXml(entry.getGenre());
Integer trackNumber = entry.getOriginalTrackNumber();
String metadata = METADATA_FORMAT.format(new Object[] { id, parentId, title, upnpClass, album, albumArtUri,
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;

View File

@ -6,5 +6,11 @@
<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>

View File

@ -11,8 +11,21 @@
<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"/>
@ -23,52 +36,163 @@
<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>
@ -115,7 +239,13 @@
<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">
@ -123,15 +253,10 @@
<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>
@ -140,4 +265,29 @@
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>

View File

@ -0,0 +1,156 @@
/**
* 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("-----------------------------------------------------------------------------------");
}
}

View File

@ -0,0 +1,928 @@
/**
* 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()));
}
}

View File

@ -0,0 +1,877 @@
/**
* 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"));
}
}