diff --git a/addons/binding/org.openhab.binding.russound/.classpath b/addons/binding/org.openhab.binding.russound/.classpath index 7f457fa4138d1..a95e0906ca013 100644 --- a/addons/binding/org.openhab.binding.russound/.classpath +++ b/addons/binding/org.openhab.binding.russound/.classpath @@ -1,6 +1,6 @@ - + diff --git a/addons/binding/org.openhab.binding.russound/ESH-INF/thing/thing-types.xml b/addons/binding/org.openhab.binding.russound/ESH-INF/thing/thing-types.xml index 7792958fae568..f2a460714cb49 100644 --- a/addons/binding/org.openhab.binding.russound/ESH-INF/thing/thing-types.xml +++ b/addons/binding/org.openhab.binding.russound/ESH-INF/thing/thing-types.xml @@ -9,8 +9,10 @@ Ethernet access point to Russound RIO control system (usually the main controller) - - + + + + @@ -31,6 +33,12 @@ 10 true + + + Scan device at startup (creating zones, sources, etc dynamically) + false + true + @@ -43,6 +51,10 @@ Controller of Zones, Sources, etc + + + + @@ -51,7 +63,7 @@ - + @@ -85,6 +97,14 @@ + + + + + + + + @@ -93,9 +113,9 @@ The zone identifier - + - + @@ -105,6 +125,7 @@ + @@ -112,7 +133,6 @@ - @@ -125,6 +145,19 @@ + + + + + + + + + + + + + @@ -134,116 +167,10 @@ - - - - - - - - - Bank of Presets for a specific Source (usually a tuner source) - - - - - - - - - The bank identifier - - - - - - - - - - - System Favorite - - - - - - - - - - The favorite identifier - - - - - - - - - - - Favorite for a Zone - - - - - - - - - - - The favorite identifier - - - - - - - - - - - Preset (usually frequency) within a Bank for a Source (usually a tuner) - - - - - - - - - - The preset identifier - - - - - - - - - Zone Preset Commands to save/restore/delete presets for the zone - - - - - - - - - - - The preset identifier (1-36 corresponds to banks 1-6, presets 1-6) - - - - - + String System Language @@ -255,12 +182,31 @@ - + Switch Toggles All Zones + + String + + JSON Array containing the valid controllers ([{id: 1, name: 'xxx'}, ...]) + + + + String + + JSON Array containing the sources ([{id: 1, name: 'xxx'}, ...]) + + + + String + + JSON Array containing the zones ([{id: 1, name: 'xxx'}, ...]) + + + String @@ -296,10 +242,9 @@ Loudness - Number + Dimmer The volume the zone will default to when turned on - String @@ -320,7 +265,6 @@ Dimmer Volume level of zone - Switch @@ -394,13 +338,49 @@ Send a generic event to the zone - + + + String + + JSON Array containing the system favorites ([{id: 1, valid: true/false, name: 'xxx'}, ...]) + + + String + + JSON Array containing the zone favorites ([{id: 1, valid: true/false, name: 'xxx'}, ...]) + + + String + + JSON Array containing the zone presets ([{id: 1, valid: true/false, name: 'xxx'}, ...]) + + + + + Switch + + Send MM back to home screen + + + + Switch + + Request a source context menu + + + String Source Name + + String + + Source Type + + String @@ -443,11 +423,6 @@ Current song's covert art url - - Image - - Cover Art Image - String @@ -521,86 +496,73 @@ - + String - - The name of the bank + + JSON Array containing the banks ([{id: 1, name: 'xxx', presets: [{id:1 ,valid:true/false, name='xxx'}, ...], ...]) + + + + String + + The MM Screen ID - + String - - The name of the favorite + + The MM Screen Title - - Switch - - If the favorite is valid or not - - + String - - The name of the zone favorite + + The MM Menu Item JSON - - - Switch - - If the zone favorite is valid or not - - + + + String - - Preset Command ("savesys", "restoresys", "deletesys", "savezone", "restorezone", "deletezone") - - - - - - - - - - - + + The MM Menu Info Attributes + + - + String - - The name of the bank preset + + The MM OK Button Text - - Switch - - If the bank preset is valid or not - - + String - - The name of the zone preset + + The MM Back Button Text - - Switch - - If the zone preset is valid or not + + + String + + The MM Info Text + - + + String - - Preset Command ("save", "restore", "delete") - - - - - - - + + The MM Help Text (label for form) + + + String + + The MM Text Field + + + diff --git a/addons/binding/org.openhab.binding.russound/META-INF/MANIFEST.MF b/addons/binding/org.openhab.binding.russound/META-INF/MANIFEST.MF index b527b69b323b5..763bf259da2d3 100644 --- a/addons/binding/org.openhab.binding.russound/META-INF/MANIFEST.MF +++ b/addons/binding/org.openhab.binding.russound/META-INF/MANIFEST.MF @@ -8,11 +8,17 @@ Bundle-RequiredExecutionEnvironment: JavaSE-1.8 Bundle-ClassPath: . Import-Package: com.google.common.collect, + com.google.gson, + com.google.gson.annotations, + com.google.gson.reflect, + com.google.gson.stream, org.apache.commons.lang, + org.apache.commons.net.util, org.eclipse.jetty.client, org.eclipse.jetty.client.api, org.eclipse.jetty.util.component, org.eclipse.smarthome.config.core, + org.eclipse.smarthome.config.discovery, org.eclipse.smarthome.core.library.types, org.eclipse.smarthome.core.thing, org.eclipse.smarthome.core.thing.binding, @@ -20,6 +26,7 @@ Import-Package: org.eclipse.smarthome.core.thing.type, org.eclipse.smarthome.core.types, org.openhab.binding.russound, + org.osgi.framework, org.slf4j Service-Component: OSGI-INF/*.xml Export-Package: org.openhab.binding.russound diff --git a/addons/binding/org.openhab.binding.russound/OSGI-INF/RussoundRioSystemDiscovery.xml b/addons/binding/org.openhab.binding.russound/OSGI-INF/RussoundRioSystemDiscovery.xml new file mode 100644 index 0000000000000..01fe709300278 --- /dev/null +++ b/addons/binding/org.openhab.binding.russound/OSGI-INF/RussoundRioSystemDiscovery.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + diff --git a/addons/binding/org.openhab.binding.russound/README.md b/addons/binding/org.openhab.binding.russound/README.md index 473df3c02bff0..742fada7d6ab3 100644 --- a/addons/binding/org.openhab.binding.russound/README.md +++ b/addons/binding/org.openhab.binding.russound/README.md @@ -1,19 +1,26 @@ # Russound Binding -This binding provides integration with any Russound system that support the RIO protocol (all MCA systems, all X systems). This binding provides compatibility with RIO Protocol v1.7 (everything but the Media Managment functionality). The protocol document can be found in the Russound Portal ("RIO Protocol for 3rd Party Integrators.pdf"). Please update to the latest firmware to provide full compatibility with this binding. This binding does provide full feedback from the Russound system if events occur outside of openHAB (such as keypad usage). +This binding provides integration with any Russound system that support the RIO protocol (all MCA systems, all X systems). This binding provides compatibility with RIO Protocol v1.10. The protocol document can be found in the Russound Portal ("RIO Protocol for 3rd Party Integrators.pdf"). Please update to the latest firmware to provide full compatibility with this binding. This binding does provide full feedback from the Russound system if events occur outside of openHAB (such as keypad usage). + +*Warning:* Russound becomes unstable if you have two IP based clients connected to the same system. Do NOT run multiple instances of this binding against the same system - this definitely causes unstability. Running this binding in addition to the MyRussound application seems to work fine however. + +*Warning:* Try to avoid having multiple media management functions open in different clients (keypads, My Russound app, HABPanel, etc). Although it seems to work a majority of the times, there have been instances where the sessions become confused. ## Supported Bridges/Things * Bridge: Russound System (usually the main controller) * Bridge: Russound Controller (1-6 controllers supported) -* Bridge: Russound Source (1-12 sources supported) -* Bridge: Russound Bank (1-6 banks supported for any tuner source) -* Thing: Russound Bank Preset (1-6 presets supported for each bank) -* Thing: Russound System Favorite (1-32 favorites supported) -* Bridge: Russound Zone (1-6 zones supported for each controller) -* Thing: Russound Zone Favorite (1-2 zone favorites for each zone) -* Thing: Russound Zone Presets (1-36 presets for each zone [corresponds to banks 1-6, presets 1-6 for each bank]) +* Thing: Russound Source (1-8 sources supported) +* Thing: Russound Zone (1-8 [depending on the controller] zones supported for each controller) +## Device Discovery + +The Russound binding does support devices discovery via the paperUI. When you start device discovery, the system will scan all network interfaces and **all IP Addresses in the subnet on each interface** looking for a Russound system device. If found, the device will be added to the inbox. Adding the device will then start a scan of the device to discover all the controllers, sources, and zones attached defined on the device. As these are found, they will be added to the inbox. + +## HABPANEL or other UI + +All media management functions are supported to allow building of a dynamic UI for the various streaming sources. All media management channels begin with "mm". An example HABPanel implementation can be found in the HABPanel forum. + ## Thing Configuration The following configurations occur for each of the bridges/things: @@ -25,12 +32,7 @@ The following configurations occur for each of the bridges/things: | ipAddress | string | IP Address or host name of the russound system (usually main controller) | | ping | int | Interval, in seconds, to ping the system to keep connection alive | | retryPolling | int | Interval, in seconds, to retry a failed connection attempt | - -### Russound System Favorite - -| Name | Type | Description | -|--------------|---------------|--------------------------------------------------------------------------| -| favorite | int | The favorite # (1-32) | +| scanDevice | boolean | Whether to scan device at startup and discover controllers/sources/zones | ### Russound Source @@ -38,18 +40,6 @@ The following configurations occur for each of the bridges/things: |--------------|---------------|--------------------------------------------------------------------------| | source | int | The source # (1-12) | -### Russound Bank - -| Name | Type | Description | -|--------------|---------------|--------------------------------------------------------------------------| -| bank | int | The bank # (1-6) | - -### Russound Bank Preset - -| Name | Type | Description | -|--------------|---------------|--------------------------------------------------------------------------| -| preset | int | The preset # (1-6) | - ### Russound Controller | Name | Type | Description | @@ -62,18 +52,6 @@ The following configurations occur for each of the bridges/things: |--------------|---------------|--------------------------------------------------------------------------| | zone | int | The zone # (1-6) | -### Russound Zone Favorite - -| Name | Type | Description | -|--------------|---------------|--------------------------------------------------------------------------| -| favorite | int | The zone favorite # (1-2) | - -### Russound Zone Preset Commands - -| Name | Type | Description | -|--------------|---------------|--------------------------------------------------------------------------| -| preset | int | The zone preset # (1-36 - corresponds to bank 1-6, preset 1-6) | - ## Channels @@ -83,82 +61,83 @@ The following channels are supported for each bridge/thing | Channel Type ID | Read/Write | Item Type | Description | |--------------------|------------|--------------|--------------------------------------------------------------------- | -| version | R | String | The firmware version of the system | -| status | R | Switch | Whether any controller/zone is on (or if all are off) | -| language | RW | String | System language (english, chinese and russian are supported) | +| lang | RW | String | System language (english, chinese and russian are supported) | +| allon | RW | Switch | Turn on/off all zones | +| controller | R | String | JSON representation of all controllers in the system | +| sources | R | String | JSON representation of all sources in the system | -### Russound System Favorite - -| Channel Type ID | Read/Write | Item Type | Description | -|--------------------|------------|--------------|--------------------------------------------------------------------- | -| name | R | String | The name of the system favorite (changed by zone favorites) | -| valid | R | Switch | If system favorite is valid or not (changed by zone favorites) | +#### Notes -### Russound Source (please see source cross-reference below for what is supported by which sources) +1. The JSON will look like: `[{"id":1, "name":"XXX"},...]`. The controller channel will contain up to 6 controllers and the sources will contain up to 8 sources (depending on how the system is configured). -| Channel Type ID | Read/Write | Item Type | Description | -|--------------------|------------|--------------|--------------------------------------------------------------------- | -| name | R | String | The name of the source | -| type | R | String | The type of source | -| ipaddress | R | String | The IP Address of the source | -| composername | R | String | The currently playing composer name | -| channel | R | String | The currently playing channel (usually tuner frequency) | -| channelname | R | String | The currently playing channel name | -| genre | R | String | The currently playing genre | -| artistname | R | String | The currently playing artist name | -| albumname | R | String | The currently playing album name | -| coverarturl | R | String | The currently playing URL to the cover art | -| coverart | R | Image | The currently playing cover art image | -| playlistname | R | String | The currently playing play list name | -| songname | R | String | The currently playing song name | -| mode | R | String | The provider mode or streaming service | -| shufflemode | R | String | The current shuffle mode | -| repeatmode | R | String | The current repeat mode | -| rating | R | String | The rating for the currently played song (can be changed via zone) | -| programservicename | R | String | The program service name (PSN) | -| radiotext | R | String | The radio text | -| radiotext2 | R | String | The radio text (line 2) | -| radiotext3 | R | String | The radio text (line 3) | -| radiotext4 | R | String | The radio text (line 4) | -| volume | R | String | The source's volume level (undocumented) | - -### Russound Bank +### Russound Source (please see source cross-reference below for what is supported by which sources) -| Channel Type ID | Read/Write | Item Type | Description | -|--------------------|------------|--------------|--------------------------------------------------------------------- | -| name | R | String | The name of the bank (changed by SCS-C5 software) | +| Channel Type ID | Read/Write | Item Type | Description | +|----------------------|------------|--------------|--------------------------------------------------------------------- | +| name | R | String | The name of the source | +| type | R | String | The type of source | +| channel | R | String | The currently playing channel (usually tuner frequency) | +| channelname | R | String | The currently playing channel name | +| composername | R | String | The currently playing composer name | +| genre | R | String | The currently playing genre | +| artistname | R | String | The currently playing artist name | +| albumname | R | String | The currently playing album name | +| coverarturl | R | String | The currently playing URL to the cover art | +| playlistname | R | String | The currently playing play list name | +| songname | R | String | The currently playing song name | +| rating | R | String | The rating for the currently played song (can be changed via zone) | +| mode | R | String | The provider mode or streaming service | +| shufflemode | R | String | The current shuffle mode | +| repeatmode | R | String | The current repeat mode | +| programservicename | R | String | The program service name (PSN) | +| radiotext | R | String | The radio text | +| radiotext2 | R | String | The radio text (line 2) | +| radiotext3 | R | String | The radio text (line 3) | +| radiotext4 | R | String | The radio text (line 4) | +| volume | R | String | The source's volume level (undocumented) | +| banks | RW | String | JSON representation of all banks in the system | +| mmscreen | R | String | The media management screen id | +| mmtitle | R | String | The media management screen title | +| mmmenu | R | String | The media management screen menu json | +| mmattr | R | String | The media management attribute | +| mmmenubuttonoktext | R | String | The media management OK button text | +| mmmenubuttonbacktext | R | String | The media management Cancel button text | +| mminfotext | R | String | The media management information text | +| mmhelptext | R | String | The media management help text | +| mmtextfield | R | String | The media management text field | + +#### Notes + +1. Banks are only supported tuner sources and the JSON array will have exactly 6 banks in it (with IDs from 1 to 6). For non-tuner sources, an empty JSON array (`[]`) will be returned. For tuner sources, the JSON will look like: `[{"id":1, "name":"XXX"},...]`. A bank's name can be updated by sending the representation back to the channel. Example: `[{"id":1,"name":"FM1"},{"id":3,"name":"FM3"}]` will set the name of bank #1 to "FM1 and bank#3 to "FM3" (leaving all other bank names the same). After an update, the banks channel will be refreshed with the full JSON representation of all banks. If the name has not been changed in the refreshed value, the russound rejected the name change for some reason (generally too long of a name or a duplicate name). + +2. All media management channels are ONLY valid on streaming sources (not tuners). All channels will return a JSON representation like `{"id":xxx, "value":"yyy"}` where 'xxx' will be a sequential identifier of the message and 'yyy' will be the payload. The payload will be a simple string in all cases. However, the mmmenu string will be a raw JSON string representing the menu structure. Please review the media management section in the RIO protocol document from russound for the specifications. -### Russound Preset +### Russound Controller -| Channel Type ID | Read/Write | Item Type | Description | -|--------------------|------------|--------------|--------------------------------------------------------------------- | -| name | R | String | The name of the Preset (changed by zone preset commands) | -| valid | R | Switch | If preset is valid or not (changed by zone preset commands) | +| Channel Type ID | Read/Write | Item Type | Description | +|-----------------|------------|--------------|--------------------------------------------------------------------- | +| zones | R | String | The JSON representation of all zones in the controller | -### Russound Controller +#### Notes -| Channel Type ID | Read/Write | Item Type | Description | -|--------------------|------------|--------------|--------------------------------------------------------------------- | -| type | R | String | The model type of the controller (i.e. "MCA-C5") | -| ipaddress | R | String | The IPAddress of the controller (only if it's the main controller) | -| macaddress | R | String | The MAC Address of the controller (only if it's the main controller) | +* The JSON will look like: `[{"id":1, "name":"XXX"},...]` ### Russound Zone | Channel Type ID | Read/Write | Item Type | Description | |--------------------|------------|--------------|--------------------------------------------------------------------- | -| status | RW | Switch | Whether the zone is on or off | | name | R | String | The name of the zone (changed by SCS-C5 software) | | source | RW | Number | The (physical) number for the current source | -| volume | RW | Number | The current volume of the zone (0 to 50) | -| mute | RW | Switch | Whether the zone is muted or not | | bass | RW | Number | The bass setting (-10 to 10) | | treble | RW | Number | The treble setting (-10 to 10) | | balance | RW | Number | The balance setting (-10 [full left] to 10 [full right]) | | loudness | RW | Switch | Set's the loudness on/off | -| turnonvolume | RW | Number | The initial volume when turned on (0 to 50) | +| turnonvolume | RW | Dimmer | The initial volume when turned on (0 to 100) | | donotdisturb | RW | String | The do not disturb setting (on/off/slave) | | partymode | RW | String | The party mode (on/off/master) | +| status | RW | Switch | Whether the zone is on or off | +| volume | RW | Dimmer | The current volume of the zone (0 to 100) | +| mute | RW | Switch | Whether the zone is muted or not | | page | R | Switch | Whether the zone is in paging mode or not | | sharedsource | R | Switch | Whether the zone's source is being shared or not | | sleeptimeremaining | RW | Number | Sleep time, in minutes, remaining (0 to 60 in 5 step increments) | @@ -172,47 +151,57 @@ The following channels are supported for each bridge/thing | keyhold | W | String | (Advanced) Send a keyhold from the zone | | keycode | W | String | (Advanced) Send a keycode from the zone | | event | W | String | (Advanced) Send an event from the zone | +| systemfavorites | RW | String* | The JSON representation for system favorites | +| zonefavorites | RW | String** | The JSON representation for zone favorites | +| presets | RW | String*** | The JSON representation for zone presets | +| mminit | W | Switch**** | Whether to initial a media management session (ON) or close an existing one (OFF) | +| mmcontextmenu | W | Switch**** | Whether to initial a media management context session (ON) or close an existing one (OFF) | + +#### Notes: -* As of the time of this document, rating ON (like) produced an error in the firmware from the related command. This has been reported to Russound. -* keypress/keyrelease/keyhold/keycode/event are advanced commands that will pass the related event string to Russound (i.e. "EVENT C[x].Z[y]!KeyPress [stringtype]"). Please see the "RIO Protocol for 3rd Party Integrators.pdf" (found at the Russound Portal) for proper string forms. -* If you send a OnOffType to the volume will have the same affect as turning the zone on/off (ie sending OnOffType to "status") -* The volume PercentType will be scaled to Russound's volume of 0-50 (ie 50% = volume of 25, 100% = volume of 50) +1. As of the time of this document, rating ON (like) produced an error in the firmware from the related command. This has been reported to Russound. +2. keypress/keyrelease/keyhold/keycode/event are advanced commands that will pass the related event string to Russound (i.e. "EVENT C[x].Z[y]!KeyPress [stringtype]"). Please see the "RIO Protocol for 3rd Party Integrators.pdf" (found at the Russound Portal) for proper string forms. +3. If you send a OnOffType to the volume will have the same affect as turning the zone on/off (ie sending OnOffType to "status") +4. The volume PercentType will be scaled to Russound's volume of 0-50 (ie 50% = volume of 25, 100% = volume of 50) +5. Initialize a media management session by sending ON to the channel. The related source thing will then start sending out media management information in the MM channels. To close the session - simply send OFF to the channel. Sending OFF to the channel when a session has not been initialized does nothing. Likewise if the related source is a tuner, this command does nothing. +##### System Favorites -### Russound Zone Favorite +The JSON will look like `[{"id":xxx,"valid":true,"name":"yyyy"},...]` and will have a representation for each VALID favorite on the system (ie where "valid" is true). You will have up to 32 system favorites in the JSON array (the ID field will be between 1 and 32). System favorites will be the same on ALL zones (because they are system level). This channel appears on the zone because when you send a system favorite representation to zone channel, it sets the system favorite to what is playing in the zone. -| Channel Type ID | Read/Write | Item Type | Description | -|--------------------|------------|--------------|----------------------------------------------------------------------------- | -| name | RW | String | The name of the zone favorite (only saved when the 'savexxx' cmd is issued) | -| valid | R | Switch | If favorite is valid or not ('on' when favorite is saved, 'off' when deleted | -| cmd | W | String | The favorite command (see note below) | +There are three different ways to use this channel: -The favorite command channel ("cmd") supports the following +1. Save a system favorite. Send a representation with "valid" set to true. Example: to set system favorite 3 to what is playing in the zone: `[{"id":3,"valid":true,"name":"80s Rock"}]`. If system favorite 3 was invalid, this would save what is currently playing and make it valid. If system favorite 3 was already valid, this would overlay the favorite with what is currently playing and change it's name. +2. Update the name of a system favorite. Send a representation of an existing ID with "valid" set to true and the new name. Example: we could update system favorite 3 (after the above statement) by sending: `[{"id":3,"valid":true,"name":"80s Rock Even More"}]`. Note this will ONLY change the name (this will NOT save what is currently playing to the system favorite). +3. Delete a system favorite. Send a representation with "valid" as false. Example: deleting system favorite 3 (after the above statements) by sending: `[{"id":3","valid":false"}]` -| Command Text | Description | -|--------------|-----------------------------------------------------| -| savesys | Save the associated zone as the a system favorite | -| restoresys | Restores the system favorite to the associated zone | -| deletesys | Deletes the system favorite | -| savezone | Save the associated zone as the a zone favorite | -| restorezone | Restores the zone favorite to the associated zone | -| deletezone | Deletes the zone favorite | +The channel will be refreshed with the new representation after processing. If the refreshed representation doesn't include the changes, the russound system rejected them for some reason (generally length of the name). -### Russound Zone Preset Commands +##### Zone Favorites -| Channel Type ID | Read/Write | Item Type | Description | -|--------------------|------------|--------------|-----------------------------------------------------------------------------------------| -| name | RW | String | The name of the preset (only saved when the 'save' preset cmd is issued) | -| valid | R | Switch | If favorite is valid or not ('on' when a preset is saved, 'off' when preset is deleted) | -| cmd | W | String | The preset command (see note below) | +The JSON will look like `[{"id":xxx,"valid":true,"name":"yyyy"},...]` and will have a representation for each VALID favorite in the zone (ie where "valid" is true). You will have up to 2 zone favorites in the JSON array (the ID field will be between 1 and 2). -The preset command channel ("cmd") supports the following +There are two different ways to use this channel: +1. Save a zone favorite. Send a representation with "valid" set to true. Example: to set zone favorite 2 to what is playing in the zone: `[{"id":2,"valid":true,"name":"80s Rock"}]`. +2. Delete a zone favorite. Send a representation with "valid" as false. Example: deleting zone favorite 2 (after the above statement) by sending: `[{"id":2","valid":false"}] ` -| Command Text | Description | -|--------------|--------------------------------------------| -| save | Save the associated zone as the preset | -| restore | Restores the preset to the associated zone | -| delete | Deletes the preset | +There is no ability to change JUST the name. Sending a new name will save the new name AND set the favorite to what is currently playing. + +The channel will be refreshed with the new representation after processing. If the refreshed representation doesn't include the changes, the russound system rejected them for some reason (generally length of the name). + +##### Zone Presets + +The JSON will look like `[{"id":xxx,"valid":true,"name":"yyyy", "bank": xxx, "bankPreset":yyyy},...]` and will have a representation for each VALID preset in the zone (ie where "valid" is true). Please note that this channel is only valid if the related source is a tuner. If not a tuner, an empty json array will be returned. You will have up to 36 presets to choose from (ID from 1 to 36). The "bank" and "bankPreset" are readonly (will be ignored if sent) and are informational only (i.e. specify the bank and the preset within the bank for convenience). + +There are two different ways to use this channel: + +1. Save a preset. Send a representation to an ID that is invalid with "valid" set to true. Example: to set a zone pret 2 to what is playing in the zone: `[{"id":2,"valid":true,"name":"103.7 FM"}]`. +2. Save a preset with default name. Send a representation to an ID that is invalid with "valid" set to true. Example: to set a zone pret 2 to what is playing in the zone: `[{"id":2,"valid":true,"name":"103.7 FM"}]`. +2. Delete a zone favorite. Send a representation with "valid" as false. Example: deleting zone favorite 2 (after the above statement) by sending: `[{"id":2","valid":false"}]` + +There is no ability to change JUST the name. Sending a new name will save the new name AND set the favorite to what is currently playing. + +The channel will be refreshed with the new representation after processing. If the refreshed representation doesn't include the changes, the russound system rejected them for some reason (generally length of the name). ### Source channel support cross reference @@ -247,49 +236,28 @@ The preset command channel ("cmd") supports the following The following is an example of 1. Main controller (#1) at ipaddress 192.168.1.24 -2. Two Sources connected to it (#1 is the internal AM/FM and #2 is a DMS 3.1) -3. Two System favorites (#1 FM 102.9, #2 Pandora on DMS) -4. One bank (called "FM-1") -5. Two presets within the bank (#1 FM 100.7, #2 FM 105.1) -6. Four zones on the controller (1-4 in various rooms) -7. Zone 1 has two favorites (#1 Spotify on DMS, #2 Airplay on DMS) -8. Zone 2 has two presets (#1 corresponds to bank 1/preset 1 [102.9], #2 corresponds to bank1/preset 2 [Pandora]) +2. One Sources connected to it (#1 is the internal AM/FM) +3. Four zones on the controller (1-4 in various rooms) .things ``` russound:rio:home [ ipAddress="192.168.1.24", ping=30, retryPolling=10 ] -russound:sysfavorite:1 (russound:rio:home) [ favorite=1 ] -russound:sysfavorite:2 (russound:rio:home) [ favorite=2 ] russound:controller:1 (russound:rio:home) [ controller=1 ] russound:source:1 (russound:rio:home) [ source=1 ] -russound:source:2 (russound:rio:home) [ source=2 ] -russound:bank:1 (russound:source:1) [ bank=1 ] -russound:bankpreset:1 (russound:bank:1) [ preset=1 ] -russound:bankpreset:2 (russound:bank:1) [ preset=2 ] russound:zone:1 (russound:controller:1) [ zone=1 ] russound:zone:2 (russound:controller:1) [ zone=2 ] russound:zone:3 (russound:controller:1) [ zone=3 ] russound:zone:4 (russound:controller:1) [ zone=4 ] -russound:zonefavorite:1 (russound:zone:1) [ favorite=1 ] -russound:zonefavorite:2 (russound:zone:1) [ favorite=2 ] -russound:zonepreset:1 (russound:zone:2) [ preset=1 ] -russound:zonepreset:2 (russound:zone:2) [ preset=2 ] ``` This is an example of all the items that can be included (regardless of the above setup) .items ``` -String Rio_Version "Version [%s]" { channel="russound:rio:home:version" } -String Rio_Lang "Language [%s]" { channel="russound:rio:home:lang" } Switch Rio_Status "Status [%s]" { channel="russound:rio:home:status" } Switch Rio_AllOn "All Zones" { channel="russound:rio:home:allon" } -String Rio_Ctl_Type "Model [%s]" { channel="russound:controller:1:type" } -String Rio_Ctl_IPAddress "IP Address [%s]" { channel="russound:controller:1:ipaddress" } -String Rio_Ctl_MacAddress "MAC [%s]" { channel="russound:controller:1:macaddress" } - String Rio_Zone_Name "Name [%s]" { channel="russound:zone:1:name" } Switch Rio_Zone_Status "Status" { channel="russound:zone:1:status" } Number Rio_Zone_Source "Source [%s]" { channel="russound:zone:1:source" } @@ -313,7 +281,6 @@ Switch Rio_Zone_Rating "Rating" { channel="russound:zone:1:rating", autoupdate=" String Rio_Src_Name "Name [%s]" { channel="russound:source:1:name" } String Rio_Src_Type "Type [%s]" { channel="russound:source:1:type" } -String Rio_Src_IP "IPAddress [%s]" { channel="russound:source:1:ipaddress" } String Rio_Src_Composer "Composer [%s]" { channel="russound:source:1:composername" } String Rio_Src_Channel "Channel [%s]" { channel="russound:source:1:channel" } String Rio_Src_ChannelName "Channel Name [%s]" { channel="russound:source:1:channelname" } @@ -333,27 +300,7 @@ String Rio_Src_RadioText2 "Radio Text #2 [%s]" { channel="russound:source:1:radi String Rio_Src_RadioText3 "Radio Text #3 [%s]" { channel="russound:source:1:radiotext3" } String Rio_Src_RadioText4 "Radio Text #4 [%s]" { channel="russound:source:1:radiotext4" } -String Rio_Sys_Favorite_Name "Name1 [%s]" { channel="russound:sysfavorite:1:name" } -Switch Rio_Sys_Favorite_Valid "Valid1 [%s]" { channel="russound:sysfavorite:1:valid" } -String Rio_Sys_Favorite_Name2 "Name2 [%s]" { channel="russound:sysfavorite:2:name" } -Switch Rio_Sys_Favorite_Valid2 "Valid2 [%s]" { channel="russound:sysfavorite:2:valid" } -String Rio_Zone_Favorite_Name "Name [%s]" { channel="russound:zonefavorite:1:name" } -Switch Rio_Zone_Favorite_Valid "Valid [%s]" { channel="russound:zonefavorite:1:valid", autoupdate="false" } -String Rio_Zone_Favorite_Cmd "Command" { channel="russound:zonefavorite:1:cmd" } -String Rio_Zone_Favorite_Name2 "Name2 [%s]" { channel="russound:zonefavorite:2:name" } -Switch Rio_Zone_Favorite_Valid2 "Valid2 [%s]" { channel="russound:zonefavorite:2:valid", autoupdate="false" } -String Rio_Zone_Favorite_Cmd2 "Command2" { channel="russound:zonefavorite:2:cmd" } - -String Rio_Src_Bank_Name "Name [%s]" { channel="russound:bank:1:name" } - -String Rio_Bank_Preset_Name "Name [%s]" { channel="russound:bankpreset:1:name" } -Switch Rio_Bank_Preset_Valid "Valid [%s]" { channel="russound:bankpreset:1:valid" } -String Rio_Bank_Preset_Name2 "Name2 [%s]" { channel="russound:bankpreset:2:name" } -Switch Rio_Bank_Preset_Valid2 "Valid2 [%s]" { channel="russound:bankpreset:2:valid" } - -String Rio_Zone_Preset_Cmd "Command" { channel="russound:zonepreset:1:cmd" } -String Rio_Zone_Preset_Cmd2 "Command2" { channel="russound:zonepreset:2:cmd" } ``` .sitemap @@ -361,38 +308,16 @@ String Rio_Zone_Preset_Cmd2 "Command2" { channel="russound:zonepreset:2:cmd" } ``` Frame label="Russound" { Text label="System" { - Text item=Rio_Version - Text item=Rio_Status Selection item=Rio_Lang mappings=[ENGLISH="English", RUSSIAN="Russian", CHINESE="Chinese"] Switch item=Rio_AllOn - Text label="Favorites" { - Text item=Rio_Sys_Favorite_Name - Text item=Rio_Sys_Favorite_Valid - Text item=Rio_Sys_Favorite_Name2 - Text item=Rio_Sys_Favorite_Valid2 - } } - Text label="Source 1" { - Text label="Bank 1" { - Text item=Rio_Src_Bank_Name - Text label="Presets" { - Text item=Rio_Bank_Preset_Name - Text item=Rio_Bank_Preset_Valid - Text item=Rio_Bank_Preset_Name2 - Text item=Rio_Bank_Preset_Valid2 - } - } - } - + Text label="Controller 1" { - Text item=Rio_Ctl_Type - Text item=Rio_Ctl_IPAddress - Text item=Rio_Ctl_MacAddress Text label="Zone 1" { Text item=Rio_Zone_Name Switch item=Rio_Zone_Status - Selection item=Rio_Zone_Source mappings=[1="Room1", 2="Room2", 3="Room3", 4="Room4"] + Selection item=Rio_Zone_Source mappings=[1="AM/FM", 2="Stream #1", 3="Stream #2", 4="Stream #3"] Setpoint item=Rio_Zone_Bass Setpoint item=Rio_Zone_Treble Setpoint item=Rio_Zone_Balance @@ -422,7 +347,6 @@ Frame label="Russound" { Text item= Rio_Src_ArtistName Text item= Rio_Src_AlbumName Text item= Rio_Src_Cover - Image item= Rio_Src_Cover Text item= Rio_Src_PlaylistName Text item= Rio_Src_SongName Text item= Rio_Src_Mode @@ -434,21 +358,7 @@ Frame label="Russound" { Text item= Rio_Src_RadioText2 Text item= Rio_Src_RadioText3 Text item= Rio_Src_RadioText4 - } - - Text label="Favorite" { - Text item=Rio_Zone_Favorite_Name - Text item=Rio_Zone_Favorite_Valid - Selection item=Rio_Zone_Favorite_Cmd mappings=[savezone="Save Zone", restorezone="Restore Zone", deletezone="Delete Zone", savesys="Save System", restoresys="Restore System", deletesys="Delete System"] - Text item=Rio_Zone_Favorite_Name2 - Text item=Rio_Zone_Favorite_Valid2 - Selection item=Rio_Zone_Favorite_Cmd2 mappings=[savezone="Save Zone", restorezone="Restore Zone", deletezone="Delete Zone", savesys="Save System", restoresys="Restore System", deletesys="Delete System"] - } - - Text label="Preset" { - Selection item=Rio_Zone_Preset_Cmd mappings=[save="Save", restore="Restore", delete="Delete"] - Selection item=Rio_Zone_Preset_Cmd2 mappings=[save="Save", restore="Restore", delete="Delete"] - } + } } } } diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/RussoundHandlerFactory.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/RussoundHandlerFactory.java index d0f05c113e3a9..aa39c5b3989fb 100644 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/RussoundHandlerFactory.java +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/RussoundHandlerFactory.java @@ -8,21 +8,23 @@ */ package org.openhab.binding.russound.internal; +import java.util.Hashtable; import java.util.Set; +import org.eclipse.smarthome.config.discovery.DiscoveryService; import org.eclipse.smarthome.core.thing.Bridge; import org.eclipse.smarthome.core.thing.Thing; import org.eclipse.smarthome.core.thing.ThingTypeUID; import org.eclipse.smarthome.core.thing.binding.BaseThingHandlerFactory; import org.eclipse.smarthome.core.thing.binding.ThingHandler; +import org.openhab.binding.russound.internal.discovery.RioSystemDeviceDiscoveryService; import org.openhab.binding.russound.internal.rio.RioConstants; -import org.openhab.binding.russound.internal.rio.bank.RioBankHandler; import org.openhab.binding.russound.internal.rio.controller.RioControllerHandler; -import org.openhab.binding.russound.internal.rio.favorites.RioFavoriteHandler; -import org.openhab.binding.russound.internal.rio.preset.RioPresetHandler; import org.openhab.binding.russound.internal.rio.source.RioSourceHandler; import org.openhab.binding.russound.internal.rio.system.RioSystemHandler; import org.openhab.binding.russound.internal.rio.zone.RioZoneHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import com.google.common.collect.ImmutableSet; @@ -33,11 +35,10 @@ * @author Tim Roberts */ public class RussoundHandlerFactory extends BaseThingHandlerFactory { + private final Logger logger = LoggerFactory.getLogger(RussoundHandlerFactory.class); - private static final Set SUPPORTED_THING_TYPES_UIDS = ImmutableSet.of(RioConstants.BRIDGE_TYPE_RIO, - RioConstants.BRIDGE_TYPE_CONTROLLER, RioConstants.BRIDGE_TYPE_SOURCE, RioConstants.BRIDGE_TYPE_ZONE, - RioConstants.BRIDGE_TYPE_BANK, RioConstants.THING_TYPE_BANK_PRESET, RioConstants.THING_TYPE_ZONE_PRESET, - RioConstants.THING_TYPE_SYSTEM_FAVORITE, RioConstants.THING_TYPE_ZONE_FAVORITE); + public static final Set SUPPORTED_THING_TYPES_UIDS = ImmutableSet.of(RioConstants.BRIDGE_TYPE_RIO, + RioConstants.BRIDGE_TYPE_CONTROLLER, RioConstants.THING_TYPE_SOURCE, RioConstants.THING_TYPE_ZONE); @Override public boolean supportsThingType(ThingTypeUID thingTypeUID) { @@ -50,25 +51,34 @@ protected ThingHandler createHandler(Thing thing) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); if (thingTypeUID.equals(RioConstants.BRIDGE_TYPE_RIO)) { - return new RioSystemHandler((Bridge) thing); + final RioSystemHandler sysHandler = new RioSystemHandler((Bridge) thing); + registerThingDiscovery(sysHandler); + return sysHandler; } else if (thingTypeUID.equals(RioConstants.BRIDGE_TYPE_CONTROLLER)) { return new RioControllerHandler((Bridge) thing); - } else if (thingTypeUID.equals(RioConstants.BRIDGE_TYPE_SOURCE)) { - return new RioSourceHandler((Bridge) thing); - } else if (thingTypeUID.equals(RioConstants.BRIDGE_TYPE_ZONE)) { - return new RioZoneHandler((Bridge) thing); - } else if (thingTypeUID.equals(RioConstants.BRIDGE_TYPE_BANK)) { - return new RioBankHandler((Bridge) thing); - } else if (thingTypeUID.equals(RioConstants.THING_TYPE_BANK_PRESET)) { - return new RioPresetHandler(thing); - } else if (thingTypeUID.equals(RioConstants.THING_TYPE_ZONE_PRESET)) { - return new RioPresetHandler(thing); - } else if (thingTypeUID.equals(RioConstants.THING_TYPE_SYSTEM_FAVORITE)) { - return new RioFavoriteHandler(thing); - } else if (thingTypeUID.equals(RioConstants.THING_TYPE_ZONE_FAVORITE)) { - return new RioFavoriteHandler(thing); + } else if (thingTypeUID.equals(RioConstants.THING_TYPE_SOURCE)) { + return new RioSourceHandler(thing); + } else if (thingTypeUID.equals(RioConstants.THING_TYPE_ZONE)) { + return new RioZoneHandler(thing); } return null; } + + /** + * Registers a {@link RioSystemDeviceDiscoveryService} from the passed {@link RioSystemHandler} and activates it. + * + * @param bridgeHandler the {@link RioSystemHandler} for discovery services + */ + private synchronized void registerThingDiscovery(RioSystemHandler bridgeHandler) { + RioSystemDeviceDiscoveryService discoveryService = new RioSystemDeviceDiscoveryService(bridgeHandler); + logger.trace("Try to register Discovery service on BundleID: {} Service: {}", + bundleContext.getBundle().getBundleId(), DiscoveryService.class.getName()); + + final Hashtable prop = new Hashtable(); + + bundleContext.registerService(DiscoveryService.class.getName(), discoveryService, prop); + discoveryService.activate(); + } + } diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/discovery/RioSystemDeviceDiscoveryService.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/discovery/RioSystemDeviceDiscoveryService.java new file mode 100644 index 0000000000000..9816b7089388b --- /dev/null +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/discovery/RioSystemDeviceDiscoveryService.java @@ -0,0 +1,261 @@ +/** + * Copyright (c) 2010-2017 by the respective copyright holders. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.openhab.binding.russound.internal.discovery; + +import java.io.IOException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.lang.StringUtils; +import org.eclipse.smarthome.config.discovery.AbstractDiscoveryService; +import org.eclipse.smarthome.config.discovery.DiscoveryResult; +import org.eclipse.smarthome.config.discovery.DiscoveryResultBuilder; +import org.eclipse.smarthome.config.discovery.DiscoveryService; +import org.eclipse.smarthome.core.thing.ThingUID; +import org.openhab.binding.russound.internal.RussoundHandlerFactory; +import org.openhab.binding.russound.internal.net.SocketChannelSession; +import org.openhab.binding.russound.internal.net.SocketSession; +import org.openhab.binding.russound.internal.net.WaitingSessionListener; +import org.openhab.binding.russound.internal.rio.RioConstants; +import org.openhab.binding.russound.internal.rio.controller.RioControllerConfig; +import org.openhab.binding.russound.internal.rio.source.RioSourceConfig; +import org.openhab.binding.russound.internal.rio.system.RioSystemHandler; +import org.openhab.binding.russound.internal.rio.zone.RioZoneConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This implementation of {@link DiscoveryService} will scan a RIO device for all controllers, source and zones attached + * to it. + * + * @author Tim Roberts + * + */ +public class RioSystemDeviceDiscoveryService extends AbstractDiscoveryService { + /** The logger */ + private final Logger logger = LoggerFactory.getLogger(RioSystemDeviceDiscoveryService.class); + + /** The system handler to scan */ + private final RioSystemHandler sysHandler; + + /** Pattern to identify controller notifications */ + private static final Pattern RSP_CONTROLLERNOTIFICATION = Pattern + .compile("(?i)^[SN] C\\[(\\d+)\\]\\.(\\w+)=\"(.*)\"$"); + + /** Pattern to identify source notifications */ + private static final Pattern RSP_SRCNOTIFICATION = Pattern.compile("(?i)^[SN] S\\[(\\d+)\\]\\.(\\w+)=\"(.*)\"$"); + + /** Pattern to identify zone notifications */ + private static final Pattern RSP_ZONENOTIFICATION = Pattern + .compile("(?i)^[SN] C\\[(\\d+)\\]\\.Z\\[(\\d+)\\]\\.(\\w+)=\"(.*)\"$"); + + /** + * The {@link SocketSession} that will be used to scan the device + */ + private SocketSession session; + + /** + * The {@link WaitingSessionListener} to the {@link #session} to receive/process responses + */ + private WaitingSessionListener listener; + + /** + * Create the discovery service from the {@link RioSystemHandler} + * + * @param sysHandler a non-null {@link RioSystemHandler} + * @throws IllegalArgumentException if sysHandler is null + */ + public RioSystemDeviceDiscoveryService(RioSystemHandler sysHandler) { + super(RussoundHandlerFactory.SUPPORTED_THING_TYPES_UIDS, 30, false); + + if (sysHandler == null) { + throw new IllegalArgumentException("sysHandler can't be null"); + } + this.sysHandler = sysHandler; + } + + /** + * Activates this discovery service. Simply registers this with + * {@link RioSystemHandler#registerDiscoveryService(RioSystemDeviceDiscoveryService)} + */ + public void activate() { + sysHandler.registerDiscoveryService(this); + } + + /** + * Deactivates the scan - will disconnect the session and remove the {@link #listener} + */ + @Override + public void deactivate() { + if (session != null) { + try { + session.disconnect(); + } catch (IOException e) { + // ignore + } + session.removeListener(listener); + session = null; + listener = null; + } + } + + /** + * Overridden to do nothing - {@link #scanDevice()} is called by {@link RioSystemHandler} instead + */ + @Override + protected void startScan() { + // do nothing - started by RioSystemHandler + } + + /** + * Starts a device scan. This will connect to the device and discover the controllers/sources/zones attached to the + * device and then disconnect via {@link #deactivate()} + */ + public void scanDevice() { + try { + final String ipAddress = sysHandler.getRioConfig().getIpAddress(); + session = new SocketChannelSession(ipAddress, RioConstants.RioPort); + listener = new WaitingSessionListener(); + session.addListener(listener); + + try { + logger.debug("Starting scan of RIO device at {}", ipAddress); + session.connect(); + discoverControllers(); + discoverSources(); + } catch (IOException e) { + logger.debug("Trying to scan device but couldn't connect: {}", e.getMessage(), e); + } + } finally { + deactivate(); + } + } + + /** + * Helper method to discover controllers - this will iterate through all possible controllers (6 of them )and see if + * any respond to the "type" command. If they do, we initiate a {@link #thingDiscovered(DiscoveryResult)} for the + * controller and then scan the controller for zones via {@link #discoverZones(ThingUID, int)} + */ + private void discoverControllers() { + for (int c = 1; c < 7; c++) { + final String type = sendAndGet("GET C[" + c + "].type", RSP_CONTROLLERNOTIFICATION, 3); + if (StringUtils.isNotEmpty(type)) { + logger.debug("Controller #{} found - {}", c, type); + + final ThingUID thingUID = new ThingUID(RioConstants.BRIDGE_TYPE_CONTROLLER, + sysHandler.getThing().getUID(), String.valueOf(c)); + + final DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUID) + .withProperty(RioControllerConfig.Controller, c).withBridge(sysHandler.getThing().getUID()) + .withLabel("Controller #" + c).build(); + thingDiscovered(discoveryResult); + + discoverZones(thingUID, c); + } + } + } + + /** + * Helper method to discover sources. This will iterate through all possible sources (8 of them) and see if they + * respond to the "type" command. If they do, we retrieve the source "name" and initial a + * {@link #thingDiscovered(DiscoveryResult)} for the source. + */ + private void discoverSources() { + for (int s = 1; s < 9; s++) { + final String type = sendAndGet("GET S[" + s + "].type", RSP_SRCNOTIFICATION, 3); + if (StringUtils.isNotEmpty(type)) { + final String name = sendAndGet("GET S[" + s + "].name", RSP_SRCNOTIFICATION, 3); + logger.debug("Source #{} - {}/{}", s, type, name); + + final ThingUID thingUID = new ThingUID(RioConstants.THING_TYPE_SOURCE, sysHandler.getThing().getUID(), + String.valueOf(s)); + + final DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUID) + .withProperty(RioSourceConfig.Source, s).withBridge(sysHandler.getThing().getUID()) + .withLabel((StringUtils.isEmpty(name) || name.equalsIgnoreCase("null") ? "Source" : name) + " (" + + s + ")") + .build(); + thingDiscovered(discoveryResult); + } + } + } + + /** + * Helper method to discover zones. This will iterate through all possible zones (8 of them) and see if they + * respond to the "name" command. If they do, initial a {@link #thingDiscovered(DiscoveryResult)} for the zone. + * + * @param controllerUID the {@link ThingUID} of the parent controller + * @param c the controller identifier + * @throws IllegalArgumentException if controllerUID is null + * @throws IllegalArgumentException if c is < 1 or > 8 + */ + private void discoverZones(ThingUID controllerUID, int c) { + if (controllerUID == null) { + throw new IllegalArgumentException("controllerUID cannot be null"); + } + if (c < 1 || c > 8) { + throw new IllegalArgumentException("c must be between 1 and 8"); + } + for (int z = 1; z < 9; z++) { + final String name = sendAndGet("GET C[" + c + "].Z[" + z + "].name", RSP_ZONENOTIFICATION, 4); + if (StringUtils.isNotEmpty(name)) { + logger.debug("Controller #{}, Zone #{} found - {}", c, z, name); + + final ThingUID thingUID = new ThingUID(RioConstants.THING_TYPE_ZONE, controllerUID, String.valueOf(z)); + + final DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUID) + .withProperty(RioZoneConfig.Zone, z).withBridge(controllerUID) + .withLabel((name.equalsIgnoreCase("null") ? "Zone" : name) + " (" + z + ")").build(); + thingDiscovered(discoveryResult); + } + } + } + + /** + * Helper method to send a message, parse the result with the given {@link Pattern} and extract the data in the + * specified group number. + * + * @param message the message to send + * @param respPattern the response pattern to apply + * @param groupNum the group # to return + * @return a possibly null response (null if an exception occurs or the response isn't a match or the response + * doesn't have the right amount of groups) + * @throws IllegalArgumentException if message is null or empty, if the pattern is null + * @throws IllegalArgumentException if groupNum is less than 0 + */ + private String sendAndGet(String message, Pattern respPattern, int groupNum) { + if (StringUtils.isEmpty(message)) { + throw new IllegalArgumentException("message cannot be a null or empty string"); + } + if (respPattern == null) { + throw new IllegalArgumentException("respPattern cannot be null"); + } + if (groupNum < 0) { + throw new IllegalArgumentException("groupNum must be >= 0"); + } + try { + session.sendCommand(message); + final String r = listener.getResponse(); + final Matcher m = respPattern.matcher(r); + if (m.matches() && m.groupCount() >= groupNum) { + logger.debug("Message '{}' returned an valid response: {}", message, r); + return m.group(groupNum); + } + logger.debug("Message '{}' returned an invalid response: {}", message, r); + return null; + } catch (InterruptedException e) { + logger.debug("Sending message '{}' was interrupted and could not be completed", message); + return null; + } catch (IOException e) { + logger.debug("Sending message '{}' resulted in an IOException and could not be completed: {}", message, + e.getMessage(), e); + return null; + } + } +} diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/discovery/RioSystemDiscovery.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/discovery/RioSystemDiscovery.java new file mode 100644 index 0000000000000..dedfe7411ce91 --- /dev/null +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/discovery/RioSystemDiscovery.java @@ -0,0 +1,229 @@ +/** + * Copyright (c) 2010-2017 by the respective copyright holders. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.openhab.binding.russound.internal.discovery; + +import java.io.IOException; +import java.net.Inet6Address; +import java.net.InterfaceAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import org.apache.commons.lang.StringUtils; +import org.apache.commons.net.util.SubnetUtils; +import org.eclipse.smarthome.config.discovery.AbstractDiscoveryService; +import org.eclipse.smarthome.config.discovery.DiscoveryResult; +import org.eclipse.smarthome.config.discovery.DiscoveryResultBuilder; +import org.eclipse.smarthome.config.discovery.DiscoveryService; +import org.eclipse.smarthome.core.thing.ThingUID; +import org.openhab.binding.russound.internal.net.SocketChannelSession; +import org.openhab.binding.russound.internal.net.SocketSession; +import org.openhab.binding.russound.internal.net.WaitingSessionListener; +import org.openhab.binding.russound.internal.rio.RioConstants; +import org.openhab.binding.russound.internal.rio.system.RioSystemConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.collect.ImmutableSet; + +/** + * This implementation of {@link DiscoveryService} will scan the network for any Russound RIO system devices. The scan + * will occur against all network interfaces. + * + * @author Tim Roberts + * + */ +public class RioSystemDiscovery extends AbstractDiscoveryService { + /** The logger */ + private final Logger logger = LoggerFactory.getLogger(RioSystemDiscovery.class); + + /** The timeout to connect (in milliseconds) */ + private static final int CONN_TIMEOUT_IN_MS = 100; + + /** The {@link ExecutorService} to use for scanning - will be null if not scanning */ + private ExecutorService executorService = null; + + /** The number of network interfaces being scanned */ + private int nbrNetworkInterfacesScanning = 0; + + /** + * Creates the system discovery service looking for {@link RioConstants#BRIDGE_TYPE_RIO}. The scan will take at most + * 120 seconds (depending on how many network interfaces there are) + */ + public RioSystemDiscovery() { + super(ImmutableSet.of(RioConstants.BRIDGE_TYPE_RIO), 120); + } + + /** + * Starts the scan. For each network interface (that is up and not a loopback), all addresses will be iterated + * and checked for something open on port 9621. If that port is open, a russound controller "type" command will be + * issued. If the response is a correct pattern, we assume it's a rio system device and will emit a + * {{@link #thingDiscovered(DiscoveryResult)} + */ + @Override + protected void startScan() { + final List interfaces; + try { + interfaces = Collections.list(NetworkInterface.getNetworkInterfaces()); + } catch (SocketException e1) { + logger.debug("Exception getting network interfaces: {}", e1.getMessage(), e1); + return; + } + + nbrNetworkInterfacesScanning = interfaces.size(); + executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 10); + + for (final NetworkInterface networkInterface : interfaces) { + try { + if (networkInterface.isLoopback() || !networkInterface.isUp()) { + continue; + } + } catch (SocketException e) { + continue; + } + + for (Iterator it = networkInterface.getInterfaceAddresses().iterator(); it.hasNext();) { + final InterfaceAddress interfaceAddress = it.next(); + + // don't bother with ipv6 addresses (russound doesn't support) + if (interfaceAddress.getAddress() instanceof Inet6Address) { + continue; + } + + final String subnetRange = interfaceAddress.getAddress().getHostAddress() + "/" + + interfaceAddress.getNetworkPrefixLength(); + + logger.debug("Scanning subnet: {}", subnetRange); + final SubnetUtils utils = new SubnetUtils(subnetRange); + + final String[] addresses = utils.getInfo().getAllAddresses(); + + for (final String address : addresses) { + executorService.execute(new Runnable() { + @Override + public void run() { + scanAddress(address); + } + }); + } + } + } + + // Finishes the scan and cleans up + stopScan(); + } + + /** + * Stops the scan by terminating the {@link #executorService} and shutting it down + */ + @Override + protected synchronized void stopScan() { + super.stopScan(); + if (executorService == null) { + return; + } + + try { + executorService.awaitTermination(CONN_TIMEOUT_IN_MS * nbrNetworkInterfacesScanning, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + // shutting down - doesn't matter + } + executorService.shutdown(); + executorService = null; + + } + + /** + * Helper method to scan a specific address. Will open up port 9621 on the address and if opened, query for any + * controller type (all 6 controllers are tested). If a valid type is found, a discovery result will be created. + * + * @param ipAddress a possibly null, possibly empty ip address (null/empty addresses will be ignored) + */ + private void scanAddress(String ipAddress) { + + if (StringUtils.isEmpty(ipAddress)) { + return; + } + + final SocketSession session = new SocketChannelSession(ipAddress, RioConstants.RioPort); + try { + final WaitingSessionListener listener = new WaitingSessionListener(); + session.addListener(listener); + session.connect(CONN_TIMEOUT_IN_MS); + logger.debug("Connected to port {}:{} - testing to see if RIO", ipAddress, RioConstants.RioPort); + + // can't check for system properties because DMS responds to those - + // need to check if any controllers are defined + for (int c = 1; c < 7; c++) { + session.sendCommand("GET C[" + c + "].type"); + final String resp = listener.getResponse(); + if (resp == null) { + continue; + } + if (!resp.startsWith("S C[" + c + "].type=\"")) { + continue; + } + final String type = resp.substring(13, resp.length() - 1); + if (!StringUtils.isBlank(type)) { + logger.debug("Found a RIO type #{}", type); + addResult(ipAddress, type); + break; + } + } + } catch (InterruptedException e) { + logger.debug("Connection was interrupted to port {}:{}", ipAddress, RioConstants.RioPort); + } catch (IOException e) { + logger.trace("Connection couldn't be established to port {}:{}", ipAddress, RioConstants.RioPort); + } finally { + try { + session.disconnect(); + } catch (IOException e) { + // do nothing + } + } + } + + /** + * Helper method to add our ip address and system type as a discovery result. + * + * @param ipAddress a non-null, non-empty ip address + * @param type a non-null, non-empty model type + * @throws IllegalArgumentException if ipaddress or type is null or empty + */ + private void addResult(String ipAddress, String type) { + if (StringUtils.isEmpty(ipAddress)) { + throw new IllegalArgumentException("ipAddress cannot be null or empty"); + } + if (StringUtils.isEmpty(type)) { + throw new IllegalArgumentException("type cannot be null or empty"); + } + + final Map properties = new HashMap<>(3); + properties.put(RioSystemConfig.IpAddress, ipAddress); + properties.put(RioSystemConfig.Ping, 30); + properties.put(RioSystemConfig.RetryPolling, 10); + properties.put(RioSystemConfig.ScanDevice, true); + + final String id = ipAddress.replace(".", ""); + final ThingUID uid = new ThingUID(RioConstants.BRIDGE_TYPE_RIO, id); + if (uid != null) { + final DiscoveryResult result = DiscoveryResultBuilder.create(uid).withProperties(properties) + .withLabel("Russound " + type).build(); + thingDiscovered(result); + } + + } +} diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/net/SocketChannelSession.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/net/SocketChannelSession.java index f4924ecc32601..913bbd22510b6 100644 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/net/SocketChannelSession.java +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/net/SocketChannelSession.java @@ -17,9 +17,7 @@ import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import org.slf4j.Logger; @@ -38,37 +36,37 @@ public class SocketChannelSession implements SocketSession { /** * The host/ip address to connect to */ - private final String _host; + private final String host; /** * The port to connect to */ - private final int _port; + private final int port; /** * The actual socket being used. Will be null if not connected */ - private final AtomicReference _socketChannel = new AtomicReference(); + private final AtomicReference socketChannel = new AtomicReference(); /** - * The {@link ResponseReader} that will be used to read from {@link #_readBuffer} + * The responses read from the {@link #responseReader} */ - private final ResponseReader _responseReader = new ResponseReader(); + private final BlockingQueue responses = new ArrayBlockingQueue(50); /** - * The responses read from the {@link #_responseReader} + * The {@link SocketSessionListener} that the {@link #dispatcher} will call */ - private final BlockingQueue _responses = new ArrayBlockingQueue(50); + private List sessionListeners = new CopyOnWriteArrayList(); /** - * The dispatcher of responses from {@link #_responses} + * The thread dispatching responses - will be null if not connected */ - private final Dispatcher _dispatcher = new Dispatcher(); + private Thread dispatchingThread = null; /** - * The {@link SocketSessionListener} that the {@link #_dispatcher} will call + * The thread processing responses - will be null if not connected */ - private List _listeners = new CopyOnWriteArrayList(); + private Thread responseThread = null; /** * Creates the socket session from the given host and port @@ -84,8 +82,8 @@ public SocketChannelSession(String host, int port) { if (port < 1 || port > 65535) { throw new IllegalArgumentException("Port must be between 1 and 65535"); } - _host = host; - _port = port; + this.host = host; + this.port = port; } /* @@ -100,7 +98,7 @@ public void addListener(SocketSessionListener listener) { if (listener == null) { throw new IllegalArgumentException("listener cannot be null"); } - _listeners.add(listener); + sessionListeners.add(listener); } /* @@ -110,7 +108,7 @@ public void addListener(SocketSessionListener listener) { */ @Override public void clearListeners() { - _listeners.clear(); + sessionListeners.clear(); } /* @@ -122,7 +120,7 @@ public void clearListeners() { */ @Override public boolean removeListener(SocketSessionListener listener) { - return _listeners.remove(listener); + return sessionListeners.remove(listener); } /* @@ -132,25 +130,33 @@ public boolean removeListener(SocketSessionListener listener) { */ @Override public void connect() throws IOException { + connect(2000); + } + + /* + * (non-Javadoc) + * + * @see org.openhab.binding.russound.internal.net.SocketSession#connect(int) + */ + @Override + public void connect(int timeout) throws IOException { disconnect(); final SocketChannel channel = SocketChannel.open(); channel.configureBlocking(true); - logger.debug("Connecting to {}:{}", _host, _port); - channel.connect(new InetSocketAddress(_host, _port)); + logger.debug("Connecting to {}:{}", host, port); + channel.socket().connect(new InetSocketAddress(host, port), timeout); - logger.debug("Waiting for connect"); - while (!channel.finishConnect()) { - try { - Thread.sleep(250); - } catch (InterruptedException e) { - } - } + socketChannel.set(channel); + + responses.clear(); - _socketChannel.set(channel); - new Thread(_dispatcher).start(); - new Thread(_responseReader).start(); + dispatchingThread = new Thread(new Dispatcher()); + responseThread = new Thread(new ResponseReader()); + + dispatchingThread.start(); + responseThread.start(); } /* @@ -161,15 +167,18 @@ public void connect() throws IOException { @Override public void disconnect() throws IOException { if (isConnected()) { - logger.debug("Disconnecting from {}:{}", _host, _port); + logger.debug("Disconnecting from {}:{}", host, port); - final SocketChannel channel = _socketChannel.getAndSet(null); + final SocketChannel channel = socketChannel.getAndSet(null); channel.close(); - _dispatcher.stopRunning(); - _responseReader.stopRunning(); + dispatchingThread.interrupt(); + dispatchingThread = null; + + responseThread.interrupt(); + responseThread = null; - _responses.clear(); + responses.clear(); } } @@ -180,7 +189,7 @@ public void disconnect() throws IOException { */ @Override public boolean isConnected() { - final SocketChannel channel = _socketChannel.get(); + final SocketChannel channel = socketChannel.get(); return channel != null && channel.isConnected(); } @@ -205,7 +214,7 @@ public synchronized void sendCommand(String command) throws IOException { ByteBuffer toSend = ByteBuffer.wrap((command + "\r\n").getBytes()); - final SocketChannel channel = _socketChannel.get(); + final SocketChannel channel = socketChannel.get(); if (channel == null) { logger.debug("Cannot send command '{}' - socket channel was closed", command); } else { @@ -224,32 +233,7 @@ public synchronized void sendCommand(String command) throws IOException { private class ResponseReader implements Runnable { /** - * Whether the reader is currently running - */ - private final AtomicBoolean _isRunning = new AtomicBoolean(false); - - /** - * Locking to allow proper shutdown of the reader - */ - private final CountDownLatch _running = new CountDownLatch(1); - - /** - * Stops the reader. Will wait 5 seconds for the runnable to stop - */ - public void stopRunning() { - if (_isRunning.getAndSet(false)) { - try { - if (!_running.await(5, TimeUnit.SECONDS)) { - logger.warn("Waited too long for response reader to finish"); - } - } catch (InterruptedException e) { - // Do nothing - } - } - } - - /** - * Runs the logic to read from the socket until {@link #_isRunning} is false. A 'response' is anything that ends + * Runs the logic to read from the socket until {@link #isRunning} is false. A 'response' is anything that ends * with a carriage-return/newline combo. Additionally, the special "Login: " and "Password: " prompts are * treated as responses for purposes of logging in. */ @@ -258,10 +242,9 @@ public void run() { final StringBuilder sb = new StringBuilder(100); final ByteBuffer readBuffer = ByteBuffer.allocate(1024); - _isRunning.set(true); - _responses.clear(); + responses.clear(); - while (_isRunning.get()) { + while (!Thread.currentThread().isInterrupted()) { try { // if reader is null, sleep and try again if (readBuffer == null) { @@ -269,17 +252,16 @@ public void run() { continue; } - final SocketChannel channel = _socketChannel.get(); + final SocketChannel channel = socketChannel.get(); if (channel == null) { // socket was closed - _isRunning.set(false); + Thread.currentThread().interrupt(); break; } int bytesRead = channel.read(readBuffer); if (bytesRead == -1) { - _responses.put(new IOException("server closed connection")); - _isRunning.set(false); + responses.put(new IOException("server closed connection")); break; } else if (bytesRead == 0) { readBuffer.clear(); @@ -295,7 +277,7 @@ public void run() { if (str.endsWith("\r\n") || str.endsWith("Login: ") || str.endsWith("Password: ")) { sb.setLength(0); final String response = str.substring(0, str.length() - 2); - _responses.put(response); + responses.put(response); } } } @@ -303,20 +285,24 @@ public void run() { readBuffer.flip(); } catch (InterruptedException e) { - // Do nothing - probably shutting down + // Ending thread execution + Thread.currentThread().interrupt(); } catch (AsynchronousCloseException e) { - // socket was definitely closed by another thread + // socket was closed by another thread but interrupt our loop anyway + Thread.currentThread().interrupt(); } catch (IOException e) { + // set before pushing the response since we'll likely call back our stop + Thread.currentThread().interrupt(); + try { - _isRunning.set(false); - _responses.put(e); + responses.put(e); + break; } catch (InterruptedException e1) { // Do nothing - probably shutting down + // Since we set isRunning to false, will drop out of loop and end the thread } } } - - _running.countDown(); } } @@ -329,51 +315,14 @@ public void run() { * @author Tim Roberts */ private class Dispatcher implements Runnable { - - /** - * Whether the dispatcher is running or not - */ - private final AtomicBoolean _isRunning = new AtomicBoolean(false); - - /** - * Locking to allow proper shutdown of the reader - */ - private final CountDownLatch _running = new CountDownLatch(1); - - /** - * Whether the dispatcher is currently processing a message - */ - private final AtomicBoolean _isProcessing = new AtomicBoolean(false); - - /** - * Stops the reader. Will wait 5 seconds for the runnable to stop (should stop within 1 second based on the poll - * timeout below) - */ - public void stopRunning() { - if (_isRunning.getAndSet(false)) { - // only wait if stopRunning didn't get called as part of processing a message - // (which would happen if we are processing an exception that forced a session close) - if (!_isProcessing.get()) { - try { - if (!_running.await(5, TimeUnit.SECONDS)) { - logger.warn("Waited too long for dispatcher to finish"); - } - } catch (InterruptedException e) { - // do nothing - } - } - } - } - /** - * Runs the logic to dispatch any responses to the current listeners until {@link #_isRunning} is false. + * Runs the logic to dispatch any responses to the current listeners until {@link #isRunning} is false. */ @Override public void run() { - _isRunning.set(true); - while (_isRunning.get()) { + while (!Thread.currentThread().isInterrupted()) { try { - final SocketSessionListener[] listeners = _listeners.toArray(new SocketSessionListener[0]); + final SocketSessionListener[] listeners = sessionListeners.toArray(new SocketSessionListener[0]); // if no listeners, we don't want to start dispatching yet. if (listeners.length == 0) { @@ -381,47 +330,31 @@ public void run() { continue; } - final Object response = _responses.poll(1, TimeUnit.SECONDS); + final Object response = responses.poll(1, TimeUnit.SECONDS); if (response != null) { if (response instanceof String) { - try { - logger.debug("Dispatching response: {}", response); - try { - _isProcessing.set(true); - for (SocketSessionListener listener : listeners) { - listener.responseReceived((String) response); - } - } finally { - _isProcessing.set(false); - } - } catch (Exception e) { - logger.warn("Exception occurred processing the response '{}': {}", response, e); + logger.debug("Dispatching response: {}", response); + for (SocketSessionListener listener : listeners) { + listener.responseReceived((String) response); } - } else if (response instanceof Exception) { + } else if (response instanceof IOException) { logger.debug("Dispatching exception: {}", response); - try { - _isProcessing.set(true); - for (SocketSessionListener listener : listeners) { - listener.responseException((Exception) response); - } - } finally { - _isProcessing.set(false); + for (SocketSessionListener listener : listeners) { + listener.responseException((IOException) response); } } else { logger.warn("Unknown response class: {}", response); } } } catch (InterruptedException e) { - // Do nothing + // Ending thread execution + Thread.currentThread().interrupt(); } catch (Exception e) { logger.debug("Uncaught exception {}: {}", e.getMessage(), e); - break; + Thread.currentThread().interrupt(); } } - _isProcessing.set(false); - _isRunning.set(false); - _running.countDown(); } } } diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/net/SocketSession.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/net/SocketSession.java index b8ba9ffd12748..a4080020ddc28 100644 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/net/SocketSession.java +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/net/SocketSession.java @@ -39,14 +39,23 @@ public interface SocketSession { */ boolean removeListener(SocketSessionListener listener); + /** + * Will attempt to connect to the {@link #_host} on port {@link #_port}. Simply calls {@link #connect(int)} with + * a 2 second timeout + * + * @throws java.io.IOException if an exception occurs during the connection attempt + */ + void connect() throws IOException; + /** * Will attempt to connect to the {@link #_host} on port {@link #_port}. If we are current connected, will * {@link #disconnect()} first. Once connected, the {@link #_writer} and {@link #_reader} will be created, the * {@link #_dispatcher} and {@link #_responseReader} will be started. * + * @param timeout a connection timeout (in milliseconds) * @throws java.io.IOException if an exception occurs during the connection attempt */ - void connect() throws IOException; + void connect(int timeout) throws IOException; /** * Disconnects from the {@link #_host} if we are {@link #isConnected()}. The {@link #_writer}, {@link #_reader} and diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/net/SocketSessionListener.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/net/SocketSessionListener.java index 89774a93cec68..bd8b022044e84 100644 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/net/SocketSessionListener.java +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/net/SocketSessionListener.java @@ -8,6 +8,8 @@ */ package org.openhab.binding.russound.internal.net; +import java.io.IOException; + /** * Interface defining a listener to a {@link SocketSession} that will receive responses and/or exceptions from the * socket @@ -19,13 +21,15 @@ public interface SocketSessionListener { * Called when a command has completed with the response for the command * * @param response a non-null, possibly empty response + * @throws InterruptedException if the response processing was interrupted */ - public void responseReceived(String response); + public void responseReceived(String response) throws InterruptedException; /** * Called when a command finished with an exception or a general exception occurred while reading * - * @param e a non-null exception + * @param e a non-null io exception + * @throws InterruptedException if the exception processing was interrupted */ - public void responseException(Exception e); + public void responseException(IOException e) throws InterruptedException; } diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/net/WaitingSessionListener.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/net/WaitingSessionListener.java new file mode 100644 index 0000000000000..55b7cf3ce5f28 --- /dev/null +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/net/WaitingSessionListener.java @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2010-2017 by the respective copyright holders. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.openhab.binding.russound.internal.net; + +import java.io.IOException; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.TimeUnit; + +/** + * Implementation of {@link SocketSessionListener} that allows a caller to wait for a response via + * {@link #getResponse()} + * + * @author Tim Roberts + */ +public class WaitingSessionListener implements SocketSessionListener { + + /** + * Cache of responses that have occurred + */ + private BlockingQueue responses = new ArrayBlockingQueue(5); + + /** + * Will return the next response from {@link #responses}. If the response is an exception, that exception will + * be thrown instead. + * + * @return a non-null, possibly empty response + * @throws IOException an IO exception occurred during reading + * @throws InterruptedException an interrupted exception occurred during reading + */ + public String getResponse() throws IOException, InterruptedException { + // note: russound is inherently single threaded even though it accepts multiple connections + // if we have another thread sending a lot of commands (such as during startup), our response + // will not come in until the other commands have been processed. So we need a large wait + // time for it to be sent to us + final Object lastResponse = responses.poll(60, TimeUnit.SECONDS); + if (lastResponse instanceof String) { + return (String) lastResponse; + } else if (lastResponse instanceof IOException) { + throw (IOException) lastResponse; + } else if (lastResponse == null) { + throw new IOException("Didn't receive response in time"); + } else { + return lastResponse.toString(); + } + } + + @Override + public void responseReceived(String response) throws InterruptedException { + responses.put(response); + } + + @Override + public void responseException(IOException e) throws InterruptedException { + responses.put(e); + } +} diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/AbstractBridgeHandler.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/AbstractBridgeHandler.java index fe45f3b622859..627ec9754d4eb 100644 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/AbstractBridgeHandler.java +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/AbstractBridgeHandler.java @@ -8,13 +8,23 @@ */ package org.openhab.binding.russound.internal.rio; +import java.util.ArrayList; +import java.util.List; + +import org.apache.commons.lang.StringUtils; +import org.eclipse.smarthome.core.library.types.StringType; import org.eclipse.smarthome.core.thing.Bridge; +import org.eclipse.smarthome.core.thing.ChannelUID; +import org.eclipse.smarthome.core.thing.Thing; import org.eclipse.smarthome.core.thing.ThingStatus; import org.eclipse.smarthome.core.thing.ThingStatusInfo; import org.eclipse.smarthome.core.thing.binding.BaseBridgeHandler; +import org.eclipse.smarthome.core.thing.binding.ThingHandler; import org.openhab.binding.russound.internal.net.SocketSession; import org.openhab.binding.russound.internal.net.SocketSessionListener; +import com.google.gson.Gson; + /** * Represents the abstract base to a {@link BaseBridgeHandler} for common functionality to all Bridges. This abstract * base provides management of the {@link AbstractRioProtocol}, parent {@link #bridgeStatusChanged(ThingStatusInfo)} @@ -23,12 +33,12 @@ * * @author Tim Roberts */ -public abstract class AbstractBridgeHandler extends BaseBridgeHandler { - +public abstract class AbstractBridgeHandler extends BaseBridgeHandler + implements RioCallbackHandler { /** * The protocol handler for this base */ - private E _protocolHandler; + private E protocolHandler; /** * Creates the handler from the given {@link Bridge} @@ -43,13 +53,13 @@ protected AbstractBridgeHandler(Bridge bridge) { * Sets a new {@link AbstractRioProtocol} as the current protocol handler. If one already exists, it will be * disposed of first. * - * @param protocolHandler a, possibly null, {@link AbstractRioProtocol} + * @param newProtocolHandler a, possibly null, {@link AbstractRioProtocol} */ - protected void setProtocolHandler(E protocolHandler) { - if (_protocolHandler != null) { - _protocolHandler.dispose(); + protected void setProtocolHandler(E newProtocolHandler) { + if (protocolHandler != null) { + protocolHandler.dispose(); } - _protocolHandler = protocolHandler; + protocolHandler = newProtocolHandler; } /** @@ -58,7 +68,18 @@ protected void setProtocolHandler(E protocolHandler) { * @return a {@link AbstractRioProtocol} handler or null if none exists */ protected E getProtocolHandler() { - return _protocolHandler; + return protocolHandler; + } + + /** + * Overridden to simply get the protocol handler's {@link RioHandlerCallback} + * + * @return the {@link RioHandlerCallback} or null if not found + */ + @Override + public RioHandlerCallback getCallback() { + final E protocolHandler = getProtocolHandler(); + return protocolHandler == null ? null : protocolHandler.getCallback(); } /** @@ -76,6 +97,39 @@ public SocketSession getSocketSession() { return null; } + /** + * Returns the {@link RioPresetsProtocol} for this {@link Bridge}. The default implementation is to look in the + * parent + * {@link #getPresetsProtocol()} for the {@link RioPresetsProtocol} + * + * @return a {@link RioPresetsProtocol} or null if none exists + */ + @SuppressWarnings("rawtypes") + public RioPresetsProtocol getPresetsProtocol() { + final Bridge bridge = getBridge(); + if (bridge != null && bridge.getHandler() instanceof AbstractBridgeHandler) { + return ((AbstractBridgeHandler) bridge.getHandler()).getPresetsProtocol(); + } + return null; + } + + /** + * Returns the {@link RioSystemFavoritesProtocol} for this {@link Bridge}. The default implementation is to look in + * the + * parent + * {@link #getPresetsProtocol()} for the {@link RioSystemFavoritesProtocol} + * + * @return a {@link RioSystemFavoritesProtocol} or null if none exists + */ + @SuppressWarnings("rawtypes") + public RioSystemFavoritesProtocol getSystemFavoritesHandler() { + final Bridge bridge = getBridge(); + if (bridge != null && bridge.getHandler() instanceof AbstractBridgeHandler) { + return ((AbstractBridgeHandler) bridge.getHandler()).getSystemFavoritesHandler(); + } + return null; + } + /** * Overrides the base to initialize or dispose the handler based on the parent bridge status changing. If offline, * {@link #dispose()} will be called instead. We then try to reinitialize ourselves when the bridge goes back online @@ -91,6 +145,57 @@ public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) { super.bridgeStatusChanged(bridgeStatusInfo); } + /** + * Creates an "{id:x, name: 'xx'}" json string from all {@link RioNamedHandler} for a specific class and sends that + * result to a channel id. + * + * @param gson a non-null {@link Gson} to use + * @param clazz a non-null class that the results will be for + * @param channelId a non-null, non-empty channel identifier to send the results to + * @throws IllegalArgumentException if any argument is null or empty + */ + protected void refreshNamedHandler(Gson gson, Class clazz, String channelId) { + if (gson == null) { + throw new IllegalArgumentException("gson cannot be null"); + } + if (clazz == null) { + throw new IllegalArgumentException("clazz cannot be null"); + } + if (StringUtils.isEmpty(channelId)) { + throw new IllegalArgumentException("channelId cannot be null or empty"); + } + + final List ids = new ArrayList(); + for (Thing thn : getThing().getThings()) { + if (thn.getStatus() == ThingStatus.ONLINE) { + final ThingHandler handler = thn.getHandler(); + if (handler != null && handler.getClass().isAssignableFrom(clazz)) { + final RioNamedHandler namedHandler = (RioNamedHandler) handler; + if (namedHandler.getId() > 0) { // 0 returned when handler is initializing + ids.add(new IdName(namedHandler.getId(), namedHandler.getName())); + } + } + } + } + + final String json = gson.toJson(ids); + updateState(channelId, new StringType(json)); + } + + /** + * Overrides the base method to remove any state linked to the {@lin ChannelUID} from the + * {@link StatefulHandlerCallback} + */ + @Override + public void channelUnlinked(ChannelUID channelUID) { + // Remove any state when unlinking (that way if it is relinked - we get it) + final RioHandlerCallback callback = getProtocolHandler().getCallback(); + if (callback instanceof StatefulHandlerCallback) { + ((StatefulHandlerCallback) callback).removeState(channelUID.getId()); + } + super.channelUnlinked(channelUID); + } + /** * Base method to reconnect the handler. The base implementation will simply {@link #disconnect()} then * {@link #initialize()} the handler. @@ -116,4 +221,22 @@ public void dispose() { disconnect(); super.dispose(); } + + /** + * Private class that simply stores an ID & Name. This class is solely used to create a json result like "{id:1, + * name:'stuff'}" + * + * @author Tim Roberts + */ + private class IdName { + @SuppressWarnings("unused") + private final int id; + @SuppressWarnings("unused") + private final String name; + + public IdName(int id, String name) { + this.id = id; + this.name = name; + } + } } diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/AbstractRioHandlerCallback.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/AbstractRioHandlerCallback.java new file mode 100644 index 0000000000000..ac3681feceec5 --- /dev/null +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/AbstractRioHandlerCallback.java @@ -0,0 +1,91 @@ +/** + * Copyright (c) 2010-2017 by the respective copyright holders. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.openhab.binding.russound.internal.rio; + +import java.util.concurrent.CopyOnWriteArrayList; + +import org.apache.commons.lang.StringUtils; +import org.eclipse.smarthome.core.types.State; + +/** + * Abstract implementation of {@link RioHandlerCallback} that will provide listener services (adding/removing and firing + * of state) + * + * @author Tim Roberts + * + */ +public abstract class AbstractRioHandlerCallback implements RioHandlerCallback { + /** Listener array */ + private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList(); + + /** + * Adds a listener to {@link #listeners} wrapping the listener in a {@link ListenerState} + */ + @Override + public void addListener(String channelId, RioHandlerCallbackListener listener) { + listeners.add(new ListenerState(channelId, listener)); + } + + /** + * Remove a listener from {@link #listeners} if the channelID matches + */ + @Override + public void removeListener(String channelId, RioHandlerCallbackListener listener) { + for (ListenerState listenerState : listeners) { + if (listenerState.channelId.equals(channelId) && listenerState.listener == listener) { + listeners.remove(listenerState); + } + } + } + + /** + * Fires a stateUpdate message to all listeners for the channelId and state + * + * @param channelId a non-null, non-empty channelId + * @param state a non-null state + * @throws IllegalArgumentException if channelId is null or empty. + * @throws IllegalArgumentException if state is null + */ + protected void fireStateUpdated(String channelId, State state) { + if (StringUtils.isEmpty(channelId)) { + throw new IllegalArgumentException("channelId cannot be null or empty)"); + } + if (state == null) { + throw new IllegalArgumentException("state cannot be null"); + } + for (ListenerState listenerState : listeners) { + if (listenerState.channelId.equals(channelId)) { + listenerState.listener.stateUpdate(channelId, state); + } + } + } + + /** + * Internal class used to associate a listener with a channel id + * + * @author Tim Roberts + */ + private class ListenerState { + /** The channelID */ + private final String channelId; + /** The listener associated with it */ + private final RioHandlerCallbackListener listener; + + /** + * Create the listener state from the channelID and listener + * + * @param channelId the channelID + * @param listener the listener + */ + ListenerState(String channelId, RioHandlerCallbackListener listener) { + this.channelId = channelId; + this.listener = listener; + } + } +} diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/AbstractRioProtocol.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/AbstractRioProtocol.java index 6424078b2100e..64ef01394961c 100644 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/AbstractRioProtocol.java +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/AbstractRioProtocol.java @@ -28,12 +28,12 @@ public abstract class AbstractRioProtocol implements SocketSessionListener { /** * The {@link SocketSession} used by this protocol handler */ - private final SocketSession _session; + private final SocketSession session; /** * The {@link RioSystemHandler} to call back to update status and state */ - private final RioHandlerCallback _callback; + private final RioHandlerCallback callback; /** * Constructs the protocol handler from given parameters and will add this handler as a @@ -53,9 +53,9 @@ protected AbstractRioProtocol(SocketSession session, RioHandlerCallback callback throw new IllegalArgumentException("callback cannot be null"); } - _session = session; - _session.addListener(this); - _callback = callback; + this.session = session; + this.session.addListener(this); + this.callback = callback; } /** @@ -68,7 +68,7 @@ protected void sendCommand(String command) { throw new IllegalArgumentException("command cannot be null"); } try { - _session.sendCommand(command); + session.sendCommand(command); } catch (IOException e) { getCallback().statusChanged(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Exception occurred sending command: " + e); @@ -111,7 +111,7 @@ protected void statusChanged(ThingStatus status, ThingStatusDetail statusDetail, * {@link SocketSession#removeListener(SocketSessionListener)} */ public void dispose() { - _session.removeListener(this); + session.removeListener(this); } /** @@ -121,7 +121,7 @@ public void dispose() { * @param e the exception */ @Override - public void responseException(Exception e) { + public void responseException(IOException e) { getCallback().statusChanged(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Exception occurred reading from the socket: " + e); } @@ -132,6 +132,6 @@ public void responseException(Exception e) { * @return a non-null {@link RioHandlerCallback} */ public RioHandlerCallback getCallback() { - return _callback; + return callback; } } diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/AbstractThingHandler.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/AbstractThingHandler.java index eefc553dfcc6a..165508679bf31 100644 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/AbstractThingHandler.java +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/AbstractThingHandler.java @@ -9,6 +9,7 @@ package org.openhab.binding.russound.internal.rio; import org.eclipse.smarthome.core.thing.Bridge; +import org.eclipse.smarthome.core.thing.ChannelUID; import org.eclipse.smarthome.core.thing.Thing; import org.eclipse.smarthome.core.thing.ThingStatus; import org.eclipse.smarthome.core.thing.ThingStatusInfo; @@ -24,11 +25,12 @@ * * @author Tim Roberts */ -public abstract class AbstractThingHandler extends BaseThingHandler { +public abstract class AbstractThingHandler extends BaseThingHandler + implements RioCallbackHandler { /** * The protocol handler for this base */ - private E _protocolHandler; + private E protocolHandler; /** * Creates the handler from the given {@link Thing} @@ -43,13 +45,13 @@ protected AbstractThingHandler(Thing thing) { * Sets a new {@link AbstractRioProtocol} as the current protocol handler. If one already exists, it will be * disposed of first. * - * @param protocolHandler a, possibly null, {@link AbstractRioProtocol} + * @param newProtocolHandler a, possibly null, {@link AbstractRioProtocol} */ - protected void setProtocolHandler(E protocolHandler) { - if (_protocolHandler != null) { - _protocolHandler.dispose(); + protected void setProtocolHandler(E newProtocolHandler) { + if (protocolHandler != null) { + protocolHandler.dispose(); } - _protocolHandler = protocolHandler; + protocolHandler = newProtocolHandler; } /** @@ -58,7 +60,18 @@ protected void setProtocolHandler(E protocolHandler) { * @return a {@link AbstractRioProtocol} handler or null if none exists */ protected E getProtocolHandler() { - return _protocolHandler; + return protocolHandler; + } + + /** + * Overridden to simply get the protocol handler's {@link RioHandlerCallback} + * + * @return the {@link RioHandlerCallback} or null if not found + */ + @Override + public RioHandlerCallback getCallback() { + final E protocolHandler = getProtocolHandler(); + return protocolHandler == null ? null : protocolHandler.getCallback(); } /** @@ -91,6 +104,20 @@ public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) { super.bridgeStatusChanged(bridgeStatusInfo); } + /** + * Overrides the base method to remove any state linked to the {@lin ChannelUID} from the + * {@link StatefulHandlerCallback} + */ + @Override + public void channelUnlinked(ChannelUID channelUID) { + // Remove any state when unlinking (that way if it is relinked - we get it) + final RioHandlerCallback callback = getProtocolHandler().getCallback(); + if (callback instanceof StatefulHandlerCallback) { + ((StatefulHandlerCallback) callback).removeState(channelUID.getId()); + } + super.channelUnlinked(channelUID); + } + /** * Base method to reconnect the handler. The base implementation will simply {@link #disconnect()} then * {@link #initialize()} the handler. diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/RioCallbackHandler.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/RioCallbackHandler.java new file mode 100644 index 0000000000000..badd64eb1d778 --- /dev/null +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/RioCallbackHandler.java @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2010-2017 by the respective copyright holders. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.openhab.binding.russound.internal.rio; + +/** + * This interface defines the methods that an implementing class needs to implement to provide the + * {@link RioHandlerCallback} used by the underlying protocol + * + * @author Tim Roberts + * + */ +public interface RioCallbackHandler { + /** + * Get's the {@link RioHandlerCallback} for the underlying thing + * + * @return the {@link RioHandlerCallback} or null if none found + */ + RioHandlerCallback getCallback(); +} diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/RioConstants.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/RioConstants.java index 32796d3beb92a..b015268b396fd 100644 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/RioConstants.java +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/RioConstants.java @@ -23,21 +23,12 @@ public class RioConstants { public static final ThingTypeUID BRIDGE_TYPE_CONTROLLER = new ThingTypeUID(RussoundBindingConstants.BINDING_ID, "controller"); - public static final ThingTypeUID BRIDGE_TYPE_ZONE = new ThingTypeUID(RussoundBindingConstants.BINDING_ID, "zone"); - public static final ThingTypeUID BRIDGE_TYPE_SOURCE = new ThingTypeUID(RussoundBindingConstants.BINDING_ID, + public static final ThingTypeUID THING_TYPE_ZONE = new ThingTypeUID(RussoundBindingConstants.BINDING_ID, "zone"); + public static final ThingTypeUID THING_TYPE_SOURCE = new ThingTypeUID(RussoundBindingConstants.BINDING_ID, "source"); - public static final ThingTypeUID BRIDGE_TYPE_BANK = new ThingTypeUID(RussoundBindingConstants.BINDING_ID, "bank"); - - // THING TYPE IDS - public static final ThingTypeUID THING_TYPE_BANK_PRESET = new ThingTypeUID(RussoundBindingConstants.BINDING_ID, - "bankpreset"); - public static final ThingTypeUID THING_TYPE_ZONE_PRESET = new ThingTypeUID(RussoundBindingConstants.BINDING_ID, - "zonepreset"); - public static final ThingTypeUID THING_TYPE_SYSTEM_FAVORITE = new ThingTypeUID(RussoundBindingConstants.BINDING_ID, - "sysfavorite"); - public static final ThingTypeUID THING_TYPE_ZONE_FAVORITE = new ThingTypeUID(RussoundBindingConstants.BINDING_ID, - "zonefavorite"); + // the port number rio listens on + public static final int RioPort = 9621; // SYSTEM PROPERTIES public static final String PROPERTY_SYSVERSION = "Firmware Version"; @@ -46,12 +37,17 @@ public class RioConstants { public static final String CHANNEL_SYSSTATUS = "status"; // readonly public static final String CHANNEL_SYSLANG = "lang"; // read/write - english, chinese, russian public static final String CHANNEL_SYSALLON = "allon"; // read/write - english, chinese, russian + public static final String CHANNEL_SYSCONTROLLERS = "controllers"; // json array [1,2,etc] + public static final String CHANNEL_SYSSOURCES = "sources"; // json array [{id: 1, name: xxx},{id:2, name: xxx}, etc] // CONTROLLER PROPERTIES public static final String PROPERTY_CTLTYPE = "Model Type"; public static final String PROPERTY_CTLIPADDRESS = "IP Address"; public static final String PROPERTY_CTLMACADDRESS = "MAC Address"; + // CONTROLLER CHANNELS + public static final String CHANNEL_CTLZONES = "zones"; // json array [{id: 1, name: xxx},{id:2, name: xxx}, etc] + // ZONE CHANNELS public static final String CHANNEL_ZONENAME = "name"; // 12 max public static final String CHANNEL_ZONESOURCE = "source"; // 1-8 or 1-12 @@ -74,6 +70,10 @@ public class RioConstants { public static final String CHANNEL_ZONEREPEAT = "repeat"; // OFF/ON/MASTER public static final String CHANNEL_ZONESHUFFLE = "shuffle"; // OFF/ON/MASTER + public static final String CHANNEL_ZONESYSFAVORITES = "systemfavorites"; // json array + public static final String CHANNEL_ZONEFAVORITES = "zonefavorites"; // json array + public static final String CHANNEL_ZONEPRESETS = "presets"; // json array + // ZONE EVENT BASED public static final String CHANNEL_ZONEKEYPRESS = "keypress"; public static final String CHANNEL_ZONEKEYRELEASE = "keyrelease"; @@ -81,6 +81,10 @@ public class RioConstants { public static final String CHANNEL_ZONEKEYCODE = "keycode"; public static final String CHANNEL_ZONEEVENT = "event"; + // ZONE MEDIA CHANNELS + public static final String CHANNEL_ZONEMMINIT = "mminit"; + public static final String CHANNEL_ZONEMMCONTEXTMENU = "mmcontextmenu"; + // FAVORITE CHANNELS public static final String CHANNEL_FAVNAME = "name"; public static final String CHANNEL_FAVVALID = "valid"; @@ -108,10 +112,10 @@ public class RioConstants { public static final String CMD_PRESETDELETE = "delete"; // SOURCE PROPERTIES - public static final String PROPERTY_SOURCETYPE = "Source Type"; public static final String PROPERTY_SOURCEIPADDRESS = "IP Address"; // SOURCE CHANNELS + public static final String CHANNEL_SOURCETYPE = "type"; public static final String CHANNEL_SOURCENAME = "name"; public static final String CHANNEL_SOURCECOMPOSERNAME = "composername"; public static final String CHANNEL_SOURCECHANNEL = "channel"; @@ -120,7 +124,6 @@ public class RioConstants { public static final String CHANNEL_SOURCEARTISTNAME = "artistname"; public static final String CHANNEL_SOURCEALBUMNAME = "albumname"; public static final String CHANNEL_SOURCECOVERARTURL = "coverarturl"; - public static final String CHANNEL_SOURCECOVERART = "coverart"; public static final String CHANNEL_SOURCEPLAYLISTNAME = "playlistname"; public static final String CHANNEL_SOURCESONGNAME = "songname"; public static final String CHANNEL_SOURCEMODE = "mode"; @@ -133,4 +136,17 @@ public class RioConstants { public static final String CHANNEL_SOURCERADIOTEXT3 = "radiotext3"; public static final String CHANNEL_SOURCERADIOTEXT4 = "radiotext4"; public static final String CHANNEL_SOURCEVOLUME = "volume"; + + public static final String CHANNEL_SOURCEBANKS = "banks"; + + // SOURCE MEDIA Channels + public static final String CHANNEL_SOURCEMMSCREEN = "mmscreen"; + public static final String CHANNEL_SOURCEMMTITLE = "mmtitle"; + public static final String CHANNEL_SOURCEMMMENU = "mmmenu"; + public static final String CHANNEL_SOURCEMMATTR = "mmattr"; + public static final String CHANNEL_SOURCEMMBUTTONOKTEXT = "mmmenubuttonoktext"; + public static final String CHANNEL_SOURCEMMBUTTONBACKTEXT = "mmmenubuttonbacktext"; + public static final String CHANNEL_SOURCEMMINFOTEXT = "mminfotext"; + public static final String CHANNEL_SOURCEMMHELPTEXT = "mmhelptext"; + public static final String CHANNEL_SOURCEMMTEXTFIELD = "mmtextfield"; } diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/RioHandlerCallback.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/RioHandlerCallback.java index 1a112c6bec15d..987b2ad50e364 100644 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/RioHandlerCallback.java +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/RioHandlerCallback.java @@ -41,9 +41,29 @@ public interface RioHandlerCallback { /** * Callback to set a property for the thing - * + * * @param propertyName a non-null, non-empty property name * @param propertyValue a non-null, possibly empty property value */ void setProperty(String propertyName, String propertyValue); + + /** + * Adds a listener to changes to the channelId + * + * @param channelId a non-null, non-empty channelID + * @param listener a non-null listener + * @throws IllegalArgumentException if channelId is null or empty + * @throws IllegalArgumentException if listener is null + */ + void addListener(String channelId, RioHandlerCallbackListener listener); + + /** + * Removes the specified listener for the specified channel + * + * @param channelId a non-null, non-empty channelID + * @param listener a non-null listener + * @throws IllegalArgumentException if channelId is null or empty + * @throws IllegalArgumentException if listener is null + */ + void removeListener(String channelId, RioHandlerCallbackListener listener); } diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/RioHandlerCallbackListener.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/RioHandlerCallbackListener.java new file mode 100644 index 0000000000000..2c0f679956c02 --- /dev/null +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/RioHandlerCallbackListener.java @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2010-2017 by the respective copyright holders. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.openhab.binding.russound.internal.rio; + +import org.eclipse.smarthome.core.types.State; + +/** + * Interface definition for any listener to state changes in a {@link RioHandlerCallback} + * + * @author Tim Roberts + * + */ +public interface RioHandlerCallbackListener { + /** + * Called when the state has change + * + * @param channelId a non null, non-empty channel id that changed + * @param state a non-null new state + */ + void stateUpdate(String channelId, State state); +} diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/RioNamedHandler.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/RioNamedHandler.java new file mode 100644 index 0000000000000..ccfe201bae747 --- /dev/null +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/RioNamedHandler.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2010-2017 by the respective copyright holders. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.openhab.binding.russound.internal.rio; + +/** + * Interface for any handler that supports an identifier and name + * + * @author Tim Roberts + * + */ +public interface RioNamedHandler { + /** + * Returns the ID of the handler + * + * @return the identifier of the handler + */ + int getId(); + + /** + * Returns the name of the handler + * + * @return + */ + String getName(); +} diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/RioPresetsProtocol.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/RioPresetsProtocol.java new file mode 100644 index 0000000000000..20826a1b2dd59 --- /dev/null +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/RioPresetsProtocol.java @@ -0,0 +1,475 @@ +/** + * Copyright (c) 2010-2017 by the respective copyright holders. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.openhab.binding.russound.internal.rio; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.lang.StringUtils; +import org.openhab.binding.russound.internal.net.SocketSession; +import org.openhab.binding.russound.internal.net.SocketSessionListener; +import org.openhab.binding.russound.internal.rio.models.GsonUtilities; +import org.openhab.binding.russound.internal.rio.models.RioPreset; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; + +/** + * This {@link AbstractRioProtocol} implementation provides the implementation for managing Russound bank presets. + * Since refreshing all 36 presets requires 72 calls to russound (for name/valid), we limit how often we can + * refresh to {@link #UPDATE_TIME_SPAN}. Presets are tracked by source ID and will only be valid if that source type is + * a + * tuner. + * + * @author Tim Roberts + * + */ + +public class RioPresetsProtocol extends AbstractRioProtocol { + + // logger + private final Logger logger = LoggerFactory.getLogger(RioPresetsProtocol.class); + + // helper names + private static final String PRESET_NAME = "name"; + private static final String PRESET_VALID = "valid"; + + /** + * The pattern representing preset notifications + */ + private static final Pattern RSP_PRESETNOTIFICATION = Pattern + .compile("(?i)^[SN] S\\[(\\d+)\\].B\\[(\\d+)\\].P\\[(\\d+)\\].(\\w+)=\"(.*)\"$"); + + /** + * The pattern representing a source type notification + */ + private static final Pattern RSP_SRCTYPENOTIFICATION = Pattern.compile("^[SN] S\\[(\\d+)\\]\\.type=\"(.*)\"$"); + + /** + * All 36 presets represented by two dimensions - 8 source by 36 presets + */ + private final RioPreset[][] presets = new RioPreset[8][36]; + + /** + * Represents whether the source is a tuner or not + */ + private final boolean[] isTuner = new boolean[8]; + + /** + * The {@link Gson} used for JSON operations + */ + private final Gson gson; + + /** + * The {@link ReentrantLock} used to control access to {@link #lastUpdateTime} + */ + private final Lock lastUpdateLock = new ReentrantLock(); + + /** + * The last time the specified source presets were updated via {@link #refreshPresets(Integer)} + */ + private final long[] lastUpdateTime = new long[8]; + + /** + * The minimum timespan between updates of source presets via {@link #refreshPresets(Integer)} + */ + private static final long UPDATE_TIME_SPAN = 60000; + + /** + * The pattern to determine if the source type is a tuner + */ + private static final Pattern IS_TUNER = Pattern.compile("(?i)^.*AM.*FM.*TUNER.*$"); + + /** + * The list of listeners that will be called when system favorites have changed + */ + private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList(); + + /** + * Constructs the preset protocol from the given session and callback. Note: the passed callback is not + * currently used + * + * @param session a non null {@link SocketSession} to use + * @param callback a non-null {@link RioHandlerCallback} to use + */ + public RioPresetsProtocol(SocketSession session, RioHandlerCallback callback) { + super(session, callback); + + gson = GsonUtilities.createGson(); + for (int s = 1; s <= 8; s++) { + sendCommand("GET S[" + s + "].type"); + + for (int x = 1; x <= 36; x++) { + presets[s - 1][x - 1] = new RioPreset(x); + } + } + } + + /** + * Adds the specified listener to changes to presets + * + * @param listener a non-null listener to add + * @throws IllegalArgumentException if listener is null + */ + public void addListener(Listener listener) { + listeners.add(listener); + } + + /** + * Removes the specified listener from change notifications + * + * @param listener a possibly null listener to remove (null is ignored) + * @return true if removed, false otherwise + */ + public boolean removeListener(Listener listener) { + return listeners.remove(listener); + } + + /** + * Fires the presetsUpdate method on all listeners with the results of {@link #getJson()} for the given source + * + * @param sourceId a valid source identifier between 1 and 8 + * @throws IllegalArgumentException if sourceId is < 1 or > 8 + */ + private void fireUpdate(int sourceId) { + if (sourceId < 1 || sourceId > 8) { + throw new IllegalArgumentException("sourceId must be between 1 and 8"); + } + final String json = getJson(sourceId); + for (Listener l : listeners) { + l.presetsUpdated(sourceId, json); + } + } + + /** + * Helper method to request the specified presets id information (name/valid) for a given source. Please note that + * this does NOT change the {@link #lastUpdateTime} + * + * @param sourceId a source identifier between 1 and 8 + * @param favIds a non-null, possibly empty list of system favorite ids to request (any id < 1 or > 32 will be + * ignored) + * @throws IllegalArgumentException if favIds is null + * @throws IllegalArgumentException if sourceId is < 1 or > 8 + */ + private void requestPresets(int sourceId, List presetIds) { + if (sourceId < 1 || sourceId > 8) { + throw new IllegalArgumentException("sourceId must be between 1 and 8"); + } + if (presetIds == null) { + throw new IllegalArgumentException("presetIds must not be null"); + } + for (RioPreset preset : presetIds) { + sendCommand("GET S[" + sourceId + "].B[" + preset.getBank() + "].P[" + preset.getBankPreset() + "].valid"); + sendCommand("GET S[" + sourceId + "].B[" + preset.getBank() + "].P[" + preset.getBankPreset() + "].name"); + } + } + + /** + * Refreshes ALL presets for all sources. Simply calls {@link #refreshPresets(Integer)} with each source identifier + */ + public void refreshPresets() { + for (int sourceId = 1; sourceId <= 8; sourceId++) { + refreshPresets(sourceId); + } + } + + /** + * Refreshes ALL presets for the given sourceId if they have not been refreshed within the last + * {@link #UPDATE_TIME_SPAN}. This method WILL change the {@link #lastUpdateTime}. No calls will be made if the + * source type is not a tuner (however the {@link #lastUpdateTime} will be reset). + * + * @param sourceId a source identifier between 1 and 8 + * @throws IllegalArgumentException if sourceId is < 1 or > 8 + */ + public void refreshPresets(Integer sourceId) { + if (sourceId < 1 || sourceId > 8) { + throw new IllegalArgumentException("sourceId must be between 1 and 8"); + } + lastUpdateLock.lock(); + try { + final long now = System.currentTimeMillis(); + if (now > lastUpdateTime[sourceId - 1] + UPDATE_TIME_SPAN) { + lastUpdateTime[sourceId - 1] = now; + + if (isTuner[sourceId - 1]) { + for (int x = 1; x <= 36; x++) { + final RioPreset preset = presets[sourceId - 1][x - 1]; + sendCommand("GET S[" + sourceId + "].B[" + preset.getBank() + "].P[" + preset.getBankPreset() + + "].valid"); + sendCommand("GET S[" + sourceId + "].B[" + preset.getBank() + "].P[" + preset.getBankPreset() + + "].name"); + } + } + } + } finally { + lastUpdateLock.unlock(); + } + } + + /** + * Returns the JSON representation of the presets for the sourceId and their state. If the sourceId does not + * represent a tuner, then an empty array JSON representation ("[]") will be returned. + * + * @return A non-null, non-empty JSON representation of {@link #_systemFavorites} + */ + public String getJson(int source) { + if (!isTuner[source - 1]) { + return "[]"; + } + + final List validPresets = new ArrayList(); + for (final RioPreset preset : presets[source - 1]) { + if (preset.isValid()) { + validPresets.add(preset); + } + } + + return gson.toJson(validPresets); + } + + /** + * Sets a zone preset. NOTE: at this time, only a single preset can be represented in the presetJson. Having more + * than one preset saved to the same underlying channel causes the russound system to become a little unstable. This + * method will save the preset if the status is changed from not valid to valid or if the name is simply changing on + * a currently valid preset. The preset will be deleted if status is changed from valid to not valid. When saving a + * preset and the name is not specified, the russound system will automatically assign a name equal to the channel + * being played. + * + * @param controller a controller between 1 and 6 + * @param zone a zone between 1 and 8 + * @param source a source between 1 and 8 + * @param presetJson the possibly empty, possibly null JSON representation of the preset + * @throws IllegalArgumentException if controller is < 1 or > 6 + * @throws IllegalArgumentException if zone is < 1 or > 8 + * @throws IllegalArgumentException if source is < 1 or > 8 + * @throws IllegalArgumentException if presetJson contains more than one preset + */ + public void setZonePresets(int controller, int zone, int source, String presetJson) { + + if (controller < 1 || controller > 6) { + throw new IllegalArgumentException("Controller must be between 1 and 6"); + } + + if (zone < 1 || zone > 8) { + throw new IllegalArgumentException("Zone must be between 1 and 8"); + } + + if (source < 1 || source > 8) { + throw new IllegalArgumentException("Source must be between 1 and 8"); + } + + if (StringUtils.isEmpty(presetJson)) { + return; + } + + final List updatePresetIds = new ArrayList(); + try { + final RioPreset[] newPresets = gson.fromJson(presetJson, RioPreset[].class); + + // Keeps from screwing up the system if you set a bunch of presets to the same playing + if (newPresets.length > 1) { + throw new IllegalArgumentException("Can only save a single preset at a time"); + } + + for (int x = newPresets.length - 1; x >= 0; x--) { + final RioPreset preset = newPresets[x]; + if (preset == null) { + continue;// caused by {id,valid,name},,{id,valid,name} + } + final int presetId = preset.getId(); + if (presetId < 1 || presetId > 36) { + logger.debug("Invalid preset id (not between 1 and 36) - ignoring: {}:{}", presetId, presetJson); + } else { + final RioPreset myPreset = presets[source][presetId]; + final boolean presetValid = preset.isValid(); + final String presetName = preset.getName(); + + // re-retrieve to see if the save/delete worked (saving on a zone that's off - valid won't be set to + // true) + if (!StringUtils.equals(myPreset.getName(), presetName) || myPreset.isValid() != presetValid) { + myPreset.setName(presetName); + myPreset.setValid(presetValid); + if (presetValid) { + if (StringUtils.isEmpty(presetName)) { + sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!savePreset " + presetId); + } else { + sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!savePreset \"" + presetName + + "\" " + presetId); + } + + updatePresetIds.add(preset); + } else { + sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!deletePreset " + presetId); + } + } + } + } + } catch (JsonSyntaxException e) { + logger.debug("Invalid JSON: {}", e.getMessage(), e); + } + + // Invalid the presets we updated + requestPresets(source, updatePresetIds); + + // Refresh our channel since 'presetJson' occupies it right now + fireUpdate(source); + } + + /** + * Handles any system notifications returned by the russound system + * + * @param m a non-null matcher + * @param resp a possibly null, possibly empty response + */ + void handlePresetNotification(Matcher m, String resp) { + if (m == null) { + throw new IllegalArgumentException("m (matcher) cannot be null"); + } + + if (m.groupCount() == 5) { + try { + final int source = Integer.parseInt(m.group(1)); + if (source >= 1 && source <= 8) { + + final int bank = Integer.parseInt(m.group(2)); + if (bank >= 1 && bank <= 6) { + + final int preset = Integer.parseInt(m.group(3)); + if (preset >= 1 && preset <= 6) { + final String key = m.group(4).toLowerCase(); + final String value = m.group(5); + + final RioPreset rioPreset = presets[source - 1][(bank - 1) * 6 + preset - 1]; + + switch (key) { + case PRESET_NAME: + rioPreset.setName(value); + fireUpdate(source); + break; + + case PRESET_VALID: + rioPreset.setValid(!"false".equalsIgnoreCase(value)); + fireUpdate(source); + break; + + default: + logger.warn("Unknown preset notification: '{}'", resp); + break; + } + } else { + logger.debug("Preset ID must be between 1 and 6: {}", resp); + } + } else { + logger.debug("Bank ID must be between 1 and 6: {}", resp); + + } + } else { + logger.debug("Source ID must be between 1 and 8: {}", resp); + } + } catch (NumberFormatException e) { + logger.warn("Invalid Preset Notification (source/bank/preset not a parsable integer): '{}')", resp); + } + } else { + logger.warn("Invalid Preset Notification: '{}')", resp); + } + } + + /** + * Handles any preset notifications returned by the russound system + * + * @param m a non-null matcher + * @param resp a possibly null, possibly empty response + */ + private void handlerSourceTypeNotification(Matcher m, String resp) { + if (m == null) { + throw new IllegalArgumentException("m (matcher) cannot be null"); + } + + if (m.groupCount() == 2) { + try { + final int sourceId = Integer.parseInt(m.group(1)); + if (sourceId >= 1 && sourceId <= 8) { + final String sourceType = m.group(2); + + final Matcher matcher = IS_TUNER.matcher(sourceType); + final boolean srcIsTuner = matcher.matches(); + + if (srcIsTuner != isTuner[sourceId - 1]) { + isTuner[sourceId - 1] = srcIsTuner; + + if (srcIsTuner) { + // force a refresh on the source + lastUpdateTime[sourceId - 1] = 0; + refreshPresets(sourceId); + } else { + for (int p = 0; p < 36; p++) { + presets[sourceId - 1][p].setValid(false); + presets[sourceId - 1][p].setName(null); + } + } + fireUpdate(sourceId); + } + } else { + logger.debug("Source is not between 1 and 8", resp); + } + } catch (NumberFormatException e) { + logger.warn("Invalid Preset Notification (source/bank/preset not a parsable integer): '{}')", resp); + } + } else { + logger.warn("Invalid Preset Notification: '{}')", resp); + } + } + + /** + * Implements {@link SocketSessionListener#responseReceived(String)} to try to process the response from the + * russound system. This response may be for other protocol handler - so ignore if we don't recognize the response. + * + * @param a possibly null, possibly empty response + */ + @Override + public void responseReceived(String response) { + if (StringUtils.isEmpty(response)) { + return; + } + + Matcher m = RSP_PRESETNOTIFICATION.matcher(response); + if (m.matches()) { + handlePresetNotification(m, response); + } + + m = RSP_SRCTYPENOTIFICATION.matcher(response); + if (m.matches()) { + handlerSourceTypeNotification(m, response); + } + } + + /** + * Defines the listener implementation to list for preset updates + * + * @author Tim Roberts + * + */ + public interface Listener { + /** + * Called when presets have changed for a specific sourceId. The jsonString will contain the current + * representation of all valid presets for the source. + * + * @param sourceId a source identifier between 1 and 8 + * @param jsonString a non-null, non-empty json representation of {@link RioPreset} + */ + void presetsUpdated(int sourceId, String jsonString); + } +} diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/RioSystemFavoritesProtocol.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/RioSystemFavoritesProtocol.java new file mode 100644 index 0000000000000..f8d6b0e96d407 --- /dev/null +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/RioSystemFavoritesProtocol.java @@ -0,0 +1,339 @@ +/** + * Copyright (c) 2010-2017 by the respective copyright holders. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.openhab.binding.russound.internal.rio; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.lang.StringUtils; +import org.openhab.binding.russound.internal.net.SocketSession; +import org.openhab.binding.russound.internal.net.SocketSessionListener; +import org.openhab.binding.russound.internal.rio.models.GsonUtilities; +import org.openhab.binding.russound.internal.rio.models.RioFavorite; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; + +/** + * This {@link AbstractRioProtocol} implementation provides the implementation for managing Russound system favorites. + * Since refreshing all 32 system favorites requires 64 calls to russound (for name/valid), we limit how often we can + * refresh to {@link #UPDATE_TIME_SPAN}. + * + * @author Tim Roberts + * + */ +public class RioSystemFavoritesProtocol extends AbstractRioProtocol { + + // logger + private final Logger logger = LoggerFactory.getLogger(RioSystemFavoritesProtocol.class); + + // Helper names in the protocol + private static final String FAV_NAME = "name"; + private static final String FAV_VALID = "valid"; + + /** + * The pattern representing system favorite notifications + */ + private static final Pattern RSP_SYSTEMFAVORITENOTIFICATION = Pattern + .compile("(?i)^[SN] System.favorite\\[(\\d+)\\].(\\w+)=\"(.*)\"$"); + + /** + * The current state of all 32 system favorites + */ + private final RioFavorite[] systemFavorites = new RioFavorite[32]; + + /** + * The {@link Gson} used for all JSON operations + */ + private final Gson gson; + + /** + * The {@link ReentrantLock} used to control access to {@link #lastUpdateTime} + */ + private final Lock lastUpdateLock = new ReentrantLock(); + + /** + * The last time we did a full refresh of system favorites via {@link #refreshSystemFavorites()} + */ + private long lastUpdateTime; + + /** + * The minimum timespan between full refreshes of system favorites (via {@link #refreshSystemFavorites()}) + */ + private static final long UPDATE_TIME_SPAN = 60000; + + /** + * The list of listeners that will be called when system favorites have changed + */ + private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList(); + + /** + * Constructs the system favorite protocol from the given session and callback. Note: the passed callback is not + * currently used + * + * @param session a non null {@link SocketSession} to use + * @param callback a non-null {@link RioHandlerCallback} to use + */ + public RioSystemFavoritesProtocol(SocketSession session, RioHandlerCallback callback) { + super(session, callback); + + gson = GsonUtilities.createGson(); + + for (int x = 1; x <= 32; x++) { + systemFavorites[x - 1] = new RioFavorite(x); + } + + } + + /** + * Adds the specified listener to changes in system favorites + * + * @param listener a non-null listener to add + * @throws IllegalArgumentException if listener is null + */ + public void addListener(Listener listener) { + if (listener == null) { + throw new IllegalArgumentException("listener cannot be null"); + } + listeners.add(listener); + } + + /** + * Removes the specified listener from change notifications + * + * @param listener a possibly null listener to remove (null is ignored) + * @return true if removed, false otherwise + */ + public boolean removeListener(Listener listener) { + return listeners.remove(listener); + } + + /** + * Fires the systemFavoritesUpdated method on all listeners with the results of {@link #getJson()} + */ + private void fireUpdate() { + final String json = getJson(); + for (Listener l : listeners) { + l.systemFavoritesUpdated(json); + } + } + + /** + * Helper method to request the specified system favorite id information (name/valid). Please note that this does + * NOT change the {@link #lastUpdateTime} + * + * @param favIds a non-null, possibly empty list of system favorite ids to request (any id < 1 or > 32 will be + * ignored) + * @throws IllegalArgumentException if favIds is null + */ + private void requestSystemFavorites(List favIds) { + if (favIds == null) { + throw new IllegalArgumentException("favIds cannot be null"); + } + for (final Integer favId : favIds) { + if (favId >= 1 && favId <= 32) { + sendCommand("GET System.favorite[" + favId + "].name"); + sendCommand("GET System.favorite[" + favId + "].valid"); + } + } + } + + /** + * Refreshes ALL system favorites if they have not been refreshed within the last + * {@link #UPDATE_TIME_SPAN}. This method WILL change the {@link #lastUpdateTime} + */ + public void refreshSystemFavorites() { + lastUpdateLock.lock(); + try { + final long now = System.currentTimeMillis(); + if (now > lastUpdateTime + UPDATE_TIME_SPAN) { + lastUpdateTime = now; + for (int x = 1; x <= 32; x++) { + sendCommand("GET System.favorite[" + x + "].valid"); + sendCommand("GET System.favorite[" + x + "].name"); + } + } + } finally { + lastUpdateLock.unlock(); + } + } + + /** + * Returns the JSON representation of all the system favorites and their state. + * + * @return A non-null, non-empty JSON representation of {@link #systemFavorites} + */ + public String getJson() { + final List favs = new ArrayList(); + for (final RioFavorite fav : systemFavorites) { + if (fav.isValid()) { + favs.add(fav); + } + } + return gson.toJson(favs); + } + + /** + * Sets the system favorites for a controller/zone. For each system favorite found in the favJson parameter, this + * method will either save the system favorite (if it's status changed from not valid to valid) or save the system + * favorite name (if only the name changed) or delete the system favorite (if status changed from valid to invalid). + * + * @param controller the controller number between 1 and 6 + * @param zone the zone number between 1 and 8 + * @param favJson the possibly empty, possibly null JSON representation of system favorites + * @throws IllegalArgumentException if controller is < 1 or > 6 + * @throws IllegalArgumentException if zone is < 1 or > 8 + */ + public void setSystemFavorites(int controller, int zone, String favJson) { + + if (controller < 1 || controller > 6) { + throw new IllegalArgumentException("Controller must be between 1 and 6"); + } + + if (zone < 1 || zone > 8) { + throw new IllegalArgumentException("Zone must be between 1 and 8"); + } + + if (StringUtils.isEmpty(favJson)) { + return; + } + + final List updateFavIds = new ArrayList(); + try { + final RioFavorite[] favs; + favs = gson.fromJson(favJson, RioFavorite[].class); + for (int x = favs.length - 1; x >= 0; x--) { + final RioFavorite fav = favs[x]; + if (fav == null) { + continue; // caused by {id,valid,name},,{id,valid,name} + } + + final int favId = fav.getId(); + if (favId < 1 || favId > 32) { + logger.debug("Invalid favorite id (not between 1 and 32) - ignoring: {}:{}", favId, favJson); + } else { + final RioFavorite myFav = systemFavorites[favId - 1]; + final boolean favValid = fav.isValid(); + final String favName = fav.getName(); + + // re-retrieve to see if the save/delete worked (saving on a zone that's off - valid won't be set to + // true) + if (myFav.isValid() != favValid) { + myFav.setValid(favValid); + if (favValid) { + myFav.setName(favName); + sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!saveSystemFavorite \"" + favName + + "\" " + favId); + updateFavIds.add(favId); + } else { + sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!deleteSystemFavorite " + favId); + } + } else if (!StringUtils.equals(myFav.getName(), favName)) { + myFav.setName(favName); + sendCommand("SET System.favorite[" + favId + "]." + FAV_NAME + "=\"" + favName + "\""); + } + } + } + } catch (JsonSyntaxException e) { + logger.debug("Invalid JSON: {}", e.getMessage(), e); + } + + // Refresh the favorites that changed (verifies if the favorite was actually saved) + requestSystemFavorites(updateFavIds); + + // Refresh any listeners immediately to reset the channel + fireUpdate(); + } + + /** + * Handles any system notifications returned by the russound system + * + * @param m a non-null matcher + * @param resp a possibly null, possibly empty response + */ + private void handleSystemFavoriteNotification(Matcher m, String resp) { + if (m == null) { + throw new IllegalArgumentException("m (matcher) cannot be null"); + } + if (m.groupCount() == 3) { + try { + final int favoriteId = Integer.parseInt(m.group(1)); + + if (favoriteId >= 1 && favoriteId <= 32) { + final RioFavorite fav = systemFavorites[favoriteId - 1]; + + final String key = m.group(2).toLowerCase(); + final String value = m.group(3); + + switch (key) { + case FAV_NAME: + fav.setName(value); + fireUpdate(); + break; + case FAV_VALID: + fav.setValid(!"false".equalsIgnoreCase(value)); + fireUpdate(); + break; + + default: + logger.warn("Unknown system favorite notification: '{}'", resp); + break; + } + } else { + logger.warn("Invalid System Favorite Notification (favorite < 1 or > 32): '{}')", resp); + } + } catch (NumberFormatException e) { + logger.warn("Invalid System Favorite Notification (favorite not a parsable integer): '{}')", resp); + } + } else { + logger.warn("Invalid System Notification response: '{}'", resp); + } + } + + /** + * Implements {@link SocketSessionListener#responseReceived(String)} to try to process the response from the + * russound system. This response may be for other protocol handler - so ignore if we don't recognize the response. + * + * @param a possibly null, possibly empty response + */ + @Override + public void responseReceived(String response) { + if (StringUtils.isEmpty(response)) { + return; + } + + final Matcher m = RSP_SYSTEMFAVORITENOTIFICATION.matcher(response); + if (m.matches()) { + handleSystemFavoriteNotification(m, response); + } + } + + /** + * Defines the listener implementation to list for system favorite updates + * + * @author Tim Roberts + * + */ + public interface Listener { + /** + * Called when system favorites have changed. The jsonString will contain the current representation of all + * valid system favorites. + * + * @param jsonString a non-null, non-empty json representation of {@link RioFavorite} + */ + void systemFavoritesUpdated(String jsonString); + } +} diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/StatefulHandlerCallback.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/StatefulHandlerCallback.java index 48221a4e94bd2..3e05923b61e8d 100644 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/StatefulHandlerCallback.java +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/StatefulHandlerCallback.java @@ -28,14 +28,14 @@ public class StatefulHandlerCallback implements RioHandlerCallback { /** The wrapped callback */ - private final RioHandlerCallback _wrappedCallback; + private final RioHandlerCallback wrappedCallback; /** The state by channel id */ - private final Map _state = new ConcurrentHashMap(); + private final Map state = new ConcurrentHashMap(); - private final Lock _statusLock = new ReentrantLock(); - private ThingStatus _lastThingStatus = null; - private ThingStatusDetail _lastThingStatusDetail = null; + private final Lock statusLock = new ReentrantLock(); + private ThingStatus lastThingStatus = null; + private ThingStatusDetail lastThingStatusDetail = null; /** * Create the callback from the other {@link RioHandlerCallback} @@ -48,11 +48,11 @@ public StatefulHandlerCallback(RioHandlerCallback wrappedCallback) { throw new IllegalArgumentException("wrappedCallback cannot be null"); } - _wrappedCallback = wrappedCallback; + this.wrappedCallback = wrappedCallback; } /** - * Overrides the status changed to simply call the {@link #_wrappedCallback} + * Overrides the status changed to simply call the {@link #wrappedCallback} * * @param status the new status * @param detail the new detail @@ -60,51 +60,52 @@ public StatefulHandlerCallback(RioHandlerCallback wrappedCallback) { */ @Override public void statusChanged(ThingStatus status, ThingStatusDetail detail, String msg) { - _statusLock.lock(); + statusLock.lock(); try { // Simply return we match the last status change (prevents loops if changing to the same status) - if (status == _lastThingStatus && detail == _lastThingStatusDetail) { + if (status == lastThingStatus && detail == lastThingStatusDetail) { return; } - _lastThingStatus = status; - _lastThingStatusDetail = detail; + lastThingStatus = status; + lastThingStatusDetail = detail; } finally { - _statusLock.unlock(); + statusLock.unlock(); } + // If we got this far - call the underlying one - _wrappedCallback.statusChanged(status, detail, msg); + wrappedCallback.statusChanged(status, detail, msg); } /** * Overrides the state changed to determine if the state is new or changed and then - * to call the {@link #_wrappedCallback} if it has + * to call the {@link #wrappedCallback} if it has * * @param channelId the channel id that changed - * @param state the new state + * @param newState the new state */ @Override - public void stateChanged(String channelId, State state) { + public void stateChanged(String channelId, State newState) { if (StringUtils.isEmpty(channelId)) { return; } - final State oldState = _state.get(channelId); + final State oldState = state.get(channelId); // If both null OR the same value (enums), nothing changed - if (oldState == state) { + if (oldState == newState) { return; } // If they are equal - nothing changed - if (oldState != null && oldState.equals(state)) { + if (oldState != null && oldState.equals(newState)) { return; } // Something changed - save the new state and call the underlying wrapped - _state.put(channelId, state); - _wrappedCallback.stateChanged(channelId, state); + state.put(channelId, newState); + wrappedCallback.stateChanged(channelId, newState); } @@ -118,18 +119,39 @@ public void removeState(String channelId) { if (StringUtils.isEmpty(channelId)) { return; } - _state.remove(channelId); + state.remove(channelId); } /** - * Overrides the set property to simply call the {@link #_wrappedCallback} + * Overrides the set property to simply call the {@link #wrappedCallback} * * @param propertyName a non-null, non-empty property name * @param propertyValue a non-null, possibly empty property value */ @Override public void setProperty(String propertyName, String propertyValue) { - _wrappedCallback.setProperty(propertyName, propertyValue); + wrappedCallback.setProperty(propertyName, propertyValue); + + } + /** + * Returns teh current state for the property + * + * @param propertyName a possibly null, possibly empty property name + * @return the {@link State} for the property or null if not found (or property name is null/empty) + */ + public State getProperty(String propertyName) { + return state.get(propertyName); + } + + @Override + public void addListener(String channelId, RioHandlerCallbackListener listener) { + wrappedCallback.addListener(channelId, listener); + + } + + @Override + public void removeListener(String channelId, RioHandlerCallbackListener listener) { + wrappedCallback.removeListener(channelId, listener); } } diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/bank/RioBankConfig.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/bank/RioBankConfig.java deleted file mode 100644 index d2fe679937232..0000000000000 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/bank/RioBankConfig.java +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Copyright (c) 2010-2017 by the respective copyright holders. - * - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the Eclipse Public License v1.0 - * which accompanies this distribution, and is available at - * http://www.eclipse.org/legal/epl-v10.html - */ -package org.openhab.binding.russound.internal.rio.bank; - -/** - * Configuration class for the {@link RioBankHandler} - * - * @author Tim Roberts - */ -public class RioBankConfig { - /** - * ID of the bank within the source (should be 1-6) - */ - private int bank; - - /** - * Gets the bank identifier - * - * @return the bank identifier - */ - public int getBank() { - return bank; - } - - /** - * Sets the bank identifier - * - * @param bank the bank identifier - */ - public void setBank(int bank) { - this.bank = bank; - } -} diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/bank/RioBankHandler.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/bank/RioBankHandler.java deleted file mode 100644 index eb621b091bcfa..0000000000000 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/bank/RioBankHandler.java +++ /dev/null @@ -1,216 +0,0 @@ -/** - * Copyright (c) 2010-2017 by the respective copyright holders. - * - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the Eclipse Public License v1.0 - * which accompanies this distribution, and is available at - * http://www.eclipse.org/legal/epl-v10.html - */ -package org.openhab.binding.russound.internal.rio.bank; - -import org.eclipse.smarthome.core.library.types.StringType; -import org.eclipse.smarthome.core.thing.Bridge; -import org.eclipse.smarthome.core.thing.ChannelUID; -import org.eclipse.smarthome.core.thing.ThingStatus; -import org.eclipse.smarthome.core.thing.ThingStatusDetail; -import org.eclipse.smarthome.core.thing.binding.ThingHandler; -import org.eclipse.smarthome.core.types.Command; -import org.eclipse.smarthome.core.types.RefreshType; -import org.eclipse.smarthome.core.types.State; -import org.openhab.binding.russound.internal.net.SocketSession; -import org.openhab.binding.russound.internal.rio.AbstractBridgeHandler; -import org.openhab.binding.russound.internal.rio.RioConstants; -import org.openhab.binding.russound.internal.rio.RioHandlerCallback; -import org.openhab.binding.russound.internal.rio.StatefulHandlerCallback; -import org.openhab.binding.russound.internal.rio.source.RioSourceHandler; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * The bridge handler for a Russound Bank. A bank provides access to presets and is generally associated with a tuner - * source. A bank is similar to "FM-1", "FM-2" or "AM" on radios where the presets are different stations. This - * implementation must be attached to a {@link RioSourceHandler} bridge. - * - * @author Tim Roberts - */ -public class RioBankHandler extends AbstractBridgeHandler { - private Logger logger = LoggerFactory.getLogger(RioBankHandler.class); - - /** - * The bank identifier of this instance - */ - private int _bank; - - /** - * The parent source identifier - */ - private int _source; - - /** - * Constructs the handler from the {@link Bridge} - * - * @param bridge a non-null {@link Bridge} the handler is for - */ - public RioBankHandler(Bridge bridge) { - super(bridge); - - } - - /** - * Returns the bank identifier for this handler - * - * @return a bank identifier from 1-6 - */ - public int getBank() { - return _bank; - } - - /** - * Returns the source identifier this handler is related to - * - * @return a source identifier from 1-12 - */ - public int getSource() { - return _source; - } - - /** - * {@inheritDoc} - * - * Handles commands to specific channels. This implementation will offload much of its work to the - * {@link RioBankProtocol}. Basically we validate the type of command for the channel then call the - * {@link RioBankProtocol} to handle the actual protocol. Special use case is the {@link RefreshType} - * where we call {{@link #handleRefresh(String)} to handle a refresh of the specific channel (which in turn calls - * {@link RioBankProtocol} to handle the actual refresh - */ - @Override - public void handleCommand(ChannelUID channelUID, Command command) { - - if (command instanceof RefreshType) { - handleRefresh(channelUID.getId()); - return; - } - String id = channelUID.getId(); - - if (id == null) { - logger.debug("Called with a null channel id - ignoring"); - return; - } - - if (id.equals(RioConstants.CHANNEL_BANKNAME)) { - if (command instanceof StringType) { - getProtocolHandler().setName(command.toString()); - } else { - logger.debug("Received a favorite name channel command with a non StringType: {}", command); - } - } else { - logger.debug("Unknown/Unsupported Channel id: {}", id); - } - } - - /** - * Method that handles the {@link RefreshType} command specifically. Calls the {@link RioBankProtocol} to - * handle the actual refresh based on the channel id. - * - * @param id a non-null, possibly empty channel id to refresh - */ - private void handleRefresh(String id) { - if (getThing().getStatus() != ThingStatus.ONLINE) { - return; - } - - if (getProtocolHandler() == null) { - return; - } - - // Remove the cache'd value to force a refreshed value - ((StatefulHandlerCallback) getProtocolHandler().getCallback()).removeState(id); - - if (id.equals(RioConstants.CHANNEL_BANKNAME)) { - getProtocolHandler().refreshName(); - - } else { - // Can't refresh any others... - } - } - - /** - * Initializes the bridge. Confirms the configuration is valid and that our parent bridge is a - * {@link RioSourceHandler}. Once validated, a {@link RioBankProtocol} is set via - * {@link #setProtocolHandler(RioBankProtocol)} and the bridge comes online. - */ - @Override - public void initialize() { - final Bridge bridge = getBridge(); - if (bridge == null) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "Cannot be initialized without a bridge"); - return; - } - if (bridge.getStatus() != ThingStatus.ONLINE) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE); - return; - } - - final ThingHandler handler = bridge.getHandler(); - if (handler == null) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "No handler specified (null) for the bridge!"); - return; - } - - if (!(handler instanceof RioSourceHandler)) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "Bank must be attached to a Source bridge: " + handler.getClass()); - return; - } - - final RioBankConfig config = getThing().getConfiguration().as(RioBankConfig.class); - if (config == null) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Configuration file missing"); - return; - } - - _bank = config.getBank(); - if (_bank < 1 || _bank > 6) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "Bank must be between 1 and 6: " + config.getBank()); - - } - - // Get the socket session from the - final SocketSession socketSession = getSocketSession(); - if (socketSession == null) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR, "No socket session found"); - return; - } - - if (getProtocolHandler() != null) { - setProtocolHandler(null); - } - - _source = ((RioSourceHandler) handler).getSource(); - - setProtocolHandler(new RioBankProtocol(_bank, _source, socketSession, - new StatefulHandlerCallback(new RioHandlerCallback() { - @Override - public void statusChanged(ThingStatus status, ThingStatusDetail detail, String msg) { - updateStatus(status, detail, msg); - } - - @Override - public void stateChanged(String channelId, State state) { - updateState(channelId, state); - } - - @Override - public void setProperty(String propertyName, String propertyValue) { - getThing().setProperty(propertyName, propertyValue); - } - }))); - - updateStatus(ThingStatus.ONLINE); - - getProtocolHandler().refreshName(); - } -} diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/bank/RioBankProtocol.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/bank/RioBankProtocol.java deleted file mode 100644 index 2a5e87eceb9f9..0000000000000 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/bank/RioBankProtocol.java +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Copyright (c) 2010-2017 by the respective copyright holders. - * - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the Eclipse Public License v1.0 - * which accompanies this distribution, and is available at - * http://www.eclipse.org/legal/epl-v10.html - */ -package org.openhab.binding.russound.internal.rio.bank; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import org.eclipse.smarthome.core.library.types.StringType; -import org.openhab.binding.russound.internal.net.SocketSession; -import org.openhab.binding.russound.internal.net.SocketSessionListener; -import org.openhab.binding.russound.internal.rio.AbstractRioProtocol; -import org.openhab.binding.russound.internal.rio.RioConstants; -import org.openhab.binding.russound.internal.rio.RioHandlerCallback; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * This is the protocol handler for the Russound Bank. This handler will issue the protocol commands and will - * process the responses from the Russound system. - * - * @author Tim Roberts - * - */ -class RioBankProtocol extends AbstractRioProtocol { - // our logger - private Logger logger = LoggerFactory.getLogger(RioBankProtocol.class); - - /** - * The bank identifier for the handler - */ - private final int _bank; - - /** - * The source identifier for the handler - */ - private final int _source; - - // Protocol constants - private static final String BANK_NAME = "name"; - - // Protocol notification patterns - private final Pattern RSP_BANKNOTIFICATION = Pattern.compile("^[SN] S\\[(\\d+)\\].B\\[(\\d+)\\].(\\w+)=\"(.*)\"$"); - - /** - * Constructs the protocol handler from given parameters - * - * @param bank the bank identifier - * @param source the source identifier - * @param session a non-null {@link SocketSession} (may be connected or disconnected) - * @param callback a non-null {@link RioHandlerCallback} to callback - */ - RioBankProtocol(int bank, int source, SocketSession session, RioHandlerCallback callback) { - super(session, callback); - _bank = bank; - _source = source; - } - - /** - * Request a refresh of the bank name - */ - void refreshName() { - sendCommand("GET S[" + _source + "].B[" + _bank + "].name"); - } - - /** - * Sets the name of the bank - * - * @param name a non-null, non-empty bank name to set - * @throws IllegalArgumentException if name is null or an empty string - */ - void setName(String name) { - if (name == null || name.trim().length() == 0) { - throw new IllegalArgumentException("name cannot be null or empty"); - } - sendCommand("SET S[" + _source + "].B[" + _bank + "].name = \"" + name + "\""); - } - - /** - * Handles any bank notifications returned by the russound system - * - * @param m a non-null matcher - * @param resp a possibly null, possibly empty response - */ - private void handleBankNotification(Matcher m, String resp) { - if (m == null) { - throw new IllegalArgumentException("m (matcher) cannot be null"); - } - - // System notification - if (m.groupCount() == 4) { - try { - final int bank = Integer.parseInt(m.group(1)); - if (bank != _bank) { - return; - } - - final int source = Integer.parseInt(m.group(2)); - if (source != _source) { - return; - } - - final String key = m.group(3); - final String value = m.group(4); - - switch (key) { - case BANK_NAME: - stateChanged(RioConstants.CHANNEL_BANKNAME, new StringType(value)); - break; - - default: - logger.warn("Unknown bank name notification: '{}'", resp); - break; - } - } catch (NumberFormatException e) { - logger.warn("Invalid Bank Name Notification (bank/source not a parsable integer): '{}')", resp); - } - - } else { - logger.warn("Invalid Bank Notification: '{}')", resp); - } - } - - /** - * Implements {@link SocketSessionListener#responseReceived(String)} to try to process the response from the - * russound system. This response may be for other protocol handler - so ignore if we don't recognize the response. - * - * @param a possibly null, possibly empty response - */ - @Override - public void responseReceived(String response) { - if (response == null || response == "") { - return; - } - - final Matcher m = RSP_BANKNOTIFICATION.matcher(response); - if (m.matches()) { - handleBankNotification(m, response); - return; - } - } -} diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/controller/RioControllerConfig.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/controller/RioControllerConfig.java index 2f706306e0d68..cd4c0ad0bc435 100644 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/controller/RioControllerConfig.java +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/controller/RioControllerConfig.java @@ -14,6 +14,11 @@ * @author Tim Roberts */ public class RioControllerConfig { + /** + * Constant defined for the "controller" configuration field + */ + public static final String Controller = "controller"; + /** * ID of the controller */ diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/controller/RioControllerHandler.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/controller/RioControllerHandler.java index 2be9b6e3d4c62..5a6f1bfe7b2a1 100644 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/controller/RioControllerHandler.java +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/controller/RioControllerHandler.java @@ -8,21 +8,32 @@ */ package org.openhab.binding.russound.internal.rio.controller; +import java.util.concurrent.atomic.AtomicInteger; + import org.eclipse.smarthome.core.thing.Bridge; import org.eclipse.smarthome.core.thing.ChannelUID; +import org.eclipse.smarthome.core.thing.Thing; import org.eclipse.smarthome.core.thing.ThingStatus; import org.eclipse.smarthome.core.thing.ThingStatusDetail; import org.eclipse.smarthome.core.thing.binding.ThingHandler; import org.eclipse.smarthome.core.types.Command; +import org.eclipse.smarthome.core.types.RefreshType; import org.eclipse.smarthome.core.types.State; import org.openhab.binding.russound.internal.net.SocketSession; import org.openhab.binding.russound.internal.rio.AbstractBridgeHandler; +import org.openhab.binding.russound.internal.rio.AbstractRioHandlerCallback; +import org.openhab.binding.russound.internal.rio.RioConstants; import org.openhab.binding.russound.internal.rio.RioHandlerCallback; +import org.openhab.binding.russound.internal.rio.RioHandlerCallbackListener; +import org.openhab.binding.russound.internal.rio.RioNamedHandler; import org.openhab.binding.russound.internal.rio.StatefulHandlerCallback; +import org.openhab.binding.russound.internal.rio.models.GsonUtilities; import org.openhab.binding.russound.internal.rio.source.RioSourceHandler; import org.openhab.binding.russound.internal.rio.system.RioSystemHandler; import org.openhab.binding.russound.internal.rio.zone.RioZoneHandler; +import com.google.gson.Gson; + /** * The bridge handler for a Russound Controller. A controller provides access to sources ({@link RioSourceHandler}) and * zones ({@link RioZoneHandler}). This @@ -30,11 +41,27 @@ * * @author Tim Roberts */ -public class RioControllerHandler extends AbstractBridgeHandler { +public class RioControllerHandler extends AbstractBridgeHandler implements RioNamedHandler { /** * The controller identifier of this instance (between 1-6) */ - private int _controller; + private final AtomicInteger controller = new AtomicInteger(0); + + /** + * {@link Gson} used for JSON operations + */ + private final Gson gson = GsonUtilities.createGson(); + + /** + * Callback listener to use when zone name changes - will call {@link #refreshNamedHandler(Gson, Class, String)} to + * refresh the {@link RioConstants#CHANNEL_CTLZONES} channel + */ + private final RioHandlerCallbackListener handlerCallbackListener = new RioHandlerCallbackListener() { + @Override + public void stateUpdate(String channelId, State state) { + refreshNamedHandler(gson, RioZoneHandler.class, RioConstants.CHANNEL_CTLZONES); + } + }; /** * Constructs the handler from the {@link Bridge} @@ -51,19 +78,46 @@ public RioControllerHandler(Bridge bridge) { * * @return the controller identifier */ - public int getController() { - return _controller; + @Override + public int getId() { + return controller.get(); + } + + /** + * Returns the controller name + * + * @return a non-empty, non-null controller name + */ + @Override + public String getName() { + return "Controller " + getId(); } /** * {@inheritDoc} * - * Handles commands to specific channels - this handler has no channels to handle and - * is a NOP + * Handles commands to specific channels. The only command this handles is a {@link RefreshType} and that's handled + * by {{@link #handleRefresh(String)} */ @Override public void handleCommand(ChannelUID channelUID, Command command) { - // no commands to implement + if (command instanceof RefreshType) { + handleRefresh(channelUID.getId()); + return; + } + } + + /** + * Method that handles the {@link RefreshType} command specifically. + * + * @param id a non-null, possibly empty channel id to refresh + */ + private void handleRefresh(String id) { + if (id.equals(RioConstants.CHANNEL_CTLZONES)) { + refreshNamedHandler(gson, RioZoneHandler.class, RioConstants.CHANNEL_CTLZONES); + } else { + // Can't refresh any others... + } } /** @@ -104,12 +158,13 @@ public void initialize() { return; } - _controller = config.getController(); - if (_controller < 1 || _controller > 8) { + final int configController = config.getController(); + if (configController < 1 || configController > 8) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "Controller must be between 1 and 8: " + _controller); + "Controller must be between 1 and 8: " + configController); return; } + controller.set(configController); // Get the socket session from the final SocketSession socketSession = getSocketSession(); @@ -122,8 +177,8 @@ public void initialize() { getProtocolHandler().dispose(); } - setProtocolHandler(new RioControllerProtocol(_controller, socketSession, - new StatefulHandlerCallback(new RioHandlerCallback() { + setProtocolHandler(new RioControllerProtocol(configController, socketSession, + new StatefulHandlerCallback(new AbstractRioHandlerCallback() { @Override public void statusChanged(ThingStatus status, ThingStatusDetail detail, String msg) { updateStatus(status, detail, msg); @@ -132,6 +187,7 @@ public void statusChanged(ThingStatus status, ThingStatusDetail detail, String m @Override public void stateChanged(String channelId, State state) { updateState(channelId, state); + fireStateUpdated(channelId, state); } @Override @@ -141,8 +197,49 @@ public void setProperty(String propertyName, String property) { }))); updateStatus(ThingStatus.ONLINE); - getProtocolHandler().refreshControllerType(); - getProtocolHandler().refreshControllerIpAddress(); - getProtocolHandler().refreshControllerMacAddress(); + getProtocolHandler().postOnline(); + + refreshNamedHandler(gson, RioZoneHandler.class, RioConstants.CHANNEL_CTLZONES); + + } + + /** + * Overrides the base to call {@link #childChanged(ThingHandler)} to recreate the zone names + */ + @Override + public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) { + childChanged(childHandler, true); + } + + /** + * Overrides the base to call {@link #childChanged(ThingHandler)} to recreate the zone names + */ + @Override + public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) { + childChanged(childHandler, false); + } + + /** + * Helper method to recreate the {@link RioConstants#CHANNEL_CTLZONES} channel + * + * @param childHandler a non-null child handler that changed + * @param added true if the handler was added, false otherwise + * @throw IllegalArgumentException if childHandler is null + */ + private void childChanged(ThingHandler childHandler, boolean added) { + if (childHandler == null) { + throw new IllegalArgumentException("childHandler cannot be null"); + } + if (childHandler instanceof RioZoneHandler) { + final RioHandlerCallback callback = ((RioZoneHandler) childHandler).getCallback(); + if (callback != null) { + if (added) { + callback.addListener(RioConstants.CHANNEL_ZONENAME, handlerCallbackListener); + } else { + callback.removeListener(RioConstants.CHANNEL_ZONENAME, handlerCallbackListener); + } + } + refreshNamedHandler(gson, RioZoneHandler.class, RioConstants.CHANNEL_CTLZONES); + } } } diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/controller/RioControllerProtocol.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/controller/RioControllerProtocol.java index 23e995e72f105..57a65873dd268 100644 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/controller/RioControllerProtocol.java +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/controller/RioControllerProtocol.java @@ -11,6 +11,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.apache.commons.lang.StringUtils; import org.openhab.binding.russound.internal.net.SocketSession; import org.openhab.binding.russound.internal.net.SocketSessionListener; import org.openhab.binding.russound.internal.rio.AbstractRioProtocol; @@ -28,20 +29,21 @@ */ class RioControllerProtocol extends AbstractRioProtocol { // logger - private Logger logger = LoggerFactory.getLogger(RioControllerProtocol.class); + private final Logger logger = LoggerFactory.getLogger(RioControllerProtocol.class); /** * The controller identifier */ - private final int _controller; + private final int controller; // Protocol constants private static final String CTL_TYPE = "type"; - private static final String CTL_IPADDRESS = "ipAddress"; - private static final String CTL_MACADDRESS = "macAddress"; + private static final String CTL_IPADDRESS = "ipaddress"; + private static final String CTL_MACADDRESS = "macaddress"; - // Response pattners - private final Pattern RSP_CONTROLLERNOTIFICATION = Pattern.compile("^[SN] C\\[(\\d+)\\]\\.(\\w+)=\"(.*)\"$"); + // Response patterns + private static final Pattern RSP_CONTROLLERNOTIFICATION = Pattern + .compile("(?i)^[SN] C\\[(\\d+)\\]\\.(\\w+)=\"(.*)\"$"); /** * Constructs the protocol handler from given parameters @@ -52,7 +54,16 @@ class RioControllerProtocol extends AbstractRioProtocol { */ RioControllerProtocol(int controller, SocketSession session, RioHandlerCallback callback) { super(session, callback); - _controller = controller; + this.controller = controller; + } + + /** + * Helper method to issue post online commands + */ + void postOnline() { + refreshControllerType(); + refreshControllerIpAddress(); + refreshControllerMacAddress(); } /** @@ -65,7 +76,7 @@ private void refreshControllerKey(String keyName) { if (keyName == null || keyName.trim().length() == 0) { throw new IllegalArgumentException("keyName cannot be null or empty"); } - sendCommand("GET C[" + _controller + "]." + keyName); + sendCommand("GET C[" + controller + "]." + keyName); } /** @@ -101,12 +112,12 @@ private void handleControllerNotification(Matcher m, String resp) { } if (m.groupCount() == 3) { try { - final int controller = Integer.parseInt(m.group(1)); - if (controller != _controller) { + final int notifyController = Integer.parseInt(m.group(1)); + if (notifyController != controller) { return; } - final String key = m.group(2); + final String key = m.group(2).toLowerCase(); final String value = m.group(3); switch (key) { @@ -143,7 +154,7 @@ private void handleControllerNotification(Matcher m, String resp) { */ @Override public void responseReceived(String response) { - if (response == null || response == "") { + if (StringUtils.isEmpty(response)) { return; } diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/favorites/RioFavoriteConfig.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/favorites/RioFavoriteConfig.java deleted file mode 100644 index cc9712f11a058..0000000000000 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/favorites/RioFavoriteConfig.java +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Copyright (c) 2010-2017 by the respective copyright holders. - * - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the Eclipse Public License v1.0 - * which accompanies this distribution, and is available at - * http://www.eclipse.org/legal/epl-v10.html - */ -package org.openhab.binding.russound.internal.rio.favorites; - -/** - * Configuration class for the {@link RioFavoriteHandler} - * - * @author Tim Roberts - */ -public class RioFavoriteConfig { - /** - * The favorite identifier (1-2 for zone, 1-32 for system) - */ - private int favorite; - - /** - * Gets the favorite identifier - * - * @return the favorite identifier - */ - public int getFavorite() { - return favorite; - } - - /** - * Sets the favorite identifier - * - * @param favorite the favorite identifier - */ - public void setFavorite(int favorite) { - this.favorite = favorite; - } -} diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/favorites/RioFavoriteHandler.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/favorites/RioFavoriteHandler.java deleted file mode 100644 index 03a4e3e9c9b83..0000000000000 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/favorites/RioFavoriteHandler.java +++ /dev/null @@ -1,246 +0,0 @@ -/** - * Copyright (c) 2010-2017 by the respective copyright holders. - * - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the Eclipse Public License v1.0 - * which accompanies this distribution, and is available at - * http://www.eclipse.org/legal/epl-v10.html - */ -package org.openhab.binding.russound.internal.rio.favorites; - -import org.eclipse.smarthome.core.library.types.StringType; -import org.eclipse.smarthome.core.thing.Bridge; -import org.eclipse.smarthome.core.thing.ChannelUID; -import org.eclipse.smarthome.core.thing.Thing; -import org.eclipse.smarthome.core.thing.ThingStatus; -import org.eclipse.smarthome.core.thing.ThingStatusDetail; -import org.eclipse.smarthome.core.thing.binding.ThingHandler; -import org.eclipse.smarthome.core.types.Command; -import org.eclipse.smarthome.core.types.RefreshType; -import org.eclipse.smarthome.core.types.State; -import org.openhab.binding.russound.internal.net.SocketSession; -import org.openhab.binding.russound.internal.rio.AbstractThingHandler; -import org.openhab.binding.russound.internal.rio.RioConstants; -import org.openhab.binding.russound.internal.rio.RioHandlerCallback; -import org.openhab.binding.russound.internal.rio.StatefulHandlerCallback; -import org.openhab.binding.russound.internal.rio.system.RioSystemHandler; -import org.openhab.binding.russound.internal.rio.zone.RioZoneHandler; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * The thing handler for a Russound Favorite. A favorite provides quick access to favorite source/configurations. A - * favorite can exist either at the system level or at a zone level. This - * implementation must be attached to either a {@link RioSystemHandler} bridge or a {@link RioZoneHandler}. - * - * @author Tim Roberts - */ -public class RioFavoriteHandler extends AbstractThingHandler { - // Logger - private Logger logger = LoggerFactory.getLogger(RioFavoriteHandler.class); - - /** - * The favorite identifier for this instance - */ - private int _favorite; - - /** - * Constructs the handler from the {@link Thing} - * - * @param thing a non-null {@link Thing} the handler is for - */ - public RioFavoriteHandler(Thing thing) { - super(thing); - - } - - /** - * {@inheritDoc} - * - * Handles commands to specific channels. This implementation will offload much of its work to the - * {@link RioFavoriteProtocol}. Basically we validate the type of command for the channel then call the - * {@link RioFavoriteProtocol} to handle the actual protocol. Special use case is the {@link RefreshType} - * where we call {{@link #handleRefresh(String)} to handle a refresh of the specific channel (which in turn calls - * {@link RioFavoriteProtocol} to handle the actual refresh - */ - @Override - public void handleCommand(ChannelUID channelUID, Command command) { - - if (command instanceof RefreshType) { - handleRefresh(channelUID.getId()); - return; - } - final String id = channelUID.getId(); - - if (id == null) { - logger.debug("Called with a null channel id - ignoring"); - return; - } - - if (id.equals(RioConstants.CHANNEL_FAVNAME)) { - if (command instanceof StringType) { - getProtocolHandler().setName(command.toString()); - } else { - logger.debug("Received a favorite name channel command with a non StringType: {}", command); - } - - } else if (id.equals(RioConstants.CHANNEL_FAVCMD)) { - if (command instanceof StringType) { - final String cmd = command.toString().toLowerCase(); - switch (cmd) { - case RioConstants.CMD_FAVSAVESYS: - getProtocolHandler().saveFavorite(true); - break; - - case RioConstants.CMD_FAVRESTORESYS: - getProtocolHandler().restoreFavorite(true); - break; - case RioConstants.CMD_FAVDELETESYS: - getProtocolHandler().deleteFavorite(true); - break; - - case RioConstants.CMD_FAVSAVEZONE: - getProtocolHandler().saveFavorite(false); - break; - - case RioConstants.CMD_FAVRESTOREZONE: - getProtocolHandler().restoreFavorite(false); - break; - case RioConstants.CMD_FAVDELETEZONE: - getProtocolHandler().deleteFavorite(false); - break; - - default: - break; - } - } else { - logger.debug("Received a favorite channel command with a non StringType: {}", command); - } - - } else { - logger.debug("Unknown/Unsupported Channel id: {}", id); - } - } - - /** - * Method that handles the {@link RefreshType} command specifically. Calls the {@link RioFavoriteProtocol} to - * handle the actual refresh based on the channel id. - * - * @param id a non-null, possibly empty channel id to refresh - */ - private void handleRefresh(String id) { - if (getThing().getStatus() != ThingStatus.ONLINE) { - return; - } - - if (getProtocolHandler() == null) { - return; - } - - // Remove the cache'd value to force a refreshed value - ((StatefulHandlerCallback) getProtocolHandler().getCallback()).removeState(id); - - if (id.equals(RioConstants.CHANNEL_FAVNAME)) { - getProtocolHandler().refreshName(); - - } else if (id.equals(RioConstants.CHANNEL_FAVVALID)) { - getProtocolHandler().refreshValid(); - } else { - // Can't refresh any others... - } - } - - /** - * Initializes the thing. Confirms the configuration is valid and that our parent bridge is either a - * {@link RioSystemHandler} or {@link RioZoneHandler}. Once validated, a {@link RioFavoriteProtocol} is set via - * {@link #setProtocolHandler(RioFavoriteProtocol)} and the thing comes online. - */ - @Override - public void initialize() { - final Bridge bridge = getBridge(); - if (bridge == null) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "Cannot be initialized without a bridge"); - return; - } - if (bridge.getStatus() != ThingStatus.ONLINE) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE); - return; - } - - final ThingHandler handler = bridge.getHandler(); - if (handler == null) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "No handler specified (null) for the bridge!"); - return; - } - - if (!(handler instanceof RioSystemHandler) && !(handler instanceof RioZoneHandler)) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "Favorite must be attached to either the System bridge or a Zone bridge: " + handler.getClass()); - return; - } - - final RioFavoriteConfig config = getThing().getConfiguration().as(RioFavoriteConfig.class); - if (config == null) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Configuration file missing"); - return; - } - - _favorite = config.getFavorite(); - if (handler instanceof RioSystemHandler) { - if (_favorite < 1 || _favorite > 32) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "Favorite must be between 1 and 32 for a system favorite: " + _favorite); - - } - } else if (_favorite < 1 || _favorite > 2) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "Favorite must be between 1 and 2 for a zone favorite: " + _favorite); - - } - - // Get the socket session from the - final SocketSession socketSession = getSocketSession(); - if (socketSession == null) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR, "No socket session found"); - return; - } - - if (getProtocolHandler() != null) { - setProtocolHandler(null); - } - - int controllerId = -1; - int zoneId = -1; - - if (handler instanceof RioZoneHandler) { - final RioZoneHandler zoneHandler = (RioZoneHandler) handler; - controllerId = zoneHandler.getController(); - zoneId = zoneHandler.getZone(); - } - - setProtocolHandler(new RioFavoriteProtocol(_favorite, zoneId, controllerId, socketSession, - new StatefulHandlerCallback(new RioHandlerCallback() { - @Override - public void statusChanged(ThingStatus status, ThingStatusDetail detail, String msg) { - updateStatus(status, detail, msg); - } - - @Override - public void stateChanged(String channelId, State state) { - updateState(channelId, state); - } - - @Override - public void setProperty(String propertyName, String propertyValue) { - getThing().setProperty(propertyName, propertyValue); - } - }))); - - updateStatus(ThingStatus.ONLINE); - - getProtocolHandler().refreshName(); - getProtocolHandler().refreshValid(); - } -} diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/favorites/RioFavoriteProtocol.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/favorites/RioFavoriteProtocol.java deleted file mode 100644 index a3531a4175cc5..0000000000000 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/favorites/RioFavoriteProtocol.java +++ /dev/null @@ -1,330 +0,0 @@ -/** - * Copyright (c) 2010-2017 by the respective copyright holders. - * - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the Eclipse Public License v1.0 - * which accompanies this distribution, and is available at - * http://www.eclipse.org/legal/epl-v10.html - */ -package org.openhab.binding.russound.internal.rio.favorites; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import org.eclipse.smarthome.core.library.types.OnOffType; -import org.eclipse.smarthome.core.library.types.StringType; -import org.openhab.binding.russound.internal.net.SocketSession; -import org.openhab.binding.russound.internal.net.SocketSessionListener; -import org.openhab.binding.russound.internal.rio.AbstractRioProtocol; -import org.openhab.binding.russound.internal.rio.RioConstants; -import org.openhab.binding.russound.internal.rio.RioHandlerCallback; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * This is the protocol handler for the Russound Favorite. This handler will issue the protocol commands and will - * process the responses from the Russound system. This handler operates at two levels: system level or zone level. - * - * @author Tim Roberts - * - */ -class RioFavoriteProtocol extends AbstractRioProtocol { - // Logger - private Logger logger = LoggerFactory.getLogger(RioFavoriteProtocol.class); - - /** - * The favorite identifier - */ - private final int _favorite; - - /** - * The zone identifier (will be -1 if operating at the system level). - */ - private final int _zone; - - /** - * The controller identifier (will be -1 if operating at the system level). - */ - private final int _controller; - - /** - * The name of the favorite - only is applied when a saveXXXFavorite event is sent - */ - private String _name; - - // Protocol constants - private static final String FAV_NAME = "name"; - private static final String FAV_VALID = "valid"; - - // Response patterns - private final Pattern RSP_SYSTEMNOTIFICATION = Pattern - .compile("^[SN] System.favorite\\[(\\d+)\\].(\\w+)=\"(.*)\"$"); - private final Pattern RSP_ZONENOTIFICATION = Pattern - .compile("^[SN] C\\[(\\d+)\\].Z\\[(\\d+)\\].favorite\\[(\\d+)\\].(\\w+)=\"(.*)\"$"); - - /** - * Constructs the protocol handler from given parameters - * - * @param favorite the favorite identifier - * @param zone the zone identifier (or -1 if at the system level) - * @param controller the controller identifier (or -1 if at the system level) - * @param session a non-null {@link SocketSession} (may be connected or disconnected) - * @param callback a non-null {@link RioHandlerCallback} to callback - */ - RioFavoriteProtocol(int favorite, int zone, int controller, SocketSession session, RioHandlerCallback callback) { - super(session, callback); - _favorite = favorite; - _zone = zone; - _controller = controller; - setName("Favorite " + favorite); - } - - /** - * Helper method to deterime if we are at the system or zone level - * - * @return true if system level, false if zone level - */ - private boolean isSystemFavorite() { - return _controller <= 0; - } - - /** - * Helper method to refresh a given key. System or zone is determined by {@link #isSystemFavorite()} - * - * @param keyName a non-null, non-empty keyname to get - * @throws IllegalArgumentException if name is null or an empty string - */ - private void refreshKey(String keyName) { - refreshKey(keyName, isSystemFavorite()); - } - - /** - * Helper method to refresh a given key and issues a system or zone command - * - * @param keyName a non-null, non-empty keyname to get - * @param systemCommand true if a system command, false if zone - * @throws IllegalArgumentException if name is null or an empty string - */ - private void refreshKey(String keyName, boolean systemCommand) { - if (keyName == null || keyName.trim().length() == 0) { - throw new IllegalArgumentException("keyName cannot be null or empty"); - } - - if (systemCommand) { - sendCommand("GET System.favorite[" + _favorite + "]." + keyName); - } else { - sendCommand("GET C[" + _controller + "].Z[" + _zone + "].favorite[" + _favorite + "]." + keyName); - } - - } - - /** - * Refresh the favorite name - */ - void refreshName() { - refreshKey(FAV_NAME); - } - - /** - * Refresh whether the favorite is valid or not - */ - void refreshValid() { - refreshKey(FAV_VALID); - } - - /** - * Sets the name of the favorite. Please note that the name will only be committed when the favorite is saved. - * - * @param name a non-null, non-empty name - * @throws IllegalArgumentException if name is null or empty - */ - void setName(String name) { - if (name == null || name.trim().length() == 0) { - throw new IllegalArgumentException("name cannot be null or empty"); - } - - _name = name; - stateChanged(RioConstants.CHANNEL_FAVNAME, new StringType(name)); - } - - /** - * Save the favorite as a system or zone favorite - this can only be done from a zone level. If called on a - * system level, a debug warning will be issued and the call ignored. The name will be saved as well. - * - * @param system true if save to system favorite, false to save to zone favorite - */ - void saveFavorite(boolean system) { - if (isSystemFavorite()) { - logger.warn("Trying to save a system favorite outside of a zone"); - } else { - sendCommand("EVENT C[" + _controller + "].Z[" + _zone + "]!" - + (system ? "saveSystemFavorite" : "saveZoneFavorite") + " \"" + _name + "\" " + _favorite); - - refreshKey(FAV_NAME, true); - refreshKey(FAV_VALID, true); - if (!system) { - refreshKey(FAV_NAME, false); - refreshKey(FAV_VALID, false); - } - } - } - - /** - * Restore a system or zone favorite - this can only be done from a zone level. If called on a - * system level, a debug warning will be issued and the call ignored. - * - * @param system true if restore a system favorite, false to restore a zone favorite - */ - void restoreFavorite(boolean system) { - if (isSystemFavorite()) { - logger.warn("Trying to restore a system favorite outside of a zone"); - } else { - sendCommand("EVENT C[" + _controller + "].Z[" + _zone + "]!" - + (system ? "restoreSystemFavorite" : "restoreZoneFavorite") + " " + _favorite); - } - } - - /** - * Delete a system or zone favorite - this can only be done from a zone level. If called on a - * system level, a debug warning will be issued and the call ignored. - * - * @param system true if delete a system favorite, false to delete a zone favorite - */ - void deleteFavorite(boolean system) { - if (isSystemFavorite()) { - logger.warn("Trying to delete a system favorite outside of a zone"); - } else { - sendCommand("EVENT C[" + _controller + "].Z[" + _zone + "]!" - + (system ? "deleteSystemFavorite" : "deleteZoneFavorite") + " " + _favorite); - - refreshKey(FAV_VALID, true); - if (!system) { - refreshKey(FAV_VALID, false); - } - } - } - - /** - * Handles any system level favorite notifications returned by the russound system - * - * @param m a non-null matcher - * @param resp a possibly null, possibly empty response - */ - private void handleSystemNotification(Matcher m, String resp) { - if (m == null) { - throw new IllegalArgumentException("m (matcher) cannot be null"); - } - - // System notification - if (m.groupCount() == 3) { - try { - final int favorite = Integer.parseInt(m.group(1)); - if (favorite != _favorite) { - return; - } - - final String key = m.group(2); - final String value = m.group(3); - switch (key) { - case FAV_NAME: - setName(value); - break; - - case FAV_VALID: - stateChanged(RioConstants.CHANNEL_FAVVALID, - "false".equalsIgnoreCase(value) ? OnOffType.OFF : OnOffType.ON); - break; - - default: - logger.warn("Unknown system favorite notification: '{}'", resp); - break; - } - } catch (NumberFormatException e) { - logger.warn("Invalid System Favorite Notification (favorite not a parsable integer): '{}')", resp); - } - - } else { - logger.warn("Invalid System Favorite Notification: '{}')", resp); - } - } - - /** - * Handles any zone level favorite notifications returned by the russound system - * - * @param m a non-null matcher - * @param resp a possibly null, possibly empty response - */ - private void handleZoneNotification(Matcher m, String resp) { - if (m == null) { - throw new IllegalArgumentException("m (matcher) cannot be null"); - } - - if (m.groupCount() == 5) { - try { - final int controller = Integer.parseInt(m.group(1)); - if (controller != _controller) { - return; - } - - final int zone = Integer.parseInt(m.group(2)); - if (zone != _zone) { - return; - } - - final int favorite = Integer.parseInt(m.group(3)); - if (favorite != _favorite) { - return; - } - - final String key = m.group(4); - final String value = m.group(5); - - switch (key) { - case FAV_NAME: - setName(value); - break; - - case FAV_VALID: - stateChanged(RioConstants.CHANNEL_FAVVALID, - "false".equalsIgnoreCase(value) ? OnOffType.OFF : OnOffType.ON); - break; - - default: - logger.warn("Unknown zone favorite notification: '{}'", resp); - break; - } - } catch (NumberFormatException e) { - logger.warn( - "Invalid zone favorite Notification (controller/zone/favorite not a parsable integer): '{}')", - resp); - } - } else { - logger.warn("Invalid Zone Favorite Notification: '{}')", resp); - } - } - - /** - * Implements {@link SocketSessionListener#responseReceived(String)} to try to process the response from the - * russound system. This response may be for other protocol handler - so ignore if we don't recognize the response. - * - * @param a possibly null, possibly empty response - */ - @Override - public void responseReceived(String response) { - if (response == null || response == "") { - return; - } - - Matcher m = RSP_SYSTEMNOTIFICATION.matcher(response); - if (m.matches()) { - handleSystemNotification(m, response); - return; - } - - m = RSP_ZONENOTIFICATION.matcher(response); - if (m.matches()) { - handleZoneNotification(m, response); - return; - } - } -} diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/models/AtomicStringTypeAdapter.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/models/AtomicStringTypeAdapter.java new file mode 100644 index 0000000000000..228e981adb5b3 --- /dev/null +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/models/AtomicStringTypeAdapter.java @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2010-2017 by the respective copyright holders. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.openhab.binding.russound.internal.rio.models; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicReference; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; + +/** + * A GSON {@link TypeAdapter} that will properly write/read {@link AtomicReference} strings + * + * @author Tim Roberts + * + */ +public class AtomicStringTypeAdapter extends TypeAdapter> { + + /** + * Overriden to read the string from the {@link JsonReader} and create an + * {@link AtomicReference} from it + */ + @Override + public AtomicReference read(JsonReader in) throws IOException { + + AtomicReference value = null; + + JsonParser jsonParser = new JsonParser(); + JsonElement je = jsonParser.parse(in); + + if (je instanceof JsonPrimitive) { + value = new AtomicReference(); + value.set(((JsonPrimitive) je).getAsString()); + } else if (je instanceof JsonObject) { + JsonObject jsonObject = (JsonObject) je; + value = new AtomicReference(); + value.set(jsonObject.get("value").getAsString()); + } + + return value; + } + + /** + * Overridden to write out the underlying string + */ + @Override + public void write(JsonWriter out, AtomicReference value) throws IOException { + if (value != null) { + out.value(value.get()); + } + } +} diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/models/GsonUtilities.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/models/GsonUtilities.java new file mode 100644 index 0000000000000..d22ce03658cb4 --- /dev/null +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/models/GsonUtilities.java @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2010-2017 by the respective copyright holders. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.openhab.binding.russound.internal.rio.models; + +import java.util.concurrent.atomic.AtomicReference; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; + +/** + * Utility class for common GSON related items + * + * @author Tim Roberts + * + */ +public class GsonUtilities { + /** + * Utility method to create a standard {@link Gson} for the system. The standard GSon will register the + * {@link AtomicStringTypeAdapter} and the various serializers (Presets, Banks, Favorites) + * + * @return a non-null {@link Gson} + */ + public static Gson createGson() { + final GsonBuilder gs = new GsonBuilder(); + gs.registerTypeAdapter(new TypeToken>() { + }.getType(), new AtomicStringTypeAdapter()); + gs.registerTypeAdapter(RioPreset.class, new RioPresetSerializer()); + gs.registerTypeAdapter(RioBank.class, new RioBankSerializer()); + gs.registerTypeAdapter(RioFavorite.class, new RioFavoriteSerializer()); + return gs.create(); + } +} diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/models/RioBank.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/models/RioBank.java new file mode 100644 index 0000000000000..fb81003499686 --- /dev/null +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/models/RioBank.java @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2010-2017 by the respective copyright holders. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.openhab.binding.russound.internal.rio.models; + +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.commons.lang.StringUtils; + +/** + * Simple model of a RIO Bank and it's attributes. Please note this class is used to serialize/deserialize to JSON. + * + * @author Tim Roberts + * + */ +public class RioBank { + /** + * The Bank ID + */ + private final int id; + + /** + * The Bank Name + */ + private final AtomicReference name = new AtomicReference(null); + + /** + * Create the object from the given ID (using the default name of "Bank" + id) + * + * @param id a bank identifier between 1 and 6 + * @throws IllegalArgumentException if id is < 1 or > 6 + */ + public RioBank(int id) { + this(id, null); + } + + /** + * Create the object from the given ID and given name. If the name is empty or null, the name will default to ("Bank + * " + id) + * + * @param id a bank identifier between 1 and 6 + * @param name a possibly null, possibly empty bank name (null or empty will result in a bank name of "Bank "+ id) + * @throws IllegalArgumentException if id is < 1 or > 6 + */ + public RioBank(int id, String name) { + if (id < 1 || id > 6) { + throw new IllegalArgumentException("Bank ID can only be between 1 and 6"); + } + this.id = id; + this.name.set(StringUtils.isEmpty(name) ? "Bank " + id : name); + } + + /** + * Returns the bank identifier + * + * @return the bank identifier between 1 and 6 + */ + public int getId() { + return id; + } + + /** + * Returns the bank name + * + * @return a non-null, non-empty bank name + */ + public String getName() { + return name.get(); + } + + /** + * Sets the bank name. If empty or a null, name defaults to "Bank " + getId() + * + * @param bankName a possibly null, possibly empty bank name + */ + public void setName(String bankName) { + name.set(StringUtils.isEmpty(bankName) ? "Bank " + getId() : bankName); + } +} diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/models/RioBankSerializer.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/models/RioBankSerializer.java new file mode 100644 index 0000000000000..62864b353f846 --- /dev/null +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/models/RioBankSerializer.java @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2010-2017 by the respective copyright holders. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.openhab.binding.russound.internal.rio.models; + +import java.lang.reflect.Type; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; + +/** + * A {@link JsonSerializer} and {@link JsonDeserializer} for the {@link RioBank}. Simply writes/reads the ID and name to + * elements called "id" and "name" + * + * @author Tim Roberts + * + */ +public class RioBankSerializer implements JsonSerializer, JsonDeserializer { + + /** + * Overridden to simply write out the id/name elements from the {@link RioBank} + * + * @param bank the {@link RioBank} to write out + * @param type the type + * @param context the serialization context + */ + @Override + public JsonElement serialize(RioBank bank, Type type, JsonSerializationContext context) { + JsonObject root = new JsonObject(); + root.addProperty("id", bank.getId()); + root.addProperty("name", bank.getName()); + + return root; + } + + /** + * Overridden to simply read the id/name elements and create a {@link RioBank} + * + * @param elm the {@link JsonElement} to read from + * @param type the type + * @param context the serialization context + */ + @Override + public RioBank deserialize(JsonElement elm, Type type, JsonDeserializationContext context) + throws JsonParseException { + final JsonObject jo = (JsonObject) elm; + + final JsonElement id = jo.get("id"); + final JsonElement name = jo.get("name"); + return new RioBank((id == null ? -1 : id.getAsInt()), (name == null ? null : name.getAsString())); + } +} diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/models/RioFavorite.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/models/RioFavorite.java new file mode 100644 index 0000000000000..faef1e6662f76 --- /dev/null +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/models/RioFavorite.java @@ -0,0 +1,117 @@ +/** + * Copyright (c) 2010-2017 by the respective copyright holders. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.openhab.binding.russound.internal.rio.models; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.commons.lang.StringUtils; + +/** + * Simple model of a RIO Favorite (both system and zone) and it's attributes. Please note this class is used to + * serialize/deserialize to JSON. + * + * @author Tim Roberts + * + */ +public class RioFavorite { + /** + * The favorite ID + */ + private final int id; + + /** + * Whether the favorite is valid or not + */ + private final AtomicBoolean valid = new AtomicBoolean(false); + + /** + * The favorite name + */ + private final AtomicReference name = new AtomicReference(null); + + /** + * Simply creates the favorite from the given ID. The favorite will not be valid and the name will default to + * "Favorite " + id + * + * @param id a favorite ID between 1 and 32 + * @throws IllegalArgumentException if id < 1 or > 32 + */ + public RioFavorite(int id) { + this(id, false, null); + } + + /** + * Creates the favorite from the given ID, validity and name. If the name is empty or null, it will default to + * "Favorite " + id + * + * @param id a favorite ID between 1 and 32 + * @param isValid true if the favorite is valid, false otherwise + * @param name a possibly null, possibly empty favorite name + * @throws IllegalArgumentException if id < 1 or > 32 + */ + public RioFavorite(int id, boolean isValid, String name) { + if (id < 1 || id > 32) { + throw new IllegalArgumentException("Favorite ID must be between 1 and 32"); + } + + if (StringUtils.isEmpty(name)) { + name = "Favorite " + id; + } + + this.id = id; + this.valid.set(isValid); + this.name.set(name); + } + + /** + * Returns the favorite identifier + * + * @return a favorite id between 1 and 32 + */ + public int getId() { + return id; + } + + /** + * Returns true if the favorite is valid, false otherwise + * + * @return true if valid, false otherwise + */ + public boolean isValid() { + return valid.get(); + } + + /** + * Sets whether the favorite is valid or not + * + * @param favValid true if valid, false otherwise + */ + public void setValid(boolean favValid) { + valid.set(favValid); + } + + /** + * Set's the favorite name. If null or empty, will default to "Favorite " + getId() + * + * @param favName a possibly null, possibly empty favorite name + */ + public void setName(String favName) { + name.set(StringUtils.isEmpty(favName) ? "Favorite " + getId() : favName); + } + + /** + * Returns the favorite name + * + * @return a non-null, non-empty favorite name + */ + public String getName() { + return name.get(); + } +} diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/models/RioFavoriteSerializer.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/models/RioFavoriteSerializer.java new file mode 100644 index 0000000000000..8f7fa6c4711d1 --- /dev/null +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/models/RioFavoriteSerializer.java @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2010-2017 by the respective copyright holders. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.openhab.binding.russound.internal.rio.models; + +import java.lang.reflect.Type; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; + +/** + * A {@link JsonSerializer} and {@link JsonDeserializer} for the {@link RioFavorite}. Simply writes/reads the ID and + * name to elements called "id", "valid" and "name" + * + * @author Tim Roberts + * + */ +public class RioFavoriteSerializer implements JsonSerializer, JsonDeserializer { + + /** + * Overridden to simply write out the id/valid/name elements from the {@link RioFavorite} + * + * @param favorite the {@link RioFavorite} to write out + * @param type the type + * @param context the serialization context + */ + @Override + public JsonElement serialize(RioFavorite favorite, Type type, JsonSerializationContext context) { + JsonObject root = new JsonObject(); + root.addProperty("id", favorite.getId()); + root.addProperty("valid", favorite.isValid()); + root.addProperty("name", favorite.getName()); + + return root; + } + + /** + * Overridden to simply read the id/valid/name elements and create a {@link RioFavorite} + * + * @param elm the {@link JsonElement} to read from + * @param type the type + * @param context the serialization context + */ + @Override + public RioFavorite deserialize(JsonElement elm, Type type, JsonDeserializationContext context) + throws JsonParseException { + final JsonObject jo = (JsonObject) elm; + + final JsonElement id = jo.get("id"); + final JsonElement valid = jo.get("valid"); + final JsonElement name = jo.get("name"); + + return new RioFavorite((id == null ? -1 : id.getAsInt()), (valid == null ? false : valid.getAsBoolean()), + (name == null ? null : name.getAsString())); + } +} diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/models/RioPreset.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/models/RioPreset.java new file mode 100644 index 0000000000000..91ffd4f070afb --- /dev/null +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/models/RioPreset.java @@ -0,0 +1,136 @@ +/** + * Copyright (c) 2010-2017 by the respective copyright holders. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.openhab.binding.russound.internal.rio.models; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.commons.lang.StringUtils; + +/** + * Simple model of a RIO Preset and it's attributes. Please note this class is used to + * serialize/deserialize to JSON. + * + * @author Tim Roberts + * + */ +public class RioPreset { + /** + * The preset id + */ + private final int id; + + /** + * Whether the preset is valid or not + */ + private final AtomicBoolean valid = new AtomicBoolean(false); + + /** + * The preset name + */ + private final AtomicReference name = new AtomicReference(null); + + /** + * Simply creates the preset from the given ID. The preset will not be valid and the name will default to + * "Preset " + id + * + * @param id a preset ID between 1 and 36 + * @throws IllegalArgumentException if id < 1 or > 36 + */ + public RioPreset(int id) { + this(id, false, "Preset " + id); + } + + /** + * Creates the preset from the given ID, validity and name. If the name is empty or null, it will default to + * "Preset " + id + * + * @param id a preset ID between 1 and 36 + * @param isValid true if the preset is valid, false otherwise + * @param name a possibly null, possibly empty preset name + * @throws IllegalArgumentException if id < 1 or > 32 + */ + public RioPreset(int id, boolean valid, String name) { + if (id < 1 || id > 36) { + throw new IllegalArgumentException("Preset ID can only be between 1 and 36"); + } + + if (StringUtils.isEmpty(name)) { + name = "Preset " + id; + } + + this.id = id; + this.valid.set(valid); + this.name.set(name); + + } + + /** + * Returns the bank identifier this preset is for + * + * @return bank identifier between 1 and 6 + */ + public int getBank() { + return ((getId() - 1) / 6) + 1; + } + + /** + * Returns the bank preset identifier this preset is for + * + * @return bank preset identifier between 1 and 6 + */ + public int getBankPreset() { + return ((getId() - 1) % 6) + 1; + } + + /** + * Returns the preset identifier + * + * @return the preset identifier between 1 and 36 + */ + public int getId() { + return id; + } + + /** + * Returns true if the preset is valid, false otherwise + * + * @return true if valid, false otherwise + */ + public boolean isValid() { + return valid.get(); + } + + /** + * Sets whether the preset is valid (true) or not (false) + * + * @param presetValid true if valid, false otherwise + */ + public void setValid(boolean presetValid) { + valid.set(presetValid); + } + + /** + * Set's the preset name. If null or empty, will default to "Preset " + getId() + * + * @param presetName a possibly null, possibly empty preset name + */ + public void setName(String presetName) { + name.set(StringUtils.isEmpty(presetName) ? "Preset " + getId() : presetName); + } + + /** + * Returns the preset name + * + * @return a non-null, non-empty preset name + */ + public String getName() { + return name.get(); + } +} diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/models/RioPresetSerializer.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/models/RioPresetSerializer.java new file mode 100644 index 0000000000000..d95f8a81674ee --- /dev/null +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/models/RioPresetSerializer.java @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2010-2017 by the respective copyright holders. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.openhab.binding.russound.internal.rio.models; + +import java.lang.reflect.Type; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; + +/** + * A {@link JsonSerializer} and {@link JsonDeserializer} for the {@link RioPreset}. Simply writes/reads the ID and + * name to elements called "id", "valid", "name", "bank" and "bankPreset" values. + * + * @author Tim Roberts + * + */ + +public class RioPresetSerializer implements JsonSerializer, JsonDeserializer { + + /** + * Overridden to simply write out the id/valid/name/bank/bankPreset elements from the {@link RioPreset} + * + * @param preset the {@link RioPreset} to write out + * @param type the type + * @param context the serialization context + */ + @Override + public JsonElement serialize(RioPreset preset, Type type, JsonSerializationContext context) { + JsonObject root = new JsonObject(); + root.addProperty("id", preset.getId()); + root.addProperty("valid", preset.isValid()); + root.addProperty("name", preset.getName()); + root.addProperty("bank", preset.getBank()); + root.addProperty("bankPreset", preset.getBankPreset()); + + return root; + } + + /** + * Overridden to simply read the id/valid/name elements and create a {@link RioPreset}. Please note that + * the bank/bankPreset are calculated fields from the ID and do not need to be read. + * + * @param elm the {@link JsonElement} to read from + * @param type the type + * @param context the serialization context + */ + @Override + public RioPreset deserialize(JsonElement elm, Type type, JsonDeserializationContext context) + throws JsonParseException { + final JsonObject jo = (JsonObject) elm; + final JsonElement id = jo.get("id"); + final JsonElement valid = jo.get("valid"); + final JsonElement name = jo.get("name"); + + return new RioPreset((id == null ? -1 : id.getAsInt()), (valid == null ? false : valid.getAsBoolean()), + (name == null ? null : name.getAsString())); + } +} diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/preset/RioPresetConfig.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/preset/RioPresetConfig.java deleted file mode 100644 index 6726ca61f6a16..0000000000000 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/preset/RioPresetConfig.java +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Copyright (c) 2010-2017 by the respective copyright holders. - * - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the Eclipse Public License v1.0 - * which accompanies this distribution, and is available at - * http://www.eclipse.org/legal/epl-v10.html - */ -package org.openhab.binding.russound.internal.rio.preset; - -/** - * Configuration class for the {@link RioPresetHandler} - * - * @author Tim Roberts - */ -public class RioPresetConfig { - /** - * ID of the preset (1-6 for a bank, 1-36 for a zone) - */ - private int preset; - - /** - * Gets the preset identifier - * - * @return the preset identifier - */ - public int getPreset() { - return preset; - } - - /** - * Sets the preset identifier - * - * @param preset the preset identifier - */ - public void setPreset(int preset) { - this.preset = preset; - } -} diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/preset/RioPresetHandler.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/preset/RioPresetHandler.java deleted file mode 100644 index c18814b55ea8a..0000000000000 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/preset/RioPresetHandler.java +++ /dev/null @@ -1,242 +0,0 @@ -/** - * Copyright (c) 2010-2017 by the respective copyright holders. - * - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the Eclipse Public License v1.0 - * which accompanies this distribution, and is available at - * http://www.eclipse.org/legal/epl-v10.html - */ -package org.openhab.binding.russound.internal.rio.preset; - -import org.eclipse.smarthome.core.library.types.StringType; -import org.eclipse.smarthome.core.thing.Bridge; -import org.eclipse.smarthome.core.thing.ChannelUID; -import org.eclipse.smarthome.core.thing.Thing; -import org.eclipse.smarthome.core.thing.ThingStatus; -import org.eclipse.smarthome.core.thing.ThingStatusDetail; -import org.eclipse.smarthome.core.thing.binding.ThingHandler; -import org.eclipse.smarthome.core.types.Command; -import org.eclipse.smarthome.core.types.RefreshType; -import org.eclipse.smarthome.core.types.State; -import org.openhab.binding.russound.internal.net.SocketSession; -import org.openhab.binding.russound.internal.rio.AbstractThingHandler; -import org.openhab.binding.russound.internal.rio.RioConstants; -import org.openhab.binding.russound.internal.rio.RioHandlerCallback; -import org.openhab.binding.russound.internal.rio.StatefulHandlerCallback; -import org.openhab.binding.russound.internal.rio.bank.RioBankHandler; -import org.openhab.binding.russound.internal.rio.zone.RioZoneHandler; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * The thing handler for a Russound Preset. A preset provides direct access (from a bank) to a specific preset channel - * (usually a radio frequency). This implementation must be attached to a {@link RioBankHandler} or - * {@link RioZoneHandler} bridge. - * - * @author Tim Roberts - */ -public class RioPresetHandler extends AbstractThingHandler { - // Logger - private Logger logger = LoggerFactory.getLogger(RioPresetHandler.class); - - /** - * The preset identifier (1-6 for bank, 1-36 for a zone) - */ - private int _preset; - - /** - * Constructs the handler from the {@link Thing} - * - * @param thing a non-null {@link Thing} the handler is for - */ - public RioPresetHandler(Thing thing) { - super(thing); - - } - - /** - * {@inheritDoc} - * - * Handles commands to specific channels. This implementation will offload much of its work to the - * {@link RioPresetProtocol}. Basically we validate the type of command for the channel then call the - * {@link RioPresetProtocol} to handle the actual protocol. Special use case is the {@link RefreshType} - * where we call {{@link #handleRefresh(String)} to handle a refresh of the specific channel (which in turn calls - * {@link RioPresetProtocol} to handle the actual refresh - */ - @Override - public void handleCommand(ChannelUID channelUID, Command command) { - - if (command instanceof RefreshType) { - handleRefresh(channelUID.getId()); - return; - } - final String id = channelUID.getId(); - - if (id == null) { - logger.debug("Called with a null channel id - ignoring"); - return; - } - - if (id.equals(RioConstants.CHANNEL_PRESETNAME)) { - if (command instanceof StringType) { - getProtocolHandler().setName(command.toString()); - } else { - logger.debug("Received a preset name channel command with a non StringType: {}", command); - } - } else if (id.equals(RioConstants.CHANNEL_PRESETCMD)) { - if (command instanceof StringType) { - final String cmd = command.toString().toLowerCase(); - switch (cmd) { - case RioConstants.CMD_PRESETSAVE: - getProtocolHandler().savePreset(); - break; - - case RioConstants.CMD_PRESETRESTORE: - getProtocolHandler().restorePreset(); - break; - case RioConstants.CMD_PRESETDELETE: - getProtocolHandler().deletePreset(); - break; - default: - logger.info("Unknown command for preset command channel: {}", cmd); - break; - } - getProtocolHandler().setName(command.toString()); - } else { - logger.debug("Received a preset name channel command with a non StringType: {}", command); - } - } else { - logger.debug("Unknown/Unsupported Channel id: {}", id); - } - } - - /** - * Method that handles the {@link RefreshType} command specifically. Calls the {@link RioPresetProtocol} to - * handle the actual refresh based on the channel id. - * - * @param id a non-null, possibly empty channel id to refresh - */ - private void handleRefresh(String id) { - if (getThing().getStatus() != ThingStatus.ONLINE) { - return; - } - - if (getProtocolHandler() == null) { - return; - } - - // Remove the cache'd value to force a refreshed value - ((StatefulHandlerCallback) getProtocolHandler().getCallback()).removeState(id); - - if (id.equals(RioConstants.CHANNEL_PRESETNAME)) { - getProtocolHandler().refreshName(); - - } else if (id.equals(RioConstants.CHANNEL_PRESETVALID)) { - getProtocolHandler().refreshValid(); - } else { - // Can't refresh any others... - } - } - - /** - * Initializes the thing. Confirms the configuration is valid and that our parent bridge is either a - * {@link RioBankHandler} or {@link RioZoneHandler}. Once validated, a {@link RioPresetProtocol} is set via - * {@link #setProtocolHandler(RioPresetProtocol)} and the thing comes online. - */ - @Override - public void initialize() { - final Bridge bridge = getBridge(); - if (bridge == null) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "Cannot be initialized without a bridge"); - return; - } - if (bridge.getStatus() != ThingStatus.ONLINE) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE); - return; - } - - final ThingHandler handler = bridge.getHandler(); - if (handler == null) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "No handler specified (null) for the bridge!"); - return; - } - - if (!(handler instanceof RioBankHandler) && !(handler instanceof RioZoneHandler)) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "Preset must be attached to either the Bank bridge or a Zone bridge: " + handler.getClass()); - return; - } - - final RioPresetConfig config = getThing().getConfiguration().as(RioPresetConfig.class); - if (config == null) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Configuration file missing"); - return; - } - - _preset = config.getPreset(); - if (handler instanceof RioBankHandler) { - if (_preset < 1 || _preset > 6) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "Preset must be between 1 and 6 for a bank: " + _preset); - - } - } else if (_preset < 1 || _preset > 36) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "Preset must be between 1 and 36 for a zone: " + _preset); - - } - - // Get the socket session from the - final SocketSession socketSession = getSocketSession(); - if (socketSession == null) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR, "No socket session found"); - return; - } - - if (getProtocolHandler() != null) { - setProtocolHandler(null); - } - - int bankId = -1; - int sourceId = -1; - int zoneId = -1; - int controllerId = -1; - - if (handler instanceof RioZoneHandler) { - final RioZoneHandler zoneHandler = (RioZoneHandler) handler; - controllerId = zoneHandler.getController(); - zoneId = zoneHandler.getZone(); - } else { - final RioBankHandler bankHandler = (RioBankHandler) handler; - bankId = bankHandler.getBank(); - sourceId = bankHandler.getSource(); - } - - setProtocolHandler(new RioPresetProtocol(_preset, bankId, sourceId, zoneId, controllerId, socketSession, - new StatefulHandlerCallback(new RioHandlerCallback() { - @Override - public void statusChanged(ThingStatus status, ThingStatusDetail detail, String msg) { - updateStatus(status, detail, msg); - } - - @Override - public void stateChanged(String channelId, State state) { - updateState(channelId, state); - } - - @Override - public void setProperty(String propertyName, String propertyValue) { - getThing().setProperty(propertyName, propertyValue); - } - }))); - - updateStatus(ThingStatus.ONLINE); - - if (sourceId > 0) { - getProtocolHandler().refreshName(); - getProtocolHandler().refreshValid(); - } - } -} diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/preset/RioPresetProtocol.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/preset/RioPresetProtocol.java deleted file mode 100644 index 92bba8c6f2ccd..0000000000000 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/preset/RioPresetProtocol.java +++ /dev/null @@ -1,268 +0,0 @@ -/** - * Copyright (c) 2010-2017 by the respective copyright holders. - * - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the Eclipse Public License v1.0 - * which accompanies this distribution, and is available at - * http://www.eclipse.org/legal/epl-v10.html - */ -package org.openhab.binding.russound.internal.rio.preset; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import org.eclipse.smarthome.core.library.types.OnOffType; -import org.eclipse.smarthome.core.library.types.StringType; -import org.openhab.binding.russound.internal.net.SocketSession; -import org.openhab.binding.russound.internal.net.SocketSessionListener; -import org.openhab.binding.russound.internal.rio.AbstractRioProtocol; -import org.openhab.binding.russound.internal.rio.RioConstants; -import org.openhab.binding.russound.internal.rio.RioHandlerCallback; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * This is the protocol handler for the Russound Preset. This handler will issue the protocol commands and will - * process the responses from the Russound system. - * - * @author Tim Roberts - * - */ -class RioPresetProtocol extends AbstractRioProtocol { - // logger - private Logger logger = LoggerFactory.getLogger(RioPresetProtocol.class); - - /** - * The preset identifier for the handler - */ - private final int _preset; - - /** - * The bank identifier - will be -1 if attached to a zone - */ - private final int _bank; - - /** - * The source identifier for the bank - will be -1 if attached to a zone - */ - private final int _source; - - /** - * The zone identifier - will be -1 if attached to a bank - */ - private final int _zone; - - /** - * The controller identifier - will be -1 if attached to a bank - */ - private final int _controller; - - /** - * The name of the preset (only is appled when {@link #savePreset()} is invoked) - */ - private String _name; - - // Protocol constants - private static final String PRESET_NAME = "name"; - private static final String PRESET_VALID = "valid"; - - // Response patterns - private final Pattern RSP_PRESETNOTIFICATION = Pattern - .compile("^[SN] S\\[(\\d+)\\].B\\[(\\d+)\\].P\\[(\\d+)\\].(\\w+)=\"(.*)\"$"); - - /** - * Constructs the protocol handler from given parameters - * - * @param preset the preset identifier - * @param bank the bank identifier or -1 if attached to a zone - * @param source the source identifier or -1 if attached to a zone - * @param zone the zone identifier or -1 if attached to a bank - * @param controller the controller identifier or -1 if attached to a bank - * @param session a non-null {@link SocketSession} (may be connected or disconnected) - * @param callback a non-null {@link RioHandlerCallback} to callback - */ - RioPresetProtocol(int preset, int bank, int source, int zone, int controller, SocketSession session, - RioHandlerCallback callback) { - super(session, callback); - _preset = preset; - _bank = bank; - _source = source; - _zone = zone; - _controller = controller; - setName("Preset " + preset); - } - - /** - * Helper method to determine if attached to a source/bank - * - * @return true if attached to a source/bank, false if attached to a controller/zone - */ - private boolean isBank() { - return _bank > 0; - } - - /** - * Refreshes the name of that preset - this can only be done from a bank level. If called on a - * zone level, a debug warning will be issued and the call ignored. - */ - void refreshName() { - if (isBank()) { - sendCommand("GET S[" + _source + "].B[" + _bank + "].P[" + _preset + "]." + PRESET_NAME); - } else { - logger.warn("Trying to refresh a name outside of a bank"); - } - } - - /** - * Refreshes the whether the preset is valid - this can only be done from a bank level. If called on a - * zone level, a debug warning will be issued and the call ignored. - */ - void refreshValid() { - if (isBank()) { - sendCommand("GET S[" + _source + "].B[" + _bank + "].P[" + _preset + "]." + PRESET_VALID); - } else { - logger.warn("Trying to refresh a valid outside of a bank"); - } - } - - /** - * Set's the name of the preset - this can only be done from a bank level. If called on a - * zone level, a debug warning will be issued and the call ignored. Please note that the name will only be committed - * when the preset is saved. Setting a name of null or empty is allowed (on {@link #savePreset()}, the Russound - * system will reset the name to the current frequency). - * - * @param name a possibly null, possibly empty name. Please note a null will be converted to an empty string. - */ - void setName(String name) { - if (isBank()) { - _name = name == null ? "" : name; - stateChanged(RioConstants.CHANNEL_PRESETNAME, new StringType(_name)); - } else { - logger.warn("Trying to set the name outside of a bank"); - } - } - - /** - * Saves the current channel as the preset - this can only be done from a zone level. If called on a - * bank level, a debug warning will be issued and the call ignored. The name will be saved as well if it's specified - * (if not specified [i.e. null or empty], the Russound system will create a name from the current frequency) - */ - void savePreset() { - if (isBank()) { - logger.warn("Trying to save a preset outside of a zone"); - } else { - sendCommand("EVENT C[" + _controller + "].Z[" + _zone + "]!savePreset " - + (_name == null || _name.trim().length() == 0 ? "" : ("\"" + _name + "\"")) + " " + _preset); - - // We are not sure what source this is for - so refresh them all - final int bank = ((_preset - 1) / 6) + 1; - final int preset = ((_preset - 1) % 6) + 1; - for (int source = 1; source < 13; source++) { - sendCommand("GET S[" + source + "].B[" + bank + "].P[" + preset + "]." + PRESET_NAME); - sendCommand("GET S[" + source + "].B[" + bank + "].P[" + preset + "]." + PRESET_VALID); - } - } - } - - /** - * Restores the saved preset to the zone - this can only be done from a zone level. If called on a - * bank level, a debug warning will be issued and the call ignored. - */ - void restorePreset() { - if (isBank()) { - logger.warn("Trying to restore a preset outside of a zone"); - } else { - sendCommand("EVENT C[" + _controller + "].Z[" + _zone + "]!restorePreset " + _preset); - } - } - - /** - * Deletes the saved preset - this can only be done from a zone level. If called on a bank level, a debug warning - * will be issued and the call ignored. - */ - void deletePreset() { - if (isBank()) { - logger.warn("Trying to restore a preset outside of a zone"); - } else { - sendCommand("EVENT C[" + _controller + "].Z[" + _zone + "]!deletePreset " + _preset); - } - // We are not sure what source this is for - so refresh them all - final int bank = ((_preset - 1) / 6) + 1; - final int preset = ((_preset - 1) % 6) + 1; - for (int source = 1; source < 13; source++) { - sendCommand("GET S[" + source + "].B[" + bank + "].P[" + preset + "]." + PRESET_VALID); - } - } - - /** - * Handles any preset notifications returned by the russound system - * - * @param m a non-null matcher - * @param resp a possibly null, possibly empty response - */ - private void handlePresetNotification(Matcher m, String resp) { - if (m == null) { - throw new IllegalArgumentException("m (matcher) cannot be null"); - } - - if (m.groupCount() == 5) { - try { - final int source = Integer.parseInt(m.group(1)); - if (source != _source) { - return; - } - - final int bank = Integer.parseInt(m.group(2)); - if (bank != _bank) { - return; - } - - final int preset = Integer.parseInt(m.group(3)); - if (preset != _preset) { - return; - } - - final String key = m.group(4); - final String value = m.group(5); - - switch (key) { - case PRESET_NAME: - setName(value); - break; - - case PRESET_VALID: - stateChanged(RioConstants.CHANNEL_PRESETVALID, - "false".equalsIgnoreCase(value) ? OnOffType.OFF : OnOffType.ON); - break; - - default: - logger.warn("Unknown preset notification: '{}'", resp); - break; - } - } catch (NumberFormatException e) { - logger.warn("Invalid Preset Notification (source/bank/preset not a parsable integer): '{}')", resp); - } - } else { - logger.warn("Invalid Preset Notification: '{}')", resp); - } - } - - /** - * Implements {@link SocketSessionListener#responseReceived(String)} to try to process the response from the - * russound system. This response may be for other protocol handler - so ignore if we don't recognize the response. - * - * @param a possibly null, possibly empty response - */ - @Override - public void responseReceived(String response) { - if (response == null || response == "") { - return; - } - - final Matcher m = RSP_PRESETNOTIFICATION.matcher(response); - if (m.matches()) { - handlePresetNotification(m, response); - return; - } - } -} diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/source/RioSourceConfig.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/source/RioSourceConfig.java index a4d3372307182..427d90ee9eb20 100644 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/source/RioSourceConfig.java +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/source/RioSourceConfig.java @@ -14,6 +14,11 @@ * @author Tim Roberts */ public class RioSourceConfig { + /** + * Constant defined for the "source" configuration field + */ + public static final String Source = "source"; + /** * ID of the source */ diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/source/RioSourceHandler.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/source/RioSourceHandler.java index c3400150add62..10338346f56e3 100644 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/source/RioSourceHandler.java +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/source/RioSourceHandler.java @@ -8,8 +8,15 @@ */ package org.openhab.binding.russound.internal.rio.source; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.commons.lang.StringUtils; +import org.eclipse.smarthome.core.library.types.StringType; import org.eclipse.smarthome.core.thing.Bridge; import org.eclipse.smarthome.core.thing.ChannelUID; +import org.eclipse.smarthome.core.thing.Thing; import org.eclipse.smarthome.core.thing.ThingStatus; import org.eclipse.smarthome.core.thing.ThingStatusDetail; import org.eclipse.smarthome.core.thing.binding.ThingHandler; @@ -17,9 +24,10 @@ import org.eclipse.smarthome.core.types.RefreshType; import org.eclipse.smarthome.core.types.State; import org.openhab.binding.russound.internal.net.SocketSession; -import org.openhab.binding.russound.internal.rio.AbstractBridgeHandler; +import org.openhab.binding.russound.internal.rio.AbstractRioHandlerCallback; +import org.openhab.binding.russound.internal.rio.AbstractThingHandler; import org.openhab.binding.russound.internal.rio.RioConstants; -import org.openhab.binding.russound.internal.rio.RioHandlerCallback; +import org.openhab.binding.russound.internal.rio.RioNamedHandler; import org.openhab.binding.russound.internal.rio.StatefulHandlerCallback; import org.openhab.binding.russound.internal.rio.system.RioSystemHandler; import org.slf4j.Logger; @@ -31,22 +39,27 @@ * * @author Tim Roberts */ -public class RioSourceHandler extends AbstractBridgeHandler { +public class RioSourceHandler extends AbstractThingHandler implements RioNamedHandler { // Logger - private Logger logger = LoggerFactory.getLogger(RioSourceHandler.class); + private final Logger logger = LoggerFactory.getLogger(RioSourceHandler.class); /** * The source identifier for this instance (1-12) */ - private int _source; + private final AtomicInteger source = new AtomicInteger(0); /** - * Constructs the handler from the {@link Bridge} + * The source name + */ + private final AtomicReference sourceName = new AtomicReference(null); + + /** + * Constructs the handler from the {@link Thing} * - * @param bridge a non-null {@link Bridge} the handler is for + * @param thing a non-null {@link Thing} the handler is for */ - public RioSourceHandler(Bridge bridge) { - super(bridge); + public RioSourceHandler(Thing thing) { + super(thing); } /** @@ -54,8 +67,20 @@ public RioSourceHandler(Bridge bridge) { * * @return the source identifier */ - public int getSource() { - return _source; + @Override + public int getId() { + return source.get(); + } + + /** + * Returns the source name for this instance + * + * @return the source name + */ + @Override + public String getName() { + final String name = sourceName.get(); + return StringUtils.isEmpty(name) ? ("Source " + getId()) : name; } /** @@ -87,7 +112,22 @@ public void handleCommand(ChannelUID channelUID, Command command) { return; } - logger.debug("There are no channels that allow commands"); + if (id.equals(RioConstants.CHANNEL_SOURCEBANKS)) { + if (command instanceof StringType) { + // Remove any state for this channel to ensure it's recreated/sent again + // (clears any bad or deleted favorites information from the channel) + ((StatefulHandlerCallback) getProtocolHandler().getCallback()) + .removeState(RioConstants.CHANNEL_SOURCEBANKS); + + // schedule the returned callback in the future (to allow the channel to process and to allow russound + // to process (before re-retrieving information) + scheduler.schedule(getProtocolHandler().setBanks(command.toString()), 250, TimeUnit.MILLISECONDS); + } else { + logger.debug("Received a BANKS channel command with a non StringType: {}", command); + } + } else { + logger.debug("Unknown/Unsupported Channel id: {}", id); + } } /** @@ -110,6 +150,8 @@ private void handleRefresh(String id) { if (id.equals(RioConstants.CHANNEL_SOURCENAME)) { getProtocolHandler().refreshSourceName(); + } else if (id.startsWith(RioConstants.CHANNEL_SOURCETYPE)) { + getProtocolHandler().refreshSourceType(); } else if (id.startsWith(RioConstants.CHANNEL_SOURCECOMPOSERNAME)) { getProtocolHandler().refreshSourceComposerName(); } else if (id.startsWith(RioConstants.CHANNEL_SOURCECHANNEL)) { @@ -124,8 +166,6 @@ private void handleRefresh(String id) { getProtocolHandler().refreshSourceAlbumName(); } else if (id.startsWith(RioConstants.CHANNEL_SOURCECOVERARTURL)) { getProtocolHandler().refreshSourceCoverArtUrl(); - } else if (id.startsWith(RioConstants.CHANNEL_SOURCECOVERART)) { - getProtocolHandler().refreshSourceCoverArtUrl(); } else if (id.startsWith(RioConstants.CHANNEL_SOURCEPLAYLISTNAME)) { getProtocolHandler().refreshSourcePlaylistName(); } else if (id.startsWith(RioConstants.CHANNEL_SOURCESONGNAME)) { @@ -148,6 +188,8 @@ private void handleRefresh(String id) { getProtocolHandler().refreshSourceRadioText3(); } else if (id.startsWith(RioConstants.CHANNEL_SOURCERADIOTEXT4)) { getProtocolHandler().refreshSourceRadioText4(); + } else if (id.equals(RioConstants.CHANNEL_SOURCEBANKS)) { + getProtocolHandler().refreshBanks(); } else { // Can't refresh any others... @@ -192,12 +234,13 @@ public void initialize() { return; } - _source = config.getSource(); - if (_source < 1 || _source > 12) { + final int configSource = config.getSource(); + if (configSource < 1 || configSource > 12) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "Source must be between 1 and 12: " + config.getSource()); + "Source must be between 1 and 12: " + configSource); return; } + source.set(configSource); // Get the socket session from the final SocketSession socketSession = getSocketSession(); @@ -207,8 +250,8 @@ public void initialize() { } try { - setProtocolHandler( - new RioSourceProtocol(_source, socketSession, new StatefulHandlerCallback(new RioHandlerCallback() { + setProtocolHandler(new RioSourceProtocol(configSource, socketSession, + new StatefulHandlerCallback(new AbstractRioHandlerCallback() { @Override public void statusChanged(ThingStatus status, ThingStatusDetail detail, String msg) { updateStatus(status, detail, msg); @@ -216,7 +259,14 @@ public void statusChanged(ThingStatus status, ThingStatusDetail detail, String m @Override public void stateChanged(String channelId, State state) { - updateState(channelId, state); + if (channelId.equals(RioConstants.CHANNEL_SOURCENAME)) { + sourceName.set(state.toString()); + } + + if (state != null) { + updateState(channelId, state); + fireStateUpdated(channelId, state); + } } @Override @@ -226,11 +276,9 @@ public void setProperty(String propertyName, String propertyValue) { }))); updateStatus(ThingStatus.ONLINE); - getProtocolHandler().watchSource(true); - getProtocolHandler().refreshSourceIpAddress(); // need to manually get this + getProtocolHandler().postOnline(); } catch (Exception e) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.toString()); } } - } diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/source/RioSourceProtocol.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/source/RioSourceProtocol.java index 1ce4d142fde35..99ab455f20e4b 100644 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/source/RioSourceProtocol.java +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/source/RioSourceProtocol.java @@ -8,25 +8,34 @@ */ package org.openhab.binding.russound.internal.rio.source; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeoutException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.apache.commons.lang.NullArgumentException; import org.apache.commons.lang.StringUtils; import org.eclipse.jetty.client.HttpClient; -import org.eclipse.jetty.client.api.ContentResponse; -import org.eclipse.smarthome.core.library.types.RawType; import org.eclipse.smarthome.core.library.types.StringType; -import org.eclipse.smarthome.core.types.UnDefType; +import org.eclipse.smarthome.core.types.State; import org.openhab.binding.russound.internal.net.SocketSession; import org.openhab.binding.russound.internal.net.SocketSessionListener; import org.openhab.binding.russound.internal.rio.AbstractRioProtocol; import org.openhab.binding.russound.internal.rio.RioConstants; import org.openhab.binding.russound.internal.rio.RioHandlerCallback; +import org.openhab.binding.russound.internal.rio.StatefulHandlerCallback; +import org.openhab.binding.russound.internal.rio.models.GsonUtilities; +import org.openhab.binding.russound.internal.rio.models.RioBank; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; + /** * This is the protocol handler for the Russound Source. This handler will issue the protocol commands and will * process the responses from the Russound system. Please see documentation for what channels are supported by which @@ -36,48 +45,104 @@ * */ class RioSourceProtocol extends AbstractRioProtocol { - private Logger logger = LoggerFactory.getLogger(RioSourceProtocol.class); + private final Logger logger = LoggerFactory.getLogger(RioSourceProtocol.class); /** * The source identifier (1-12) */ - private final int _source; + private final int source; // Protocol constants private static final String SRC_NAME = "name"; private static final String SRC_TYPE = "type"; - private static final String SRC_IPADDRESS = "ipAddress"; - private static final String SRC_IPADDRESS2 = "IPAddress"; // russound wasn't consistent on capitalization on - // notifications - private static final String SRC_COMPOSERNAME = "composerName"; + private static final String SRC_IPADDRESS = "ipaddress"; + private static final String SRC_COMPOSERNAME = "composername"; private static final String SRC_CHANNEL = "channel"; - private static final String SRC_CHANNELNAME = "channelName"; + private static final String SRC_CHANNELNAME = "channelname"; private static final String SRC_GENRE = "genre"; - private static final String SRC_ARTISTNAME = "artistName"; - private static final String SRC_ALBUMNAME = "albumName"; - private static final String SRC_COVERARTURL = "coverArtURL"; - private static final String SRC_PLAYLISTNAME = "playlistName"; - private static final String SRC_SONGNAME = "songName"; + private static final String SRC_ARTISTNAME = "artistname"; + private static final String SRC_ALBUMNAME = "albumname"; + private static final String SRC_COVERARTURL = "coverarturl"; + private static final String SRC_PLAYLISTNAME = "playlistname"; + private static final String SRC_SONGNAME = "songname"; private static final String SRC_MODE = "mode"; - private static final String SRC_SHUFFLEMODE = "shuffleMode"; - private static final String SRC_REPEATMODE = "repeatMode"; + private static final String SRC_SHUFFLEMODE = "shufflemode"; + private static final String SRC_REPEATMODE = "repeatmode"; private static final String SRC_RATING = "rating"; - private static final String SRC_PROGRAMSERVICENAME = "programServiceName"; - private static final String SRC_RADIOTEXT = "radioText"; - private static final String SRC_RADIOTEXT2 = "radioText2"; - private static final String SRC_RADIOTEXT3 = "radioText3"; - private static final String SRC_RADIOTEXT4 = "radioText4"; + private static final String SRC_PROGRAMSERVICENAME = "programservicename"; + private static final String SRC_RADIOTEXT = "radiotext"; + private static final String SRC_RADIOTEXT2 = "radiotext2"; + private static final String SRC_RADIOTEXT3 = "radiotext3"; + private static final String SRC_RADIOTEXT4 = "radiotext4"; + + // Multimedia channels + private static final String SRC_MMScreen = "mmscreen"; + private static final String SRC_MMTitle = "mmtitle.text"; + private static final String SRC_MMAttr = "attr"; + private static final String SRC_MMBtnOk = "mmbtnok.text"; + private static final String SRC_MMBtnBack = "mmbtnback.text"; + private static final String SRC_MMInfoBlock = "mminfoblock.text"; + + private static final String SRC_MMHelp = "mmhelp.text"; + private static final String SRC_MMTextField = "mmtextfield.text"; // This is an undocumented volume private static final String SRC_VOLUME = "volume"; + private static final String BANK_NAME = "name"; + // Response patterns - private final Pattern RSP_SRCNOTIFICATION = Pattern.compile("^[SN] S\\[(\\d+)\\]\\.(\\w+)=\"(.*)\"$"); + private static final Pattern RSP_MMMENUNOTIFICATION = Pattern.compile("^\\{.*\\}$"); + private static final Pattern RSP_SRCNOTIFICATION = Pattern + .compile("(?i)^[SN] S\\[(\\d+)\\]\\.([a-zA-Z_0-9.\\[\\]]+)=\"(.*)\"$"); + private static final Pattern RSP_BANKNOTIFICATION = Pattern + .compile("(?i)^[SN] S\\[(\\d+)\\].B\\[(\\d+)\\].(\\w+)=\"(.*)\"$"); + private static final Pattern RSP_PRESETNOTIFICATION = Pattern + .compile("(?i)^[SN] S\\[(\\d+)\\].B\\[(\\d+)\\].P\\[(\\d+)\\].(\\w+)=\"(.*)\"$"); + + /** + * Current banks + */ + private final RioBank[] banks = new RioBank[6]; + + /** + * {@link Gson} use to create/read json + */ + private final Gson gson; + + /** + * Lock used to control access to {@link #infoText} + */ + private final Lock infoLock = new ReentrantLock(); + + /** + * The information text appeneded from media management calls + */ + private final StringBuilder infoText = new StringBuilder(100); + + /** + * The table of channels to unique identifiers for media management functions + */ + @SuppressWarnings("serial") + private final Map mmSeqNbrs = Collections + .unmodifiableMap(new HashMap() { + { + put(RioConstants.CHANNEL_SOURCEMMMENU, new AtomicInteger(0)); + put(RioConstants.CHANNEL_SOURCEMMSCREEN, new AtomicInteger(0)); + put(RioConstants.CHANNEL_SOURCEMMTITLE, new AtomicInteger(0)); + put(RioConstants.CHANNEL_SOURCEMMATTR, new AtomicInteger(0)); + put(RioConstants.CHANNEL_SOURCEMMBUTTONOKTEXT, new AtomicInteger(0)); + put(RioConstants.CHANNEL_SOURCEMMBUTTONBACKTEXT, new AtomicInteger(0)); + put(RioConstants.CHANNEL_SOURCEMMINFOTEXT, new AtomicInteger(0)); + put(RioConstants.CHANNEL_SOURCEMMHELPTEXT, new AtomicInteger(0)); + put(RioConstants.CHANNEL_SOURCEMMTEXTFIELD, new AtomicInteger(0)); + } + }); /** * The client used for http requests */ - private final HttpClient _httpClient; + private final HttpClient httpClient; /** * Constructs the protocol handler from given parameters @@ -92,10 +157,27 @@ class RioSourceProtocol extends AbstractRioProtocol { if (source < 1 || source > 12) { throw new IllegalArgumentException("Source must be between 1-12: " + source); } - _source = source; - _httpClient = new HttpClient(); - _httpClient.setFollowRedirects(true); - _httpClient.start(); + this.source = source; + httpClient = new HttpClient(); + httpClient.setFollowRedirects(true); + httpClient.start(); + + gson = GsonUtilities.createGson(); + + for (int x = 1; x <= 6; x++) { + banks[x - 1] = new RioBank(x); + } + } + + /** + * Helper method to issue post online commands + */ + void postOnline() { + watchSource(true); + refreshSourceIpAddress(); + refreshSourceName(); + + updateBanksChannel(); } /** @@ -108,7 +190,7 @@ private void refreshSourceKey(String keyName) { if (keyName == null || keyName.trim().length() == 0) { throw new IllegalArgumentException("keyName cannot be null or empty"); } - sendCommand("GET S[" + _source + "]." + keyName); + sendCommand("GET S[" + source + "]." + keyName); } /** @@ -265,28 +347,146 @@ void refreshSourceVolume() { refreshSourceKey(SRC_VOLUME); } + /** + * Refreshes the names of the banks + */ + void refreshBanks() { + for (int b = 1; b <= 6; b++) { + sendCommand("GET S[" + source + "].B[" + b + "]." + BANK_NAME); + } + } + + /** + * Sets the bank names from the supplied bank JSON and returns a runnable to call {@link #updateBanksChannel()} + * + * @param bankJson a possibly null, possibly empty json containing the {@link RioBank} to update + * @return a non-null {@link Runnable} to execute after this call + */ + Runnable setBanks(String bankJson) { + + // If null or empty - simply return a do nothing runnable + if (StringUtils.isEmpty(bankJson)) { + return new Runnable() { + @Override + public void run() { + } + }; + } + + try { + final RioBank[] newBanks; + newBanks = gson.fromJson(bankJson, RioBank[].class); + for (int x = 0; x < newBanks.length; x++) { + final RioBank bank = newBanks[x]; + if (bank == null) { + continue; // caused by {id,valid,name},,{id,valid,name} + } + + final int bankId = bank.getId(); + if (bankId < 1 || bankId > 6) { + logger.debug("Invalid bank id (not between 1 and 6) - ignoring: {}:{}", bankId, bankJson); + } else { + final RioBank myBank = banks[bankId - 1]; + + if (!StringUtils.equals(myBank.getName(), bank.getName())) { + myBank.setName(bank.getName()); + sendCommand( + "SET S[" + source + "].B[" + bankId + "]." + BANK_NAME + "=\"" + bank.getName() + "\""); + } + } + } + } catch (JsonSyntaxException e) { + logger.debug("Invalid JSON: {}", e.getMessage(), e); + } + + // regardless of what happens above - reupdate the channel + // (to remove anything bad from it) + return new Runnable() { + @Override + public void run() { + updateBanksChannel(); + } + }; + } + + /** + * Helper method to simply update the banks channel. Will create a JSON representation from {@link #banks} and send + * it via the channel + */ + private void updateBanksChannel() { + final String bankJson = gson.toJson(banks); + stateChanged(RioConstants.CHANNEL_SOURCEBANKS, new StringType(bankJson)); + } + /** * Turns on/off watching the source for notifications * * @param watch true to turn on, false to turn off */ void watchSource(boolean watch) { - sendCommand("WATCH S[" + _source + "] " + (watch ? "ON" : "OFF")); + sendCommand("WATCH S[" + source + "] " + (watch ? "ON" : "OFF")); } - private void handleCoverArt(String url) { - stateChanged(RioConstants.CHANNEL_SOURCECOVERARTURL, new StringType(url)); + /** + * Helper method to handle any media management change. If the channel is the INFO text channel, we delegate to + * {@link #handleMMInfoText(String)} instead. This helper method will simply get the next MM identifier and send the + * json representation out for the channel change (this ensures unique messages for each MM notification) + * + * @param channelId a non-null, non-empty channelId + * @param value the value for the channel + * @throws IllegalArgumentException if channelID is null or empty + */ + private void handleMMChange(String channelId, String value) { + if (StringUtils.isEmpty(channelId)) { + throw new NullArgumentException("channelId cannot be null or empty"); + } - if (StringUtils.isEmpty(url)) { - stateChanged(RioConstants.CHANNEL_SOURCECOVERART, UnDefType.UNDEF); + final AtomicInteger ai = mmSeqNbrs.get(channelId); + if (ai == null) { + logger.error("Channel {} does not have an ID configuration - programmer error!", channelId); } else { - try { - final ContentResponse content = _httpClient.GET(url); - stateChanged(RioConstants.CHANNEL_SOURCECOVERART, new RawType(content.getContent())); - } catch (InterruptedException | ExecutionException | TimeoutException e) { - logger.warn("Exception retrieving cover art image from {}: {}", url, e); - stateChanged(RioConstants.CHANNEL_SOURCECOVERART, UnDefType.UNDEF); + + if (channelId.equals(RioConstants.CHANNEL_SOURCEMMINFOTEXT)) { + value = handleMMInfoText(value); + if (value == null) { + return; + } + } + + final int id = ai.getAndIncrement(); + + final String json = gson.toJson(new IdValue(id, value)); + stateChanged(channelId, new StringType(json)); + } + } + + /** + * Helper method to handle MMInfoText notifications. There may be multiple infotext messages that represent a single + * message. We know when we get the last info text when the MMATTR contains an 'E' (last item). Once we have the + * last item, we update the channel with the complete message. + * + * @param infoTextValue the last info text value + * @return a non-null containing the complete or null if the message isn't complete yet + */ + private String handleMMInfoText(String infoTextValue) { + final StatefulHandlerCallback callback = ((StatefulHandlerCallback) getCallback()); + + final State attr = callback.getProperty(RioConstants.CHANNEL_SOURCEMMATTR); + + infoLock.lock(); + try { + infoText.append(infoTextValue.toString()); + if (attr != null && attr.toString().indexOf("E") >= 0) { + final String text = infoText.toString(); + + infoText.setLength(0); + callback.removeState(RioConstants.CHANNEL_SOURCEMMATTR); + + return text; } + return null; + } finally { + infoLock.unlock(); } } @@ -302,11 +502,11 @@ private void handleSourceNotification(Matcher m, String resp) { } if (m.groupCount() == 3) { try { - final int source = Integer.parseInt(m.group(1)); - if (source != _source) { + final int notifySource = Integer.parseInt(m.group(1)); + if (notifySource != source) { return; } - final String key = m.group(2); + final String key = m.group(2).toLowerCase(); final String value = m.group(3); switch (key) { @@ -315,11 +515,10 @@ private void handleSourceNotification(Matcher m, String resp) { break; case SRC_TYPE: - setProperty(RioConstants.PROPERTY_SOURCETYPE, value); + stateChanged(RioConstants.CHANNEL_SOURCETYPE, new StringType(value)); break; case SRC_IPADDRESS: - case SRC_IPADDRESS2: setProperty(RioConstants.PROPERTY_SOURCEIPADDRESS, value); break; @@ -348,7 +547,7 @@ private void handleSourceNotification(Matcher m, String resp) { break; case SRC_COVERARTURL: - handleCoverArt(value); + stateChanged(RioConstants.CHANNEL_SOURCECOVERARTURL, new StringType(value)); break; case SRC_PLAYLISTNAME: @@ -399,6 +598,37 @@ private void handleSourceNotification(Matcher m, String resp) { stateChanged(RioConstants.CHANNEL_SOURCEVOLUME, new StringType(value)); break; + case SRC_MMScreen: + handleMMChange(RioConstants.CHANNEL_SOURCEMMSCREEN, value); + break; + + case SRC_MMTitle: + handleMMChange(RioConstants.CHANNEL_SOURCEMMTITLE, value); + break; + + case SRC_MMAttr: + handleMMChange(RioConstants.CHANNEL_SOURCEMMATTR, value); + break; + + case SRC_MMBtnOk: + handleMMChange(RioConstants.CHANNEL_SOURCEMMBUTTONOKTEXT, value); + break; + + case SRC_MMBtnBack: + handleMMChange(RioConstants.CHANNEL_SOURCEMMBUTTONBACKTEXT, value); + break; + + case SRC_MMHelp: + handleMMChange(RioConstants.CHANNEL_SOURCEMMHELPTEXT, value); + break; + + case SRC_MMTextField: + handleMMChange(RioConstants.CHANNEL_SOURCEMMTEXTFIELD, value); + break; + + case SRC_MMInfoBlock: + handleMMChange(RioConstants.CHANNEL_SOURCEMMINFOTEXT, value); + break; default: logger.warn("Unknown source notification: '{}'", resp); break; @@ -412,6 +642,53 @@ private void handleSourceNotification(Matcher m, String resp) { } + /** + * Handles any bank notifications returned by the russound system + * + * @param m a non-null matcher + * @param resp a possibly null, possibly empty response + */ + private void handleBankNotification(Matcher m, String resp) { + if (m == null) { + throw new IllegalArgumentException("m (matcher) cannot be null"); + } + + // System notification + if (m.groupCount() == 4) { + try { + final int bank = Integer.parseInt(m.group(2)); + if (bank >= 1 && bank <= 6) { + final int notifySource = Integer.parseInt(m.group(1)); + if (notifySource != source) { + return; + } + + final String key = m.group(3).toLowerCase(); + final String value = m.group(4); + + switch (key) { + case BANK_NAME: + banks[bank - 1].setName(value); + updateBanksChannel(); + break; + + default: + logger.warn("Unknown bank name notification: '{}'", resp); + break; + } + } else { + logger.debug("Bank ID must be between 1 and 6: {}", resp); + } + + } catch (NumberFormatException e) { + logger.warn("Invalid Bank Name Notification (bank/source not a parsable integer): '{}')", resp); + } + + } else { + logger.warn("Invalid Bank Notification: '{}')", resp); + } + } + /** * Implements {@link SocketSessionListener#responseReceived(String)} to try to process the response from the * russound system. This response may be for other protocol handler - so ignore if we don't recognize the response. @@ -420,14 +697,35 @@ private void handleSourceNotification(Matcher m, String resp) { */ @Override public void responseReceived(String response) { - if (response == null || response == "") { + if (StringUtils.isEmpty(response)) { + return; + } + + Matcher m = RSP_BANKNOTIFICATION.matcher(response); + if (m.matches()) { + handleBankNotification(m, response); + return; + } + + m = RSP_PRESETNOTIFICATION.matcher(response); + if (m.matches()) { + // does nothing return; } - final Matcher m = RSP_SRCNOTIFICATION.matcher(response); + m = RSP_SRCNOTIFICATION.matcher(response); if (m.matches()) { handleSourceNotification(m, response); } + + m = RSP_MMMENUNOTIFICATION.matcher(response); + if (m.matches()) { + try { + handleMMChange(RioConstants.CHANNEL_SOURCEMMMENU, response); + } catch (NumberFormatException e) { + logger.debug("Could not parse the menu text (1) from {}", response); + } + } } /** @@ -436,13 +734,41 @@ public void responseReceived(String response) { @Override public void dispose() { watchSource(false); - if (_httpClient != null) { + if (httpClient != null) { try { - _httpClient.stop(); + httpClient.stop(); } catch (Exception e) { logger.debug("Error stopping the httpclient: {}", e); } } super.dispose(); } + + /** + * The following class is simply used as a model for an id/value combination that will be serialized to JSON. + * Nothing needs to be public because the serialization walks the properties. + * + * @author Tim Roberts + * + */ + @SuppressWarnings("unused") + private class IdValue { + /** The id of the value */ + private final int id; + + /** The value for the id */ + private final String value; + + /** + * Constructions ID/Value from the given parms (no validations are done) + * + * @param id the identifier + * @param value the associated value + */ + public IdValue(int id, String value) { + this.id = id; + this.value = value; + } + } + } diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/system/RioSystemConfig.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/system/RioSystemConfig.java index 7a7a7320424a8..0397929b92d30 100644 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/system/RioSystemConfig.java +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/system/RioSystemConfig.java @@ -15,6 +15,26 @@ */ public class RioSystemConfig { + /** + * Constant defined for the "ipAddress" configuration field + */ + public static final String IpAddress = "ipAddress"; + + /** + * Constant defined for the "ping" configuration field + */ + public static final String Ping = "ping"; + + /** + * Constant defined for the "retryPolling" configuration field + */ + public static final String RetryPolling = "retryPolling"; + + /** + * Constant defined for the "scanDevice" configuration field + */ + public static final String ScanDevice = "scanDevice"; + /** * IP Address (or host name) of system */ @@ -30,6 +50,11 @@ public class RioSystemConfig { */ private int retryPolling; + /** + * Whether to scan the device at startup (and create zones, source, etc dynamically) + */ + private boolean scanDevice; + /** * Returns the IP address or host name * @@ -83,4 +108,64 @@ public int getPing() { public void setPing(int ping) { this.ping = ping; } + + /** + * Whether the device should be scanned at startup + * + * @return true to scan, false otherwise + */ + public boolean isScanDevice() { + return scanDevice; + } + + /** + * Sets whether the device should be scanned at startup + * + * @param scanDevice true to scan, false otherwise + */ + public void setScanDevice(boolean scanDevice) { + this.scanDevice = scanDevice; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((ipAddress == null) ? 0 : ipAddress.hashCode()); + result = prime * result + ping; + result = prime * result + retryPolling; + result = prime * result + (scanDevice ? 1231 : 1237); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + final RioSystemConfig other = (RioSystemConfig) obj; + if (ipAddress == null) { + if (other.ipAddress != null) { + return false; + } + } else if (!ipAddress.equals(other.ipAddress)) { + return false; + } + if (ping != other.ping) { + return false; + } + if (retryPolling != other.retryPolling) { + return false; + } + if (scanDevice != other.scanDevice) { + return false; + } + return true; + } } diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/system/RioSystemHandler.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/system/RioSystemHandler.java index 100e830a82274..c13f82a6820ee 100644 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/system/RioSystemHandler.java +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/system/RioSystemHandler.java @@ -11,6 +11,8 @@ import java.io.IOException; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.ReentrantLock; import org.eclipse.smarthome.core.library.types.OnOffType; import org.eclipse.smarthome.core.library.types.StringType; @@ -19,18 +21,29 @@ import org.eclipse.smarthome.core.thing.Thing; import org.eclipse.smarthome.core.thing.ThingStatus; import org.eclipse.smarthome.core.thing.ThingStatusDetail; +import org.eclipse.smarthome.core.thing.binding.ThingHandler; import org.eclipse.smarthome.core.types.Command; import org.eclipse.smarthome.core.types.RefreshType; import org.eclipse.smarthome.core.types.State; +import org.openhab.binding.russound.internal.discovery.RioSystemDeviceDiscoveryService; import org.openhab.binding.russound.internal.net.SocketChannelSession; import org.openhab.binding.russound.internal.net.SocketSession; import org.openhab.binding.russound.internal.rio.AbstractBridgeHandler; +import org.openhab.binding.russound.internal.rio.AbstractRioHandlerCallback; import org.openhab.binding.russound.internal.rio.RioConstants; import org.openhab.binding.russound.internal.rio.RioHandlerCallback; +import org.openhab.binding.russound.internal.rio.RioHandlerCallbackListener; +import org.openhab.binding.russound.internal.rio.RioPresetsProtocol; +import org.openhab.binding.russound.internal.rio.RioSystemFavoritesProtocol; import org.openhab.binding.russound.internal.rio.StatefulHandlerCallback; +import org.openhab.binding.russound.internal.rio.controller.RioControllerHandler; +import org.openhab.binding.russound.internal.rio.models.GsonUtilities; +import org.openhab.binding.russound.internal.rio.source.RioSourceHandler; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.gson.Gson; + /** * The bridge handler for a Russound System. This is the entry point into the whole russound system and is generally * points to the main controller. This implementation must be attached to a {@link RioSystemHandler} bridge. @@ -39,28 +52,83 @@ */ public class RioSystemHandler extends AbstractBridgeHandler { // Logger - private Logger logger = LoggerFactory.getLogger(RioSystemHandler.class); + private final Logger logger = LoggerFactory.getLogger(RioSystemHandler.class); /** * The configuration for the system - will be recreated when the configuration changes and will be null when not * online */ - private RioSystemConfig _config; + private RioSystemConfig config; + + /** + * The lock used to control access to {@link #config} + */ + private final ReentrantLock configLock = new ReentrantLock(); /** * The {@link SocketSession} telnet session to the switch. Will be null if not connected. */ - private SocketSession _session; + private SocketSession session; + + /** + * The lock used to control access to {@link #session} + */ + private final ReentrantLock sessionLock = new ReentrantLock(); /** * The retry connection event - will only be created when we are retrying the connection attempt */ - private ScheduledFuture _retryConnection; + private ScheduledFuture retryConnection; + + /** + * The lock used to control access to {@link #retryConnection} + */ + private final ReentrantLock retryConnectionLock = new ReentrantLock(); /** * The ping event - will be non-null when online (null otherwise) */ - private ScheduledFuture _ping; + private ScheduledFuture ping; + + /** + * The lock used to control access to {@link #ping} + */ + private final ReentrantLock pingLock = new ReentrantLock(); + + /** + * {@link Gson} used for JSON serialization/deserialization + */ + private final Gson gson = GsonUtilities.createGson(); + + /** + * Callback listener to use when source name changes - will call {@link #refreshNamedHandler(Gson, Class, String)} + * to + * refresh the {@link RioConstants#CHANNEL_SYSSOURCES} channel + */ + private final RioHandlerCallbackListener handlerCallbackListener = new RioHandlerCallbackListener() { + @Override + public void stateUpdate(String channelId, State state) { + refreshNamedHandler(gson, RioSourceHandler.class, RioConstants.CHANNEL_SYSSOURCES); + } + }; + + /** + * The protocol for favorites handling + */ + private final AtomicReference favoritesProtocol = new AtomicReference( + null); + + /** + * The protocol for presets handling + */ + private final AtomicReference presetsProtocol = new AtomicReference(null); + + /** + * The discovery service to discover the zones/sources, etc + * Will be null if not active. + */ + private final AtomicReference discoveryService = new AtomicReference( + null); /** * Constructs the handler from the {@link Bridge} @@ -78,7 +146,12 @@ public RioSystemHandler(Bridge bridge) { */ @Override public SocketSession getSocketSession() { - return _session; + sessionLock.lock(); + try { + return session; + } finally { + sessionLock.unlock(); + } } /** @@ -146,6 +219,14 @@ private void handleRefresh(String id) { } else if (id.equals(RioConstants.CHANNEL_SYSSTATUS)) { getProtocolHandler().refreshSystemStatus(); + } else if (id.equals(RioConstants.CHANNEL_SYSALLON)) { + getProtocolHandler().refreshSystemAllOn(); + + } else if (id.equals(RioConstants.CHANNEL_SYSCONTROLLERS)) { + refreshNamedHandler(gson, RioControllerHandler.class, RioConstants.CHANNEL_SYSCONTROLLERS); + } else if (id.equals(RioConstants.CHANNEL_SYSSOURCES)) { + refreshNamedHandler(gson, RioSourceHandler.class, RioConstants.CHANNEL_SYSSOURCES); + } else { // Can't refresh any others... } @@ -159,19 +240,24 @@ private void handleRefresh(String id) { */ @Override public void initialize() { - final RioSystemConfig config = getRioConfig(); + final RioSystemConfig rioConfig = getRioConfig(); - if (config == null) { + if (rioConfig == null) { return; } - if (config.getIpAddress() == null || config.getIpAddress().trim().length() == 0) { + if (rioConfig.getIpAddress() == null || rioConfig.getIpAddress().trim().length() == 0) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "IP Address of Russound is missing from configuration"); return; } - _session = new SocketChannelSession(config.getIpAddress(), 9621); + sessionLock.lock(); + try { + session = new SocketChannelSession(rioConfig.getIpAddress(), RioConstants.RioPort); + } finally { + sessionLock.unlock(); + } // Try initial connection in a scheduled task this.scheduler.schedule(new Runnable() { @@ -191,10 +277,13 @@ public void run() { */ private void connect() { String response = "Server is offline - will try to reconnect later"; + + sessionLock.lock(); + pingLock.lock(); try { - _session.connect(); + session.connect(); - setProtocolHandler(new RioSystemProtocol(_session, new StatefulHandlerCallback(new RioHandlerCallback() { + final StatefulHandlerCallback callback = new StatefulHandlerCallback(new AbstractRioHandlerCallback() { @Override public void statusChanged(ThingStatus status, ThingStatusDetail detail, String msg) { updateStatus(status, detail, msg); @@ -207,6 +296,7 @@ public void statusChanged(ThingStatus status, ThingStatusDetail detail, String m @Override public void stateChanged(String channelId, State state) { updateState(channelId, state); + fireStateUpdated(channelId, state); } @Override @@ -214,19 +304,23 @@ public void setProperty(String propertyName, String propertyValue) { getThing().setProperty(propertyName, propertyValue); } - }))); + }); + + setProtocolHandler(new RioSystemProtocol(session, callback)); + favoritesProtocol.set(new RioSystemFavoritesProtocol(session, callback)); + presetsProtocol.set(new RioPresetsProtocol(session, callback)); response = getProtocolHandler().login(); if (response == null) { - final RioSystemConfig config = getRioConfig(); - if (config != null) { - _ping = this.scheduler.scheduleAtFixedRate(new Runnable() { + final RioSystemConfig rioConfig = getRioConfig(); + if (rioConfig != null) { + ping = this.scheduler.scheduleAtFixedRate(new Runnable() { @Override public void run() { try { final ThingStatus status = getThing().getStatus(); if (status == ThingStatus.ONLINE) { - if (_session.isConnected()) { + if (session.isConnected()) { getProtocolHandler().ping(); } } @@ -234,10 +328,14 @@ public void run() { logger.error("Exception while pinging: {}", e.getMessage(), e); } } - }, config.getPing(), config.getPing(), TimeUnit.SECONDS); + }, rioConfig.getPing(), rioConfig.getPing(), TimeUnit.SECONDS); - logger.info("Going online"); + logger.debug("Going online!"); updateStatus(ThingStatus.ONLINE); + startScan(rioConfig); + refreshNamedHandler(gson, RioSourceHandler.class, RioConstants.CHANNEL_SYSSOURCES); + refreshNamedHandler(gson, RioControllerHandler.class, RioConstants.CHANNEL_SYSCONTROLLERS); + return; } else { logger.debug("getRioConfig returned a null!"); @@ -249,6 +347,9 @@ public void run() { } catch (Exception e) { logger.error("Error connecting: {}", e.getMessage(), e); // do nothing + } finally { + pingLock.unlock(); + sessionLock.unlock(); } updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, response); @@ -261,43 +362,53 @@ public void run() { */ @Override protected void reconnect() { - if (_retryConnection == null) { - final RioSystemConfig config = getRioConfig(); - if (config != null) { + retryConnectionLock.lock(); + try { + if (retryConnection == null) { + final RioSystemConfig rioConfig = getRioConfig(); + if (rioConfig != null) { - logger.info("Will try to reconnect in {} seconds", config.getRetryPolling()); - _retryConnection = this.scheduler.schedule(new Runnable() { - @Override - public void run() { - _retryConnection = null; - try { - if (getThing().getStatus() != ThingStatus.ONLINE) { - connect(); + logger.info("Will try to reconnect in {} seconds", rioConfig.getRetryPolling()); + retryConnection = this.scheduler.schedule(new Runnable() { + @Override + public void run() { + retryConnection = null; + try { + if (getThing().getStatus() != ThingStatus.ONLINE) { + connect(); + } + } catch (Exception e) { + logger.error("Exception connecting: {}", e.getMessage(), e); } - } catch (Exception e) { - logger.error("Exception connecting: {}", e.getMessage(), e); } - } - }, config.getRetryPolling(), TimeUnit.SECONDS); + }, rioConfig.getRetryPolling(), TimeUnit.SECONDS); + } + } else { + logger.debug("RetryConnection called when a retry connection is pending - ignoring request"); } - } else { - logger.debug("RetryConnection called when a retry connection is pending - ignoring request"); + } finally { + retryConnectionLock.unlock(); } } /** * {@inheritDoc} * - * Attempts to disconnect from the session. The protocol handler will be set to null, the {@link #_ping} will be - * cancelled/set to null and the {@link #_session} will be disconnected + * Attempts to disconnect from the session. The protocol handler will be set to null, the {@link #ping} will be + * cancelled/set to null and the {@link #session} will be disconnected */ @Override protected void disconnect() { // Cancel ping - if (_ping != null) { - _ping.cancel(true); - _ping = null; + pingLock.lock(); + try { + if (ping != null) { + ping.cancel(true); + ping = null; + } + } finally { + pingLock.unlock(); } if (getProtocolHandler() != null) { @@ -305,10 +416,13 @@ protected void disconnect() { setProtocolHandler(null); } + sessionLock.lock(); try { - _session.disconnect(); + session.disconnect(); } catch (IOException e) { // ignore - we don't care + } finally { + sessionLock.unlock(); } } @@ -318,17 +432,114 @@ protected void disconnect() { * * @return a possible null {@link RioSystemConfig} */ - private RioSystemConfig getRioConfig() { - if (_config == null) { - final RioSystemConfig config = getThing().getConfiguration().as(RioSystemConfig.class); + public RioSystemConfig getRioConfig() { + configLock.lock(); + try { + final RioSystemConfig sysConfig = getThing().getConfiguration().as(RioSystemConfig.class); - if (config == null) { + if (sysConfig == null) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Configuration file missing"); } else { - _config = config; + config = sysConfig; + } + return config; + } finally { + configLock.unlock(); + } + } + + /** + * Registers the {@link RioSystemDeviceDiscoveryService} with this handler. The discovery service will be called in + * {@link #startScan(RioSystemConfig)} when a device should be scanned and 'things' discovered from it + * + * @param service a possibly null {@link RioSystemDeviceDiscoveryService} + */ + public void registerDiscoveryService(RioSystemDeviceDiscoveryService service) { + discoveryService.set(service); + } + + /** + * Helper method to possibly start a scan. A scan will ONLY be started if the {@link RioSystemConfig#isScanDevice()} + * is true and a discovery service has been set ({@link #registerDiscoveryService(RioSystemDeviceDiscoveryService)}) + * + * @param sysConfig a non-null {@link RioSystemConfig} + */ + private void startScan(RioSystemConfig sysConfig) { + final RioSystemDeviceDiscoveryService service = discoveryService.get(); + if (service != null) { + if (sysConfig != null && sysConfig.isScanDevice()) { + this.scheduler.execute(new Runnable() { + @Override + public void run() { + logger.info("Starting device discovery"); + service.scanDevice(); + } + }); + } + } + } + + /** + * Overrides the base to call {@link #childChanged(ThingHandler)} to recreate the sources/controllers names + */ + @Override + public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) { + childChanged(childHandler, true); + } + + /** + * Overrides the base to call {@link #childChanged(ThingHandler)} to recreate the sources/controllers names + */ + @Override + public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) { + childChanged(childHandler, false); + } + + /** + * Helper method to recreate the {@link RioConstants#CHANNEL_SYSSOURCES} && + * {@link RioConstants#CHANNEL_SYSCONTROLLERS} channels + * + * @param childHandler a non-null child handler that changed + * @param added true if added, false otherwise + * @throw IllegalArgumentException if childHandler is null + */ + private void childChanged(ThingHandler childHandler, boolean added) { + if (childHandler == null) { + throw new IllegalArgumentException("childHandler cannot be null"); + } + if (childHandler instanceof RioSourceHandler) { + final RioHandlerCallback callback = ((RioSourceHandler) childHandler).getCallback(); + if (callback != null) { + if (added) { + callback.addListener(RioConstants.CHANNEL_SOURCENAME, handlerCallbackListener); + } else { + callback.removeListener(RioConstants.CHANNEL_SOURCENAME, handlerCallbackListener); + } } + refreshNamedHandler(gson, RioSourceHandler.class, RioConstants.CHANNEL_SYSSOURCES); + } else if (childHandler instanceof RioControllerHandler) { + refreshNamedHandler(gson, RioControllerHandler.class, RioConstants.CHANNEL_SYSCONTROLLERS); } - return _config; + } + + /** + * Returns the {@link RioSystemFavoritesProtocol} for the system + * + * @return a possibly null {@link RioSystemFavoritesProtocol} + */ + @Override + public RioSystemFavoritesProtocol getSystemFavoritesHandler() { + return favoritesProtocol.get(); + } + + /** + * Returns the {@link RioPresetsProtocol} for the system + * + * @return a possibly null {@link RioPresetsProtocol} + */ + @Override + public RioPresetsProtocol getPresetsProtocol() { + return presetsProtocol.get(); } } diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/system/RioSystemProtocol.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/system/RioSystemProtocol.java index 4fe12531f611e..9ad7519b57ee0 100644 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/system/RioSystemProtocol.java +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/system/RioSystemProtocol.java @@ -8,9 +8,11 @@ */ package org.openhab.binding.russound.internal.rio.system; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.apache.commons.lang.StringUtils; import org.eclipse.smarthome.core.library.types.OnOffType; import org.eclipse.smarthome.core.library.types.StringType; import org.eclipse.smarthome.core.thing.ThingStatus; @@ -32,17 +34,20 @@ */ class RioSystemProtocol extends AbstractRioProtocol { // Logger - private Logger logger = LoggerFactory.getLogger(RioSystemProtocol.class); + private final Logger logger = LoggerFactory.getLogger(RioSystemProtocol.class); // Protocol Constants - private static final String SYS_VERSION = "VERSION"; // 12 max + private static final String SYS_VERSION = "version"; // 12 max private static final String SYS_STATUS = "status"; // 12 max private static final String SYS_LANG = "language"; // 12 max // Response patterns - private final Pattern RSP_VERSION = Pattern.compile("^S VERSION=\"(.+)\"$"); - private final Pattern RSP_FAILURE = Pattern.compile("^E (.*)"); - private final Pattern RSP_SYSTEMNOTIFICATION = Pattern.compile("^[SN] System\\.(\\w+)=\"(.*)\"$"); + private static final Pattern RSP_VERSION = Pattern.compile("(?i)^S VERSION=\"(.+)\"$"); + private static final Pattern RSP_FAILURE = Pattern.compile("(?i)^E (.*)"); + private static final Pattern RSP_SYSTEMNOTIFICATION = Pattern.compile("(?i)^[SN] System\\.(\\w+)=\"(.*)\"$"); + + // all on state (there is no corresponding value) + private final AtomicBoolean allOn = new AtomicBoolean(false); /** * This represents our ping command. There is no ping command in the protocol so we simply send an empty command to @@ -58,7 +63,6 @@ class RioSystemProtocol extends AbstractRioProtocol { */ RioSystemProtocol(SocketSession session, RioHandlerCallback callback) { super(session, callback); - } /** @@ -116,8 +120,8 @@ private void refreshSystemKey(String keyName) { /** * Refresh the system status */ - void refreshSystemStatus() { - refreshSystemKey(SYS_STATUS); + void refreshSystemAllOn() { + stateChanged(RioConstants.CHANNEL_SYSALLON, allOn.get() ? OnOffType.ON : OnOffType.OFF); } /** @@ -127,6 +131,13 @@ void refreshSystemLanguage() { refreshSystemKey(SYS_LANG); } + /** + * Refresh the system status + */ + void refreshSystemStatus() { + refreshSystemKey(SYS_STATUS); + } + /** * Turns on/off watching for system notifications * @@ -143,6 +154,8 @@ void watchSystem(boolean on) { */ void setSystemAllOn(boolean on) { sendCommand("EVENT C[1].Z[1]!All" + (on ? "On" : "Off")); + allOn.set(on); + refreshSystemAllOn(); } /** @@ -193,7 +206,7 @@ void handleSystemNotification(Matcher m, String resp) { throw new IllegalArgumentException("m (matcher) cannot be null"); } if (m.groupCount() == 2) { - final String key = m.group(1); + final String key = m.group(1).toLowerCase(); final String value = m.group(2); switch (key) { @@ -221,7 +234,7 @@ void handleSystemNotification(Matcher m, String resp) { * @param resp a possibly null, possibly empty response */ private void handleFailureNotification(Matcher m, String resp) { - logger.info("Error notification: {}", resp); + logger.debug("Error notification: {}", resp); } /** @@ -232,7 +245,7 @@ private void handleFailureNotification(Matcher m, String resp) { */ @Override public void responseReceived(String response) { - if (response == null || response == "") { + if (StringUtils.isEmpty(response)) { return; } @@ -253,6 +266,7 @@ public void responseReceived(String response) { handleFailureNotification(m, response); return; } + } /** diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/zone/RioZoneConfig.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/zone/RioZoneConfig.java index 032e80f4da094..0bc302700f661 100644 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/zone/RioZoneConfig.java +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/zone/RioZoneConfig.java @@ -14,6 +14,11 @@ * @author Tim Roberts */ public class RioZoneConfig { + /** + * Constant defined for the "zone" configuration field + */ + public static final String Zone = "zone"; + /** * ID of the zone */ diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/zone/RioZoneHandler.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/zone/RioZoneHandler.java index a79c9b7c09341..4fe7d50a5cd7d 100644 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/zone/RioZoneHandler.java +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/zone/RioZoneHandler.java @@ -8,6 +8,11 @@ */ package org.openhab.binding.russound.internal.rio.zone; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.commons.lang.StringUtils; import org.eclipse.smarthome.core.library.types.DecimalType; import org.eclipse.smarthome.core.library.types.IncreaseDecreaseType; import org.eclipse.smarthome.core.library.types.OnOffType; @@ -15,6 +20,7 @@ import org.eclipse.smarthome.core.library.types.StringType; import org.eclipse.smarthome.core.thing.Bridge; import org.eclipse.smarthome.core.thing.ChannelUID; +import org.eclipse.smarthome.core.thing.Thing; import org.eclipse.smarthome.core.thing.ThingStatus; import org.eclipse.smarthome.core.thing.ThingStatusDetail; import org.eclipse.smarthome.core.thing.binding.ThingHandler; @@ -23,8 +29,14 @@ import org.eclipse.smarthome.core.types.State; import org.openhab.binding.russound.internal.net.SocketSession; import org.openhab.binding.russound.internal.rio.AbstractBridgeHandler; +import org.openhab.binding.russound.internal.rio.AbstractRioHandlerCallback; +import org.openhab.binding.russound.internal.rio.AbstractThingHandler; +import org.openhab.binding.russound.internal.rio.RioCallbackHandler; import org.openhab.binding.russound.internal.rio.RioConstants; import org.openhab.binding.russound.internal.rio.RioHandlerCallback; +import org.openhab.binding.russound.internal.rio.RioNamedHandler; +import org.openhab.binding.russound.internal.rio.RioPresetsProtocol; +import org.openhab.binding.russound.internal.rio.RioSystemFavoritesProtocol; import org.openhab.binding.russound.internal.rio.StatefulHandlerCallback; import org.openhab.binding.russound.internal.rio.controller.RioControllerHandler; import org.slf4j.Logger; @@ -36,27 +48,33 @@ * * @author Tim Roberts */ -public class RioZoneHandler extends AbstractBridgeHandler { +public class RioZoneHandler extends AbstractThingHandler + implements RioNamedHandler, RioCallbackHandler { // Logger - private Logger logger = LoggerFactory.getLogger(RioZoneHandler.class); + private final Logger logger = LoggerFactory.getLogger(RioZoneHandler.class); /** * The controller identifier we are attached to */ - private int _controller; + private final AtomicInteger controller = new AtomicInteger(0); /** * The zone identifier for this instance */ - private int _zone; + private final AtomicInteger zone = new AtomicInteger(0); + + /** + * The zone name for this instance + */ + private final AtomicReference zoneName = new AtomicReference(null); /** - * Constructs the handler from the {@link Bridge} + * Constructs the handler from the {@link Thing} * - * @param bridge a non-null {@link Bridge} the handler is for + * @param thing a non-null {@link Thing} the handler is for */ - public RioZoneHandler(Bridge bridge) { - super(bridge); + public RioZoneHandler(Thing thing) { + super(thing); } /** @@ -65,7 +83,7 @@ public RioZoneHandler(Bridge bridge) { * @return the controller identifier */ public int getController() { - return _controller; + return controller.get(); } /** @@ -73,8 +91,20 @@ public int getController() { * * @return the zone identifier */ - public int getZone() { - return _zone; + @Override + public int getId() { + return zone.get(); + } + + /** + * Returns the zone name + * + * @return the zone name + */ + @Override + public String getName() { + final String name = zoneName.get(); + return StringUtils.isEmpty(name) ? ("Zone " + getId()) : name; } /** @@ -128,10 +158,13 @@ public void handleCommand(ChannelUID channelUID, Command command) { } } else if (id.equals(RioConstants.CHANNEL_ZONETURNONVOLUME)) { - if (command instanceof DecimalType) { - getProtocolHandler().setZoneTurnOnVolume(((DecimalType) command).intValue()); + if (command instanceof PercentType) { + getProtocolHandler().setZoneTurnOnVolume(((PercentType) command).intValue() / 100d); + } else if (command instanceof DecimalType) { + getProtocolHandler().setZoneTurnOnVolume(((DecimalType) command).doubleValue()); } else { - logger.debug("Received a ZONE TURN ON VOLUME channel command with a non DecimalType: {}", command); + logger.debug("Received a ZONE TURN ON VOLUME channel command with a non PercentType/DecimalType: {}", + command); } } else if (id.equals(RioConstants.CHANNEL_ZONELOUDNESS)) { @@ -202,10 +235,12 @@ public void handleCommand(ChannelUID channelUID, Command command) { } else if (command instanceof IncreaseDecreaseType) { getProtocolHandler().setZoneVolume(command == IncreaseDecreaseType.INCREASE); } else if (command instanceof PercentType) { - getProtocolHandler().setZoneVolume(((PercentType) command).intValue() / 2); // only support 0-50 + getProtocolHandler().setZoneVolume(((PercentType) command).intValue() / 100d); + } else if (command instanceof DecimalType) { + getProtocolHandler().setZoneVolume(((DecimalType) command).doubleValue()); } else { logger.debug( - "Received a ZONE VOLUME channel command with a non OnOffType/IncreaseDecreaseType/PercentType: {}", + "Received a ZONE VOLUME channel command with a non OnOffType/IncreaseDecreaseType/PercentType/DecimalTye: {}", command); } @@ -251,6 +286,48 @@ public void handleCommand(ChannelUID channelUID, Command command) { logger.debug("Received a ZONE EVENT channel command with a non StringType: {}", command); } + } else if (id.equals(RioConstants.CHANNEL_ZONEMMINIT)) { + getProtocolHandler().sendMMInit(); + + } else if (id.equals(RioConstants.CHANNEL_ZONEMMCONTEXTMENU)) { + getProtocolHandler().sendMMContextMenu(); + + } else if (id.equals(RioConstants.CHANNEL_ZONESYSFAVORITES)) { + if (command instanceof StringType) { + // Remove any state for this channel to ensure it's recreated/sent again + // (clears any bad or deleted favorites information from the channel) + ((StatefulHandlerCallback) getProtocolHandler().getCallback()) + .removeState(RioConstants.CHANNEL_ZONESYSFAVORITES); + + getProtocolHandler().setSystemFavorites(command.toString()); + } else { + logger.debug("Received a SYSTEM FAVORITES channel command with a non StringType: {}", command); + } + + } else if (id.equals(RioConstants.CHANNEL_ZONEFAVORITES)) { + if (command instanceof StringType) { + // Remove any state for this channel to ensure it's recreated/sent again + // (clears any bad or deleted favorites information from the channel) + ((StatefulHandlerCallback) getProtocolHandler().getCallback()) + .removeState(RioConstants.CHANNEL_ZONEFAVORITES); + + // schedule the returned callback in the future (to allow the channel to process and to allow russound + // to process (before re-retrieving information) + scheduler.schedule(getProtocolHandler().setZoneFavorites(command.toString()), 250, + TimeUnit.MILLISECONDS); + + } else { + logger.debug("Received a ZONE FAVORITES channel command with a non StringType: {}", command); + } + } else if (id.equals(RioConstants.CHANNEL_ZONEPRESETS)) { + if (command instanceof StringType) { + ((StatefulHandlerCallback) getProtocolHandler().getCallback()) + .removeState(RioConstants.CHANNEL_ZONEPRESETS); + + getProtocolHandler().setZonePresets(command.toString()); + } else { + logger.debug("Received a ZONE FAVORITES channel command with a non StringType: {}", command); + } } else { logger.debug("Unknown/Unsupported Channel id: {}", id); } @@ -306,6 +383,12 @@ private void handleRefresh(String id) { getProtocolHandler().refreshZoneSleepTimeRemaining(); } else if (id.startsWith(RioConstants.CHANNEL_ZONELASTERROR)) { getProtocolHandler().refreshZoneLastError(); + } else if (id.equals(RioConstants.CHANNEL_ZONESYSFAVORITES)) { + getProtocolHandler().refreshSystemFavorites(); + } else if (id.equals(RioConstants.CHANNEL_ZONEFAVORITES)) { + getProtocolHandler().refreshZoneFavorites(); + } else if (id.equals(RioConstants.CHANNEL_ZONEPRESETS)) { + getProtocolHandler().refreshZonePresets(); } else { // Can't refresh any others... } @@ -348,14 +431,16 @@ public void initialize() { return; } - _zone = config.getZone(); - if (_zone < 1 || _zone > 6) { + final int configZone = config.getZone(); + if (configZone < 1 || configZone > 8) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "Source must be between 1 and 8: " + _zone); + "Source must be between 1 and 8: " + configZone); return; } + zone.set(configZone); - _controller = ((RioControllerHandler) handler).getController(); + final int handlerController = ((RioControllerHandler) handler).getId(); + controller.set(handlerController); // Get the socket session from the final SocketSession socketSession = getSocketSession(); @@ -364,8 +449,8 @@ public void initialize() { return; } - setProtocolHandler(new RioZoneProtocol(_zone, _controller, socketSession, - new StatefulHandlerCallback(new RioHandlerCallback() { + setProtocolHandler(new RioZoneProtocol(configZone, handlerController, getSystemFavoritesHandler(), + getPresetsProtocol(), socketSession, new StatefulHandlerCallback(new AbstractRioHandlerCallback() { @Override public void statusChanged(ThingStatus status, ThingStatusDetail detail, String msg) { updateStatus(status, detail, msg); @@ -373,7 +458,11 @@ public void statusChanged(ThingStatus status, ThingStatusDetail detail, String m @Override public void stateChanged(String channelId, State state) { + if (channelId.equals(RioConstants.CHANNEL_ZONENAME)) { + zoneName.set(state.toString()); + } updateState(channelId, state); + fireStateUpdated(channelId, state); } @Override @@ -381,9 +470,49 @@ public void setProperty(String propertyName, String propertyValue) { getThing().setProperty(propertyName, propertyValue); } }))); + updateStatus(ThingStatus.ONLINE); - getProtocolHandler().watchZone(true); - getProtocolHandler().refreshZoneEnabled(); + getProtocolHandler().postOnline(); + } + + /** + * Returns the {@link RioHandlerCallback} related to the zone + * + * @return a possibly null {@link RioHandlerCallback} + */ + @Override + public RioHandlerCallback getCallback() { + final RioZoneProtocol protocolHandler = getProtocolHandler(); + return protocolHandler == null ? null : protocolHandler.getCallback(); + } + + /** + * Returns the {@link RioPresetsProtocol} related to the system. This simply queries the parent bridge for the + * protocol + * + * @return a possibly null {@link RioPresetsProtocol} + */ + @SuppressWarnings("rawtypes") + RioPresetsProtocol getPresetsProtocol() { + final Bridge bridge = getBridge(); + if (bridge != null && bridge.getHandler() instanceof AbstractBridgeHandler) { + return ((AbstractBridgeHandler) bridge.getHandler()).getPresetsProtocol(); + } + return null; } + /** + * Returns the {@link RioSystemFavoritesProtocol} related to the system. This simply queries the parent bridge for + * the protocol + * + * @return a possibly null {@link RioSystemFavoritesProtocol} + */ + @SuppressWarnings("rawtypes") + RioSystemFavoritesProtocol getSystemFavoritesHandler() { + final Bridge bridge = getBridge(); + if (bridge != null && bridge.getHandler() instanceof AbstractBridgeHandler) { + return ((AbstractBridgeHandler) bridge.getHandler()).getSystemFavoritesHandler(); + } + return null; + } } diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/zone/RioZoneProtocol.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/zone/RioZoneProtocol.java index 272f7077573f3..dbab68662b6c6 100644 --- a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/zone/RioZoneProtocol.java +++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/rio/zone/RioZoneProtocol.java @@ -8,9 +8,13 @@ */ package org.openhab.binding.russound.internal.rio.zone; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.apache.commons.lang.StringUtils; import org.eclipse.smarthome.core.library.types.DecimalType; import org.eclipse.smarthome.core.library.types.OnOffType; import org.eclipse.smarthome.core.library.types.PercentType; @@ -20,9 +24,16 @@ import org.openhab.binding.russound.internal.rio.AbstractRioProtocol; import org.openhab.binding.russound.internal.rio.RioConstants; import org.openhab.binding.russound.internal.rio.RioHandlerCallback; +import org.openhab.binding.russound.internal.rio.RioPresetsProtocol; +import org.openhab.binding.russound.internal.rio.RioSystemFavoritesProtocol; +import org.openhab.binding.russound.internal.rio.models.GsonUtilities; +import org.openhab.binding.russound.internal.rio.models.RioFavorite; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; + /** * This is the protocol handler for the Russound Zone. This handler will issue the protocol commands and will * process the responses from the Russound system. @@ -30,52 +41,82 @@ * @author Tim Roberts * */ -class RioZoneProtocol extends AbstractRioProtocol { +class RioZoneProtocol extends AbstractRioProtocol + implements RioSystemFavoritesProtocol.Listener, RioPresetsProtocol.Listener { // logger - private Logger logger = LoggerFactory.getLogger(RioZoneProtocol.class); + private final Logger logger = LoggerFactory.getLogger(RioZoneProtocol.class); /** * The controller identifier */ - private int _controller; + private final int controller; /** * The zone identifier */ - private int _zone; + private final int zone; // Zone constants private static final String ZONE_NAME = "name"; // 12 max - private static final String ZONE_SOURCE = "currentSource"; // 1-8 or 1-12 + private static final String ZONE_SOURCE = "currentsource"; // 1-8 or 1-12 private static final String ZONE_BASS = "bass"; // -10 to 10 private static final String ZONE_TREBLE = "treble"; // -10 to 10 private static final String ZONE_BALANCE = "balance"; // -10 to 10 private static final String ZONE_LOUDNESS = "loudness"; // OFF/ON - private static final String ZONE_TURNONVOLUME = "turnOnVolume"; // 0 to 50 - private static final String ZONE_DONOTDISTURB = "doNotDisturb"; // OFF/ON/SLAVE - private static final String ZONE_PARTYMODE = "partyMode"; // OFF/ON/MASTER + private static final String ZONE_TURNONVOLUME = "turnonvolume"; // 0 to 50 + private static final String ZONE_DONOTDISTURB = "donotdisturb"; // OFF/ON/SLAVE + private static final String ZONE_PARTYMODE = "partymode"; // OFF/ON/MASTER private static final String ZONE_STATUS = "status"; // OFF/ON/MASTER private static final String ZONE_VOLUME = "volume"; // 0 to 50 private static final String ZONE_MUTE = "mute"; // OFF/ON/MASTER private static final String ZONE_PAGE = "page"; // OFF/ON/MASTER - private static final String ZONE_SHAREDSOURCE = "sharedSource"; // OFF/ON/MASTER - private static final String ZONE_SLEEPTIMEREMAINING = "sleepTimeRemaining"; // OFF/ON/MASTER - private static final String ZONE_LASTERROR = "lastError"; // OFF/ON/MASTER + private static final String ZONE_SHAREDSOURCE = "sharedsource"; // OFF/ON/MASTER + private static final String ZONE_SLEEPTIMEREMAINING = "sleeptimeremaining"; // OFF/ON/MASTER + private static final String ZONE_LASTERROR = "lasterror"; // OFF/ON/MASTER private static final String ZONE_ENABLED = "enabled"; // OFF/ON + // Multimedia functions + private static final String ZONE_MMInit = "MMInit"; // button + private static final String ZONE_MMContextMenu = "MMContextMenu"; // button + + // Favorites + private static final String FAV_NAME = "name"; + private static final String FAV_VALID = "valid"; + // Respone patterns - private final Pattern RSP_ZONENOTIFICATION = Pattern - .compile("^[SN] C\\[(\\d+)\\]\\.Z\\[(\\d+)\\]\\.(\\w+)=\"(.*)\"$"); + private static final Pattern RSP_ZONENOTIFICATION = Pattern + .compile("(?i)^[SN] C\\[(\\d+)\\]\\.Z\\[(\\d+)\\]\\.(\\w+)=\"(.*)\"$"); + + private static final Pattern RSP_ZONEFAVORITENOTIFICATION = Pattern + .compile("(?i)^[SN] C\\[(\\d+)\\].Z\\[(\\d+)\\].favorite\\[(\\d+)\\].(\\w+)=\"(.*)\"$"); + + // The zone favorites + private final RioFavorite[] zoneFavorites = new RioFavorite[2]; + + // The current source identifier (or -1 if none) + private final AtomicInteger sourceId = new AtomicInteger(-1); + + // GSON object used for json + private final Gson gson; + + // The favorites protocol + private final RioSystemFavoritesProtocol favoritesProtocol; + + // The presets protocol + private final RioPresetsProtocol presetsProtocol; /** * Constructs the protocol handler from given parameters * * @param zone the zone identifier * @param controller the controller identifier + * @param favoritesProtocol a non-null {@link RioSystemFavoritesProtocol} + * @param presetsProtocol a non-null {@link RioPresetsProtocol} * @param session a non-null {@link SocketSession} (may be connected or disconnected) * @param callback a non-null {@link RioHandlerCallback} to callback */ - RioZoneProtocol(int zone, int controller, SocketSession session, RioHandlerCallback callback) { + RioZoneProtocol(int zone, int controller, RioSystemFavoritesProtocol favoritesProtocol, + RioPresetsProtocol presetsProtocol, SocketSession session, RioHandlerCallback callback) { super(session, callback); if (controller < 1 || controller > 6) { @@ -85,8 +126,32 @@ class RioZoneProtocol extends AbstractRioProtocol { throw new IllegalArgumentException("Zone must be between 1-6: " + zone); } - _controller = controller; - _zone = zone; + this.controller = controller; + this.zone = zone; + + this.favoritesProtocol = favoritesProtocol; + this.favoritesProtocol.addListener(this); + + this.presetsProtocol = presetsProtocol; + this.presetsProtocol.addListener(this); + + this.gson = GsonUtilities.createGson(); + + this.zoneFavorites[0] = new RioFavorite(1); + this.zoneFavorites[1] = new RioFavorite(2); + + } + + /** + * Helper method to issue post online commands + */ + void postOnline() { + watchZone(true); + refreshZoneSource(); + refreshZoneEnabled(); + refreshZoneName(); + + systemFavoritesUpdated(favoritesProtocol.getJson()); } /** @@ -100,7 +165,7 @@ private void refreshZoneKey(String keyname) { throw new IllegalArgumentException("keyName cannot be null or empty"); } - sendCommand("GET C[" + _controller + "].Z[" + _zone + "]." + keyname); + sendCommand("GET C[" + controller + "].Z[" + zone + "]." + keyname); } /** @@ -222,13 +287,37 @@ void refreshZoneEnabled() { refreshZoneKey(ZONE_ENABLED); } + /** + * Refreshes the system favorites via {@link #favoritesProtocol} + */ + void refreshSystemFavorites() { + favoritesProtocol.refreshSystemFavorites(); + } + + /** + * Refreshes the zone favorites + */ + void refreshZoneFavorites() { + for (int x = 1; x <= 2; x++) { + sendCommand("GET C[" + controller + "].Z[" + zone + "].favorite[" + x + "].valid"); + sendCommand("GET C[" + controller + "].Z[" + zone + "].favorite[" + x + "].name"); + } + } + + /** + * Refresh the zone preset via {@link #presetsProtocol} + */ + void refreshZonePresets() { + presetsProtocol.refreshPresets(); + } + /** * Turns on/off watching for zone notifications * * @param on true to turn on, false to turn off */ void watchZone(boolean watch) { - sendCommand("WATCH C[" + _controller + "].Z[" + _zone + "] " + (watch ? "ON" : "OFF")); + sendCommand("WATCH C[" + controller + "].Z[" + zone + "] " + (watch ? "ON" : "OFF")); } /** @@ -241,7 +330,7 @@ void setZoneBass(int bass) { if (bass < -10 || bass > 10) { throw new IllegalArgumentException("Bass must be between -10 and 10: " + bass); } - sendCommand("SET C[" + _controller + "].Z[" + _zone + "]." + ZONE_BASS + "=\"" + bass + "\""); + sendCommand("SET C[" + controller + "].Z[" + zone + "]." + ZONE_BASS + "=\"" + bass + "\""); } /** @@ -254,7 +343,7 @@ void setZoneTreble(int treble) { if (treble < -10 || treble > 10) { throw new IllegalArgumentException("Treble must be between -10 and 10: " + treble); } - sendCommand("SET C[" + _controller + "].Z[" + _zone + "]." + ZONE_TREBLE + "=\"" + treble + "\""); + sendCommand("SET C[" + controller + "].Z[" + zone + "]." + ZONE_TREBLE + "=\"" + treble + "\""); } /** @@ -267,7 +356,7 @@ void setZoneBalance(int balance) { if (balance < -10 || balance > 10) { throw new IllegalArgumentException("Balance must be between -10 and 10: " + balance); } - sendCommand("SET C[" + _controller + "].Z[" + _zone + "]." + ZONE_BALANCE + "=\"" + balance + "\""); + sendCommand("SET C[" + controller + "].Z[" + zone + "]." + ZONE_BALANCE + "=\"" + balance + "\""); } /** @@ -276,21 +365,22 @@ void setZoneBalance(int balance) { * @param on true to turn on loudness, false to turn off */ void setZoneLoudness(boolean on) { - sendCommand( - "SET C[" + _controller + "].Z[" + _zone + "]." + ZONE_LOUDNESS + "=\"" + (on ? "ON" : "OFF") + "\""); + sendCommand("SET C[" + controller + "].Z[" + zone + "]." + ZONE_LOUDNESS + "=\"" + (on ? "ON" : "OFF") + "\""); } /** - * Set's the zone turn on volume (from 0 to 50) + * Set's the zone turn on volume (will be scaled between 0 and 50) * - * @param volume the turn on volume (from 0 to 50) - * @throws IllegalArgumentException if volume < 0 or > 50 + * @param volume the turn on volume (between 0 and 1) + * @throws IllegalArgumentException if volume < 0 or > 1 */ - void setZoneTurnOnVolume(int volume) { - if (volume < 0 || volume > 50) { - throw new IllegalArgumentException("Volume must be between 0 and 50: " + volume); + void setZoneTurnOnVolume(double volume) { + if (volume < 0 || volume > 1) { + throw new IllegalArgumentException("Volume must be between 0 and 1: " + volume); } - sendCommand("SET C[" + _controller + "].Z[" + _zone + "]." + ZONE_TURNONVOLUME + "=\"" + volume + "\""); + + final int scaledVolume = (int) ((volume * 100) / 2); + sendCommand("SET C[" + controller + "].Z[" + zone + "]." + ZONE_TURNONVOLUME + "=\"" + scaledVolume + "\""); } /** @@ -305,8 +395,7 @@ void setZoneSleepTimeRemaining(int sleepTime) { throw new IllegalArgumentException("Sleep Time Remaining must be between 0 and 60: " + sleepTime); } sleepTime = (int) (5 * Math.round(sleepTime / 5.0)); - sendCommand( - "SET C[" + _controller + "].Z[" + _zone + "]." + ZONE_SLEEPTIMEREMAINING + "=\"" + sleepTime + "\""); + sendCommand("SET C[" + controller + "].Z[" + zone + "]." + ZONE_SLEEPTIMEREMAINING + "=\"" + sleepTime + "\""); } /** @@ -319,7 +408,7 @@ void setZoneSource(int source) { if (source < 1 || source > 12) { throw new IllegalArgumentException("Source must be between 1 and 12"); } - sendCommand("EVENT C[" + _controller + "].Z[" + _zone + "]!SelectSource " + source); + sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!SelectSource " + source); } /** @@ -328,7 +417,7 @@ void setZoneSource(int source) { * @param on true to turn on, false otherwise */ void setZoneStatus(boolean on) { - sendCommand("EVENT C[" + _controller + "].Z[" + _zone + "]!Zone" + (on ? "On" : "Off")); + sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!Zone" + (on ? "On" : "Off")); } /** @@ -346,7 +435,7 @@ void setZonePartyMode(String partyMode) { throw new IllegalArgumentException( "Party mode can only be set to on, off or master: " + partyMode.toUpperCase()); } - sendCommand("EVENT C[" + _controller + "].Z[" + _zone + "]!PartyMode " + partyMode); + sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!PartyMode " + partyMode); } /** @@ -364,21 +453,23 @@ void setZoneDoNotDisturb(String doNotDisturb) { if ("|on|off|slave|".indexOf("|" + doNotDisturb + "|") == -1) { throw new IllegalArgumentException("Do Not Disturb can only be set to on, off or slave: " + doNotDisturb); } - sendCommand("EVENT C[" + _controller + "].Z[" + _zone + "]!DoNotDisturb " + sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!DoNotDisturb " + ("off".equals(doNotDisturb) ? "OFF" : "ON")); // translate "slave" to "on" } /** - * Sets the zone's volume level (0-50) + * Sets the zone's volume level (scaled to 0-50) * - * @param volume the volume level (0-50) - * @throws IllegalArgumentException if volume is < 0 or > 50 + * @param volume the volume level + * @throws IllegalArgumentException if volume is < 0 or > 1 */ - void setZoneVolume(int volume) { - if (volume < 0 || volume > 50) { - throw new IllegalArgumentException("Volume must be between 0 and 50"); + void setZoneVolume(double volume) { + if (volume < 0 || volume > 1) { + throw new IllegalArgumentException("Volume must be between 0 and 1"); } - sendKeyPress("Volume " + volume); + + final int scaledVolume = (int) ((volume * 100) / 2); + sendKeyPress("Volume " + scaledVolume); } /** @@ -401,14 +492,14 @@ void toggleZoneMute() { * Toggles the zone's shuffle if the source supports shuffle mode */ void toggleZoneShuffle() { - sendCommand("EVENT C[" + _controller + "].Z[" + _zone + "]!Shuffle"); + sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!Shuffle"); } /** * Toggles the zone's repeat if the source supports repeat mod */ void toggleZoneRepeat() { - sendCommand("EVENT C[" + _controller + "].Z[" + _zone + "]!Repeat"); + sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!Repeat"); } /** @@ -417,7 +508,85 @@ void toggleZoneRepeat() { * @param like true to like, false to dislike */ void setZoneRating(boolean like) { - sendCommand("EVENT C[" + _controller + "].Z[" + _zone + "]!MMRate " + (like ? "hi" : "low")); + sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!MMRate " + (like ? "hi" : "low")); + } + + /** + * Sets the system favorite based on what is currently being played in the zone via {@link #favoritesProtocol} + * + * @param favJson a possibly null, possibly empty JSON of favorites to set + */ + void setSystemFavorites(String favJson) { + favoritesProtocol.setSystemFavorites(controller, zone, favJson); + } + + /** + * Sets the zone favorites to what is currently playing + * + * @param favJson a possibly null, possibly empty json for favorites to set + * @return a non-null {@link Runnable} that should be run after the call + */ + Runnable setZoneFavorites(String favJson) { + if (StringUtils.isEmpty(favJson)) { + return new Runnable() { + @Override + public void run() { + } + }; + } + + final List updateFavIds = new ArrayList(); + try { + final RioFavorite[] favs = gson.fromJson(favJson, RioFavorite[].class); + for (int x = favs.length - 1; x >= 0; x--) { + final RioFavorite fav = favs[x]; + if (fav == null) { + continue;// caused by {id,valid,name},,{id,valid,name} + } + final int favId = fav.getId(); + if (favId < 1 || favId > 2) { + logger.debug("Invalid favorite id (not between 1 and 2) - ignoring: {}:{}", favId, favJson); + } else { + final RioFavorite myFav = zoneFavorites[favId - 1]; + final boolean favValid = fav.isValid(); + final String favName = fav.getName(); + + if (!StringUtils.equals(myFav.getName(), favName) || myFav.isValid() != favValid) { + myFav.setName(favName); + myFav.setValid(favValid); + if (favValid) { + sendEvent("saveZoneFavorite \"" + favName + "\" " + favId); + updateFavIds.add(favId); + } else { + sendEvent("deleteZoneFavorite " + favId); + } + } + } + } + } catch (JsonSyntaxException e) { + logger.debug("Invalid JSON: {}", e.getMessage(), e); + } + // regardless of what happens above - reupdate the channel + // (to remove anything bad from it) + return new Runnable() { + @Override + public void run() { + for (Integer favId : updateFavIds) { + sendCommand("GET C[" + controller + "].Z[" + zone + "].favorite[" + favId + "].valid"); + sendCommand("GET C[" + controller + "].Z[" + zone + "].favorite[" + favId + "].name"); + } + updateZoneFavoritesChannel(); + } + }; + } + + /** + * Sets the zone presets for what is currently playing via {@link #presetsProtocol} + * + * @param presetJson a possibly empty, possibly null preset json + */ + void setZonePresets(String presetJson) { + presetsProtocol.setZonePresets(controller, zone, sourceId.get(), presetJson); } /** @@ -430,7 +599,7 @@ void sendKeyPress(String keyPress) { if (keyPress == null || keyPress.trim().length() == 0) { throw new IllegalArgumentException("keyPress cannot be null or empty"); } - sendCommand("EVENT C[" + _controller + "].Z[" + _zone + "]!KeyPress " + keyPress); + sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!KeyPress " + keyPress); } /** @@ -443,7 +612,7 @@ void sendKeyRelease(String keyRelease) { if (keyRelease == null || keyRelease.trim().length() == 0) { throw new IllegalArgumentException("keyRelease cannot be null or empty"); } - sendCommand("EVENT C[" + _controller + "].Z[" + _zone + "]!KeyRelease " + keyRelease); + sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!KeyRelease " + keyRelease); } /** @@ -456,7 +625,7 @@ void sendKeyHold(String keyHold) { if (keyHold == null || keyHold.trim().length() == 0) { throw new IllegalArgumentException("keyHold cannot be null or empty"); } - sendCommand("EVENT C[" + _controller + "].Z[" + _zone + "]!KeyHold " + keyHold); + sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!KeyHold " + keyHold); } /** @@ -469,7 +638,7 @@ void sendKeyCode(String keyCode) { if (keyCode == null || keyCode.trim().length() == 0) { throw new IllegalArgumentException("keyCode cannot be null or empty"); } - sendCommand("EVENT C[" + _controller + "].Z[" + _zone + "]!KeyCode " + keyCode); + sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!KeyCode " + keyCode); } /** @@ -482,7 +651,35 @@ void sendEvent(String event) { if (event == null || event.trim().length() == 0) { throw new IllegalArgumentException("event cannot be null or empty"); } - sendCommand("EVENT C[" + _controller + "].Z[" + _zone + "]!" + event); + sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!" + event); + } + + /** + * Sends the MMInit [home screen] command + */ + void sendMMInit() { + sendEvent("MMVerbosity 2"); + sendEvent("MMIndex ABSOLUTE"); + sendEvent("MMFormat JSON"); + sendEvent("MMUseBlockInfo TRUE"); + sendEvent("MMUseForms FALSE"); + sendEvent("MMMaxItems 25"); + + sendEvent(ZONE_MMInit); + } + + /** + * Requests a context menu + */ + void sendMMContextMenu() { + sendEvent("MMVerbosity 2"); + sendEvent("MMIndex ABSOLUTE"); + sendEvent("MMFormat JSON"); + sendEvent("MMUseBlockInfo TRUE"); + sendEvent("MMUseForms FALSE"); + sendEvent("MMMaxItems 25"); + + sendEvent(ZONE_MMContextMenu); } /** @@ -497,15 +694,15 @@ private void handleZoneNotification(Matcher m, String resp) { } if (m.groupCount() == 4) { try { - final int controller = Integer.parseInt(m.group(1)); - if (controller != _controller) { + final int notifyController = Integer.parseInt(m.group(1)); + if (notifyController != controller) { return; } - final int zone = Integer.parseInt(m.group(2)); - if (zone != _zone) { + final int notifyZone = Integer.parseInt(m.group(2)); + if (notifyZone != zone) { return; } - final String key = m.group(3); + final String key = m.group(3).toLowerCase(); final String value = m.group(4); switch (key) { @@ -517,6 +714,11 @@ private void handleZoneNotification(Matcher m, String resp) { try { final int nbr = Integer.parseInt(value); stateChanged(RioConstants.CHANNEL_ZONESOURCE, new DecimalType(nbr)); + + if (nbr != sourceId.getAndSet(nbr)) { + sourceId.set(nbr); + presetsUpdated(nbr, presetsProtocol.getJson(nbr)); + } } catch (NumberFormatException e) { logger.warn("Invalid zone notification (source not parsable): '{}')", resp); } @@ -557,7 +759,7 @@ private void handleZoneNotification(Matcher m, String resp) { case ZONE_TURNONVOLUME: try { final int nbr = Integer.parseInt(value); - stateChanged(RioConstants.CHANNEL_ZONETURNONVOLUME, new DecimalType(nbr)); + stateChanged(RioConstants.CHANNEL_ZONETURNONVOLUME, new PercentType(nbr * 2)); } catch (NumberFormatException e) { logger.warn("Invalid zone notification (turnonvolume not parsable): '{}')", resp); } @@ -628,6 +830,96 @@ private void handleZoneNotification(Matcher m, String resp) { } + /** + * Handles any system notifications returned by the russound system + * + * @param m a non-null matcher + * @param resp a possibly null, possibly empty response + */ + void handleZoneFavoriteNotification(Matcher m, String resp) { + if (m == null) { + throw new IllegalArgumentException("m (matcher) cannot be null"); + } + if (m.groupCount() == 5) { + try { + final int notifyController = Integer.parseInt(m.group(1)); + if (notifyController != controller) { + return; + } + final int notifyZone = Integer.parseInt(m.group(2)); + if (notifyZone != zone) { + return; + } + + final int favoriteId = Integer.parseInt(m.group(3)); + + if (favoriteId >= 1 && favoriteId <= 2) { + final RioFavorite fav = zoneFavorites[favoriteId - 1]; + + final String key = m.group(4); + final String value = m.group(5); + + switch (key) { + case FAV_NAME: + fav.setName(value); + updateZoneFavoritesChannel(); + break; + case FAV_VALID: + fav.setValid(!"false".equalsIgnoreCase(value)); + updateZoneFavoritesChannel(); + break; + + default: + logger.warn("Unknown zone favorite notification: '{}'", resp); + break; + } + } else { + logger.warn("Invalid Zone Favorite Notification (favorite < 1 or > 2): '{}')", resp); + } + } catch (NumberFormatException e) { + logger.warn("Invalid Zone Favorite Notification (favorite not a parsable integer): '{}')", resp); + } + } else { + logger.warn("Invalid Zone Notification response: '{}'", resp); + } + } + + /** + * Will update the zone favorites channel with only valid favorites + */ + private void updateZoneFavoritesChannel() { + final List favs = new ArrayList(); + for (final RioFavorite fav : zoneFavorites) { + if (fav.isValid()) { + favs.add(fav); + } + } + + final String favJson = gson.toJson(favs); + stateChanged(RioConstants.CHANNEL_ZONEFAVORITES, new StringType(favJson)); + } + + /** + * Callback method when system favorites are updated. Simply issues a state change for the zone system favorites + * channel using the jsonString as the value + */ + @Override + public void systemFavoritesUpdated(String jsonString) { + stateChanged(RioConstants.CHANNEL_ZONESYSFAVORITES, new StringType(jsonString)); + } + + /** + * Callback method when presets are updated. Simply issues a state change for the zone presets channel using the + * jsonString as the value + */ + @Override + public void presetsUpdated(int sourceIdUpdated, String jsonString) { + if (sourceIdUpdated != sourceId.get()) { + return; + } + stateChanged(RioConstants.CHANNEL_ZONEPRESETS, new StringType(jsonString)); + } + /** * Implements {@link SocketSessionListener#responseReceived(String)} to try to process the response from the * russound system. This response may be for other protocol handler - so ignore if we don't recognize the response. @@ -636,14 +928,20 @@ private void handleZoneNotification(Matcher m, String resp) { */ @Override public void responseReceived(String response) { - if (response == null || response == "") { + if (StringUtils.isEmpty(response)) { return; } - final Matcher m = RSP_ZONENOTIFICATION.matcher(response); + Matcher m = RSP_ZONENOTIFICATION.matcher(response); if (m.matches()) { handleZoneNotification(m, response); } + + m = RSP_ZONEFAVORITENOTIFICATION.matcher(response); + if (m.matches()) { + handleZoneFavoriteNotification(m, response); + } + } /** @@ -652,6 +950,8 @@ public void responseReceived(String response) { @Override public void dispose() { watchZone(false); + favoritesProtocol.removeListener(this); super.dispose(); } + }