From 821a84067aae7513ebe9664cbdbc2634a9fba17a Mon Sep 17 00:00:00 2001 From: Mark Herwege Date: Sat, 28 Nov 2020 13:38:44 +0100 Subject: [PATCH] [upnpcontrol] Rework and extension of binding. (#9081) Signed-off-by: Mark Herwege --- .../org.openhab.binding.upnpcontrol/README.md | 383 +++- .../upnpcontrol/internal/UpnpChannelName.java | 172 ++ .../internal/UpnpControlBindingConstants.java | 68 +- .../internal/UpnpControlHandlerFactory.java | 173 +- .../{ => audiosink}/UpnpAudioSink.java | 15 +- .../{ => audiosink}/UpnpAudioSinkReg.java | 3 +- .../audiosink/UpnpNotificationAudioSink.java | 67 + .../UpnpControlBindingConfiguration.java | 60 + .../config/UpnpControlConfiguration.java | 2 + .../UpnpControlRendererConfiguration.java | 26 + .../UpnpControlServerConfiguration.java | 4 +- .../UpnpControlDiscoveryParticipant.java | 18 +- .../internal/handler/UpnpHandler.java | 562 +++++- .../internal/handler/UpnpRendererHandler.java | 1754 ++++++++++++----- .../internal/handler/UpnpServerHandler.java | 723 ++++--- .../internal/{ => queue}/UpnpEntry.java | 6 +- .../internal/queue/UpnpEntryQueue.java | 402 ++++ .../internal/{ => queue}/UpnpEntryRes.java | 15 +- .../internal/queue/UpnpFavorite.java | 150 ++ .../internal/queue/UpnpPlaylistsListener.java | 27 + .../UpnpRenderingControlConfiguration.java | 66 + .../internal/util/UpnpControlUtil.java | 129 ++ .../{ => util}/UpnpProtocolMatcher.java | 2 +- .../internal/{ => util}/UpnpXMLParser.java | 138 +- .../main/resources/OH-INF/binding/binding.xml | 8 +- .../resources/OH-INF/thing/thing-types.xml | 176 +- .../internal/handler/UpnpHandlerTest.java | 156 ++ .../handler/UpnpRendererHandlerTest.java | 928 +++++++++ .../handler/UpnpServerHandlerTest.java | 877 +++++++++ 29 files changed, 6176 insertions(+), 934 deletions(-) create mode 100644 bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpChannelName.java rename bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/{ => audiosink}/UpnpAudioSink.java (92%) rename bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/{ => audiosink}/UpnpAudioSinkReg.java (89%) create mode 100644 bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/audiosink/UpnpNotificationAudioSink.java create mode 100644 bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/config/UpnpControlBindingConfiguration.java create mode 100644 bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/config/UpnpControlRendererConfiguration.java rename bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/{ => queue}/UpnpEntry.java (97%) create mode 100644 bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpEntryQueue.java rename bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/{ => queue}/UpnpEntryRes.java (78%) create mode 100644 bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpFavorite.java create mode 100644 bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpPlaylistsListener.java create mode 100644 bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/services/UpnpRenderingControlConfiguration.java create mode 100644 bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/util/UpnpControlUtil.java rename bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/{ => util}/UpnpProtocolMatcher.java (98%) rename bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/{ => util}/UpnpXMLParser.java (78%) create mode 100644 bundles/org.openhab.binding.upnpcontrol/src/test/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpHandlerTest.java create mode 100644 bundles/org.openhab.binding.upnpcontrol/src/test/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpRendererHandlerTest.java create mode 100644 bundles/org.openhab.binding.upnpcontrol/src/test/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpServerHandlerTest.java diff --git a/bundles/org.openhab.binding.upnpcontrol/README.md b/bundles/org.openhab.binding.upnpcontrol/README.md index 5418c6566..5e4dc52d3 100644 --- a/bundles/org.openhab.binding.upnpcontrol/README.md +++ b/bundles/org.openhab.binding.upnpcontrol/README.md @@ -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: [udn=""] -Thing upnpcontrol:upnprenderer: [udn="", filter=, sortcriteria=""] +Thing upnpcontrol:upnpserver: [udn="", refresh=, seekStep=] +Thing upnpcontrol:upnprenderer: [udn="", refresh=, filter=, sortCriteria="", browseDown=, searchfromroot=] ``` + ## 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 Dimmer Volume "Volume [%.1f %%]" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:volume"} Switch Mute "Mute" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:mute"} +Switch Loudness "Loudness" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:loudness"} +Dimmer LeftVolume "Volume [%.1f %%]" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:lfvolume"} +Dimmer RightVolume "Volume [%.1f %%]" (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]" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:title"} String Album "Album" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:album"} Image AlbumArt "Album Art" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:albumart"} @@ -128,33 +357,53 @@ String Genre "Genre" (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]" (MediaServer) {channel="upnpcontrol:upnpserver:mymediaserver:title"} -String CurrentId "Current Entry [%s]" (MediaServer) {channel="upnpcontrol:upnpserver:mymediaserver:currentid"} -String Browse "Browse" (MediaServer) {channel="upnpcontrol:upnpserver:mymediaserver:browse"} +String CurrentTitle "Current Entry [%s]" (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)) + ``` diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpChannelName.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpChannelName.java new file mode 100644 index 000000000..43ff3577c --- /dev/null +++ b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpChannelName.java @@ -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 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); + } +} diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpControlBindingConstants.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpControlBindingConstants.java index c3ef0b2fb..24ab6f22e 100644 --- a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpControlBindingConstants.java +++ b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpControlBindingConstants.java @@ -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 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 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"; } diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpControlHandlerFactory.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpControlHandlerFactory.java index d5a5a8d38..17b412d88 100644 --- a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpControlHandlerFactory.java +++ b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpControlHandlerFactory.java @@ -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> audioSinkRegistrations = new ConcurrentHashMap<>(); private ConcurrentMap upnpRenderers = new ConcurrentHashMap<>(); private ConcurrentMap upnpServers = new ConcurrentHashMap<>(); + private ConcurrentMap handlers = new ConcurrentHashMap<>(); + private ConcurrentMap 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 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 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 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 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 notificationReg = (ServiceRegistration) bundleContext + .registerService(AudioSink.class.getName(), notificationAudioSink, new Hashtable()); + 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() { + } } diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpAudioSink.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/audiosink/UpnpAudioSink.java similarity index 92% rename from bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpAudioSink.java rename to bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/audiosink/UpnpAudioSink.java index dbc557f56..fd807039c 100644 --- a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpAudioSink.java +++ b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/audiosink/UpnpAudioSink.java @@ -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> 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; diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpAudioSinkReg.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/audiosink/UpnpAudioSinkReg.java similarity index 89% rename from bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpAudioSinkReg.java rename to bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/audiosink/UpnpAudioSinkReg.java index 244834d44..30bba76ca 100644 --- a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpAudioSinkReg.java +++ b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/audiosink/UpnpAudioSinkReg.java @@ -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; /** diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/audiosink/UpnpNotificationAudioSink.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/audiosink/UpnpNotificationAudioSink.java new file mode 100644 index 000000000..4756c325d --- /dev/null +++ b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/audiosink/UpnpNotificationAudioSink.java @@ -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); + } +} diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/config/UpnpControlBindingConfiguration.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/config/UpnpControlBindingConfiguration.java new file mode 100644 index 000000000..78efb8e65 --- /dev/null +++ b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/config/UpnpControlBindingConfiguration.java @@ -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); + } +} diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/config/UpnpControlConfiguration.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/config/UpnpControlConfiguration.java index c62bb692c..9717ce08a 100644 --- a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/config/UpnpControlConfiguration.java +++ b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/config/UpnpControlConfiguration.java @@ -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; } diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/config/UpnpControlRendererConfiguration.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/config/UpnpControlRendererConfiguration.java new file mode 100644 index 000000000..b1797007e --- /dev/null +++ b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/config/UpnpControlRendererConfiguration.java @@ -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; +} diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/config/UpnpControlServerConfiguration.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/config/UpnpControlServerConfiguration.java index 6ec2812a4..3c26dc008 100644 --- a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/config/UpnpControlServerConfiguration.java +++ b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/config/UpnpControlServerConfiguration.java @@ -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; } diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/discovery/UpnpControlDiscoveryParticipant.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/discovery/UpnpControlDiscoveryParticipant.java index 8987b7eb0..dc78a6717 100644 --- a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/discovery/UpnpControlDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/discovery/UpnpControlDiscoveryParticipant.java @@ -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 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); diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpHandler.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpHandler.java index ee5a34833..2b3430b7b 100644 --- a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpHandler.java +++ b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpHandler.java @@ -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 updatedChannels = new ArrayList<>(); + private final List 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 isConnectionIdSet; + protected volatile @Nullable CompletableFuture isAvTransportIdSet; + protected volatile @Nullable CompletableFuture isRcsIdSet; + + protected static final int SUBSCRIPTION_DURATION_SECONDS = 3600; + protected List 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 connectionIdFuture = isConnectionIdSet; + if (connectionIdFuture != null) { + connectionIdFuture.complete(false); + isConnectionIdSet = null; + } + CompletableFuture avTransportIdFuture = isAvTransportIdSet; + if (avTransportIdFuture != null) { + avTransportIdFuture.complete(false); + isAvTransportIdSet = null; + } + CompletableFuture 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 stateOptionList) { + StateDescription stateDescription = StateDescriptionFragmentBuilder.create().withReadOnly(false) + .withOptions(stateOptionList).build().toStateDescription(); + upnpStateDescriptionProvider.setDescription(channelUID, stateDescription); + } + + protected void updateCommandDescription(ChannelUID channelUID, List 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 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 settingConnection = isConnectionIdSet; + CompletableFuture settingAVTransport = isAvTransportIdSet; + CompletableFuture 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(); + isAvTransportIdSet = new CompletableFuture(); + isRcsIdSet = new CompletableFuture(); + HashMap inputs = new HashMap(); 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 inputs = new HashMap(); - inputs.put("ConnectionID", String.valueOf(connectionId)); + protected void connectionComplete() { + Map 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 inputs = new HashMap(); - inputs.put("InstanceID", Integer.toString(avTransportId)); + protected void getCurrentConnectionIDs() { + Map 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 settingAVTransport = isAvTransportIdSet; + CompletableFuture 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(); + isRcsIdSet = new CompletableFuture(); + + // ConnectionID will default to 0 if not set through prepareForConnection method + Map 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 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 inputs = new HashMap<>(); + Map 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 inputs) { - scheduler.submit(() -> { - Map 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 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 preProcessInvokeActionResult(Map inputs, + @Nullable String service, @Nullable String action, Map result) { + Map 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 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 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 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 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 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; + } } diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpRendererHandler.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpRendererHandler.java index 3bbe561a7..d521f403f 100644 --- a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpRendererHandler.java +++ b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpRendererHandler.java @@ -14,29 +14,44 @@ package org.openhab.binding.upnpcontrol.internal.handler; import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; -import java.util.ListIterator; import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Collectors; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.upnpcontrol.internal.UpnpAudioSink; -import org.openhab.binding.upnpcontrol.internal.UpnpAudioSinkReg; -import org.openhab.binding.upnpcontrol.internal.UpnpEntry; -import org.openhab.binding.upnpcontrol.internal.UpnpXMLParser; +import org.jupnp.model.meta.RemoteDevice; +import org.openhab.binding.upnpcontrol.internal.UpnpChannelName; +import org.openhab.binding.upnpcontrol.internal.UpnpDynamicCommandDescriptionProvider; +import org.openhab.binding.upnpcontrol.internal.UpnpDynamicStateDescriptionProvider; +import org.openhab.binding.upnpcontrol.internal.audiosink.UpnpAudioSink; +import org.openhab.binding.upnpcontrol.internal.audiosink.UpnpAudioSinkReg; +import org.openhab.binding.upnpcontrol.internal.config.UpnpControlBindingConfiguration; +import org.openhab.binding.upnpcontrol.internal.config.UpnpControlRendererConfiguration; +import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntry; +import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntryQueue; +import org.openhab.binding.upnpcontrol.internal.queue.UpnpFavorite; +import org.openhab.binding.upnpcontrol.internal.services.UpnpRenderingControlConfiguration; +import org.openhab.binding.upnpcontrol.internal.util.UpnpControlUtil; +import org.openhab.binding.upnpcontrol.internal.util.UpnpXMLParser; import org.openhab.core.audio.AudioFormat; import org.openhab.core.io.net.http.HttpUtil; import org.openhab.core.io.transport.upnp.UpnpIOService; @@ -49,11 +64,13 @@ import org.openhab.core.library.types.QuantityType; import org.openhab.core.library.types.RewindFastforwardType; import org.openhab.core.library.types.StringType; import org.openhab.core.library.unit.SmartHomeUnits; +import org.openhab.core.thing.Channel; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingStatus; import org.openhab.core.thing.ThingStatusDetail; import org.openhab.core.types.Command; +import org.openhab.core.types.CommandOption; import org.openhab.core.types.RefreshType; import org.openhab.core.types.State; import org.openhab.core.types.UnDefType; @@ -62,7 +79,8 @@ import org.slf4j.LoggerFactory; /** * The {@link UpnpRendererHandler} is responsible for handling commands sent to the UPnP Renderer. It extends - * {@link UpnpHandler} with UPnP renderer specific logic. + * {@link UpnpHandler} with UPnP renderer specific logic. It implements UPnP AVTransport and RenderingControl service + * actions. * * @author Mark Herwege - Initial contribution * @author Karel Goderis - Based on UPnP logic in Sonos binding @@ -72,10 +90,10 @@ public class UpnpRendererHandler extends UpnpHandler { private final Logger logger = LoggerFactory.getLogger(UpnpRendererHandler.class); - private static final int SUBSCRIPTION_DURATION_SECONDS = 3600; - - // UPnP protocol pattern - private static final Pattern PROTOCOL_PATTERN = Pattern.compile("(?:.*):(?:.*):(.*):(?:.*)"); + // UPnP constants + static final String RENDERING_CONTROL = "RenderingControl"; + static final String AV_TRANSPORT = "AVTransport"; + static final String INSTANCE_ID = "InstanceID"; private volatile boolean audioSupport; protected volatile Set supportedAudioFormats = new HashSet<>(); @@ -83,101 +101,76 @@ public class UpnpRendererHandler extends UpnpHandler { private volatile UpnpAudioSinkReg audioSinkReg; - private volatile boolean upnpSubscribed; + private volatile Set serverHandlers = ConcurrentHashMap.newKeySet(); - private static final String UPNP_CHANNEL = "Master"; + protected @NonNullByDefault({}) UpnpControlRendererConfiguration config; + private UpnpRenderingControlConfiguration renderingControlConfiguration = new UpnpRenderingControlConfiguration(); + + private volatile List favoriteCommandOptionList = List.of(); + private volatile List playlistCommandOptionList = List.of(); + + private @NonNullByDefault({}) ChannelUID favoriteSelectChannelUID; + private @NonNullByDefault({}) ChannelUID playlistSelectChannelUID; - private volatile OnOffType soundMute = OnOffType.OFF; private volatile PercentType soundVolume = new PercentType(); + private @Nullable volatile PercentType notificationVolume; private volatile List sink = new ArrayList<>(); - private volatile ArrayList currentQueue = new ArrayList<>(); - private volatile UpnpIterator queueIterator = new UpnpIterator<>(currentQueue.listIterator()); - private volatile @Nullable UpnpEntry currentEntry = null; - private volatile @Nullable UpnpEntry nextEntry = null; - private volatile boolean playerStopped; - private volatile boolean playing; - private volatile @Nullable CompletableFuture isSettingURI; + private volatile String favoriteName = ""; // Currently selected favorite + + private volatile boolean repeat; + private volatile boolean shuffle; + private volatile boolean onlyplayone; // Set to true if we only want to play one at a time + + // Queue as received from server and current and next media entries for playback + private volatile UpnpEntryQueue currentQueue = new UpnpEntryQueue(); + volatile @Nullable UpnpEntry currentEntry = null; + volatile @Nullable UpnpEntry nextEntry = null; + + // Group of fields representing current state of player + private volatile String nowPlayingUri = ""; // Used to block waiting for setting URI when it is the same as current + // as some players will not send URI update when it is the same as + // previous + private volatile String transportState = ""; // Current transportState to be able to refresh the control + volatile boolean playerStopped; // Set if the player is stopped from OH command or code, allows to identify + // if STOP came from other source when receiving STOP state from GENA event + volatile boolean playing; // Set to false when a STOP is received, so we can filter two consecutive STOPs + // and not play next entry second time + private volatile @Nullable ScheduledFuture paused; // Set when a pause command is given, to compensate for + // renderers that cannot pause playback + private volatile @Nullable CompletableFuture isSettingURI; // Set to wait for setting URI before starting + // to play or seeking + private volatile @Nullable CompletableFuture isStopping; // Set when stopping to be able to wait for stop + // confirmation for subsequent actions that need + // the player to be stopped + volatile boolean registeredQueue; // Set when registering a new queue. This allows to decide if we just + // need to play URI, or serve the first entry in a queue when a play + // command is given. + volatile boolean playingQueue; // Identifies if we are playing a queue received from a server. If so, a new + // queue received will be played after the currently playing entry + private volatile boolean oneplayed; // Set to true when the one entry is being played, allows to check if stop is + // needed when only playing one + volatile boolean playingNotification; // Set when playing a notification + private volatile @Nullable ScheduledFuture playingNotificationFuture; // Set when playing a notification, allows + // timing out notification + private volatile String notificationUri = ""; // Used to check if the received URI is from the notification + private final Object notificationLock = new Object(); + + // Track position and duration fields private volatile int trackDuration = 0; private volatile int trackPosition = 0; + private volatile long expectedTrackend = 0; private volatile @Nullable ScheduledFuture trackPositionRefresh; + private volatile int posAtNotificationStart = 0; - private volatile @Nullable ScheduledFuture subscriptionRefreshJob; - private final Runnable subscriptionRefresh = () -> { - removeSubscription("AVTransport"); - addSubscription("AVTransport", SUBSCRIPTION_DURATION_SECONDS); - }; + public UpnpRendererHandler(Thing thing, UpnpIOService upnpIOService, UpnpAudioSinkReg audioSinkReg, + UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider, + UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider, + UpnpControlBindingConfiguration configuration) { + super(thing, upnpIOService, configuration, upnpStateDescriptionProvider, upnpCommandDescriptionProvider); - /** - * The {@link ListIterator} class does not keep a cursor position and therefore will not give the previous element - * when next was called before, or give the next element when previous was called before. This iterator will always - * go to previous/next. - */ - private static class UpnpIterator { - private final ListIterator listIterator; - - private boolean nextWasCalled = false; - private boolean previousWasCalled = false; - - public UpnpIterator(ListIterator listIterator) { - this.listIterator = listIterator; - } - - public T next() { - if (previousWasCalled) { - previousWasCalled = false; - listIterator.next(); - } - nextWasCalled = true; - return listIterator.next(); - } - - public T previous() { - if (nextWasCalled) { - nextWasCalled = false; - listIterator.previous(); - } - previousWasCalled = true; - return listIterator.previous(); - } - - public boolean hasNext() { - if (previousWasCalled) { - return true; - } else { - return listIterator.hasNext(); - } - } - - public boolean hasPrevious() { - if (previousIndex() < 0) { - return false; - } else if (nextWasCalled) { - return true; - } else { - return listIterator.hasPrevious(); - } - } - - public int nextIndex() { - if (previousWasCalled) { - return listIterator.nextIndex() + 1; - } else { - return listIterator.nextIndex(); - } - } - - public int previousIndex() { - if (nextWasCalled) { - return listIterator.previousIndex() - 1; - } else { - return listIterator.previousIndex(); - } - } - } - - public UpnpRendererHandler(Thing thing, UpnpIOService upnpIOService, UpnpAudioSinkReg audioSinkReg) { - super(thing, upnpIOService); + serviceSubscriptions.add(AV_TRANSPORT); + serviceSubscriptions.add(RENDERING_CONTROL); this.audioSinkReg = audioSinkReg; } @@ -185,112 +178,229 @@ public class UpnpRendererHandler extends UpnpHandler { @Override public void initialize() { super.initialize(); - + config = getConfigAs(UpnpControlRendererConfiguration.class); + if (config.seekStep < 1) { + config.seekStep = 1; + } logger.debug("Initializing handler for media renderer device {}", thing.getLabel()); - if (config.udn != null) { - if (service.isRegistered(this)) { - initRenderer(); - } else { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, - "Communication cannot be established with " + thing.getLabel()); - } + Channel favoriteSelectChannel = thing.getChannel(FAVORITE_SELECT); + if (favoriteSelectChannel != null) { + favoriteSelectChannelUID = favoriteSelectChannel.getUID(); } else { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "No UDN configured for " + thing.getLabel()); + "Channel " + FAVORITE_SELECT + " not defined"); + return; } + Channel playlistSelectChannel = thing.getChannel(PLAYLIST_SELECT); + if (playlistSelectChannel != null) { + playlistSelectChannelUID = playlistSelectChannel.getUID(); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "Channel " + PLAYLIST_SELECT + " not defined"); + return; + } + + initDevice(); } @Override public void dispose() { - cancelSubscriptionRefreshJob(); - removeSubscription("AVTransport"); + logger.debug("Disposing handler for media renderer device {}", thing.getLabel()); cancelTrackPositionRefresh(); + resetPaused(); + CompletableFuture settingURI = isSettingURI; + if (settingURI != null) { + settingURI.complete(false); + } super.dispose(); } - private void cancelSubscriptionRefreshJob() { - ScheduledFuture refreshJob = subscriptionRefreshJob; + @Override + protected void initJob() { + synchronized (jobLock) { + if (!upnpIOService.isRegistered(this)) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "UPnP device with UDN " + getUDN() + " not yet registered"); + return; + } - if (refreshJob != null) { - refreshJob.cancel(true); + if (!ThingStatus.ONLINE.equals(thing.getStatus())) { + getProtocolInfo(); + + getCurrentConnectionInfo(); + if (!checkForConnectionIds()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "No connection Id's set for UPnP device with UDN " + getUDN()); + return; + } + + getTransportState(); + + updateFavoritesList(); + playlistsListChanged(); + + RemoteDevice device = getDevice(); + if (device != null) { // The handler factory will update the device config later when it has not been + // set yet + updateDeviceConfig(device); + } + + updateStatus(ThingStatus.ONLINE); + } + + if (!upnpSubscribed) { + addSubscriptions(); + } } - subscriptionRefreshJob = null; - - upnpSubscribed = false; } - private void initRenderer() { - if (!upnpSubscribed) { - addSubscription("AVTransport", SUBSCRIPTION_DURATION_SECONDS); - upnpSubscribed = true; + @Override + public void updateDeviceConfig(RemoteDevice device) { + super.updateDeviceConfig(device); - subscriptionRefreshJob = scheduler.scheduleWithFixedDelay(subscriptionRefresh, - SUBSCRIPTION_DURATION_SECONDS / 2, SUBSCRIPTION_DURATION_SECONDS / 2, TimeUnit.SECONDS); + UpnpRenderingControlConfiguration config = new UpnpRenderingControlConfiguration(device); + renderingControlConfiguration = config; + for (String audioChannel : config.audioChannels) { + createAudioChannels(audioChannel); } - getProtocolInfo(); - getTransportState(); - updateStatus(ThingStatus.ONLINE); + updateChannels(); + } + + private void createAudioChannels(String audioChannel) { + UpnpRenderingControlConfiguration config = renderingControlConfiguration; + if (config.volume && !UPNP_MASTER.equals(audioChannel)) { + String name = audioChannel + "volume"; + if (UpnpChannelName.channelIdToUpnpChannelName(name) != null) { + createChannel(UpnpChannelName.channelIdToUpnpChannelName(name)); + } else { + createChannel(name, name, "Vendor specific UPnP volume channel", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME); + } + } + if (config.mute && !UPNP_MASTER.equals(audioChannel)) { + String name = audioChannel + "mute"; + if (UpnpChannelName.channelIdToUpnpChannelName(name) != null) { + createChannel(UpnpChannelName.channelIdToUpnpChannelName(name)); + } else { + createChannel(name, name, "Vendor specific UPnP mute channel", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE); + } + } + if (config.loudness) { + String name = (UPNP_MASTER.equals(audioChannel) ? "" : audioChannel) + "loudness"; + if (UpnpChannelName.channelIdToUpnpChannelName(name) != null) { + createChannel(UpnpChannelName.channelIdToUpnpChannelName(name)); + } else { + createChannel(name, name, "Vendor specific UPnP loudness channel", ITEM_TYPE_LOUDNESS, + CHANNEL_TYPE_LOUDNESS); + } + } } /** * Invoke Stop on UPnP AV Transport. */ public void stop() { - Map inputs = Collections.singletonMap("InstanceID", Integer.toString(avTransportId)); + playerStopped = true; - invokeAction("AVTransport", "Stop", inputs); + if (playing) { + CompletableFuture stopping = isStopping; + if (stopping != null) { + stopping.complete(false); + } + isStopping = new CompletableFuture(); // set this so we can check if stop confirmation has been + // received + } + + Map inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId)); + + invokeAction(AV_TRANSPORT, "Stop", inputs); } /** * Invoke Play on UPnP AV Transport. */ public void play() { - CompletableFuture setting = isSettingURI; + CompletableFuture settingURI = isSettingURI; + boolean uriSet = true; try { - if ((setting == null) || (setting.get(2500, TimeUnit.MILLISECONDS))) { + if (settingURI != null) { // wait for maximum 2.5s until the media URI is set before playing - Map inputs = new HashMap<>(); - inputs.put("InstanceID", Integer.toString(avTransportId)); - inputs.put("Speed", "1"); - - invokeAction("AVTransport", "Play", inputs); - } else { - logger.debug("Cannot play, cancelled setting URI in the renderer"); + uriSet = settingURI.get(config.responseTimeout, TimeUnit.MILLISECONDS); } } catch (InterruptedException | ExecutionException | TimeoutException e) { - logger.debug("Cannot play, media URI not yet set in the renderer"); + logger.debug("Timeout exception, media URI not yet set in renderer {}, trying to play anyway", + thing.getLabel()); + } + + if (uriSet) { + Map inputs = new HashMap<>(); + inputs.put(INSTANCE_ID, Integer.toString(avTransportId)); + inputs.put("Speed", "1"); + + invokeAction(AV_TRANSPORT, "Play", inputs); + } else { + logger.debug("Cannot play, cancelled setting URI in the renderer {}", thing.getLabel()); } } /** * Invoke Pause on UPnP AV Transport. */ - public void pause() { - Map inputs = Collections.singletonMap("InstanceID", Integer.toString(avTransportId)); + protected void pause() { + Map inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId)); - invokeAction("AVTransport", "Pause", inputs); + invokeAction(AV_TRANSPORT, "Pause", inputs); } /** * Invoke Next on UPnP AV Transport. */ protected void next() { - Map inputs = Collections.singletonMap("InstanceID", Integer.toString(avTransportId)); + Map inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId)); - invokeAction("AVTransport", "Next", inputs); + invokeAction(AV_TRANSPORT, "Next", inputs); } /** * Invoke Previous on UPnP AV Transport. */ protected void previous() { - Map inputs = Collections.singletonMap("InstanceID", Integer.toString(avTransportId)); + Map inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId)); - invokeAction("AVTransport", "Previous", inputs); + invokeAction(AV_TRANSPORT, "Previous", inputs); + } + + /** + * Invoke Seek on UPnP AV Transport. + * + * @param seekTarget relative position in current track, format HH:mm:ss + */ + protected void seek(String seekTarget) { + CompletableFuture settingURI = isSettingURI; + boolean uriSet = true; + try { + if (settingURI != null) { + // wait for maximum 2.5s until the media URI is set before seeking + uriSet = settingURI.get(config.responseTimeout, TimeUnit.MILLISECONDS); + } + } catch (InterruptedException | ExecutionException | TimeoutException e) { + logger.debug("Timeout exception, media URI not yet set in renderer {}, skipping seek", thing.getLabel()); + return; + } + + if (uriSet) { + Map inputs = new HashMap<>(); + inputs.put(INSTANCE_ID, Integer.toString(avTransportId)); + inputs.put("Unit", "REL_TIME"); + inputs.put("Target", seekTarget); + + invokeAction(AV_TRANSPORT, "Seek", inputs); + } else { + logger.debug("Cannot seek, cancelled setting URI in the renderer {}", thing.getLabel()); + } } /** @@ -300,22 +410,31 @@ public class UpnpRendererHandler extends UpnpHandler { * @param URIMetaData */ public void setCurrentURI(String URI, String URIMetaData) { - CompletableFuture setting = isSettingURI; - if (setting != null) { - setting.complete(false); - } - isSettingURI = new CompletableFuture(); // set this so we don't start playing when not finished setting - // URI - Map inputs = new HashMap<>(); + String uri = ""; try { - inputs.put("InstanceID", Integer.toString(avTransportId)); - inputs.put("CurrentURI", URI); - inputs.put("CurrentURIMetaData", URIMetaData); - - invokeAction("AVTransport", "SetAVTransportURI", inputs); - } catch (NumberFormatException ex) { - logger.debug("Action Invalid Value Format Exception {}", ex.getMessage()); + uri = URLDecoder.decode(URI.trim(), StandardCharsets.UTF_8.name()); + // Some renderers don't send a URI Last Changed event when the same URI is requested, so don't wait for it + // before starting to play + if (!uri.equals(nowPlayingUri) && !playingNotification) { + CompletableFuture settingURI = isSettingURI; + if (settingURI != null) { + settingURI.complete(false); + } + isSettingURI = new CompletableFuture(); // set this so we don't start playing when not finished + // setting URI + } else { + logger.debug("New URI {} is same as previous on renderer {}", nowPlayingUri, thing.getLabel()); + } + } catch (UnsupportedEncodingException ignore) { + uri = URI; } + + Map inputs = new HashMap<>(); + inputs.put(INSTANCE_ID, Integer.toString(avTransportId)); + inputs.put("CurrentURI", uri); + inputs.put("CurrentURIMetaData", URIMetaData); + + invokeAction(AV_TRANSPORT, "SetAVTransportURI", inputs); } /** @@ -324,26 +443,43 @@ public class UpnpRendererHandler extends UpnpHandler { * @param nextURI * @param nextURIMetaData */ - public void setNextURI(String nextURI, String nextURIMetaData) { + protected void setNextURI(String nextURI, String nextURIMetaData) { Map inputs = new HashMap<>(); - try { - inputs.put("InstanceID", Integer.toString(avTransportId)); - inputs.put("NextURI", nextURI); - inputs.put("NextURIMetaData", nextURIMetaData); + inputs.put(INSTANCE_ID, Integer.toString(avTransportId)); + inputs.put("NextURI", nextURI); + inputs.put("NextURIMetaData", nextURIMetaData); - invokeAction("AVTransport", "SetNextAVTransportURI", inputs); - } catch (NumberFormatException ex) { - logger.debug("Action Invalid Value Format Exception {}", ex.getMessage()); - } + invokeAction(AV_TRANSPORT, "SetNextAVTransportURI", inputs); } /** - * Retrieves the current audio channel ('Master' by default). - * - * @return current audio channel + * Invoke GetTransportState on UPnP AV Transport. + * Result is received in {@link onValueReceived}. */ - public String getCurrentChannel() { - return UPNP_CHANNEL; + protected void getTransportState() { + Map inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId)); + + invokeAction(AV_TRANSPORT, "GetTransportInfo", inputs); + } + + /** + * Invoke getPositionInfo on UPnP AV Transport. + * Result is received in {@link onValueReceived}. + */ + protected void getPositionInfo() { + Map inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId)); + + invokeAction(AV_TRANSPORT, "GetPositionInfo", inputs); + } + + /** + * Invoke GetMediaInfo on UPnP AV Transport. + * Result is received in {@link onValueReceived}. + */ + protected void getMediaInfo() { + Map inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId)); + + invokeAction(AV_TRANSPORT, "smarthome:audio stream http://icecast.vrtcdn.be/stubru_tijdloze-high.mp3", inputs); } /** @@ -364,10 +500,10 @@ public class UpnpRendererHandler extends UpnpHandler { */ protected void getVolume(String channel) { Map inputs = new HashMap<>(); - inputs.put("InstanceID", Integer.toString(rcsId)); + inputs.put(INSTANCE_ID, Integer.toString(rcsId)); inputs.put("Channel", channel); - invokeAction("RenderingControl", "GetVolume", inputs); + invokeAction(RENDERING_CONTROL, "GetVolume", inputs); } /** @@ -376,13 +512,25 @@ public class UpnpRendererHandler extends UpnpHandler { * @param channel * @param volume */ - public void setVolume(String channel, PercentType volume) { - Map inputs = new HashMap<>(); - inputs.put("InstanceID", Integer.toString(rcsId)); - inputs.put("Channel", channel); - inputs.put("DesiredVolume", String.valueOf(volume.intValue())); + protected void setVolume(String channel, PercentType volume) { + UpnpRenderingControlConfiguration config = renderingControlConfiguration; - invokeAction("RenderingControl", "SetVolume", inputs); + long newVolume = volume.intValue() * config.maxvolume / 100; + Map inputs = new HashMap<>(); + inputs.put(INSTANCE_ID, Integer.toString(rcsId)); + inputs.put("Channel", channel); + inputs.put("DesiredVolume", String.valueOf(newVolume)); + + invokeAction(RENDERING_CONTROL, "SetVolume", inputs); + } + + /** + * Invoke SetVolume for Master channel on UPnP Rendering Control. + * + * @param volume + */ + public void setVolume(PercentType volume) { + setVolume(UPNP_MASTER, volume); } /** @@ -393,10 +541,10 @@ public class UpnpRendererHandler extends UpnpHandler { */ protected void getMute(String channel) { Map inputs = new HashMap<>(); - inputs.put("InstanceID", Integer.toString(rcsId)); + inputs.put(INSTANCE_ID, Integer.toString(rcsId)); inputs.put("Channel", channel); - invokeAction("RenderingControl", "GetMute", inputs); + invokeAction(RENDERING_CONTROL, "GetMute", inputs); } /** @@ -407,119 +555,508 @@ public class UpnpRendererHandler extends UpnpHandler { */ protected void setMute(String channel, OnOffType mute) { Map inputs = new HashMap<>(); - inputs.put("InstanceID", Integer.toString(rcsId)); + inputs.put(INSTANCE_ID, Integer.toString(rcsId)); inputs.put("Channel", channel); inputs.put("DesiredMute", mute == OnOffType.ON ? "1" : "0"); - invokeAction("RenderingControl", "SetMute", inputs); + invokeAction(RENDERING_CONTROL, "SetMute", inputs); } /** - * Invoke getPositionInfo on UPnP Rendering Control. + * Invoke getMute on UPnP Rendering Control. * Result is received in {@link onValueReceived}. + * + * @param channel */ - protected void getPositionInfo() { - Map inputs = Collections.singletonMap("InstanceID", Integer.toString(rcsId)); + protected void getLoudness(String channel) { + Map inputs = new HashMap<>(); + inputs.put(INSTANCE_ID, Integer.toString(rcsId)); + inputs.put("Channel", channel); - invokeAction("AVTransport", "GetPositionInfo", inputs); + invokeAction(RENDERING_CONTROL, "GetLoudness", inputs); + } + + /** + * Invoke SetMute on UPnP Rendering Control. + * + * @param channel + * @param mute + */ + protected void setLoudness(String channel, OnOffType mute) { + Map inputs = new HashMap<>(); + inputs.put(INSTANCE_ID, Integer.toString(rcsId)); + inputs.put("Channel", channel); + inputs.put("DesiredLoudness", mute == OnOffType.ON ? "1" : "0"); + + invokeAction(RENDERING_CONTROL, "SetLoudness", inputs); + } + + /** + * Called from server handler for renderer to be able to send back status to server handler + * + * @param handler + */ + protected void setServerHandler(UpnpServerHandler handler) { + logger.debug("Set server handler {} on renderer {}", handler.getThing().getLabel(), thing.getLabel()); + serverHandlers.add(handler); + } + + /** + * Should be called from server handler when server stops serving this renderer + */ + protected void unsetServerHandler() { + logger.debug("Unset server handler on renderer {}", thing.getLabel()); + for (UpnpServerHandler handler : serverHandlers) { + Thing serverThing = handler.getThing(); + Channel serverChannel; + for (String channel : SERVER_CONTROL_CHANNELS) { + if ((serverChannel = serverThing.getChannel(channel)) != null) { + handler.updateServerState(serverChannel.getUID(), UnDefType.UNDEF); + } + } + + serverHandlers.remove(handler); + } + } + + @Override + protected void updateState(ChannelUID channelUID, State state) { + // override to be able to propagate channel state updates to corresponding channels on the server + if (SERVER_CONTROL_CHANNELS.contains(channelUID.getId())) { + for (UpnpServerHandler handler : serverHandlers) { + Thing serverThing = handler.getThing(); + Channel serverChannel = serverThing.getChannel(channelUID.getId()); + if (serverChannel != null) { + logger.debug("Update server {} channel {} with state {} from renderer {}", serverThing.getLabel(), + state, channelUID, thing.getLabel()); + handler.updateServerState(serverChannel.getUID(), state); + } + } + } + super.updateState(channelUID, state); } @Override public void handleCommand(ChannelUID channelUID, Command command) { logger.debug("Handle command {} for channel {} on renderer {}", command, channelUID, thing.getLabel()); - String transportState; - if (command instanceof RefreshType) { - switch (channelUID.getId()) { - case VOLUME: - getVolume(getCurrentChannel()); - break; - case MUTE: - getMute(getCurrentChannel()); + String id = channelUID.getId(); + + if (id.endsWith("volume")) { + handleCommandVolume(command, id); + } else if (id.endsWith("mute")) { + handleCommandMute(command, id); + } else if (id.endsWith("loudness")) { + handleCommandLoudness(command, id); + } else { + switch (id) { + case STOP: + handleCommandStop(command); break; case CONTROL: - transportState = this.transportState; - State newState = UnDefType.UNDEF; - if ("PLAYING".equals(transportState)) { - newState = PlayPauseType.PLAY; - } else if ("STOPPED".equals(transportState)) { - newState = PlayPauseType.PAUSE; - } else if ("PAUSED_PLAYBACK".equals(transportState)) { - newState = PlayPauseType.PAUSE; - } - updateState(channelUID, newState); + handleCommandControl(channelUID, command); + break; + case REPEAT: + handleCommandRepeat(channelUID, command); + break; + case SHUFFLE: + handleCommandShuffle(channelUID, command); + case ONLY_PLAY_ONE: + handleCommandOnlyPlayOne(channelUID, command); + break; + case URI: + handleCommandUri(channelUID, command); + break; + case FAVORITE_SELECT: + handleCommandFavoriteSelect(command); + break; + case FAVORITE: + handleCommandFavorite(channelUID, command); + break; + case FAVORITE_ACTION: + handleCommandFavoriteAction(command); + break; + case PLAYLIST_SELECT: + handleCommandPlaylistSelect(command); + break; + case TRACK_POSITION: + handleCommandTrackPosition(channelUID, command); + break; + case REL_TRACK_POSITION: + handleCommandRelTrackPosition(channelUID, command); + break; + default: break; } - return; + } + } + + private void handleCommandVolume(Command command, String id) { + if (command instanceof RefreshType) { + getVolume("volume".equals(id) ? UPNP_MASTER : id.replace("volume", "")); + } else if (command instanceof PercentType) { + setVolume("volume".equals(id) ? UPNP_MASTER : id.replace("volume", ""), (PercentType) command); + } + } + + private void handleCommandMute(Command command, String id) { + if (command instanceof RefreshType) { + getMute("mute".equals(id) ? UPNP_MASTER : id.replace("mute", "")); + } else if (command instanceof OnOffType) { + setMute("mute".equals(id) ? UPNP_MASTER : id.replace("mute", ""), (OnOffType) command); + } + } + + private void handleCommandLoudness(Command command, String id) { + if (command instanceof RefreshType) { + getLoudness("loudness".equals(id) ? UPNP_MASTER : id.replace("loudness", "")); + } else if (command instanceof OnOffType) { + setLoudness("loudness".equals(id) ? UPNP_MASTER : id.replace("loudness", ""), (OnOffType) command); + } + } + + private void handleCommandStop(Command command) { + if (OnOffType.ON.equals(command)) { + updateState(CONTROL, PlayPauseType.PAUSE); + stop(); + updateState(TRACK_POSITION, new QuantityType<>(0, SmartHomeUnits.SECOND)); + } + } + + private void handleCommandControl(ChannelUID channelUID, Command command) { + String state; + if (command instanceof RefreshType) { + state = transportState; + State newState = UnDefType.UNDEF; + if ("PLAYING".equals(state)) { + newState = PlayPauseType.PLAY; + } else if ("STOPPED".equals(state)) { + newState = PlayPauseType.PAUSE; + } else if ("PAUSED_PLAYBACK".equals(state)) { + newState = PlayPauseType.PAUSE; + } + updateState(channelUID, newState); + } else if (command instanceof PlayPauseType) { + if (PlayPauseType.PLAY.equals(command)) { + if (registeredQueue) { + registeredQueue = false; + playingQueue = true; + oneplayed = false; + serve(); + } else { + play(); + } + } else if (PlayPauseType.PAUSE.equals(command)) { + checkPaused(); + pause(); + } + } else if (command instanceof NextPreviousType) { + if (NextPreviousType.NEXT.equals(command)) { + serveNext(); + } else if (NextPreviousType.PREVIOUS.equals(command)) { + servePrevious(); + } + } else if (command instanceof RewindFastforwardType) { + int pos = 0; + if (RewindFastforwardType.FASTFORWARD.equals(command)) { + pos = Integer.min(trackDuration, trackPosition + config.seekStep); + } else if (command == RewindFastforwardType.REWIND) { + pos = Integer.max(0, trackPosition - config.seekStep); + } + seek(String.format("%02d:%02d:%02d", pos / 3600, (pos % 3600) / 60, pos % 60)); + } + } + + private void handleCommandRepeat(ChannelUID channelUID, Command command) { + if (command instanceof RefreshType) { + updateState(channelUID, OnOffType.from(repeat)); } else { - switch (channelUID.getId()) { - case VOLUME: - setVolume(getCurrentChannel(), (PercentType) command); + repeat = (OnOffType.ON.equals(command)); + currentQueue.setRepeat(repeat); + updateState(channelUID, OnOffType.from(repeat)); + logger.debug("Repeat set to {} for {}", repeat, thing.getLabel()); + } + } + + private void handleCommandShuffle(ChannelUID channelUID, Command command) { + if (command instanceof RefreshType) { + updateState(channelUID, OnOffType.from(shuffle)); + } else { + shuffle = (OnOffType.ON.equals(command)); + currentQueue.setShuffle(shuffle); + if (!playing) { + resetToStartQueue(); + } + updateState(channelUID, OnOffType.from(shuffle)); + logger.debug("Shuffle set to {} for {}", shuffle, thing.getLabel()); + } + } + + private void handleCommandOnlyPlayOne(ChannelUID channelUID, Command command) { + if (command instanceof RefreshType) { + updateState(channelUID, OnOffType.from(onlyplayone)); + } else { + onlyplayone = (OnOffType.ON.equals(command)); + oneplayed = (onlyplayone && playing) ? true : false; + if (oneplayed) { + setNextURI("", ""); + } else { + UpnpEntry next = nextEntry; + if (next != null) { + setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next)); + } + } + updateState(channelUID, OnOffType.from(onlyplayone)); + logger.debug("OnlyPlayOne set to {} for {}", onlyplayone, thing.getLabel()); + } + } + + private void handleCommandUri(ChannelUID channelUID, Command command) { + if (command instanceof RefreshType) { + updateState(channelUID, StringType.valueOf(nowPlayingUri)); + } else if (command instanceof StringType) { + setCurrentURI(command.toString(), ""); + play(); + } + } + + private void handleCommandFavoriteSelect(Command command) { + if (command instanceof StringType) { + favoriteName = command.toString(); + updateState(FAVORITE, StringType.valueOf(favoriteName)); + playFavorite(); + } + } + + private void handleCommandFavorite(ChannelUID channelUID, Command command) { + if (command instanceof StringType) { + favoriteName = command.toString(); + if (favoriteCommandOptionList.contains(new CommandOption(favoriteName, favoriteName))) { + playFavorite(); + } + } + updateState(channelUID, StringType.valueOf(favoriteName)); + } + + private void handleCommandFavoriteAction(Command command) { + if (command instanceof StringType) { + switch (command.toString()) { + case SAVE: + handleCommandFavoriteSave(); break; - case MUTE: - setMute(getCurrentChannel(), (OnOffType) command); - break; - case STOP: - if (command == OnOffType.ON) { - updateState(CONTROL, PlayPauseType.PAUSE); - playerStopped = true; - stop(); - updateState(TRACK_POSITION, new QuantityType<>(0, SmartHomeUnits.SECOND)); - } - break; - case CONTROL: - playerStopped = false; - if (command instanceof PlayPauseType) { - if (command == PlayPauseType.PLAY) { - play(); - } else if (command == PlayPauseType.PAUSE) { - pause(); - } - } else if (command instanceof NextPreviousType) { - if (command == NextPreviousType.NEXT) { - playerStopped = true; - serveNext(); - } else if (command == NextPreviousType.PREVIOUS) { - playerStopped = true; - servePrevious(); - } - } else if (command instanceof RewindFastforwardType) { - } + case DELETE: + handleCommandFavoriteDelete(); break; } + } + } + + private void handleCommandFavoriteSave() { + if (!favoriteName.isEmpty()) { + UpnpFavorite favorite = new UpnpFavorite(favoriteName, nowPlayingUri, currentEntry); + favorite.saveFavorite(favoriteName, bindingConfig.path); + updateFavoritesList(); + } + } + + private void handleCommandFavoriteDelete() { + if (!favoriteName.isEmpty()) { + UpnpControlUtil.deleteFavorite(favoriteName, bindingConfig.path); + updateFavoritesList(); + updateState(FAVORITE, UnDefType.UNDEF); + } + } + + private void handleCommandPlaylistSelect(Command command) { + if (command instanceof StringType) { + String playlistName = command.toString(); + UpnpEntryQueue queue = new UpnpEntryQueue(); + queue.restoreQueue(playlistName, null, bindingConfig.path); + registerQueue(queue); + resetToStartQueue(); + playingQueue = true; + serve(); + } + } + + private void handleCommandTrackPosition(ChannelUID channelUID, Command command) { + if (command instanceof RefreshType) { + updateState(channelUID, new QuantityType<>(trackPosition, SmartHomeUnits.SECOND)); + } else if (command instanceof QuantityType) { + QuantityType position = ((QuantityType) command).toUnit(SmartHomeUnits.SECOND); + if (position != null) { + int pos = Integer.min(trackDuration, position.intValue()); + seek(String.format("%02d:%02d:%02d", pos / 3600, (pos % 3600) / 60, pos % 60)); + } + } + } + + private void handleCommandRelTrackPosition(ChannelUID channelUID, Command command) { + if (command instanceof RefreshType) { + int relPosition = (trackDuration != 0) ? (trackPosition * 100) / trackDuration : 0; + updateState(channelUID, new PercentType(relPosition)); + } else if (command instanceof PercentType) { + int pos = ((PercentType) command).intValue() * trackDuration / 100; + seek(String.format("%02d:%02d:%02d", pos / 3600, (pos % 3600) / 60, pos % 60)); + } + } + + /** + * Set the volume for notifications. + * + * @param volume + */ + public void setNotificationVolume(PercentType volume) { + notificationVolume = volume; + } + + /** + * Play a notification. Previous state of the renderer will resume at the end of the notification, or after the + * maximum notification duration as defined in the renderer parameters. + * + * @param URI for notification sound + */ + public void playNotification(String URI) { + synchronized (notificationLock) { + if (URI.isEmpty()) { + logger.debug("UPnP device {} received empty notification URI", thing.getLabel()); + return; + } - return; + notificationUri = URI; + + logger.debug("UPnP device {} playing notification {}", thing.getLabel(), URI); + + cancelTrackPositionRefresh(); + getPositionInfo(); + + cancelPlayingNotificationFuture(); + + if (config.maxNotificationDuration > 0) { + playingNotificationFuture = upnpScheduler.schedule(this::stop, config.maxNotificationDuration, + TimeUnit.SECONDS); + } + playingNotification = true; + + setCurrentURI(URI, ""); + setNextURI("", ""); + PercentType volume = notificationVolume; + setVolume(volume == null + ? new PercentType(Math.min(100, + Math.max(0, (100 + config.notificationVolumeAdjustment) * soundVolume.intValue() / 100))) + : volume); + + CompletableFuture stopping = isStopping; + try { + if (stopping != null) { + // wait for maximum 2.5s until the renderer stopped before playing + stopping.get(config.responseTimeout, TimeUnit.MILLISECONDS); + } + } catch (InterruptedException | ExecutionException | TimeoutException e) { + logger.debug("Timeout exception, renderer {} didn't stop yet, trying to play anyway", thing.getLabel()); + } + play(); } } + private void cancelPlayingNotificationFuture() { + ScheduledFuture future = playingNotificationFuture; + if (future != null) { + future.cancel(true); + playingNotificationFuture = null; + } + } + + private void resumeAfterNotification() { + synchronized (notificationLock) { + logger.debug("UPnP device {} resume after playing notification", thing.getLabel()); + + setCurrentURI(nowPlayingUri, ""); + setVolume(soundVolume); + + cancelPlayingNotificationFuture(); + + playingNotification = false; + notificationVolume = null; + notificationUri = ""; + + if (playing) { + int pos = posAtNotificationStart; + seek(String.format("%02d:%02d:%02d", pos / 3600, (pos % 3600) / 60, pos % 60)); + play(); + } + posAtNotificationStart = 0; + } + } + + private void playFavorite() { + UpnpFavorite favorite = new UpnpFavorite(favoriteName, bindingConfig.path); + String uri = favorite.getUri(); + UpnpEntry entry = favorite.getUpnpEntry(); + if (!uri.isEmpty()) { + String metadata = ""; + if (entry != null) { + metadata = UpnpXMLParser.compileMetadataString(entry); + } + setCurrentURI(uri, metadata); + play(); + } + } + + void updateFavoritesList() { + favoriteCommandOptionList = UpnpControlUtil.favorites(bindingConfig.path).stream() + .map(p -> (new CommandOption(p, p))).collect(Collectors.toList()); + updateCommandDescription(favoriteSelectChannelUID, favoriteCommandOptionList); + } + + @Override + public void playlistsListChanged() { + playlistCommandOptionList = UpnpControlUtil.playlists().stream().map(p -> (new CommandOption(p, p))) + .collect(Collectors.toList()); + updateCommandDescription(playlistSelectChannelUID, playlistCommandOptionList); + } + @Override public void onStatusChanged(boolean status) { - logger.debug("Renderer status changed to {}", status); - if (status) { - initRenderer(); - } else { - cancelSubscriptionRefreshJob(); + if (!status) { + removeSubscriptions(); updateState(CONTROL, PlayPauseType.PAUSE); cancelTrackPositionRefresh(); - - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, - "Communication lost with " + thing.getLabel()); } super.onStatusChanged(status); } + @Override + protected @Nullable String preProcessValueReceived(Map inputs, @Nullable String variable, + @Nullable String value, @Nullable String service, @Nullable String action) { + if (variable == null) { + return null; + } else { + switch (variable) { + case "CurrentVolume": + return (inputs.containsKey("Channel") ? inputs.get("Channel") : UPNP_MASTER) + "Volume"; + case "CurrentMute": + return (inputs.containsKey("Channel") ? inputs.get("Channel") : UPNP_MASTER) + "Mute"; + case "CurrentLoudness": + return (inputs.containsKey("Channel") ? inputs.get("Channel") : UPNP_MASTER) + "Loudness"; + default: + return variable; + } + } + } + @Override public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) { if (logger.isTraceEnabled()) { - logger.trace("Upnp device {} received variable {} with value {} from service {}", thing.getLabel(), + logger.trace("UPnP device {} received variable {} with value {} from service {}", thing.getLabel(), variable, value, service); } else { if (logger.isDebugEnabled() && !("AbsTime".equals(variable) || "RelCount".equals(variable) || "RelTime".equals(variable) || "AbsCount".equals(variable) || "Track".equals(variable) || "TrackDuration".equals(variable))) { // don't log all variables received when updating the track position every second - logger.debug("Upnp device {} received variable {} with value {} from service {}", thing.getLabel(), + logger.debug("UPnP device {} received variable {} with value {} from service {}", thing.getLabel(), variable, value, service); } } @@ -527,139 +1064,313 @@ public class UpnpRendererHandler extends UpnpHandler { return; } - switch (variable) { - case "CurrentMute": - if (!((value == null) || (value.isEmpty()))) { - soundMute = OnOffType.from(Boolean.parseBoolean(value)); - updateState(MUTE, soundMute); - } - break; - case "CurrentVolume": - if (!((value == null) || (value.isEmpty()))) { - soundVolume = PercentType.valueOf(value); - updateState(VOLUME, soundVolume); - } - break; - case "Sink": - if (!((value == null) || (value.isEmpty()))) { - updateProtocolInfo(value); - } - break; - case "LastChange": - // pre-process some variables, eg XML processing - if (!((value == null) || value.isEmpty())) { - if ("AVTransport".equals(service)) { - Map parsedValues = UpnpXMLParser.getAVTransportFromXML(value); - for (Map.Entry entrySet : parsedValues.entrySet()) { - // Update the transport state after the update of the media information - // to not break the notification mechanism - if (!"TransportState".equals(entrySet.getKey())) { - onValueReceived(entrySet.getKey(), entrySet.getValue(), service); - } - if ("AVTransportURI".equals(entrySet.getKey())) { - onValueReceived("CurrentTrackURI", entrySet.getValue(), service); - } else if ("AVTransportURIMetaData".equals(entrySet.getKey())) { - onValueReceived("CurrentTrackMetaData", entrySet.getValue(), service); - } - } - if (parsedValues.containsKey("TransportState")) { - onValueReceived("TransportState", parsedValues.get("TransportState"), service); - } - } - } - break; - case "TransportState": - transportState = (value == null) ? "" : value; - if ("STOPPED".equals(value)) { - updateState(CONTROL, PlayPauseType.PAUSE); - cancelTrackPositionRefresh(); - // playerStopped is true if stop came from openHAB. This allows us to identify if we played to the - // end of an entry. We should then move to the next entry if the queue is not at the end already. - if (playing && !playerStopped) { - // Only go to next for first STOP command, then wait until we received PLAYING before moving - // to next (avoids issues with renderers sending multiple stop states) - playing = false; - serveNext(); - } else { - currentEntry = nextEntry; // Try to get the metadata for the next entry if controlled by an - // external control point - playing = false; - } - } else if ("PLAYING".equals(value)) { - playerStopped = false; - playing = true; - updateState(CONTROL, PlayPauseType.PLAY); - scheduleTrackPositionRefresh(); - } else if ("PAUSED_PLAYBACK".equals(value)) { - updateState(CONTROL, PlayPauseType.PAUSE); - } - break; - case "CurrentTrackURI": - UpnpEntry current = currentEntry; - if (queueIterator.hasNext() && (current != null) && !current.getRes().equals(value) - && currentQueue.get(queueIterator.nextIndex()).getRes().equals(value)) { - // Renderer advanced to next entry independent of openHAB UPnP control point. - // Advance in the queue to keep proper position status. - // Make the next entry available to renderers that support it. - updateMetaDataState(currentQueue.get(queueIterator.nextIndex())); - logger.trace("Renderer moved from '{}' to next entry '{}' in queue", currentEntry, - currentQueue.get(queueIterator.nextIndex())); - currentEntry = queueIterator.next(); - if (queueIterator.hasNext()) { - UpnpEntry next = currentQueue.get(queueIterator.nextIndex()); - setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next)); - } - } - if (isSettingURI != null) { - isSettingURI.complete(true); // We have received current URI, so can allow play to start - } - break; - case "CurrentTrackMetaData": - if (!((value == null) || (value.isEmpty()))) { - List list = UpnpXMLParser.getEntriesFromXML(value); - if (!list.isEmpty()) { - updateMetaDataState(list.get(0)); - } - } - break; - case "NextAVTransportURIMetaData": - if (!((value == null) || (value.isEmpty() || "NOT_IMPLEMENTED".equals(value)))) { - List list = UpnpXMLParser.getEntriesFromXML(value); - if (!list.isEmpty()) { - nextEntry = list.get(0); - } - } - break; - case "CurrentTrackDuration": - case "TrackDuration": - // track duration and track position have format H+:MM:SS[.F+] or H+:MM:SS[.F0/F1]. We are not - // interested in the fractional seconds, so drop everything after . and calculate in seconds. - if ((value == null) || ("NOT_IMPLEMENTED".equals(value))) { - trackDuration = 0; - updateState(TRACK_DURATION, UnDefType.UNDEF); - } else { - trackDuration = Arrays.stream(value.split("\\.")[0].split(":")).mapToInt(n -> Integer.parseInt(n)) - .reduce(0, (n, m) -> n * 60 + m); - updateState(TRACK_DURATION, new QuantityType<>(trackDuration, SmartHomeUnits.SECOND)); - } - break; - case "RelTime": - if ((value == null) || ("NOT_IMPLEMENTED".equals(value))) { - trackPosition = 0; - updateState(TRACK_POSITION, UnDefType.UNDEF); - } else { - trackPosition = Arrays.stream(value.split("\\.")[0].split(":")).mapToInt(n -> Integer.parseInt(n)) - .reduce(0, (n, m) -> n * 60 + m); - updateState(TRACK_POSITION, new QuantityType<>(trackPosition, SmartHomeUnits.SECOND)); - } - break; - default: - super.onValueReceived(variable, value, service); - break; + if (variable.endsWith("Volume")) { + onValueReceivedVolume(variable, value); + } else if (variable.endsWith("Mute")) { + onValueReceivedMute(variable, value); + } else if (variable.endsWith("Loudness")) { + onValueReceivedLoudness(variable, value); + } else { + switch (variable) { + case "LastChange": + onValueReceivedLastChange(value, service); + break; + case "CurrentTransportState": + case "TransportState": + onValueReceivedTransportState(value); + break; + case "CurrentTrackURI": + case "CurrentURI": + onValueReceivedCurrentURI(value); + break; + case "CurrentTrackMetaData": + case "CurrentURIMetaData": + onValueReceivedCurrentMetaData(value); + break; + case "NextAVTransportURIMetaData": + case "NextURIMetaData": + onValueReceivedNextMetaData(value); + break; + case "CurrentTrackDuration": + case "TrackDuration": + onValueReceivedDuration(value); + break; + case "RelTime": + onValueReceivedRelTime(value); + break; + default: + super.onValueReceived(variable, value, service); + break; + } } } - private void updateProtocolInfo(String value) { + private void onValueReceivedVolume(String variable, @Nullable String value) { + if (value != null && !value.isEmpty()) { + UpnpRenderingControlConfiguration config = renderingControlConfiguration; + + long volume = Long.valueOf(value); + volume = volume * 100 / config.maxvolume; + + String upnpChannel = variable.replace("Volume", "volume").replace("Master", ""); + updateState(upnpChannel, new PercentType((int) volume)); + + if (!playingNotification && "volume".equals(upnpChannel)) { + soundVolume = new PercentType((int) volume); + } + } + } + + private void onValueReceivedMute(String variable, @Nullable String value) { + if (value != null && !value.isEmpty()) { + String upnpChannel = variable.replace("Mute", "mute").replace("Master", ""); + updateState(upnpChannel, + ("1".equals(value) || "true".equals(value.toLowerCase())) ? OnOffType.ON : OnOffType.OFF); + } + } + + private void onValueReceivedLoudness(String variable, @Nullable String value) { + if (value != null && !value.isEmpty()) { + String upnpChannel = variable.replace("Loudness", "loudness").replace("Master", ""); + updateState(upnpChannel, + ("1".equals(value) || "true".equals(value.toLowerCase())) ? OnOffType.ON : OnOffType.OFF); + } + } + + private void onValueReceivedLastChange(@Nullable String value, @Nullable String service) { + // This is returned from a GENA subscription. The jupnp library does not allow receiving new GENA subscription + // messages as long as this thread has not finished. As we may trigger long running processes based on this + // result, we run it in a separate thread. + upnpScheduler.submit(() -> { + // pre-process some variables, eg XML processing + if (value != null && !value.isEmpty()) { + if (AV_TRANSPORT.equals(service)) { + Map parsedValues = UpnpXMLParser.getAVTransportFromXML(value); + for (Map.Entry entrySet : parsedValues.entrySet()) { + switch (entrySet.getKey()) { + case "TransportState": + // Update the transport state after the update of the media information + // to not break the notification mechanism + break; + case "AVTransportURI": + onValueReceived("CurrentTrackURI", entrySet.getValue(), service); + break; + case "AVTransportURIMetaData": + onValueReceived("CurrentTrackMetaData", entrySet.getValue(), service); + break; + default: + onValueReceived(entrySet.getKey(), entrySet.getValue(), service); + } + } + if (parsedValues.containsKey("TransportState")) { + onValueReceived("TransportState", parsedValues.get("TransportState"), service); + } + } else if (RENDERING_CONTROL.equals(service)) { + Map parsedValues = UpnpXMLParser.getRenderingControlFromXML(value); + for (String parsedValue : parsedValues.keySet()) { + onValueReceived(parsedValue, parsedValues.get(parsedValue), RENDERING_CONTROL); + } + } + } + }); + } + + private void onValueReceivedTransportState(@Nullable String value) { + transportState = (value == null) ? "" : value; + + if ("STOPPED".equals(value)) { + CompletableFuture stopping = isStopping; + if (stopping != null) { + stopping.complete(true); // We have received stop confirmation + isStopping = null; + } + + if (playingNotification) { + resumeAfterNotification(); + return; + } + + cancelCheckPaused(); + updateState(CONTROL, PlayPauseType.PAUSE); + cancelTrackPositionRefresh(); + // Only go to next for first STOP command, then wait until we received PLAYING before moving + // to next (avoids issues with renderers sending multiple stop states) + if (playing) { + playing = false; + + // playerStopped is true if stop came from openHAB. This allows us to identify if we played to the + // end of an entry, because STOP would come from the player and not from openHAB. We should then + // move to the next entry if the queue is not at the end already. + if (!playerStopped) { + if (Instant.now().toEpochMilli() >= expectedTrackend) { + // If we are receiving track duration info, we know when the track is expected to end. If we + // received STOP before track end, and it is not coming from openHAB, it must have been stopped + // from the renderer directly, and we do not want to play the next entry. + if (playingQueue) { + serveNext(); + } + } + } else if (playingQueue) { + playingQueue = false; + } + } + } else if ("PLAYING".equals(value)) { + if (playingNotification) { + return; + } + + playerStopped = false; + playing = true; + registeredQueue = false; // reset queue registration flag as we are playing something + updateState(CONTROL, PlayPauseType.PLAY); + scheduleTrackPositionRefresh(); + } else if ("PAUSED_PLAYBACK".equals(value)) { + cancelCheckPaused(); + updateState(CONTROL, PlayPauseType.PAUSE); + } else if ("NO_MEDIA_PRESENT".equals(value)) { + updateState(CONTROL, UnDefType.UNDEF); + } + } + + private void onValueReceivedCurrentURI(@Nullable String value) { + CompletableFuture settingURI = isSettingURI; + if (settingURI != null) { + settingURI.complete(true); // We have received current URI, so can allow play to start + } + + UpnpEntry current = currentEntry; + UpnpEntry next = nextEntry; + + String uri = ""; + String currentUri = ""; + String nextUri = ""; + try { + if (value != null) { + uri = URLDecoder.decode(value.trim(), StandardCharsets.UTF_8.name()); + } + if (current != null) { + currentUri = URLDecoder.decode(current.getRes().trim(), StandardCharsets.UTF_8.name()); + } + if (next != null) { + nextUri = URLDecoder.decode(next.getRes(), StandardCharsets.UTF_8.name()); + } + } catch (UnsupportedEncodingException ignore) { + // If not valid current URI, we assume there is none + } + + if (playingNotification && uri.equals(notificationUri)) { + // No need to update anything more if this is for playing a notification + return; + } + + nowPlayingUri = uri; + updateState(URI, StringType.valueOf(uri)); + + logger.trace("Renderer {} received URI: {}", thing.getLabel(), uri); + logger.trace("Renderer {} current URI: {}, equal to received URI {}", thing.getLabel(), currentUri, + uri.equals(currentUri)); + logger.trace("Renderer {} next URI: {}", thing.getLabel(), nextUri); + + if (!uri.equals(currentUri)) { + if ((next != null) && uri.equals(nextUri)) { + // Renderer advanced to next entry independent of openHAB UPnP control point. + // Advance in the queue to keep proper position status. + // Make the next entry available to renderers that support it. + logger.trace("Renderer {} moved from '{}' to next entry '{}' in queue", thing.getLabel(), current, + next); + currentEntry = currentQueue.next(); + nextEntry = currentQueue.get(currentQueue.nextIndex()); + logger.trace("Renderer {} auto move forward, current queue index: {}", thing.getLabel(), + currentQueue.index()); + + updateMetaDataState(next); + + // look one further to get next entry for next URI + next = nextEntry; + if ((next != null) && !onlyplayone) { + setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next)); + } + } else { + // A new entry is being served that does not match the next entry in the queue. This can be because a + // sound or stream is being played through an action, or another control point started a new entry. + // We should clear the metadata in this case and wait for new metadata to arrive. + clearMetaDataState(); + } + } + } + + private void onValueReceivedCurrentMetaData(@Nullable String value) { + if (playingNotification) { + // Don't update metadata when playing notification + return; + } + + if (value != null && !value.isEmpty()) { + List list = UpnpXMLParser.getEntriesFromXML(value); + if (!list.isEmpty()) { + updateMetaDataState(list.get(0)); + return; + } + } + clearMetaDataState(); + } + + private void onValueReceivedNextMetaData(@Nullable String value) { + if (value != null && !value.isEmpty() && !"NOT_IMPLEMENTED".equals(value)) { + List list = UpnpXMLParser.getEntriesFromXML(value); + if (!list.isEmpty()) { + nextEntry = list.get(0); + } + } + } + + private void onValueReceivedDuration(@Nullable String value) { + // track duration and track position have format H+:MM:SS[.F+] or H+:MM:SS[.F0/F1]. We are not + // interested in the fractional seconds, so drop everything after . and calculate in seconds. + if (value == null || "NOT_IMPLEMENTED".equals(value)) { + trackDuration = 0; + updateState(TRACK_DURATION, UnDefType.UNDEF); + updateState(REL_TRACK_POSITION, UnDefType.UNDEF); + } else { + try { + trackDuration = Arrays.stream(value.split("\\.")[0].split(":")).mapToInt(n -> Integer.parseInt(n)) + .reduce(0, (n, m) -> n * 60 + m); + updateState(TRACK_DURATION, new QuantityType<>(trackDuration, SmartHomeUnits.SECOND)); + } catch (NumberFormatException e) { + logger.debug("Illegal format for track duration {}", value); + return; + } + } + setExpectedTrackend(); + } + + private void onValueReceivedRelTime(@Nullable String value) { + if (value == null || "NOT_IMPLEMENTED".equals(value)) { + trackPosition = 0; + updateState(TRACK_POSITION, UnDefType.UNDEF); + updateState(REL_TRACK_POSITION, UnDefType.UNDEF); + } else { + try { + trackPosition = Arrays.stream(value.split("\\.")[0].split(":")).mapToInt(n -> Integer.parseInt(n)) + .reduce(0, (n, m) -> n * 60 + m); + updateState(TRACK_POSITION, new QuantityType<>(trackPosition, SmartHomeUnits.SECOND)); + int relPosition = (trackDuration != 0) ? trackPosition * 100 / trackDuration : 0; + updateState(REL_TRACK_POSITION, new PercentType(relPosition)); + } catch (NumberFormatException e) { + logger.trace("Illegal format for track position {}", value); + return; + } + } + + if (playingNotification) { + posAtNotificationStart = trackPosition; + } + + setExpectedTrackend(); + } + + @Override + protected void updateProtocolInfo(String value) { sink.clear(); supportedAudioFormats.clear(); audioSupport = false; @@ -686,37 +1397,19 @@ public class UpnpRendererHandler extends UpnpHandler { } if (audioSupport) { - logger.debug("Device {} supports audio", thing.getLabel()); + logger.debug("Renderer {} supports audio", thing.getLabel()); registerAudioSink(); } } - private void registerAudioSink() { - if (audioSinkRegistered) { - logger.debug("Audio Sink already registered for renderer {}", thing.getLabel()); - return; - } else if (!service.isRegistered(this)) { - logger.debug("Audio Sink registration for renderer {} failed, no service", thing.getLabel()); - return; - } - logger.debug("Registering Audio Sink for renderer {}", thing.getLabel()); - audioSinkReg.registerAudioSink(this); - audioSinkRegistered = true; - } - private void clearCurrentEntry() { - updateState(TITLE, UnDefType.UNDEF); - updateState(ALBUM, UnDefType.UNDEF); - updateState(ALBUM_ART, UnDefType.UNDEF); - updateState(CREATOR, UnDefType.UNDEF); - updateState(ARTIST, UnDefType.UNDEF); - updateState(PUBLISHER, UnDefType.UNDEF); - updateState(GENRE, UnDefType.UNDEF); - updateState(TRACK_NUMBER, UnDefType.UNDEF); + clearMetaDataState(); + trackDuration = 0; updateState(TRACK_DURATION, UnDefType.UNDEF); trackPosition = 0; updateState(TRACK_POSITION, UnDefType.UNDEF); + updateState(REL_TRACK_POSITION, UnDefType.UNDEF); currentEntry = null; } @@ -728,26 +1421,28 @@ public class UpnpRendererHandler extends UpnpHandler { * * @param queue */ - public void registerQueue(ArrayList queue) { + protected void registerQueue(UpnpEntryQueue queue) { + if (currentQueue.equals(queue)) { + // We get the same queue, so do nothing + return; + } + logger.debug("Registering queue on renderer {}", thing.getLabel()); + + registeredQueue = true; currentQueue = queue; - queueIterator = new UpnpIterator<>(currentQueue.listIterator()); - if (playing) { - if (queueIterator.hasNext()) { + currentQueue.setRepeat(repeat); + currentQueue.setShuffle(shuffle); + if (playingQueue) { + nextEntry = currentQueue.get(currentQueue.nextIndex()); + UpnpEntry next = nextEntry; + if ((next != null) && !onlyplayone) { // make the next entry available to renderers that support it - logger.trace("Still playing, set new queue as next entry"); - UpnpEntry next = currentQueue.get(queueIterator.nextIndex()); + logger.trace("Renderer {} still playing, set new queue as next entry", thing.getLabel()); setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next)); } } else { - if (queueIterator.hasNext()) { - UpnpEntry entry = queueIterator.next(); - updateMetaDataState(entry); - setCurrentURI(entry.getRes(), UpnpXMLParser.compileMetadataString(entry)); - currentEntry = entry; - } else { - clearCurrentEntry(); - } + resetToStartQueue(); } } @@ -755,23 +1450,16 @@ public class UpnpRendererHandler extends UpnpHandler { * Move to next position in queue and start playing. */ private void serveNext() { - if (queueIterator.hasNext()) { - currentEntry = queueIterator.next(); + if (currentQueue.hasNext()) { + currentEntry = currentQueue.next(); + nextEntry = currentQueue.get(currentQueue.nextIndex()); logger.debug("Serve next media '{}' from queue on renderer {}", currentEntry, thing.getLabel()); + logger.trace("Serve next, current queue index: {}", currentQueue.index()); + serve(); } else { logger.debug("Cannot serve next, end of queue on renderer {}", thing.getLabel()); - cancelTrackPositionRefresh(); - stop(); - queueIterator = new UpnpIterator<>(currentQueue.listIterator()); // reset to beginning of queue - if (currentQueue.isEmpty()) { - clearCurrentEntry(); - } else { - updateMetaDataState(currentQueue.get(queueIterator.nextIndex())); - UpnpEntry entry = queueIterator.next(); - setCurrentURI(entry.getRes(), UpnpXMLParser.compileMetadataString(entry)); - currentEntry = entry; - } + resetToStartQueue(); } } @@ -779,62 +1467,132 @@ public class UpnpRendererHandler extends UpnpHandler { * Move to previous position in queue and start playing. */ private void servePrevious() { - if (queueIterator.hasPrevious()) { - currentEntry = queueIterator.previous(); + if (currentQueue.hasPrevious()) { + currentEntry = currentQueue.previous(); + nextEntry = currentQueue.get(currentQueue.nextIndex()); logger.debug("Serve previous media '{}' from queue on renderer {}", currentEntry, thing.getLabel()); + logger.trace("Serve previous, current queue index: {}", currentQueue.index()); + serve(); } else { logger.debug("Cannot serve previous, already at start of queue on renderer {}", thing.getLabel()); - cancelTrackPositionRefresh(); - stop(); - queueIterator = new UpnpIterator<>(currentQueue.listIterator()); // reset to beginning of queue - if (currentQueue.isEmpty()) { - clearCurrentEntry(); - } else { - updateMetaDataState(currentQueue.get(queueIterator.nextIndex())); - UpnpEntry entry = queueIterator.next(); - setCurrentURI(entry.getRes(), UpnpXMLParser.compileMetadataString(entry)); - currentEntry = entry; + resetToStartQueue(); + } + } + + private void resetToStartQueue() { + logger.trace("Reset to start queue on renderer {}", thing.getLabel()); + + playingQueue = false; + registeredQueue = true; + + stop(); + + currentQueue.resetIndex(); // reset to beginning of queue + currentEntry = currentQueue.next(); + nextEntry = currentQueue.get(currentQueue.nextIndex()); + logger.trace("Reset queue, current queue index: {}", currentQueue.index()); + UpnpEntry current = currentEntry; + if (current != null) { + clearMetaDataState(); + updateMetaDataState(current); + setCurrentURI(current.getRes(), UpnpXMLParser.compileMetadataString(current)); + } else { + clearCurrentEntry(); + } + + UpnpEntry next = nextEntry; + if (onlyplayone) { + setNextURI("", ""); + } else if (next != null) { + setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next)); + } + } + + /** + * Serve media from a queue and play immediately when already playing. + * + * @param media + */ + private void serve() { + logger.trace("Serve media on renderer {}", thing.getLabel()); + + UpnpEntry entry = currentEntry; + if (entry != null) { + clearMetaDataState(); + String res = entry.getRes(); + if (res.isEmpty()) { + logger.debug("Renderer {} cannot serve media '{}', no URI", thing.getLabel(), currentEntry); + playingQueue = false; + return; + } + updateMetaDataState(entry); + setCurrentURI(res, UpnpXMLParser.compileMetadataString(entry)); + + if ((playingQueue || playing) && !(onlyplayone && oneplayed)) { + logger.trace("Ready to play '{}' from queue", currentEntry); + + trackDuration = 0; + trackPosition = 0; + expectedTrackend = 0; + play(); + + oneplayed = true; + playingQueue = true; + } + + // make the next entry available to renderers that support it + if (!onlyplayone) { + UpnpEntry next = nextEntry; + if (next != null) { + setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next)); + } } } } /** - * Play media. - * - * @param media + * Called before handling a pause CONTROL command. If we do not received PAUSED_PLAYBACK or STOPPED back within + * timeout, we will revert to playing state. This takes care of renderers that cannot pause playback. */ - private void serve() { - UpnpEntry entry = currentEntry; - if (entry != null) { - logger.trace("Ready to play '{}' from queue", currentEntry); - updateMetaDataState(entry); - String res = entry.getRes(); - if (res.isEmpty()) { - logger.debug("Cannot serve media '{}', no URI", currentEntry); - return; - } - setCurrentURI(res, UpnpXMLParser.compileMetadataString(entry)); - play(); + private void checkPaused() { + paused = upnpScheduler.schedule(this::resetPaused, config.responseTimeout, TimeUnit.MILLISECONDS); + } - // make the next entry available to renderers that support it - if (queueIterator.hasNext()) { - UpnpEntry next = currentQueue.get(queueIterator.nextIndex()); - setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next)); - } + private void resetPaused() { + updateState(CONTROL, PlayPauseType.PLAY); + } + + private void cancelCheckPaused() { + ScheduledFuture future = paused; + if (future != null) { + future.cancel(true); + paused = null; } } + private void setExpectedTrackend() { + expectedTrackend = Instant.now().toEpochMilli() + (trackDuration - trackPosition) * 1000 + - config.responseTimeout; + } + /** * Update the current track position every second if the channel is linked. */ private void scheduleTrackPositionRefresh() { - cancelTrackPositionRefresh(); - if (!isLinked(TRACK_POSITION)) { + if (playingNotification) { return; } - if (trackPositionRefresh == null) { - trackPositionRefresh = scheduler.scheduleWithFixedDelay(this::getPositionInfo, 1, 1, TimeUnit.SECONDS); + + cancelTrackPositionRefresh(); + if (!(isLinked(TRACK_POSITION) || isLinked(REL_TRACK_POSITION))) { + // only get it once, so we can use the track end to correctly identify STOP pressed directly on renderer + getPositionInfo(); + } else { + if (trackPositionRefresh == null) { + trackPositionRefresh = upnpScheduler.scheduleWithFixedDelay(this::getPositionInfo, 1, 1, + TimeUnit.SECONDS); + } } } @@ -848,6 +1606,8 @@ public class UpnpRendererHandler extends UpnpHandler { trackPosition = 0; updateState(TRACK_POSITION, new QuantityType<>(trackPosition, SmartHomeUnits.SECOND)); + int relPosition = (trackDuration != 0) ? trackPosition / trackDuration : 0; + updateState(REL_TRACK_POSITION, new PercentType(relPosition)); } /** @@ -856,20 +1616,40 @@ public class UpnpRendererHandler extends UpnpHandler { * @param media */ private void updateMetaDataState(UpnpEntry media) { - // The AVTransport passes the URI resource in the ID. - // We don't want to update metadata if the metadata from the AVTransport is empty for the current entry. - boolean isCurrent; - UpnpEntry entry = currentEntry; - if (entry == null) { - entry = new UpnpEntry(media.getId(), media.getId(), "", "object.item"); - currentEntry = entry; - isCurrent = false; - } else { - isCurrent = media.getId().equals(entry.getRes()); + // We don't want to update metadata if the metadata from the AVTransport is less complete than in the current + // entry. + boolean isCurrent = false; + UpnpEntry entry = null; + if (playingQueue) { + entry = currentEntry; } - logger.trace("Media ID: {}", media.getId()); - logger.trace("Current queue res: {}", entry.getRes()); - logger.trace("Updating current entry: {}", isCurrent); + + logger.trace("Renderer {}, received media ID: {}", thing.getLabel(), media.getId()); + + if ((entry != null) && entry.getId().equals(media.getId())) { + logger.trace("Current ID: {}", entry.getId()); + + isCurrent = true; + } else { + // Sometimes we receive the media URL without the ID, then compare on URL + String mediaRes = media.getRes().trim(); + String entryRes = (entry != null) ? entry.getRes().trim() : ""; + + try { + String mediaUrl = URLDecoder.decode(mediaRes, StandardCharsets.UTF_8.name()); + String entryUrl = URLDecoder.decode(entryRes, StandardCharsets.UTF_8.name()); + isCurrent = mediaUrl.equals(entryUrl); + } catch (UnsupportedEncodingException e) { + logger.debug("Renderer {} unsupported encoding for new {} or current {} res URL, trying string compare", + thing.getLabel(), mediaRes, entryRes); + isCurrent = mediaRes.equals(entryRes); + } + + logger.trace("Current queue res: {}", entryRes); + logger.trace("Updated media res: {}", mediaRes); + } + + logger.trace("Received meta data is for current entry: {}", isCurrent); if (!(isCurrent && media.getTitle().isEmpty())) { updateState(TITLE, StringType.valueOf(media.getTitle())); @@ -912,6 +1692,17 @@ public class UpnpRendererHandler extends UpnpHandler { } } + private void clearMetaDataState() { + updateState(TITLE, UnDefType.UNDEF); + updateState(ALBUM, UnDefType.UNDEF); + updateState(ALBUM_ART, UnDefType.UNDEF); + updateState(CREATOR, UnDefType.UNDEF); + updateState(ARTIST, UnDefType.UNDEF); + updateState(PUBLISHER, UnDefType.UNDEF); + updateState(GENRE, UnDefType.UNDEF); + updateState(TRACK_NUMBER, UnDefType.UNDEF); + } + /** * @return Audio formats supported by the renderer. */ @@ -919,6 +1710,19 @@ public class UpnpRendererHandler extends UpnpHandler { return supportedAudioFormats; } + private void registerAudioSink() { + if (audioSinkRegistered) { + logger.debug("Audio Sink already registered for renderer {}", thing.getLabel()); + return; + } else if (!upnpIOService.isRegistered(this)) { + logger.debug("Audio Sink registration for renderer {} failed, no service", thing.getLabel()); + return; + } + logger.debug("Registering Audio Sink for renderer {}", thing.getLabel()); + audioSinkReg.registerAudioSink(this); + audioSinkRegistered = true; + } + /** * @return UPnP sink definitions supported by the renderer. */ diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpServerHandler.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpServerHandler.java index 1b5715a4b..11a78f77a 100644 --- a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpServerHandler.java +++ b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpServerHandler.java @@ -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 upnpRenderers; + // UPnP constants + static final String CONTENT_DIRECTORY = "ContentDirectory"; + static final String DIRECTORY_ROOT = "0"; + static final String UP = ".."; + + ConcurrentMap upnpRenderers; private volatile @Nullable UpnpRendererHandler currentRendererHandler; private volatile List rendererStateOptionList = Collections.synchronizedList(new ArrayList<>()); + private volatile List 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 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 entries = Collections.synchronizedList(new ArrayList<>()); // current entry list in - // selection - private volatile Map 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 entries = Collections.synchronizedList(new ArrayList<>()); + // store parents in hierarchy separately to be able to move up in directory structure + private ConcurrentMap parentMap = new ConcurrentHashMap<>(); - private UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider; - private UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider; + private volatile String playlistName = ""; protected @NonNullByDefault({}) UpnpControlServerConfiguration config; public UpnpServerHandler(Thing thing, UpnpIOService upnpIOService, ConcurrentMap 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 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 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(); - browse(currentEntry.getId(), "BrowseDirectChildren", "*", "0", "0", config.sortcriteria); + Map 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 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(); + + Map 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 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 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 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 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 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 titleList) { // Optionally, filter only items that can be played on the renderer logger.debug("Filtering content on server {}: {}", thing.getLabel(), config.filter); List resultList = config.filter ? filterEntries(titleList, true) : titleList; - List commandOptionList = new ArrayList<>(); + List 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 filterEntries(List resultList, boolean includeContainers) { - logger.debug("Raw result list {}", resultList); - List list = new ArrayList<>(); + logger.debug("Server {}, raw result list {}", thing.getLabel(), resultList); + UpnpRendererHandler handler = currentRendererHandler; - if (handler != null) { - List 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 sink = (handler != null) ? handler.getSink() : null; + List 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 stateOptionList) { - StateDescription stateDescription = StateDescriptionFragmentBuilder.create().withReadOnly(false) - .withOptions(stateOptionList).build().toStateDescription(); - upnpStateDescriptionProvider.setDescription(channelUID, stateDescription); - } - - private void updateCommandDescription(ChannelUID channelUID, List 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 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 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()); - } + 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 browsing = isBrowsing; + if (!((value == null) || (value.isEmpty()))) { + List 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()); + } + 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 removeDuplicates(List list) { List newList = new ArrayList<>(); Set refIdSet = new HashSet<>(); - final Set 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 mediaQueue = new ArrayList<>(); + List 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()); diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpEntry.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpEntry.java similarity index 97% rename from bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpEntry.java rename to bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpEntry.java index a33102b1f..1884a02cf 100644 --- a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpEntry.java +++ b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpEntry.java @@ -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; diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpEntryQueue.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpEntryQueue.java new file mode 100644 index 000000000..98320566d --- /dev/null +++ b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpEntryQueue.java @@ -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> masterList; + + Playlist(String name, Map> masterList) { + this.name = name; + this.masterList = masterList; + } + } + + private volatile Playlist playlist; + + private volatile List currentQueue; + private volatile List shuffledQueue = Collections.emptyList(); + + private final Gson gson = new Gson(); + + public UpnpEntryQueue() { + this(Collections.emptyList()); + } + + /** + * @param queue + */ + public UpnpEntryQueue(List 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 queue, @Nullable String udn) { + String serverUdn = (udn != null) ? udn : ""; + Map> masterList = Collections.synchronizedMap(new HashMap<>()); + List 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(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>> 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 getEntryList() { + return currentQueue; + } +} diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpEntryRes.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpEntryRes.java similarity index 78% rename from bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpEntryRes.java rename to bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpEntryRes.java index 7c0944a79..845fc6e04 100644 --- a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpEntryRes.java +++ b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpEntryRes.java @@ -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() { diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpFavorite.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpFavorite.java new file mode 100644 index 000000000..96e5961f7 --- /dev/null +++ b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpFavorite.java @@ -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()); + } + } +} diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpPlaylistsListener.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpPlaylistsListener.java new file mode 100644 index 000000000..705ea6a2a --- /dev/null +++ b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpPlaylistsListener.java @@ -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(); +} diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/services/UpnpRenderingControlConfiguration.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/services/UpnpRenderingControlConfiguration.java new file mode 100644 index 000000000..55e5b26da --- /dev/null +++ b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/services/UpnpRenderingControlConfiguration.java @@ -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 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(Arrays + .asList(rcService.getStateVariable("A_ARG_TYPE_Channel").getTypeDetails().getAllowedValues())); + } + } + } +} diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/util/UpnpControlUtil.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/util/UpnpControlUtil.java new file mode 100644 index 000000000..d49a7008a --- /dev/null +++ b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/util/UpnpControlUtil.java @@ -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 playlistList = new ArrayList<>(); + private static final Set 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 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 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 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 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(); + } +} diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpProtocolMatcher.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/util/UpnpProtocolMatcher.java similarity index 98% rename from bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpProtocolMatcher.java rename to bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/util/UpnpProtocolMatcher.java index 11f4fe844..262297599 100644 --- a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpProtocolMatcher.java +++ b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/util/UpnpProtocolMatcher.java @@ -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; diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpXMLParser.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/util/UpnpXMLParser.java similarity index 78% rename from bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpXMLParser.java rename to bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/util/UpnpXMLParser.java index f98318029..2691c1daa 100644 --- a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpXMLParser.java +++ b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/util/UpnpXMLParser.java @@ -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( - "" - + "" + "{2}" - + "{3}" + "{4}" - + "{5}" + "{6}" - + "{7}" + "{8}" - + "{9}" + "{10}" - + ""); + private static final String METADATA_PATTERN = "" + + "{2}" + + "{3}{4}" + + "{5}{6}" + + "{7}{8}" + + "{9}{10}" + + ""; private enum Element { TITLE, @@ -69,6 +70,62 @@ public class UpnpXMLParser { RES } + public static Map 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 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 getChanges() { + return changes; + } + } + public static Map 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 changes = new HashMap(); + + 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 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 getChanges() { + return changes; + } + } + public static List 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 changes = new HashMap(); - - 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 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 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; diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/resources/OH-INF/binding/binding.xml b/bundles/org.openhab.binding.upnpcontrol/src/main/resources/OH-INF/binding/binding.xml index 0e0ff5042..dba295071 100644 --- a/bundles/org.openhab.binding.upnpcontrol/src/main/resources/OH-INF/binding/binding.xml +++ b/bundles/org.openhab.binding.upnpcontrol/src/main/resources/OH-INF/binding/binding.xml @@ -6,5 +6,11 @@ UPnP Control Binding This binding acts as a UPnP Control Point that can query media server content directories and serve content to media renderers. - + + + + 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. + + diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.upnpcontrol/src/main/resources/OH-INF/thing/thing-types.xml index 821d6f642..85af5763d 100644 --- a/bundles/org.openhab.binding.upnpcontrol/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.upnpcontrol/src/main/resources/OH-INF/thing/thing-types.xml @@ -11,8 +11,21 @@ + + + + + + + + + + + + + @@ -23,52 +36,163 @@ + + udn The UDN identifies the UPnP Renderer + + + Specifies the refresh interval in seconds + 60 + + + + Specifies the percentage adjustment to the current sound volume when playing notifications + 10 + + + + Specifies the maximum duration for notifications, longer notification sounds will be interrupted. O + represents no maximum duration + 15 + + + + Step in seconds for fast forward rewind + 5 + + + + Specifies the timeout in milliseconds when waiting for responses on UPnP actions + 2500 + true + - + UPnP AV Server - + + + + + + + + + + + + udn The UDN identifies the UPnP Media Server - + + + Specifies the refresh interval in seconds + 60 + + Only list content which is playable on the selected renderer false - false - + 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 +dc:title + + + 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 + true + + + + Always search from the root directory + false + + + + Specifies the timeout in milliseconds when waiting for responses on UPnP actions + 2500 + true + + + Switch + + Loudness + SoundVolume + Switch Stop the player veto + + Switch + + Repeat the selection + + + Switch + + Random shuffle the selection + + + Switch + + Stop playback after playing one media entry from queue + + + String + + Now playing URI + + + String + + Select favorite to play + veto + + + String + + Favorite name + + + String + + Favorite action + + + + + + + veto + String @@ -115,7 +239,13 @@ Number:Time Now playing track position - + + + + Dimmer + + Track position as percentage of track duration + MediaControl @@ -123,15 +253,10 @@ Select AV renderer - - String - - Current id of media entry or container - String - - Browse selection for playing + + Current id of media entry or container, option list to browse hierarchy String @@ -140,4 +265,29 @@ Examples: dc:title contains "song", dc:creator contains "SpringSteen", unp:class = "object.item.audioItem", upnp:album contains "Born in" + + String + + Playlist for selection + veto + + + String + + Playlist name + + + String + + Playlist action + + + + + + + + + veto + diff --git a/bundles/org.openhab.binding.upnpcontrol/src/test/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpHandlerTest.java b/bundles/org.openhab.binding.upnpcontrol/src/test/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpHandlerTest.java new file mode 100644 index 000000000..81b432fee --- /dev/null +++ b/bundles/org.openhab.binding.upnpcontrol/src/test/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpHandlerTest.java @@ -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 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("-----------------------------------------------------------------------------------"); + } +} diff --git a/bundles/org.openhab.binding.upnpcontrol/src/test/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpRendererHandlerTest.java b/bundles/org.openhab.binding.upnpcontrol/src/test/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpRendererHandlerTest.java new file mode 100644 index 000000000..4ea37355c --- /dev/null +++ b/bundles/org.openhab.binding.upnpcontrol/src/test/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpRendererHandlerTest.java @@ -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 = "" + + ""; + private static final String LAST_CHANGE_FOOTER = ""; + private static final String AV_TRANSPORT_URI = ""; + + 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 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 createUpnpEntries() { + List entries = new ArrayList<>(); + UpnpEntry entry; + List 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 startList = new ArrayList(); + 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> 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 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 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 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 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 uriCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor 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 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())); + } +} diff --git a/bundles/org.openhab.binding.upnpcontrol/src/test/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpServerHandlerTest.java b/bundles/org.openhab.binding.upnpcontrol/src/test/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpServerHandlerTest.java new file mode 100644 index 000000000..ef7a3f1aa --- /dev/null +++ b/bundles/org.openhab.binding.upnpcontrol/src/test/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpServerHandlerTest.java @@ -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 = ""; + private static final String RESPONSE_FOOTER = ""; + + private static final String BASE_CONTAINER = RESPONSE_HEADER + + "" + + "All Audio Itemsobject.container" + + "UNKNOWN" + + "" + + "All Image Itemsobject.container" + + "UNKNOWN" + RESPONSE_FOOTER; + + private static final String SINGLE_CONTAINER = RESPONSE_HEADER + + "" + + "Morning Musicobject.container" + + "UNKNOWN" + RESPONSE_FOOTER; + + private static final String DOUBLE_CONTAINER = RESPONSE_HEADER + + "" + + "Morning Musicobject.container" + + "UNKNOWN" + + "" + + "Evening Musicobject.container" + + "UNKNOWN" + RESPONSE_FOOTER; + + private static final String DOUBLE_MEDIA = RESPONSE_HEADER + "" + + "Music_01object.item.audioItem" + + "Creator_1" + + "http://MediaServerContent_0/1/M1/Test_1.mp3" + + "UNKNOWN" + + "" + + "Music_02object.item.audioItem" + + "Creator_2" + + "http://MediaServerContent_0/3/M2/Test_2.wav" + + "UNKNOWN" + RESPONSE_FOOTER; + + private static final String EXTRA_MEDIA = RESPONSE_HEADER + "" + + "Extra_01object.item.audioItem" + + "Creator_3" + + "http://MediaServerContent_0/1/M3/Test_3.mp3" + + "UNKNOWN" + 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 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 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 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> 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 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 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> 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 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 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> 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 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 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> 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 resultContainer = new HashMap<>(); + resultContainer.put("Result", SINGLE_CONTAINER); + Map 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 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> 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 resultContainer = new HashMap<>(); + resultContainer.put("Result", SINGLE_CONTAINER); + Map 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 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> 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 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 resultContainer = new HashMap<>(); + resultContainer.put("Result", SINGLE_CONTAINER); + Map 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 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> 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 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 resultContainer = new HashMap<>(); + resultContainer.put("Result", SINGLE_CONTAINER); + Map 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 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> 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 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 resultContainer = new HashMap<>(); + resultContainer.put("Result", SINGLE_CONTAINER); + Map 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 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> 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 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 resultContainer = new HashMap<>(); + resultContainer.put("Result", SINGLE_CONTAINER); + Map 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 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> 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 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 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 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> 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 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> 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 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")); + } +}