mirror of
https://github.com/openhab/openhab-addons.git
synced 2025-01-10 15:11:59 +01:00
[upnpcontrol] Rework and extension of binding. (#9081)
Signed-off-by: Mark Herwege <mark.herwege@telenet.be>
This commit is contained in:
parent
6b2d217e08
commit
821a84067a
@ -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))
|
||||
|
||||
```
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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";
|
||||
}
|
||||
|
@ -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() {
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
@ -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;
|
||||
|
||||
/**
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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());
|
||||
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
@ -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() {
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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("-----------------------------------------------------------------------------------");
|
||||
}
|
||||
}
|
@ -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()));
|
||||
}
|
||||
}
|
@ -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"));
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user