diff --git a/addons/binding/org.openhab.binding.russound/.classpath b/addons/binding/org.openhab.binding.russound/.classpath
new file mode 100644
index 0000000000000..642bfb34f723f
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/.classpath
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/addons/binding/org.openhab.binding.russound/.project b/addons/binding/org.openhab.binding.russound/.project
new file mode 100644
index 0000000000000..1ddabf014232e
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/.project
@@ -0,0 +1,33 @@
+
+
+ org.openhab.binding.russound
+
+
+
+
+
+ org.eclipse.jdt.core.javabuilder
+
+
+
+
+ org.eclipse.pde.ManifestBuilder
+
+
+
+
+ org.eclipse.pde.SchemaBuilder
+
+
+
+
+ org.eclipse.pde.ds.core.builder
+
+
+
+
+
+ org.eclipse.pde.PluginNature
+ org.eclipse.jdt.core.javanature
+
+
diff --git a/addons/binding/org.openhab.binding.russound/ESH-INF/binding/binding.xml b/addons/binding/org.openhab.binding.russound/ESH-INF/binding/binding.xml
new file mode 100644
index 0000000000000..cfe07e8179cc6
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/ESH-INF/binding/binding.xml
@@ -0,0 +1,11 @@
+
+
+
+ Russound Binding
+ This is the binding for Russound.
+ Tim Roberts
+
+
diff --git a/addons/binding/org.openhab.binding.russound/ESH-INF/i18n/russound_xx_XX.properties b/addons/binding/org.openhab.binding.russound/ESH-INF/i18n/russound_xx_XX.properties
new file mode 100644
index 0000000000000..41cf8fd433805
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/ESH-INF/i18n/russound_xx_XX.properties
@@ -0,0 +1,11 @@
+# binding
+binding.russound.name =
+binding.russound.description =
+
+# thing types
+thing-type.russound.sample.label =
+thing-type.russound.sample.description =
+
+# channel types
+channel-type.russound.sample-channel.label =
+channel-type.russound.sample-channel.description =
\ No newline at end of file
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
new file mode 100644
index 0000000000000..cc34cba948dfe
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/ESH-INF/thing/thing-types.xml
@@ -0,0 +1,324 @@
+
+
+
+
+ Russound RIO Device
+ Ethernet access point to Russound RIO control system (usually the main controller)
+
+
+ Firmware Version Firmware Version
+
+ All Zones Toggles All Zones
+
+
+
+
+ network-address
+ IP or Host Name
+ The IP or host name of the Russound RIO access point
+
+
+ Ping Interval
+ The ping interval in seconds to keep the connection alive
+ 30
+
+
+ Retry Polling
+ The polling, in seconds, to retry a connection attempt
+ 10
+
+
+
+
+
+
+
+
+
+
+ Russound Controller
+ Controller of Zones, Sources, etc
+
+
+ Model Type Model Type
+ IP Address IP Address
+ MAC Address MAC Address
+
+
+
+
+ Controller ID
+ The controller identifier
+
+
+
+
+
+
+
+
+
+ Russound Zone
+ Zone within a Controller
+
+
+ Name Zone Name
+
+ Bass Bass setting
+ Treble Treble setting
+
+ Loudness Loudness
+
+ Do Not Disturb Do Not Disturb
+ Party Mode Party Mode
+ Status Zone Status
+
+ Mute Mute
+ Page Page Status
+ Shared Source Shared Source Status
+
+ Last Error Last Zone Error
+ Enabled Whether the zone is enabled or not
+ Repeat Toggle the repeat mode
+ Shuffle Toggle the shuffle mode
+ Rating How to rate current song
+ KeyPress event Send a KeyPress event
+ KeyRelease event Send a KeyRelease event
+ KeyHold event Send a KeyHold event
+ KeyCode event Send a KeyCode event
+ Generic event Send a generic event
+
+
+
+
+ Zone ID
+ The zone identifier
+
+
+
+
+
+
+
+
+
+ Russound Source
+ Source (tuner, streamer, etc) within the Russound System
+
+
+ Name Source Name
+ Type Source Type
+ IP Address IP Address
+ Composer Name Composer Name
+ Channel Channel
+ Channel Name Channel Name
+ Genre Genre
+ Artist Name Artist Name
+ Album Name Album Name
+ Cover Art URL Cover Art URL
+
+ Playlist Name Playlist Name
+ Song Name Song Name
+ Mode Provider Mode or Streaming Service
+ Shuffle Shuffle Mode
+ Repeat Repeat Mode
+ Rating Rating
+ Program Service Name Program Service Name (PSN)
+ Radio Text Radio Text
+ Radio Text2 Radio Text line 2
+ Radio Text3 Radio Text line 3
+ Radio Text4 Radio Text line 4
+ Volume Level The volume level from the source
+
+
+
+
+ Source ID
+ The source identifier
+
+
+
+
+
+
+
+
+
+
+ Russound Bank
+ Bank of Presets for a specific Source (usually a tuner source)
+
+
+ Bank Name The bank name
+
+
+
+
+ Bank ID
+ The bank identifier
+
+
+
+
+
+
+
+
+
+ System Favorite
+ System Favorite
+
+
+ Favorite Name The system favorite name
+ Favorite Valid If the favorite is valid or not
+
+
+
+
+ Favorite ID
+ The favorite identifier
+
+
+
+
+
+
+
+
+
+ Zone Favorite
+ Favorite for a Zone
+
+
+ Favorite Name The zone favorite name
+ Favorite Valid If the favorite is valid or not
+ Save Favorite Saves the favorite (ON=as system favorite, OFF=as zone favorite)
+ Restore Favorite Restores the favorite (ON=system favorite, OFF=zone favorite)
+ Delete Favorite Deletes the favorite (ON=system favorite, OFF=zone favorite)
+
+
+
+
+ Favorite ID
+ The favorite identifier
+
+
+
+
+
+
+
+
+
+ Russound Bank Preset
+ Preset (usually frequency) within a Bank for a Source (usually a tuner)
+
+
+ Favorite Name The system favorite name
+ Favorite Valid If the favorite is valid or not
+
+
+
+
+ Preset ID
+ The preset identifier
+
+
+
+
+
+
+
+
+
+
+ Russound Zone Presets
+ Zone Preset Commands to save/restore/delete presets for the zone
+
+
+ Save Favorite Saves zone as the preset
+ Restore Favorite Restores the reset to the zone
+ Delete Favorite Deletes preset for the zone
+
+
+
+
+ Preset ID
+ The preset identifier (1-36 corresponds to banks 1-6, presets 1-6)
+
+
+
+
+
+ String
+ String
+ String
+
+
+ String
+ ReaonlyString
+ ReadonlyString
+
+
+
+ Switch
+ Switch
+ Switch
+
+
+ String
+ Language
+ System Language
+
+
+ English
+ Chinese
+ Russian
+
+
+
+
+ Number
+ Source
+ Physical Source Number
+
+
+
+ Number
+ BassTreble
+ BassTreble
+
+
+
+ Number
+ Balance
+ Balance (-10 full left, 10 full right)
+
+
+
+ Number
+ Turn On Volume
+ The volume the zone will default to when turned on
+
+
+
+ Dimmer
+ Volume
+ Volume level of zone
+
+
+
+ Number
+ Sleep Time Remaining
+ Sleep Time (in seconds) remaining
+
+
+
+ Image
+ Cover Art Image
+ Cover Art Image
+
+
diff --git a/addons/binding/org.openhab.binding.russound/META-INF/MANIFEST.MF b/addons/binding/org.openhab.binding.russound/META-INF/MANIFEST.MF
new file mode 100644
index 0000000000000..62729486b1f29
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/META-INF/MANIFEST.MF
@@ -0,0 +1,27 @@
+Manifest-Version: 1.0
+Bundle-ManifestVersion: 2
+Bundle-Name: Russound Binding
+Bundle-SymbolicName: org.openhab.binding.russound;singleton:=true
+Bundle-Vendor: openHAB
+Bundle-Version: 2.0.0.qualifier
+Bundle-RequiredExecutionEnvironment: JavaSE-1.7
+Bundle-ClassPath: .
+Import-Package:
+ com.google.common.collect,
+ org.apache.commons.lang,
+ org.eclipse.jetty.client,
+ org.eclipse.jetty.client.api,
+ org.eclipse.jetty.util.component,
+ org.eclipse.smarthome.config.core,
+ org.eclipse.smarthome.core.library.types,
+ org.eclipse.smarthome.core.thing,
+ org.eclipse.smarthome.core.thing.binding,
+ org.eclipse.smarthome.core.thing.binding.builder,
+ org.eclipse.smarthome.core.thing.type,
+ org.eclipse.smarthome.core.types,
+ org.openhab.binding.russound,
+ org.openhab.binding.russound.rio,
+ org.slf4j
+Service-Component: OSGI-INF/*.xml
+Export-Package: org.openhab.binding.russound,
+ org.openhab.binding.russound.rio
diff --git a/addons/binding/org.openhab.binding.russound/OSGI-INF/RussoundHandlerFactory.xml b/addons/binding/org.openhab.binding.russound/OSGI-INF/RussoundHandlerFactory.xml
new file mode 100644
index 0000000000000..1ec49c6b29981
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/OSGI-INF/RussoundHandlerFactory.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
new file mode 100644
index 0000000000000..d07775dc8d239
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/README.md
@@ -0,0 +1,441 @@
+# 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).
+
+## 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 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 Preset Commands (1-36 presets commands for each zone [corresponds to banks 1-6, presets 1-6 for each bank])
+
+## Thing Configuration
+
+The following configurations occur for each of the bridges/things:
+
+### Russound System
+| Name | Type | Description |
+|--------------|---------------|--------------------------------------------------------------------------|
+| 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) |
+
+### Russound Source
+| Name | Type | Description |
+|--------------|---------------|--------------------------------------------------------------------------|
+| source | int | The source # (1-12) |
+
+### Russound Bank
+| Name | Type | Description |
+|--------------|---------------|--------------------------------------------------------------------------|
+| bank | int | The bank # (1-6) |
+
+### Russound Preset
+| Name | Type | Description |
+|--------------|---------------|--------------------------------------------------------------------------|
+| preset | int | The preset # (1-6) |
+
+### Russound Controller
+| Name | Type | Description |
+|--------------|---------------|--------------------------------------------------------------------------|
+| controller | int | The controller address # (1-6) |
+
+### Russound Zone
+| Name | Type | Description |
+|--------------|---------------|--------------------------------------------------------------------------|
+| 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
+
+The following channels are supported for each bridge/thing
+
+### Russound System
+| 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) |
+
+### 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) |
+
+### 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 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
+| Channel Type ID | Read/Write | Item Type | Description |
+|--------------------|------------|--------------|--------------------------------------------------------------------- |
+| name | R | String | The name of the bank (changed by SCS-C5 software) |
+
+### Russound Preset
+| 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) |
+
+### Russound Controller
+| 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) |
+
+### 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) |
+| donotdisturb | RW | String | The do not disturb setting (on/off/slave) |
+| partymode | RW | String | The party mode (on/off/master) |
+| 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) |
+| lasterror | R | String | The last error that occurred in the zone |
+| enabled | R | Switch | Whether the zone is enabled or not |
+| repeat | W | Switch | Toggle the repeat mode for the current source |
+| shuffle | W | Switch | Toggle the shuffle mode for the current source |
+| rating | W | Switch | Signal a like (ON) or dislike (OFF) to the current source |
+| keypress | W | String | (Advanced) Send a keypress from the zone |
+| keyrelease | W | String | (Advanced) Send a keyrelease from the zone |
+| 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 |
+
+* 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)
+
+
+### Russound Zone Favorite
+| Channel Type ID | Read/Write | Item Type | Description |
+|--------------------|------------|--------------|--------------------------------------------------------------------- |
+| name | RW | String | The name of the zone favorite (only saved via 'save' channel) |
+| valid | R | Switch | If favorite is valid or not (changed by save [on], delete [off]) |
+| save | W | Switch | Save the favorite (ON=as system favorite, OFF=as zone favorite) |
+| restore | W | Switch | Restores the favorite (ON=System favorite, OFF=zone favorite) |
+| delete | W | Switch | Deletes the favorite (ON=System favorite, OFF=zone favorite) |
+
+### Russound Zone Preset Commands
+| Channel Type ID | Read/Write | Item Type | Description |
+|--------------------|------------|--------------|--------------------------------------------------------------------- |
+| save | W | Switch | Save the zone as a preset |
+| restore | W | Switch | Restores the preset to the zone |
+| delete | W | Switch | Deletes the preset |
+
+
+### Source channel support cross reference
+| Channel Type ID | Sirius | XM | SMS3 | DMS 3.1 Media | DMS 3.1 AM/FM | iBridge | Internal AM/FM | Arcam T32 | Others |
+|--------------------|--------|----|------|---------------|---------------|---------|----------------|-----------|--------|
+| name | X | X | X | X | X | X | X | X | X |
+| type | X | X | X | X | X | X | X | X | X |
+| ipaddress | | | X | X | X | | | | |
+| composername | X | | | | | | | | |
+| channel | | | | | X | | X | | |
+| channelname | X | X | | X | | | | X | |
+| genre | X | X | | | | | | X | |
+| artistname | X | X | X | X | | X | | | |
+| albumname | | | X | X | | X | | | |
+| coverarturl | 1 | | | X | | | | | |
+| playlistname | | | X | X | | X | | | |
+| songname | X | X | X | X | | X | | | |
+| mode | | | | X | | | | | |
+| shufflemode | | | | X | | X | | | |
+| repeatmode | | | | X | | | | | |
+| rating | | | | X | | | | | |
+| programservicename | | | | | X | | X | | |
+| radiotext | | | | | X | | X | X | |
+| radiotext2 | | | | | | | | X | |
+| radiotext3 | | | | | | | | X | |
+| radiotext4 | | | | | | | | X | |
+
+1. Sirius Internal Radio Only
+
+## Full Example
+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])
+
+.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" }
+Number Rio_Zone_Bass "Bass [%s]" { channel="russound:zone:1:bass" }
+Number Rio_Zone_Treble "Treble [%s]" { channel="russound:zone:1:treble" }
+Number Rio_Zone_Balance "Balance [%s]" { channel="russound:zone:1:balance" }
+Switch Rio_Zone_Loudness "Loudness [%s]" { channel="russound:zone:1:loudness" }
+Number Rio_Zone_TurnOnVolume "Turn on Volume [%s]" { channel="russound:zone:1:turnonvolume" }
+String Rio_Zone_DoNotDisturb "Do not Disturb [%s]" { channel="russound:zone:1:donotdisturb" }
+String Rio_Zone_PartyMode "Party Mode [%s]" { channel="russound:zone:1:partymode" }
+Dimmer Rio_Zone_Volume "Volume [%s %%]" { channel="russound:zone:1:volume" }
+Switch Rio_Zone_Mute "Mute [%s]" { channel="russound:zone:1:mute" }
+Switch Rio_Zone_Page "Page [%s]" { channel="russound:zone:1:page" }
+Switch Rio_Zone_SharedSource "Shared Source [%s]" { channel="russound:zone:1:sharedsource" }
+Number Rio_Zone_SleepTime "Sleep Time Remaining [%s]" { channel="russound:zone:1:sleeptimeremaining" }
+String Rio_Zone_LastError "Last Error [%s]" { channel="russound:zone:1:lasterror" }
+Switch Rio_Zone_Enabled "Enabled [%s]" { channel="russound:zone:1:enabled" }
+Switch Rio_Zone_Repeat "Toggle Repeat" { channel="russound:zone:1:repeat", autoupdate="false" }
+Switch Rio_Zone_Shuffle "Toggle Shuffle" { channel="russound:zone:1:shuffle", autoupdate="false" }
+Switch Rio_Zone_Rating "Rating" { channel="russound:zone:1:rating", autoupdate="false" }
+
+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" }
+String Rio_Src_Genre "Genre [%s]" { channel="russound:source:1:genre" }
+String Rio_Src_ArtistName "Artist [%s]" { channel="russound:source:1:artistname" }
+String Rio_Src_AlbumName "Album [%s]" { channel="russound:source:1:albumname" }
+String Rio_Src_Cover "Cover Art [%s]" { channel="russound:source:1:coverarturl" }
+String Rio_Src_PlaylistName "PlayList [%s]" { channel="russound:source:1:playlistname" }
+String Rio_Src_SongName "Song [%s]" { channel="russound:source:1:songname" }
+String Rio_Src_Mode "Mode [%s]" { channel="russound:source:1:mode" }
+String Rio_Src_Shuffle "Shuffle [%s]" { channel="russound:source:1:shufflemode" }
+String Rio_Src_Repeat "Repeat [%s]" { channel="russound:source:1:repeatmode" }
+String Rio_Src_Rating "Rating [%s]" { channel="russound:source:1:rating" }
+String Rio_Src_ProgramServiceName "PSN [%s]" { channel="russound:source:1:programservicename" }
+String Rio_Src_RadioText "Radio Text [%s]" { channel="russound:source:1:radiotext" }
+String Rio_Src_RadioText2 "Radio Text #2 [%s]" { channel="russound:source:1:radiotext2" }
+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" }
+Switch Rio_Zone_Favorite_Save "Save" { channel="russound:zonefavorite:1:save", autoupdate="false" }
+Switch Rio_Zone_Favorite_Restore "Restore" { channel="russound:zonefavorite:1:restore", autoupdate="false" }
+Switch Rio_Zone_Favorite_Delete "Delete" { channel="russound:zonefavorite:1:delete", autoupdate="false" }
+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" }
+Switch Rio_Zone_Favorite_Save2 "Save2" { channel="russound:zonefavorite:2:save", autoupdate="false" }
+Switch Rio_Zone_Favorite_Restore2 "Restore2" { channel="russound:zonefavorite:2:restore", autoupdate="false" }
+Switch Rio_Zone_Favorite_Delete2 "Delete2" { channel="russound:zonefavorite:2:delete", autoupdate="false" }
+
+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" }
+
+Switch Rio_Zone_Preset_Save "Save" { channel="russound:zonepreset:1:save", autoupdate="false" }
+Switch Rio_Zone_Preset_Restore "Restore" { channel="russound:zonepreset:1:restore", autoupdate="false" }
+Switch Rio_Zone_Preset_Delete "Delete" { channel="russound:zonepreset:1:delete", autoupdate="false" }
+Switch Rio_Zone_Preset_Save2 "Save2" { channel="russound:zonepreset:2:save", autoupdate="false" }
+Switch Rio_Zone_Preset_Restore2 "Restore2" { channel="russound:zonepreset:2:restore", autoupdate="false" }
+Switch Rio_Zone_Preset_Delete2 "Delete2" { channel="russound:zonepreset:2:delete", autoupdate="false" }
+```
+
+.sitemap
+```
+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"]
+ Setpoint item=Rio_Zone_Bass
+ Setpoint item=Rio_Zone_Treble
+ Setpoint item=Rio_Zone_Balance
+ Switch item=Rio_Zone_Loudness
+ Setpoint item=Rio_Zone_TurnOnVolume
+ Selection item=Rio_Zone_DoNotDisturb mappings=[ON="On", OFF="Off", SLAVE="Slave"]
+ Selection item=Rio_Zone_PartyMode mappings=[ON="On", OFF="Off", MASTER="Master"]
+ Slider item=Rio_Zone_Volume
+ Switch item=Rio_Zone_Mute
+ Text item=Rio_Zone_Page
+ Text item=Rio_Zone_SharedSource
+ Setpoint item=Rio_Zone_SleepTime minValue="0" maxValue="60" step="5"
+ Text item=Rio_Zone_LastError
+ Text item=Rio_Zone_Enabled
+ Switch item=Rio_Zone_Shuffle mappings=[ON="Toggle"]
+ Switch item=Rio_Zone_Repeat mappings=[ON="Toggle"]
+ Switch item=Rio_Zone_Rating mappings=[ON="Like"]
+ Switch item=Rio_Zone_Rating mappings=[OFF="Dislike"]
+
+ Text label="Source" {
+ Text item= Rio_Src_Type
+ Text item= Rio_Src_IP
+ Text item= Rio_Src_Composer
+ Text item= Rio_Src_Channel
+ Text item= Rio_Src_ChannelName
+ Text item= Rio_Src_Genre
+ 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
+ Text item= Rio_Src_Shuffle
+ Text item= Rio_Src_Repeat
+ Text item= Rio_Src_Rating
+ Text item= Rio_Src_ProgramServiceName
+ Text item= Rio_Src_RadioText
+ 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
+ Switch item=Rio_Zone_Favorite_Save mappings=[ON="System"]
+ Switch item=Rio_Zone_Favorite_Save mappings=[OFF="Zone"]
+ Switch item=Rio_Zone_Favorite_Restore mappings=[ON="System"]
+ Switch item=Rio_Zone_Favorite_Restore mappings=[OFF="Zone"]
+ Switch item=Rio_Zone_Favorite_Delete mappings=[ON="System"]
+ Switch item=Rio_Zone_Favorite_Delete mappings=[OFF="Zone"]
+ Text item=Rio_Zone_Favorite_Name2
+ Text item=Rio_Zone_Favorite_Valid2
+ Switch item=Rio_Zone_Favorite_Save2 mappings=[ON="System"]
+ Switch item=Rio_Zone_Favorite_Save2 mappings=[OFF="Zone"]
+ Switch item=Rio_Zone_Favorite_Restore2 mappings=[ON="System"]
+ Switch item=Rio_Zone_Favorite_Restore2 mappings=[OFF="Zone"]
+ Switch item=Rio_Zone_Favorite_Delete2 mappings=[ON="System"]
+ Switch item=Rio_Zone_Favorite_Delete2 mappings=[OFF="Zone"]
+ }
+
+ Text label="Preset" {
+ Switch item=Rio_Zone_Preset_Save mappings=[ON="Save"]
+ Switch item=Rio_Zone_Preset_Restore mappings=[ON="Restore"]
+ Switch item=Rio_Zone_Preset_Delete mappings=[ON="Delete"]
+ Switch item=Rio_Zone_Preset_Save2 mappings=[ON="Save"]
+ Switch item=Rio_Zone_Preset_Restore2 mappings=[ON="Restore"]
+ Switch item=Rio_Zone_Preset_Delete2 mappings=[ON="Delete"]
+ }
+ }
+ }
+}
+```
+
+
diff --git a/addons/binding/org.openhab.binding.russound/build.properties b/addons/binding/org.openhab.binding.russound/build.properties
new file mode 100644
index 0000000000000..62eda9dddd8eb
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/build.properties
@@ -0,0 +1,6 @@
+source.. = src/main/java/
+output.. = target/classes
+bin.includes = META-INF/,\
+ .,\
+ OSGI-INF/,\
+ ESH-INF/
\ No newline at end of file
diff --git a/addons/binding/org.openhab.binding.russound/pom.xml b/addons/binding/org.openhab.binding.russound/pom.xml
new file mode 100644
index 0000000000000..a379bff7de610
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/pom.xml
@@ -0,0 +1,19 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.binding
+ pom
+ 2.0.0-SNAPSHOT
+
+
+ org.openhab.binding
+ org.openhab.binding.russound
+ 2.0.0-SNAPSHOT
+
+ Russound Binding
+ eclipse-plugin
+
+
diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/RussoundBindingConstants.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/RussoundBindingConstants.java
new file mode 100644
index 0000000000000..f8eeb98dba237
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/RussoundBindingConstants.java
@@ -0,0 +1,20 @@
+/**
+ * Copyright (c) 2014-2016 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;
+
+/**
+ * The {@link RussoundBinding} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author Tim Roberts - Initial contribution
+ */
+public class RussoundBindingConstants {
+
+ public static final String BINDING_ID = "russound";
+}
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
new file mode 100644
index 0000000000000..54ccd0ff12a90
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/RussoundHandlerFactory.java
@@ -0,0 +1,74 @@
+/**
+ * Copyright (c) 2014-2016 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;
+
+import java.util.Set;
+
+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.rio.RioConstants;
+import org.openhab.binding.russound.rio.bank.RioBankHandler;
+import org.openhab.binding.russound.rio.controller.RioControllerHandler;
+import org.openhab.binding.russound.rio.favorites.RioFavoriteHandler;
+import org.openhab.binding.russound.rio.preset.RioPresetHandler;
+import org.openhab.binding.russound.rio.source.RioSourceHandler;
+import org.openhab.binding.russound.rio.system.RioSystemHandler;
+import org.openhab.binding.russound.rio.zone.RioZoneHandler;
+
+import com.google.common.collect.ImmutableSet;
+
+/**
+ * The {@link RussoundHandlerFactory} is responsible for creating bridge and thing
+ * handlers.
+ *
+ * @author Tim Roberts - Initial contribution
+ */
+public class RussoundHandlerFactory extends BaseThingHandlerFactory {
+
+ private final static 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);
+
+ @Override
+ public boolean supportsThingType(ThingTypeUID thingTypeUID) {
+ return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
+ }
+
+ @Override
+ protected ThingHandler createHandler(Thing thing) {
+
+ ThingTypeUID thingTypeUID = thing.getThingTypeUID();
+
+ if (thingTypeUID.equals(RioConstants.BRIDGE_TYPE_RIO)) {
+ return new RioSystemHandler((Bridge) thing);
+ } 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);
+ }
+
+ return null;
+ }
+}
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
new file mode 100644
index 0000000000000..457437c41e362
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/net/SocketSession.java
@@ -0,0 +1,409 @@
+/**
+ * Copyright (c) 2014-2016 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.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.PrintStream;
+import java.net.Socket;
+import java.net.SocketTimeoutException;
+import java.util.List;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.locks.Condition;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Represents a restartable socket connection to the underlying telnet session. Commands can be sent via
+ * {@link #sendCommand(String)} and responses will be received on any {@link SocketSessionListener}
+ *
+ * @author Tim Roberts
+ */
+public class SocketSession {
+ private Logger _logger = LoggerFactory.getLogger(SocketSession.class);
+
+ /**
+ * The host/ip address to connect to
+ */
+ private final String _host;
+
+ /**
+ * The port to connect to
+ */
+ private final int _port;
+
+ /**
+ * The actual socket being used. Will be null if not connected
+ */
+ private Socket _client;
+
+ /**
+ * The writer to the {@link #_client}. Will be null if not connected
+ */
+ private PrintStream _writer;
+
+ /**
+ * The reader from the {@link #_client}. Will be null if not connected
+ */
+ private BufferedReader _reader;
+
+ /**
+ * The {@link ResponseReader} that will be used to read from {@link #_reader}
+ */
+ private final ResponseReader _responseReader = new ResponseReader();
+
+ /**
+ * The responses read from the {@link #_responseReader}
+ */
+ private final BlockingQueue _responses = new ArrayBlockingQueue(50);
+
+ /**
+ * The dispatcher of responses from {@link #_responses}
+ */
+ private final Dispatcher _dispatcher = new Dispatcher();
+
+ /**
+ * The {@link SocketSessionListener} that the {@link #_dispatcher} will call
+ */
+ private List _listeners = new CopyOnWriteArrayList();
+
+ /**
+ * Creates the socket session from the given host and port
+ *
+ * @param host a non-null, non-empty host/ip address
+ * @param port the port number between 1 and 65535
+ */
+ public SocketSession(String host, int port) {
+ if (host == null || host.trim().length() == 0) {
+ throw new IllegalArgumentException("Host cannot be null or empty");
+ }
+
+ if (port < 1 || port > 65535) {
+ throw new IllegalArgumentException("Port must be between 1 and 65535");
+ }
+ _host = host;
+ _port = port;
+ }
+
+ /**
+ * Adds a {@link SocketSessionListener} to call when responses/exceptions have been received
+ *
+ * @param listener a non-null {@link SocketSessionListener} to use
+ */
+ public void addListener(SocketSessionListener listener) {
+ if (listener == null) {
+ throw new IllegalArgumentException("listener cannot be null");
+ }
+ _listeners.add(listener);
+ }
+
+ /**
+ * Clears all listeners
+ */
+ public void clearListeners() {
+ _listeners.clear();
+ }
+
+ /**
+ * Removes a {@link SocketSessionListener} from this session
+ *
+ * @param listener a non-null {@link SocketSessionListener} to remove
+ * @return true if removed, false otherwise
+ */
+ public boolean removeListener(SocketSessionListener listener) {
+ return _listeners.remove(listener);
+ }
+
+ /**
+ * 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.
+ *
+ * @throws java.io.IOException if an exception occurs during the connection attempt
+ */
+ public void connect() throws IOException {
+ disconnect();
+
+ _client = new Socket(_host, _port);
+ _client.setKeepAlive(true);
+ _client.setSoTimeout(1000); // allow reader to check to see if it should stop every 1 second
+
+ _logger.debug("Connecting to {}:{}", _host, _port);
+ _writer = new PrintStream(_client.getOutputStream());
+ _reader = new BufferedReader(new InputStreamReader(_client.getInputStream()));
+
+ new Thread(_responseReader).start();
+ new Thread(_dispatcher).start();
+ }
+
+ /**
+ * Disconnects from the {@link #_host} if we are {@link #isConnected()}. The {@link #_writer}, {@link #_reader} and
+ * {@link #_client}
+ * will be closed and set to null. The {@link #_dispatcher} and {@link #_responseReader} will be stopped, the
+ * {@link #_listeners} will be nulled and the {@link #_responses} will be cleared.
+ *
+ * @throws java.io.IOException if an exception occurs during the disconnect attempt
+ */
+ public void disconnect() throws IOException {
+ if (isConnected()) {
+ _logger.debug("Disconnecting from {}:{}", _host, _port);
+
+ _dispatcher.stopRunning();
+ _responseReader.stopRunning();
+
+ _writer.close();
+ _writer = null;
+
+ _reader.close();
+ _reader = null;
+
+ _client.close();
+ _client = null;
+
+ _responses.clear();
+ }
+ }
+
+ /**
+ * Returns true if we are connected ({@link #_client} is not null and is connected)
+ *
+ * @return true if connected, false otherwise
+ */
+ public boolean isConnected() {
+ return _client != null && _client.isConnected();
+ }
+
+ /**
+ * Sends the specified command to the underlying socket
+ *
+ * @param command a non-null, non-empty command
+ * @throws java.io.IOException an exception that occurred while sending
+ */
+ public synchronized void sendCommand(String command) throws IOException {
+ if (command == null) {
+ throw new IllegalArgumentException("command cannot be null");
+ }
+
+ // if (command.trim().length() == 0) {
+ // throw new IllegalArgumentException("Command cannot be empty");
+ // }
+
+ if (!isConnected()) {
+ throw new IOException("Cannot send message - disconnected");
+ }
+
+ _logger.debug("Sending Command: '{}'", command);
+ _writer.println(command + "\n"); // as pre spec - each command must have a newline
+ _writer.flush();
+
+ }
+
+ /**
+ * This is the runnable that will read from the socket and add messages to the responses queue (to be processed by
+ * the dispatcher)
+ *
+ * @author Tim Roberts
+ *
+ */
+ 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 Lock _lock = new ReentrantLock();
+ private final Condition _running = _lock.newCondition();
+
+ /**
+ * Stops the reader. Will wait 5 seconds for the runnable to stop (should stop within 1 second based on the
+ * setSOTimeout)
+ */
+ public void stopRunning() {
+ _lock.lock();
+ try {
+ if (_isRunning.getAndSet(false)) {
+ if (!_running.await(5, TimeUnit.SECONDS)) {
+ _logger.warn("Waited too long for dispatcher to finish");
+ }
+ }
+ } catch (InterruptedException e) {
+ // shouldn't happen
+ } finally {
+ _lock.unlock();
+ }
+ }
+
+ /**
+ * 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.
+ */
+ @Override
+ public void run() {
+ final StringBuilder sb = new StringBuilder(100);
+ int c;
+
+ _isRunning.set(true);
+ _responses.clear();
+
+ while (_isRunning.get()) {
+ try {
+ // if reader is null, sleep and try again
+ if (_reader == null) {
+ Thread.sleep(250);
+ continue;
+ }
+
+ c = _reader.read();
+ if (c == -1) {
+ _responses.put(new IOException("server closed connection"));
+ _isRunning.set(false);
+ break;
+ }
+ final char ch = (char) c;
+ sb.append(ch);
+ if (ch == '\n' || ch == ' ') {
+ final String str = sb.toString();
+ if (str.endsWith("\r\n") || str.endsWith("Login: ") || str.endsWith("Password: ")) {
+ sb.setLength(0);
+ final String response = str.substring(0, str.length() - 2);
+ _logger.debug("Received response: {}", response);
+ _responses.put(response);
+ }
+ }
+ // _logger.debug(">>> reading: " + sb + ":" + (int) ch);
+ } catch (SocketTimeoutException e) {
+ // do nothing - we expect this (setSOTimeout) to check the _isReading
+ } catch (InterruptedException e) {
+ // Do nothing - probably shutting down
+ } catch (IOException e) {
+ try {
+ _isRunning.set(false);
+ _responses.put(e);
+ } catch (InterruptedException e1) {
+ // Do nothing - probably shutting down
+ }
+ }
+ }
+
+ _lock.lock();
+ try {
+ _running.signalAll();
+ } finally {
+ _lock.unlock();
+ }
+ }
+ }
+
+ /**
+ * The dispatcher runnable is responsible for reading the response queue and dispatching it to the current callable.
+ * Since the dispatcher is ONLY started when a callable is set, responses may pile up in the queue and be dispatched
+ * when a callable is set. Unlike the socket reader, this can be assigned to another thread (no state outside of the
+ * class).
+ *
+ * @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 Lock _lock = new ReentrantLock();
+ private final Condition _running = _lock.newCondition();
+
+ /**
+ * 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() {
+
+ _lock.lock();
+ try {
+ if (_isRunning.getAndSet(false)) {
+ if (!_running.await(5, TimeUnit.SECONDS)) {
+ _logger.warn("Waited too long for dispatcher to finish");
+ }
+ }
+ } catch (InterruptedException e) {
+ // do nothing
+ } finally {
+ _lock.unlock();
+ }
+
+ }
+
+ /**
+ * 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()) {
+ try {
+ final SocketSessionListener[] listeners = _listeners.toArray(new SocketSessionListener[0]);
+
+ // if no listeners, we don't want to start dispatching yet.
+ if (listeners.length == 0) {
+ Thread.sleep(250);
+ continue;
+ }
+
+ final Object response = _responses.poll(1, TimeUnit.SECONDS);
+
+ if (response != null) {
+ if (response instanceof String) {
+ try {
+ _logger.debug("Dispatching response: {}", response);
+ for (SocketSessionListener listener : listeners) {
+ listener.responseReceived((String) response);
+ }
+ } catch (Exception e) {
+ _logger.warn("Exception occurred processing the response '{}': {}", response, e);
+ }
+ } else if (response instanceof Exception) {
+ _logger.debug("Dispatching exception: {}", response);
+ for (SocketSessionListener listener : listeners) {
+ listener.responseException((Exception) response);
+ }
+ } else {
+ _logger.error("Unknown response class: {}", response);
+ }
+ }
+ } catch (InterruptedException e) {
+ // Do nothing
+ }
+ }
+
+ _lock.lock();
+ try {
+ // Signal that we are done
+ _running.signalAll();
+ } finally {
+ _lock.unlock();
+ }
+ }
+ }
+}
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
new file mode 100644
index 0000000000000..1b7a3181afdb3
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/internal/net/SocketSessionListener.java
@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) 2014-2016 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;
+
+/**
+ * Interface defining a listener to a {@link SocketSession} that will receive responses and/or exceptions from the
+ * socket
+ *
+ * @author Tim Roberts
+ */
+public interface SocketSessionListener {
+ /**
+ * Called when a command has completed with the response for the command
+ *
+ * @param response a non-null, possibly empty response
+ */
+ public void responseReceived(String response);
+
+ /**
+ * Called when a command finished with an exception or a general exception occurred while reading
+ *
+ * @param e a non-null exception
+ */
+ public void responseException(Exception e);
+}
diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/AbstractBridgeHandler.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/AbstractBridgeHandler.java
new file mode 100644
index 0000000000000..10e83cc890bd5
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/AbstractBridgeHandler.java
@@ -0,0 +1,107 @@
+/**
+ * Copyright (c) 2014-2016 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.rio;
+
+import org.eclipse.smarthome.core.thing.Bridge;
+import org.eclipse.smarthome.core.thing.ThingStatus;
+import org.eclipse.smarthome.core.thing.ThingStatusDetail;
+import org.eclipse.smarthome.core.thing.ThingStatusInfo;
+import org.eclipse.smarthome.core.thing.binding.BaseBridgeHandler;
+import org.openhab.binding.russound.internal.net.SocketSession;
+import org.openhab.binding.russound.internal.net.SocketSessionListener;
+
+/**
+ * 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)}
+ * event processing and the ability to get the current {@link SocketSession}.
+ * {@link #sendCommand(String)} and responses will be received on any {@link SocketSessionListener}
+ *
+ * @author Tim Roberts
+ */
+public abstract class AbstractBridgeHandler extends BaseBridgeHandler {
+
+ /**
+ * The protocol handler for this base
+ */
+ private E _protocolHandler;
+
+ /**
+ * Creates the handler from the given {@link Bridge}
+ *
+ * @param bridge a non-null {@link Bridge}
+ */
+ protected AbstractBridgeHandler(Bridge bridge) {
+ super(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}
+ */
+ protected void setProtocolHandler(E protocolHandler) {
+ if (_protocolHandler != null) {
+ _protocolHandler.dispose();
+ }
+ _protocolHandler = protocolHandler;
+ }
+
+ /**
+ * Get's the {@link AbstractRioProtocol} handler. May be null if none currently exists
+ *
+ * @return a {@link AbstractRioProtocol} handler or null if none exists
+ */
+ protected E getProtocolHandler() {
+ return _protocolHandler;
+ }
+
+ /**
+ * Returns the {@link SocketSession} for this {@link Bridge}. The default implementation is to look in the parent
+ * {@link #getBridge()} for the {@link SocketSession}
+ *
+ * @return a {@link SocketSession} or null if none exists
+ */
+ @SuppressWarnings("rawtypes")
+ public SocketSession getSocketSession() {
+ final Bridge bridge = getBridge();
+ if (bridge.getHandler() instanceof AbstractBridgeHandler) {
+ return ((AbstractBridgeHandler) bridge.getHandler()).getSocketSession();
+ }
+ 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
+ * via the {@link #retryBridge()} method.
+ */
+ @Override
+ public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
+ if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE) {
+ if (getThing().getStatusInfo().getStatus() != ThingStatus.ONLINE) {
+ dispose();
+ initialize();
+ updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
+ }
+ } else if (bridgeStatusInfo.getStatus() == ThingStatus.OFFLINE) {
+ dispose();
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
+ }
+ }
+
+ /**
+ * Overrides the dispose to simply set the protocol handler to null (thereby disposing the existing protocol handler
+ * via {@link #setProtocolHandler(AbstractRioProtocol)}
+ */
+ @Override
+ public void dispose() {
+ setProtocolHandler(null);
+ }
+}
diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/AbstractRioProtocol.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/AbstractRioProtocol.java
new file mode 100644
index 0000000000000..c86ee41090d6d
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/AbstractRioProtocol.java
@@ -0,0 +1,127 @@
+/**
+ * Copyright (c) 2014-2016 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.rio;
+
+import java.io.IOException;
+
+import org.eclipse.smarthome.core.thing.ThingStatus;
+import org.eclipse.smarthome.core.thing.ThingStatusDetail;
+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.rio.system.RioSystemHandler;
+
+/**
+ * Defines the abstract base for a protocol handler. This base provides managment of the {@link SocketSession} and
+ * provides helper methods that will callback {@link RioHandlerCallback}
+ *
+ * @author Tim Roberts
+ *
+ */
+public abstract class AbstractRioProtocol implements SocketSessionListener {
+ /**
+ * The {@link SocketSession} used by this protocol handler
+ */
+ private final SocketSession _session;
+
+ /**
+ * The {@link RioSystemHandler} to call back to update status and state
+ */
+ private final RioHandlerCallback _callback;
+
+ /**
+ * Constructs the protocol handler from given parameters and will add this handler as a
+ * {@link SocketSessionListener} to the specified {@link SocketSession} via
+ * {@link SocketSession#addListener(SocketSessionListener)}
+ *
+ * @param session a non-null {@link SocketSession} (may be connected or disconnected)
+ * @param callback a non-null {@link RioHandlerCallback} to update state and status
+ */
+ protected AbstractRioProtocol(SocketSession session, RioHandlerCallback callback) {
+
+ if (session == null) {
+ throw new IllegalArgumentException("session cannot be null");
+ }
+
+ if (callback == null) {
+ throw new IllegalArgumentException("callback cannot be null");
+ }
+
+ _session = session;
+ _session.addListener(this);
+ _callback = callback;
+ }
+
+ /**
+ * Sends the command and puts the thing into {@link ThingStatus#OFFLINE} if an IOException occurs
+ *
+ * @param command a non-null, non-empty command to send
+ */
+ protected void sendCommand(String command) {
+ if (command == null) {
+ throw new IllegalArgumentException("command cannot be null");
+ }
+ try {
+ _session.sendCommand(command);
+ } catch (IOException e) {
+ getCallback().statusChanged(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ "Exception occurred sending command: " + e);
+ }
+ }
+
+ /**
+ * Updates the state via the {@link RioHandlerCallback#stateChanged(String, State)}
+ *
+ * @param channelId the channel id to update state
+ * @param newState the new state
+ */
+ protected void stateChanged(String channelId, State newState) {
+ getCallback().stateChanged(channelId, newState);
+ }
+
+ /**
+ * Updates the status via {@link RioHandlerCallback#statusChanged(ThingStatus, ThingStatusDetail, String)}
+ *
+ * @param status the new status
+ * @param statusDetail the status detail
+ * @param msg the status detail message
+ */
+ protected void statusChanged(ThingStatus status, ThingStatusDetail statusDetail, String msg) {
+ getCallback().statusChanged(status, statusDetail, msg);
+ }
+
+ /**
+ * Disposes of the protocol by removing ourselves from listening to the socket via
+ * {@link SocketSession#removeListener(SocketSessionListener)}
+ */
+ public void dispose() {
+ _session.removeListener(this);
+ }
+
+ /**
+ * Implements the {@link SocketSessionListener#responseException(Exception)} to automatically take the thing offline
+ * via {@link RioHandlerCallback#statusChanged(ThingStatus, ThingStatusDetail, String)}
+ *
+ * @param e the exception
+ */
+ @Override
+ public void responseException(Exception e) {
+ getCallback().statusChanged(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ "Exception occurred reading from the socket: " + e);
+ }
+
+ /**
+ * Returns the {@link RioHandlerCallback} used by this protocol
+ *
+ * @return a non-null {@link RioHandlerCallback}
+ */
+ public RioHandlerCallback getCallback() {
+ return _callback;
+ }
+}
diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/AbstractThingHandler.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/AbstractThingHandler.java
new file mode 100644
index 0000000000000..fada35a19f218
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/AbstractThingHandler.java
@@ -0,0 +1,106 @@
+/**
+ * Copyright (c) 2014-2016 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.rio;
+
+import org.eclipse.smarthome.core.thing.Bridge;
+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.ThingStatusInfo;
+import org.eclipse.smarthome.core.thing.binding.BaseThingHandler;
+import org.openhab.binding.russound.internal.net.SocketSession;
+import org.openhab.binding.russound.internal.net.SocketSessionListener;
+
+/**
+ * Represents the abstract base to a {@link BaseThingHandler} for common functionality to all Things. This abstract
+ * base provides management of the {@link AbstractRioProtocol}, parent {@link #bridgeStatusChanged(ThingStatusInfo)}
+ * event processing and the ability to get the current {@link SocketSession}.
+ * {@link #sendCommand(String)} and responses will be received on any {@link SocketSessionListener}
+ *
+ * @author Tim Roberts
+ */
+public abstract class AbstractThingHandler extends BaseThingHandler {
+ /**
+ * The protocol handler for this base
+ */
+ private E _protocolHandler;
+
+ /**
+ * Creates the handler from the given {@link Thing}
+ *
+ * @param thing a non-null {@link Thing}
+ */
+ protected AbstractThingHandler(Thing thing) {
+ super(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}
+ */
+ protected void setProtocolHandler(E protocolHandler) {
+ if (_protocolHandler != null) {
+ _protocolHandler.dispose();
+ }
+ _protocolHandler = protocolHandler;
+ }
+
+ /**
+ * Get's the {@link AbstractRioProtocol} handler. May be null if none currently exists
+ *
+ * @return a {@link AbstractRioProtocol} handler or null if none exists
+ */
+ protected E getProtocolHandler() {
+ return _protocolHandler;
+ }
+
+ /**
+ * Returns the {@link SocketSession} for this {@link Bridge}. The default implementation is to look in the parent
+ * {@link #getBridge()} for the {@link SocketSession}
+ *
+ * @return a {@link SocketSession} or null if none exists
+ */
+ @SuppressWarnings("rawtypes")
+ protected SocketSession getSocketSession() {
+ final Bridge bridge = getBridge();
+ if (bridge != null && bridge.getHandler() instanceof AbstractBridgeHandler) {
+ return ((AbstractBridgeHandler) bridge.getHandler()).getSocketSession();
+ }
+ return null;
+ }
+
+ /**
+ * Overrides the base to initialize or dispose the handler based on the parent bridge status changing. This will
+ * call {@link #initialize()} if the status becomes online. If offline, {@link #dispose()} will be called instead.
+ */
+ @Override
+ public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
+ if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE) {
+ if (getThing().getStatusInfo().getStatus() != ThingStatus.ONLINE) {
+ dispose();
+ initialize();
+ updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
+ }
+ } else if (bridgeStatusInfo.getStatus() == ThingStatus.OFFLINE) {
+ dispose();
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
+ }
+ }
+
+ /**
+ * Overrides the dispose to simply set the protocol handler to null (thereby disposing the existing protocol handler
+ * via {@link #setProtocolHandler(AbstractRioProtocol)}
+ */
+ @Override
+ public void dispose() {
+ setProtocolHandler(null);
+ }
+}
diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/RioConstants.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/RioConstants.java
new file mode 100644
index 0000000000000..dd0c289469b0b
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/RioConstants.java
@@ -0,0 +1,123 @@
+/**
+ * Copyright (c) 2014-2016 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.rio;
+
+import org.eclipse.smarthome.core.thing.ThingTypeUID;
+import org.openhab.binding.russound.RussoundBindingConstants;
+
+/**
+ * The class defines common constants ({@link ThingTypeUID} and channels), which are used across the rio binding
+ *
+ * @author Tim Roberts - Initial contribution
+ */
+public class RioConstants {
+
+ // BRIDGE TYPE IDS
+ public final static ThingTypeUID BRIDGE_TYPE_RIO = new ThingTypeUID(RussoundBindingConstants.BINDING_ID, "rio");
+ public final static ThingTypeUID BRIDGE_TYPE_CONTROLLER = new ThingTypeUID(RussoundBindingConstants.BINDING_ID,
+ "controller");
+
+ public final static ThingTypeUID BRIDGE_TYPE_ZONE = new ThingTypeUID(RussoundBindingConstants.BINDING_ID, "zone");
+ public final static ThingTypeUID BRIDGE_TYPE_SOURCE = new ThingTypeUID(RussoundBindingConstants.BINDING_ID,
+ "source");
+
+ public final static ThingTypeUID BRIDGE_TYPE_BANK = new ThingTypeUID(RussoundBindingConstants.BINDING_ID, "bank");
+
+ // THING TYPE IDS
+ public final static ThingTypeUID THING_TYPE_BANK_PRESET = new ThingTypeUID(RussoundBindingConstants.BINDING_ID,
+ "bankpreset");
+ public final static ThingTypeUID THING_TYPE_ZONE_PRESET = new ThingTypeUID(RussoundBindingConstants.BINDING_ID,
+ "zonepreset");
+ public final static ThingTypeUID THING_TYPE_SYSTEM_FAVORITE = new ThingTypeUID(RussoundBindingConstants.BINDING_ID,
+ "sysfavorite");
+ public final static ThingTypeUID THING_TYPE_ZONE_FAVORITE = new ThingTypeUID(RussoundBindingConstants.BINDING_ID,
+ "zonefavorite");
+
+ // SYSTEM CHANNELS
+ public final static String CHANNEL_SYSVERSION = "version"; // readonly
+ public final static String CHANNEL_SYSSTATUS = "status"; // readonly
+ public final static String CHANNEL_SYSLANG = "lang"; // read/write - english, chinese, russian
+ public final static String CHANNEL_SYSALLON = "allon"; // read/write - english, chinese, russian
+
+ // CONTROLLER CHANNELS
+ public final static String CHANNEL_CTLTYPE = "type"; // readonly
+ public final static String CHANNEL_CTLIPADDRESS = "ipaddress"; // readonly
+ public final static String CHANNEL_CTLMACADDRESS = "macaddress"; // readonly
+
+ // ZONE CHANNELS
+ public final static String CHANNEL_ZONENAME = "name"; // 12 max
+ public final static String CHANNEL_ZONESOURCE = "source"; // 1-8 or 1-12
+ public final static String CHANNEL_ZONEBASS = "bass"; // -10 to 10
+ public final static String CHANNEL_ZONETREBLE = "treble"; // -10 to 10
+ public final static String CHANNEL_ZONEBALANCE = "balance"; // -10 to 10
+ public final static String CHANNEL_ZONELOUDNESS = "loudness"; // OFF/ON
+ public final static String CHANNEL_ZONETURNONVOLUME = "turnonvolume"; // 0 to 50
+ public final static String CHANNEL_ZONEDONOTDISTURB = "donotdisturb"; // OFF/ON/SLAVE
+ public final static String CHANNEL_ZONEPARTYMODE = "partymode"; // OFF/ON/MASTER
+ public final static String CHANNEL_ZONESTATUS = "status"; // OFF/ON/MASTER
+ public final static String CHANNEL_ZONEVOLUME = "volume"; // OFF/ON/MASTER
+ public final static String CHANNEL_ZONEMUTE = "mute"; // OFF/ON/MASTER
+ public final static String CHANNEL_ZONEPAGE = "page"; // OFF/ON/MASTER
+ public final static String CHANNEL_ZONERATING = "rating"; // OFF=Dislike, On=Like
+ public final static String CHANNEL_ZONESHAREDSOURCE = "sharedsource"; // OFF/ON/MASTER
+ public final static String CHANNEL_ZONESLEEPTIMEREMAINING = "sleeptimeremaining"; // OFF/ON/MASTER
+ public final static String CHANNEL_ZONELASTERROR = "lasterror"; // OFF/ON/MASTER
+ public final static String CHANNEL_ZONEENABLED = "enabled"; // OFF/ON/MASTER
+ public final static String CHANNEL_ZONEREPEAT = "repeat"; // OFF/ON/MASTER
+ public final static String CHANNEL_ZONESHUFFLE = "shuffle"; // OFF/ON/MASTER
+
+ // ZONE EVENT BASED
+ public final static String CHANNEL_ZONEKEYPRESS = "keypress";
+ public final static String CHANNEL_ZONEKEYRELEASE = "keyrelease";
+ public final static String CHANNEL_ZONEKEYHOLD = "keyhold";
+ public final static String CHANNEL_ZONEKEYCODE = "keycode";
+ public final static String CHANNEL_ZONEEVENT = "event";
+
+ // FAVORITE CHANNELS
+ public final static String CHANNEL_FAVNAME = "name";
+ public final static String CHANNEL_FAVVALID = "valid";
+ public final static String CHANNEL_FAVSAVE = "save";
+ public final static String CHANNEL_FAVRESTORE = "restore";
+ public final static String CHANNEL_FAVDELETE = "delete";
+
+ // BANK CHANNELS
+ public final static String CHANNEL_BANKNAME = "name";
+
+ // PRESET CHANNELS
+ public final static String CHANNEL_PRESETNAME = "name";
+ public final static String CHANNEL_PRESETVALID = "valid";
+ public final static String CHANNEL_PRESETSAVE = "save";
+ public final static String CHANNEL_PRESETRESTORE = "restore";
+ public final static String CHANNEL_PRESETDELETE = "delete";
+
+ // SOURCE CHANNELS
+ public final static String CHANNEL_SOURCENAME = "name";
+ public final static String CHANNEL_SOURCETYPE = "type";
+ public final static String CHANNEL_SOURCEIPADDRESS = "ipaddress";
+ public final static String CHANNEL_SOURCECOMPOSERNAME = "composername";
+ public final static String CHANNEL_SOURCECHANNEL = "channel";
+ public final static String CHANNEL_SOURCECHANNELNAME = "channelname";
+ public final static String CHANNEL_SOURCEGENRE = "genre";
+ public final static String CHANNEL_SOURCEARTISTNAME = "artistname";
+ public final static String CHANNEL_SOURCEALBUMNAME = "albumname";
+ public final static String CHANNEL_SOURCECOVERARTURL = "coverarturl";
+ public final static String CHANNEL_SOURCECOVERART = "coverart";
+ public final static String CHANNEL_SOURCEPLAYLISTNAME = "playlistname";
+ public final static String CHANNEL_SOURCESONGNAME = "songname";
+ public final static String CHANNEL_SOURCEMODE = "mode";
+ public final static String CHANNEL_SOURCESHUFFLEMODE = "shufflemode";
+ public final static String CHANNEL_SOURCEREPEATMODE = "repeatmode";
+ public final static String CHANNEL_SOURCERATING = "rating";
+ public final static String CHANNEL_SOURCEPROGRAMSERVICENAME = "programservicename";
+ public final static String CHANNEL_SOURCERADIOTEXT = "radiotext";
+ public final static String CHANNEL_SOURCERADIOTEXT2 = "radiotext2";
+ public final static String CHANNEL_SOURCERADIOTEXT3 = "radiotext3";
+ public final static String CHANNEL_SOURCERADIOTEXT4 = "radiotext4";
+ public final static String CHANNEL_SOURCEVOLUME = "volume";
+}
diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/RioHandlerCallback.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/RioHandlerCallback.java
new file mode 100644
index 0000000000000..6eaf156b4226f
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/RioHandlerCallback.java
@@ -0,0 +1,41 @@
+/**
+ * Copyright (c) 2014-2016 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.rio;
+
+import org.eclipse.smarthome.core.thing.ThingStatus;
+import org.eclipse.smarthome.core.thing.ThingStatusDetail;
+import org.eclipse.smarthome.core.types.State;
+
+/**
+ *
+ * This interface is used to provide a callback mechanism between {@link AbstractRioProtocol} and the associated
+ * bridge/thing ({@link AbstractBridgeHandler} and {@link AbstractThingHandler}). This is necessary since the status and
+ * state of a bridge/thing is private and the protocol handler cannot access it directly.
+ *
+ * @author Tim Roberts
+ *
+ */
+public interface RioHandlerCallback {
+ /**
+ * Callback to the bridge/thing to update the status of the bridge/thing.
+ *
+ * @param status a non-null {@link ThingStatus}
+ * @param detail a non-null {@link ThingStatusDetail}
+ * @param msg a possibly null, possibly empty message
+ */
+ void statusChanged(ThingStatus status, ThingStatusDetail detail, String msg);
+
+ /**
+ * Callback to the bridge/thing to update the state of a channel in the bridge/thing.
+ *
+ * @param channelId the non-null, non-empty channel id
+ * @param state the new non-null {@State}
+ */
+ void stateChanged(String channelId, State state);
+}
diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/StatefulHandlerCallback.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/StatefulHandlerCallback.java
new file mode 100644
index 0000000000000..1c64df2398fb9
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/StatefulHandlerCallback.java
@@ -0,0 +1,104 @@
+/**
+ * Copyright (c) 2014-2016 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.rio;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.commons.lang.StringUtils;
+import org.eclipse.smarthome.core.thing.ThingStatus;
+import org.eclipse.smarthome.core.thing.ThingStatusDetail;
+import org.eclipse.smarthome.core.types.State;
+
+/**
+ * Defines an implementation of {@link RioHandlerCallback} that will remember the last state
+ * for an channelId and suppress the callback if the state hasn't changed
+ *
+ * @author Tim Roberts
+ *
+ */
+public class StatefulHandlerCallback implements RioHandlerCallback {
+
+ /** The wrapped callback */
+ private final RioHandlerCallback _wrappedCallback;
+
+ /** The state by channel id */
+ private final Map _state = new HashMap();
+
+ /**
+ * Create the callback from the other {@link RioHandlerCallback}
+ *
+ * @param wrappedCallback a non-null {@link RioHandlerCallback}
+ * @throws NullPointerException if wrappedCallback is null
+ */
+ public StatefulHandlerCallback(RioHandlerCallback wrappedCallback) {
+ if (wrappedCallback == null) {
+ throw new NullPointerException("wrappedCallback cannot be null");
+ }
+
+ _wrappedCallback = wrappedCallback;
+ }
+
+ /**
+ * Overrides the status changed to simply call the {@link #_wrappedCallback}
+ *
+ * @param status the new status
+ * @param detail the new detail
+ * @param msg the new message
+ */
+ @Override
+ public void statusChanged(ThingStatus status, ThingStatusDetail detail, String 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
+ *
+ * @param channelId the channel id that changed
+ * @param state the new state
+ */
+ @Override
+ public void stateChanged(String channelId, State state) {
+ if (StringUtils.isEmpty(channelId)) {
+ return;
+ }
+
+ final State oldState = _state.get(channelId);
+
+ // If both null OR the same value (enums), nothing changed
+ if (oldState == state) {
+ return;
+ }
+
+ // If they are equal - nothing changed
+ if (oldState != null && oldState.equals(state)) {
+ return;
+ }
+
+ // Something changed - save the new state and call the underlying wrapped
+ _state.put(channelId, state);
+ _wrappedCallback.stateChanged(channelId, state);
+
+ }
+
+ /**
+ * Removes the state associated with the channel id. If the channelid
+ * doesn't exist (or is null or is empty), this method will do nothing.
+ *
+ * @param channelId the channel id to remove state
+ */
+ public void removeState(String channelId) {
+ if (StringUtils.isEmpty(channelId)) {
+ return;
+ }
+ _state.remove(channelId);
+ }
+}
diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/bank/RioBankConfig.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/bank/RioBankConfig.java
new file mode 100644
index 0000000000000..2c4dd1b11e06f
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/bank/RioBankConfig.java
@@ -0,0 +1,39 @@
+/**
+ * Copyright (c) 2014-2016 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.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/rio/bank/RioBankHandler.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/bank/RioBankHandler.java
new file mode 100644
index 0000000000000..cf3dbf1bc7714
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/bank/RioBankHandler.java
@@ -0,0 +1,211 @@
+/**
+ * Copyright (c) 2014-2016 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.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.rio.AbstractBridgeHandler;
+import org.openhab.binding.russound.rio.RioConstants;
+import org.openhab.binding.russound.rio.RioHandlerCallback;
+import org.openhab.binding.russound.rio.StatefulHandlerCallback;
+import org.openhab.binding.russound.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.warn("Called with a null channel id - ignoring");
+ return;
+ }
+
+ if (id.equals(RioConstants.CHANNEL_BANKNAME)) {
+ if (command instanceof StringType) {
+ getProtocolHandler().setName(command.toString());
+ } else {
+ logger.error("Received a favorite name channel command with a non StringType: {}", command);
+ }
+ } else {
+ logger.error("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);
+ }
+ })));
+
+ updateStatus(ThingStatus.ONLINE);
+
+ getProtocolHandler().refreshName();
+ }
+}
diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/bank/RioBankProtocol.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/bank/RioBankProtocol.java
new file mode 100644
index 0000000000000..17edc39046eb4
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/bank/RioBankProtocol.java
@@ -0,0 +1,147 @@
+/**
+ * Copyright (c) 2014-2016 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.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.rio.AbstractRioProtocol;
+import org.openhab.binding.russound.rio.RioConstants;
+import org.openhab.binding.russound.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 final static 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.error("Invalid Bank Name Notification (bank/source not a parsable integer): '{}')", resp);
+ }
+
+ } else {
+ logger.error("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/rio/controller/RioControllerConfig.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/controller/RioControllerConfig.java
new file mode 100644
index 0000000000000..3d609abac12d4
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/controller/RioControllerConfig.java
@@ -0,0 +1,39 @@
+/**
+ * Copyright (c) 2014-2016 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.rio.controller;
+
+/**
+ * Configuration class for the {@link RioControllerHandler}
+ *
+ * @author Tim Roberts
+ */
+public class RioControllerConfig {
+ /**
+ * ID of the controller
+ */
+ private int controller;
+
+ /**
+ * Gets the controller identifier
+ *
+ * @return the controller identifier
+ */
+ public int getController() {
+ return controller;
+ }
+
+ /**
+ * Sets the controller identifier
+ *
+ * @param controller the controller identifier
+ */
+ public void setController(int controller) {
+ this.controller = controller;
+ }
+}
diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/controller/RioControllerHandler.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/controller/RioControllerHandler.java
new file mode 100644
index 0000000000000..88581c0634b50
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/controller/RioControllerHandler.java
@@ -0,0 +1,185 @@
+/**
+ * Copyright (c) 2014-2016 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.rio.controller;
+
+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.rio.AbstractBridgeHandler;
+import org.openhab.binding.russound.rio.RioConstants;
+import org.openhab.binding.russound.rio.RioHandlerCallback;
+import org.openhab.binding.russound.rio.StatefulHandlerCallback;
+import org.openhab.binding.russound.rio.source.RioSourceHandler;
+import org.openhab.binding.russound.rio.system.RioSystemHandler;
+import org.openhab.binding.russound.rio.zone.RioZoneHandler;
+
+/**
+ * The bridge handler for a Russound Controller. A controller provides access to sources ({@link RioSourceHandler}) and
+ * zones ({@link RioZoneHandler}). This
+ * implementation must be attached to a {@link RioSystemHandler} bridge.
+ *
+ * @author Tim Roberts
+ */
+public class RioControllerHandler extends AbstractBridgeHandler {
+ /**
+ * The controller identifier of this instance (between 1-6)
+ */
+ private int _controller;
+
+ /**
+ * Constructs the handler from the {@link Bridge}
+ *
+ * @param bridge a non-null {@link Bridge} the handler is for
+ */
+ public RioControllerHandler(Bridge bridge) {
+ super(bridge);
+
+ }
+
+ /**
+ * Returns the controller identifier
+ *
+ * @return the controller identifier
+ */
+ public int getController() {
+ return _controller;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Handles commands to specific channels. This implementation will offload much of its work to the
+ * {@link RioControllerProtocol}. Basically we validate the type of command for the channel then call the
+ * {@link RioControllerProtocol} 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 RioControllerProtocol} to handle the actual refresh
+ */
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+
+ if (command instanceof RefreshType) {
+ handleRefresh(channelUID.getId());
+ return;
+ }
+
+ // no commands to implement
+ }
+
+ /**
+ * Method that handles the {@link RefreshType} command specifically. Calls the {@link RioControllerProtocol} 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_CTLTYPE)) {
+ getProtocolHandler().refreshControllerType();
+
+ } else if (id.equals(RioConstants.CHANNEL_CTLIPADDRESS)) {
+ getProtocolHandler().refreshControllerIpAddress();
+
+ } else if (id.equals(RioConstants.CHANNEL_CTLMACADDRESS)) {
+ getProtocolHandler().refreshControllerMacAddress();
+ } else {
+ // Can't refresh any others...
+ }
+ }
+
+ /**
+ * Initializes the bridge. Confirms the configuration is valid and that our parent bridge is a
+ * {@link RioSystemHandler}. Once validated, a {@link RioControllerProtocol} is set via
+ * {@link #setProtocolHandler(RioControllerProtocol)} 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 RioSystemHandler)) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "Controller must be attached to a system bridge: " + handler.getClass());
+ return;
+ }
+
+ final RioControllerConfig config = getThing().getConfiguration().as(RioControllerConfig.class);
+ if (config == null) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Configuration file missing");
+ return;
+ }
+
+ _controller = config.getController();
+ if (_controller < 1 || _controller > 8) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "Controller must be between 1 and 8: " + _controller);
+ return;
+ }
+
+ // 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) {
+ getProtocolHandler().dispose();
+ }
+
+ setProtocolHandler(new RioControllerProtocol(_controller, 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);
+ }
+ })));
+
+ updateStatus(ThingStatus.ONLINE);
+ getProtocolHandler().refreshControllerType();
+ getProtocolHandler().refreshControllerIpAddress();
+ getProtocolHandler().refreshControllerMacAddress();
+ }
+}
diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/controller/RioControllerProtocol.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/controller/RioControllerProtocol.java
new file mode 100644
index 0000000000000..72be3e1a3b289
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/controller/RioControllerProtocol.java
@@ -0,0 +1,157 @@
+/**
+ * Copyright (c) 2014-2016 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.rio.controller;
+
+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.rio.AbstractRioProtocol;
+import org.openhab.binding.russound.rio.RioConstants;
+import org.openhab.binding.russound.rio.RioHandlerCallback;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This is the protocol handler for the Russound controller. This handler will issue the protocol commands and will
+ * process the responses from the Russound system.
+ *
+ * @author Tim Roberts
+ *
+ */
+class RioControllerProtocol extends AbstractRioProtocol {
+ // logger
+ private Logger logger = LoggerFactory.getLogger(RioControllerProtocol.class);
+
+ /**
+ * The controller identifier
+ */
+ private final int _controller;
+
+ // Protocol constants
+ private final static String CTL_TYPE = "type";
+ private final static String CTL_IPADDRESS = "ipAddress";
+ private final static String CTL_MACADDRESS = "macAddress";
+
+ // Response pattners
+ private final Pattern RSP_CONTROLLERNOTIFICATION = Pattern.compile("^[SN] C\\[(\\d+)\\]\\.(\\w+)=\"(.*)\"$");
+
+ /**
+ * Constructs the protocol handler from given parameters
+ *
+ * @param controller the controller identifier
+ * @param session a non-null {@link SocketSession} (may be connected or disconnected)
+ * @param callback a non-null {@link RioHandlerCallback} to callback
+ */
+ RioControllerProtocol(int controller, SocketSession session, RioHandlerCallback callback) {
+ super(session, callback);
+ _controller = controller;
+ }
+
+ /**
+ * Issues a get command for the controller given the keyname
+ *
+ * @param keyName a non-null, non-empty keyname to get
+ * @throws IllegalArgumentException if name is null or an empty string
+ */
+ 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);
+ }
+
+ /**
+ * Refreshes the controller IP address
+ */
+ void refreshControllerIpAddress() {
+ refreshControllerKey(CTL_IPADDRESS);
+ }
+
+ /**
+ * Refreshes the controller MAC address
+ */
+ void refreshControllerMacAddress() {
+ refreshControllerKey(CTL_MACADDRESS);
+ }
+
+ /**
+ * Refreshes the controller Model Type
+ */
+ void refreshControllerType() {
+ refreshControllerKey(CTL_TYPE);
+ }
+
+ /**
+ * Handles any controller notifications returned by the russound system
+ *
+ * @param m a non-null matcher
+ * @param resp a possibly null, possibly empty response
+ */
+ private void handleControllerNotification(Matcher m, String resp) {
+ if (m == null) {
+ throw new IllegalArgumentException("m (matcher) cannot be null");
+ }
+ if (m.groupCount() == 3) {
+ try {
+ final int controller = Integer.parseInt(m.group(1));
+ if (controller != _controller) {
+ return;
+ }
+
+ final String key = m.group(2);
+ final String value = m.group(3);
+
+ switch (key) {
+ case CTL_TYPE:
+ stateChanged(RioConstants.CHANNEL_CTLTYPE, new StringType(value));
+ break;
+
+ case CTL_IPADDRESS:
+ stateChanged(RioConstants.CHANNEL_CTLIPADDRESS, new StringType(value));
+ break;
+
+ case CTL_MACADDRESS:
+ stateChanged(RioConstants.CHANNEL_CTLMACADDRESS, new StringType(value));
+ break;
+
+ default:
+ logger.warn("Unknown controller notification: '{}'", resp);
+ break;
+ }
+ } catch (NumberFormatException e) {
+ logger.error("Invalid Controller Notification (controller not a parsable integer): '{}')", resp);
+ }
+ } else {
+ logger.error("Invalid Controller 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 (response == null || response == "") {
+ return;
+ }
+
+ final Matcher m = RSP_CONTROLLERNOTIFICATION.matcher(response);
+ if (m.matches()) {
+ handleControllerNotification(m, response);
+ return;
+ }
+ }
+}
diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/favorites/RioFavoriteConfig.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/favorites/RioFavoriteConfig.java
new file mode 100644
index 0000000000000..4dc85659ab5fc
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/favorites/RioFavoriteConfig.java
@@ -0,0 +1,39 @@
+/**
+ * Copyright (c) 2014-2016 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.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/rio/favorites/RioFavoriteHandler.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/favorites/RioFavoriteHandler.java
new file mode 100644
index 0000000000000..0b1e3568e3374
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/favorites/RioFavoriteHandler.java
@@ -0,0 +1,227 @@
+/**
+ * Copyright (c) 2014-2016 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.rio.favorites;
+
+import org.eclipse.smarthome.core.library.types.OnOffType;
+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.rio.AbstractThingHandler;
+import org.openhab.binding.russound.rio.RioConstants;
+import org.openhab.binding.russound.rio.RioHandlerCallback;
+import org.openhab.binding.russound.rio.StatefulHandlerCallback;
+import org.openhab.binding.russound.rio.system.RioSystemHandler;
+import org.openhab.binding.russound.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;
+ }
+ String id = channelUID.getId();
+
+ if (id == null) {
+ logger.warn("Called with a null channel id - ignoring");
+ return;
+ }
+
+ if (id.equals(RioConstants.CHANNEL_FAVNAME)) {
+ if (command instanceof StringType) {
+ getProtocolHandler().setName(command.toString());
+ } else {
+ logger.error("Received a favorite name channel command with a non StringType: {}", command);
+ }
+ } else if (id.equals(RioConstants.CHANNEL_FAVSAVE)) {
+ if (command instanceof OnOffType) {
+ getProtocolHandler().saveFavorite(command == OnOffType.ON);
+ } else {
+ logger.error("Received a favorite save channel command with a non OnOffType: {}", command);
+ }
+
+ } else if (id.equals(RioConstants.CHANNEL_FAVRESTORE)) {
+ if (command instanceof OnOffType) {
+ getProtocolHandler().restoreFavorite(command == OnOffType.ON);
+ } else {
+ logger.error("Received a favorite restore channel command with a non OnOffType: {}", command);
+ }
+ } else if (id.equals(RioConstants.CHANNEL_FAVDELETE)) {
+ if (command instanceof OnOffType) {
+ getProtocolHandler().deleteFavorite(command == OnOffType.ON);
+ } else {
+ logger.error("Received a favorite delete channel command with a non OnOffType: {}", command);
+ }
+ } else {
+ logger.error("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);
+ }
+ })));
+
+ updateStatus(ThingStatus.ONLINE);
+
+ getProtocolHandler().refreshName();
+ getProtocolHandler().refreshValid();
+ }
+}
diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/favorites/RioFavoriteProtocol.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/favorites/RioFavoriteProtocol.java
new file mode 100644
index 0000000000000..0f709d5d1609c
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/favorites/RioFavoriteProtocol.java
@@ -0,0 +1,330 @@
+/**
+ * Copyright (c) 2014-2016 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.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.rio.AbstractRioProtocol;
+import org.openhab.binding.russound.rio.RioConstants;
+import org.openhab.binding.russound.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 final static String FAV_NAME = "name";
+ private final static 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.error("Invalid System Favorite Notification (favorite not a parsable integer): '{}')", resp);
+ }
+
+ } else {
+ logger.error("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.error(
+ "Invalid zone favorite Notification (controller/zone/favorite not a parsable integer): '{}')",
+ resp);
+ }
+ } else {
+ logger.error("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/rio/preset/RioPresetConfig.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/preset/RioPresetConfig.java
new file mode 100644
index 0000000000000..6a2717933c2e9
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/preset/RioPresetConfig.java
@@ -0,0 +1,39 @@
+/**
+ * Copyright (c) 2014-2016 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.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/rio/preset/RioPresetHandler.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/preset/RioPresetHandler.java
new file mode 100644
index 0000000000000..9b925d348723e
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/preset/RioPresetHandler.java
@@ -0,0 +1,221 @@
+/**
+ * Copyright (c) 2014-2016 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.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.rio.AbstractThingHandler;
+import org.openhab.binding.russound.rio.RioConstants;
+import org.openhab.binding.russound.rio.RioHandlerCallback;
+import org.openhab.binding.russound.rio.StatefulHandlerCallback;
+import org.openhab.binding.russound.rio.bank.RioBankHandler;
+import org.openhab.binding.russound.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;
+ }
+ String id = channelUID.getId();
+
+ if (id == null) {
+ logger.warn("Called with a null channel id - ignoring");
+ return;
+ }
+
+ if (id.equals(RioConstants.CHANNEL_PRESETNAME)) {
+ if (command instanceof StringType) {
+ getProtocolHandler().setName(command.toString());
+ } else {
+ logger.error("Received a preset name channel command with a non StringType: {}", command);
+ }
+ } else if (id.equals(RioConstants.CHANNEL_PRESETSAVE)) {
+ getProtocolHandler().savePreset();
+ } else if (id.equals(RioConstants.CHANNEL_PRESETRESTORE)) {
+ getProtocolHandler().restorePreset();
+ } else if (id.equals(RioConstants.CHANNEL_PRESETDELETE)) {
+ getProtocolHandler().deletePreset();
+ } else {
+ logger.error("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);
+ }
+ })));
+
+ 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/rio/preset/RioPresetProtocol.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/preset/RioPresetProtocol.java
new file mode 100644
index 0000000000000..3503f0aa7f1a7
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/preset/RioPresetProtocol.java
@@ -0,0 +1,268 @@
+/**
+ * Copyright (c) 2014-2016 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.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.rio.AbstractRioProtocol;
+import org.openhab.binding.russound.rio.RioConstants;
+import org.openhab.binding.russound.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 final static String PRESET_NAME = "name";
+ private final static 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.error("Invalid Preset Notification (source/bank/preset not a parsable integer): '{}')", resp);
+ }
+ } else {
+ logger.error("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/rio/source/RioSourceConfig.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/source/RioSourceConfig.java
new file mode 100644
index 0000000000000..c81dc9869fe08
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/source/RioSourceConfig.java
@@ -0,0 +1,39 @@
+/**
+ * Copyright (c) 2014-2016 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.rio.source;
+
+/**
+ * Configuration class for the {@link RioSourceHandler}
+ *
+ * @author Tim Roberts
+ */
+public class RioSourceConfig {
+ /**
+ * ID of the source
+ */
+ private int source;
+
+ /**
+ * Gets the source identifier
+ *
+ * @return the source identifier
+ */
+ public int getSource() {
+ return source;
+ }
+
+ /**
+ * Sets the source identifier
+ *
+ * @param source the source identifier
+ */
+ public void setSource(int source) {
+ this.source = source;
+ }
+}
diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/source/RioSourceHandler.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/source/RioSourceHandler.java
new file mode 100644
index 0000000000000..144b6aa4baf2b
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/source/RioSourceHandler.java
@@ -0,0 +1,235 @@
+/**
+ * Copyright (c) 2014-2016 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.rio.source;
+
+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.rio.AbstractBridgeHandler;
+import org.openhab.binding.russound.rio.RioConstants;
+import org.openhab.binding.russound.rio.RioHandlerCallback;
+import org.openhab.binding.russound.rio.StatefulHandlerCallback;
+import org.openhab.binding.russound.rio.system.RioSystemHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The bridge handler for a Russound Source. A source provides source music to the russound system (along with metadata
+ * about the streaming music). This implementation must be attached to a {@link RioSystemHandler} bridge.
+ *
+ * @author Tim Roberts
+ */
+public class RioSourceHandler extends AbstractBridgeHandler {
+ // Logger
+ private Logger logger = LoggerFactory.getLogger(RioSourceHandler.class);
+
+ /**
+ * The source identifier for this instance (1-12)
+ */
+ private int _source;
+
+ /**
+ * Constructs the handler from the {@link Bridge}
+ *
+ * @param bridge a non-null {@link Bridge} the handler is for
+ */
+ public RioSourceHandler(Bridge bridge) {
+ super(bridge);
+ }
+
+ /**
+ * Returns the source identifier for this instance
+ *
+ * @return the source identifier
+ */
+ public int getSource() {
+ return _source;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Handles commands to specific channels. This implementation will offload much of its work to the
+ * {@link RioSourceProtocol}. Basically we validate the type of command for the channel then call the
+ * {@link RioSourceProtocol} 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 RioSourceProtocol} to handle the actual refresh
+ */
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+
+ if (command instanceof RefreshType) {
+ handleRefresh(channelUID.getId());
+ return;
+ }
+
+ // if (getThing().getStatus() != ThingStatus.ONLINE) {
+ // // Ignore any command if not online
+ // return;
+ // }
+
+ String id = channelUID.getId();
+
+ if (id == null) {
+ logger.warn("Called with a null channel id - ignoring");
+ return;
+ }
+
+ logger.warn("There are no channels that allow commands");
+ }
+
+ /**
+ * Method that handles the {@link RefreshType} command specifically. Calls the {@link RioSourceProtocol} 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_SOURCENAME)) {
+ getProtocolHandler().refreshSourceName();
+ } else if (id.startsWith(RioConstants.CHANNEL_SOURCETYPE)) {
+ getProtocolHandler().refreshSourceType();
+ } else if (id.startsWith(RioConstants.CHANNEL_SOURCEIPADDRESS)) {
+ getProtocolHandler().refreshSourceIpAddress();
+ } else if (id.startsWith(RioConstants.CHANNEL_SOURCECOMPOSERNAME)) {
+ getProtocolHandler().refreshSourceComposerName();
+ } else if (id.startsWith(RioConstants.CHANNEL_SOURCECHANNEL)) {
+ getProtocolHandler().refreshSourceChannel();
+ } else if (id.startsWith(RioConstants.CHANNEL_SOURCECHANNELNAME)) {
+ getProtocolHandler().refreshSourceChannelName();
+ } else if (id.startsWith(RioConstants.CHANNEL_SOURCEGENRE)) {
+ getProtocolHandler().refreshSourceGenre();
+ } else if (id.startsWith(RioConstants.CHANNEL_SOURCEARTISTNAME)) {
+ getProtocolHandler().refreshSourceArtistName();
+ } else if (id.startsWith(RioConstants.CHANNEL_SOURCEALBUMNAME)) {
+ 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)) {
+ getProtocolHandler().refreshSourceSongName();
+ } else if (id.startsWith(RioConstants.CHANNEL_SOURCEMODE)) {
+ getProtocolHandler().refreshSourceMode();
+ } else if (id.startsWith(RioConstants.CHANNEL_SOURCESHUFFLEMODE)) {
+ getProtocolHandler().refreshSourceShuffleMode();
+ } else if (id.startsWith(RioConstants.CHANNEL_SOURCEREPEATMODE)) {
+ getProtocolHandler().refreshSourceRepeatMode();
+ } else if (id.startsWith(RioConstants.CHANNEL_SOURCERATING)) {
+ getProtocolHandler().refreshSourceRating();
+ } else if (id.startsWith(RioConstants.CHANNEL_SOURCEPROGRAMSERVICENAME)) {
+ getProtocolHandler().refreshSourceProgramServiceName();
+ } else if (id.startsWith(RioConstants.CHANNEL_SOURCERADIOTEXT)) {
+ getProtocolHandler().refreshSourceRadioText();
+ } else if (id.startsWith(RioConstants.CHANNEL_SOURCERADIOTEXT2)) {
+ getProtocolHandler().refreshSourceRadioText2();
+ } else if (id.startsWith(RioConstants.CHANNEL_SOURCERADIOTEXT3)) {
+ getProtocolHandler().refreshSourceRadioText3();
+ } else if (id.startsWith(RioConstants.CHANNEL_SOURCERADIOTEXT4)) {
+ getProtocolHandler().refreshSourceRadioText4();
+
+ } else {
+ // Can't refresh any others...
+ }
+ }
+
+ /**
+ * Initializes the bridge. Confirms the configuration is valid and that our parent bridge is a
+ * {@link RioSystemHandler}. Once validated, a {@link RioSystemProtocol} is set via
+ * {@link #setProtocolHandler(RioSystemProtocol)} and the bridge comes online.
+ */
+ @Override
+ public void initialize() {
+ logger.debug("Initializing");
+ 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)) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "Source must be attached to a System bridge: " + handler.getClass());
+ return;
+ }
+
+ final RioSourceConfig config = getThing().getConfiguration().as(RioSourceConfig.class);
+ if (config == null) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Configuration file missing");
+ return;
+ }
+
+ _source = config.getSource();
+ if (_source < 1 || _source > 12) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "Source must be between 1 and 12: " + config.getSource());
+ return;
+ }
+
+ // 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;
+ }
+
+ try {
+ setProtocolHandler(
+ new RioSourceProtocol(_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);
+ }
+ })));
+
+ updateStatus(ThingStatus.ONLINE);
+ getProtocolHandler().watchSource(true);
+ getProtocolHandler().refreshSourceIpAddress(); // need to manually get this
+ } 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/rio/source/RioSourceProtocol.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/source/RioSourceProtocol.java
new file mode 100644
index 0000000000000..a20f8435c30b6
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/source/RioSourceProtocol.java
@@ -0,0 +1,448 @@
+/**
+ * Copyright (c) 2014-2016 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.rio.source;
+
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+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.openhab.binding.russound.internal.net.SocketSession;
+import org.openhab.binding.russound.internal.net.SocketSessionListener;
+import org.openhab.binding.russound.rio.AbstractRioProtocol;
+import org.openhab.binding.russound.rio.RioConstants;
+import org.openhab.binding.russound.rio.RioHandlerCallback;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * 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
+ * source types.
+ *
+ * @author Tim Roberts
+ *
+ */
+class RioSourceProtocol extends AbstractRioProtocol {
+ private Logger logger = LoggerFactory.getLogger(RioSourceProtocol.class);
+
+ /**
+ * The source identifier (1-12)
+ */
+ private final int _source;
+
+ // Protocol constants
+ private final static String SRC_NAME = "name";
+ private final static String SRC_TYPE = "type";
+ private final static String SRC_IPADDRESS = "ipAddress";
+ private final static String SRC_IPADDRESS2 = "IPAddress"; // russound wasn't consistent on capitalization on
+ // notifications
+ private final static String SRC_COMPOSERNAME = "composerName";
+ private final static String SRC_CHANNEL = "channel";
+ private final static String SRC_CHANNELNAME = "channelName";
+ private final static String SRC_GENRE = "genre";
+ private final static String SRC_ARTISTNAME = "artistName";
+ private final static String SRC_ALBUMNAME = "albumName";
+ private final static String SRC_COVERARTURL = "coverArtURL";
+ private final static String SRC_PLAYLISTNAME = "playlistName";
+ private final static String SRC_SONGNAME = "songName";
+ private final static String SRC_MODE = "mode";
+ private final static String SRC_SHUFFLEMODE = "shuffleMode";
+ private final static String SRC_REPEATMODE = "repeatMode";
+ private final static String SRC_RATING = "rating";
+ private final static String SRC_PROGRAMSERVICENAME = "programServiceName";
+ private final static String SRC_RADIOTEXT = "radioText";
+ private final static String SRC_RADIOTEXT2 = "radioText2";
+ private final static String SRC_RADIOTEXT3 = "radioText3";
+ private final static String SRC_RADIOTEXT4 = "radioText4";
+
+ // This is an undocumented volume
+ private final static String SRC_VOLUME = "volume";
+
+ // Response patterns
+ private final Pattern RSP_SRCNOTIFICATION = Pattern.compile("^[SN] S\\[(\\d+)\\]\\.(\\w+)=\"(.*)\"$");
+
+ /**
+ * The client used for http requests
+ */
+ private final HttpClient _httpClient;
+
+ /**
+ * Constructs the protocol handler from given parameters
+ *
+ * @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
+ * @throws Exception exception when starting the {@link HttpClient}
+ */
+ RioSourceProtocol(int source, SocketSession session, RioHandlerCallback callback) throws Exception {
+ super(session, callback);
+ 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();
+ }
+
+ /**
+ * Helper method to refresh a source key
+ *
+ * @param keyName a non-null, non-empty source key to refresh
+ * @throws IllegalArgumentException if keyName is null or empty
+ */
+ 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);
+ }
+
+ /**
+ * Refreshes the source name
+ */
+ void refreshSourceName() {
+ refreshSourceKey(SRC_NAME);
+ }
+
+ /**
+ * Refresh the source model type
+ */
+ void refreshSourceType() {
+ refreshSourceKey(SRC_TYPE);
+ }
+
+ /**
+ * Refresh the source ip address
+ */
+ void refreshSourceIpAddress() {
+ refreshSourceKey(SRC_IPADDRESS);
+ }
+
+ /**
+ * Refresh composer name
+ */
+ void refreshSourceComposerName() {
+ refreshSourceKey(SRC_COMPOSERNAME);
+ }
+
+ /**
+ * Refresh the channel frequency (for tuners)
+ */
+ void refreshSourceChannel() {
+ refreshSourceKey(SRC_CHANNEL);
+ }
+
+ /**
+ * Refresh the channel's name
+ */
+ void refreshSourceChannelName() {
+ refreshSourceKey(SRC_CHANNELNAME);
+ }
+
+ /**
+ * Refresh the song's genre
+ */
+ void refreshSourceGenre() {
+ refreshSourceKey(SRC_GENRE);
+ }
+
+ /**
+ * Refresh the artist name
+ */
+ void refreshSourceArtistName() {
+ refreshSourceKey(SRC_ARTISTNAME);
+ }
+
+ /**
+ * Refresh the album name
+ */
+ void refreshSourceAlbumName() {
+ refreshSourceKey(SRC_ALBUMNAME);
+ }
+
+ /**
+ * Refresh the cover art URL
+ */
+ void refreshSourceCoverArtUrl() {
+ refreshSourceKey(SRC_COVERARTURL);
+ }
+
+ /**
+ * Refresh the playlist name
+ */
+ void refreshSourcePlaylistName() {
+ refreshSourceKey(SRC_PLAYLISTNAME);
+ }
+
+ /**
+ * Refresh the song name
+ */
+ void refreshSourceSongName() {
+ refreshSourceKey(SRC_SONGNAME);
+ }
+
+ /**
+ * Refresh the provider mode/streaming service
+ */
+ void refreshSourceMode() {
+ refreshSourceKey(SRC_MODE);
+ }
+
+ /**
+ * Refresh the shuffle mode
+ */
+ void refreshSourceShuffleMode() {
+ refreshSourceKey(SRC_SHUFFLEMODE);
+ }
+
+ /**
+ * Refresh the repeat mode
+ */
+ void refreshSourceRepeatMode() {
+ refreshSourceKey(SRC_REPEATMODE);
+ }
+
+ /**
+ * Refresh the rating of the song
+ */
+ void refreshSourceRating() {
+ refreshSourceKey(SRC_RATING);
+ }
+
+ /**
+ * Refresh the program service name
+ */
+ void refreshSourceProgramServiceName() {
+ refreshSourceKey(SRC_PROGRAMSERVICENAME);
+ }
+
+ /**
+ * Refresh the radio text
+ */
+ void refreshSourceRadioText() {
+ refreshSourceKey(SRC_RADIOTEXT);
+ }
+
+ /**
+ * Refresh the radio text (line #2)
+ */
+ void refreshSourceRadioText2() {
+ refreshSourceKey(SRC_RADIOTEXT2);
+ }
+
+ /**
+ * Refresh the radio text (line #3)
+ */
+ void refreshSourceRadioText3() {
+ refreshSourceKey(SRC_RADIOTEXT3);
+ }
+
+ /**
+ * Refresh the radio text (line #4)
+ */
+ void refreshSourceRadioText4() {
+ refreshSourceKey(SRC_RADIOTEXT4);
+ }
+
+ /**
+ * Refresh the source volume
+ */
+ void refreshSourceVolume() {
+ refreshSourceKey(SRC_VOLUME);
+ }
+
+ /**
+ * 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"));
+ }
+
+ private void handleCoverArt(String url) {
+ stateChanged(RioConstants.CHANNEL_SOURCECOVERARTURL, new StringType(url));
+
+ if (StringUtils.isEmpty(url)) {
+ stateChanged(RioConstants.CHANNEL_SOURCECOVERART, UnDefType.UNDEF);
+ } 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);
+ }
+ }
+ }
+
+ /**
+ * Handles any source notifications returned by the russound system
+ *
+ * @param m a non-null matcher
+ * @param resp a possibly null, possibly empty response
+ */
+ private void handleSourceNotification(Matcher m, String resp) {
+ if (m == null) {
+ throw new IllegalArgumentException("m (matcher) cannot be null");
+ }
+ if (m.groupCount() == 3) {
+ try {
+ final int source = Integer.parseInt(m.group(1));
+ if (source != _source) {
+ return;
+ }
+ final String key = m.group(2);
+ final String value = m.group(3);
+
+ switch (key) {
+ case SRC_NAME:
+ stateChanged(RioConstants.CHANNEL_SOURCENAME, new StringType(value));
+ break;
+
+ case SRC_TYPE:
+ stateChanged(RioConstants.CHANNEL_SOURCETYPE, new StringType(value));
+ break;
+
+ case SRC_IPADDRESS:
+ case SRC_IPADDRESS2:
+ stateChanged(RioConstants.CHANNEL_SOURCEIPADDRESS, new StringType(value));
+ break;
+
+ case SRC_COMPOSERNAME:
+ stateChanged(RioConstants.CHANNEL_SOURCECOMPOSERNAME, new StringType(value));
+ break;
+
+ case SRC_CHANNEL:
+ stateChanged(RioConstants.CHANNEL_SOURCECHANNEL, new StringType(value));
+ break;
+
+ case SRC_CHANNELNAME:
+ stateChanged(RioConstants.CHANNEL_SOURCECHANNELNAME, new StringType(value));
+ break;
+
+ case SRC_GENRE:
+ stateChanged(RioConstants.CHANNEL_SOURCEGENRE, new StringType(value));
+ break;
+
+ case SRC_ARTISTNAME:
+ stateChanged(RioConstants.CHANNEL_SOURCEARTISTNAME, new StringType(value));
+ break;
+
+ case SRC_ALBUMNAME:
+ stateChanged(RioConstants.CHANNEL_SOURCEALBUMNAME, new StringType(value));
+ break;
+
+ case SRC_COVERARTURL:
+ handleCoverArt(value);
+ break;
+
+ case SRC_PLAYLISTNAME:
+ stateChanged(RioConstants.CHANNEL_SOURCEPLAYLISTNAME, new StringType(value));
+ break;
+
+ case SRC_SONGNAME:
+ stateChanged(RioConstants.CHANNEL_SOURCESONGNAME, new StringType(value));
+ break;
+
+ case SRC_MODE:
+ stateChanged(RioConstants.CHANNEL_SOURCEMODE, new StringType(value));
+ break;
+
+ case SRC_SHUFFLEMODE:
+ stateChanged(RioConstants.CHANNEL_SOURCESHUFFLEMODE, new StringType(value));
+ break;
+
+ case SRC_REPEATMODE:
+ stateChanged(RioConstants.CHANNEL_SOURCEREPEATMODE, new StringType(value));
+ break;
+
+ case SRC_RATING:
+ stateChanged(RioConstants.CHANNEL_SOURCERATING, new StringType(value));
+ break;
+
+ case SRC_PROGRAMSERVICENAME:
+ stateChanged(RioConstants.CHANNEL_SOURCEPROGRAMSERVICENAME, new StringType(value));
+ break;
+
+ case SRC_RADIOTEXT:
+ stateChanged(RioConstants.CHANNEL_SOURCERADIOTEXT, new StringType(value));
+ break;
+
+ case SRC_RADIOTEXT2:
+ stateChanged(RioConstants.CHANNEL_SOURCERADIOTEXT2, new StringType(value));
+ break;
+
+ case SRC_RADIOTEXT3:
+ stateChanged(RioConstants.CHANNEL_SOURCERADIOTEXT3, new StringType(value));
+ break;
+
+ case SRC_RADIOTEXT4:
+ stateChanged(RioConstants.CHANNEL_SOURCERADIOTEXT4, new StringType(value));
+ break;
+
+ case SRC_VOLUME:
+ stateChanged(RioConstants.CHANNEL_SOURCEVOLUME, new StringType(value));
+ break;
+
+ default:
+ logger.warn("Unknown source notification: '{}'", resp);
+ break;
+ }
+ } catch (NumberFormatException e) {
+ logger.error("Invalid Source Notification (source not a parsable integer): '{}')", resp);
+ }
+ } else {
+ logger.error("Invalid Source 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 (response == null || response == "") {
+ return;
+ }
+
+ final Matcher m = RSP_SRCNOTIFICATION.matcher(response);
+ if (m.matches()) {
+ handleSourceNotification(m, response);
+ }
+ }
+
+ /**
+ * Overrides the default implementation to turn watch off ({@link #watchSource(boolean)}) before calling the dispose
+ */
+ @Override
+ public void dispose() {
+ watchSource(false);
+ if (_httpClient != null) {
+ try {
+ _httpClient.stop();
+ } catch (Exception e) {
+ logger.debug("Error stopping the httpclient: {}", e);
+ }
+ }
+ super.dispose();
+ }
+}
diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/system/RioSystemConfig.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/system/RioSystemConfig.java
new file mode 100644
index 0000000000000..270b4660d802d
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/system/RioSystemConfig.java
@@ -0,0 +1,86 @@
+/**
+ * Copyright (c) 2014-2016 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.rio.system;
+
+/**
+ * Configuration class for the {@link RioSystemHandler}
+ *
+ * @author Tim Roberts
+ */
+public class RioSystemConfig {
+
+ /**
+ * IP Address (or host name) of system
+ */
+ private String ipAddress;
+
+ /**
+ * Ping time (in seconds) to keep the connection alive.
+ */
+ private int ping;
+
+ /**
+ * Polling time (in seconds) to attempt a reconnect if the socket session has failed
+ */
+ private int retryPolling;
+
+ /**
+ * Returns the IP address or host name
+ *
+ * @return the IP address or host name
+ */
+ public String getIpAddress() {
+ return ipAddress;
+ }
+
+ /**
+ * Sets the IP address or host name
+ *
+ * @param ipAddress the IP Address or host name
+ */
+ public void setIpAddress(String ipAddress) {
+ this.ipAddress = ipAddress;
+ }
+
+ /**
+ * Gets the polling (in seconds) to reconnect
+ *
+ * @return the polling (in seconds) to reconnect
+ */
+ public int getRetryPolling() {
+ return retryPolling;
+ }
+
+ /**
+ * Sets the polling (in seconds) to reconnect
+ *
+ * @param retryPolling the polling (in seconds to reconnect)
+ */
+ public void setRetryPolling(int retryPolling) {
+ this.retryPolling = retryPolling;
+ }
+
+ /**
+ * Gets the ping interval (in seconds)
+ *
+ * @return the ping interval (in seconds)
+ */
+ public int getPing() {
+ return ping;
+ }
+
+ /**
+ * Sets the ping interval (in seconds)
+ *
+ * @param ping the ping interval (in seconds)
+ */
+ public void setPing(int ping) {
+ this.ping = ping;
+ }
+}
diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/system/RioSystemHandler.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/system/RioSystemHandler.java
new file mode 100644
index 0000000000000..5b8d8eabffa9f
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/system/RioSystemHandler.java
@@ -0,0 +1,344 @@
+/**
+ * Copyright (c) 2014-2016 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.rio.system;
+
+import java.io.IOException;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.smarthome.core.library.types.OnOffType;
+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.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.rio.AbstractBridgeHandler;
+import org.openhab.binding.russound.rio.RioConstants;
+import org.openhab.binding.russound.rio.RioHandlerCallback;
+import org.openhab.binding.russound.rio.StatefulHandlerCallback;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * 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.
+ *
+ * @author Tim Roberts
+ */
+public class RioSystemHandler extends AbstractBridgeHandler {
+ // Logger
+ private 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;
+
+ /**
+ * The {@link SocketSession} telnet session to the switch. Will be null if not connected.
+ */
+ private SocketSession _session;
+
+ /**
+ * The retry connection event - will only be created when we are retrying the connection attempt
+ */
+ private ScheduledFuture> _retryConnection;
+
+ /**
+ * The ping event - will be non-null when online (null otherwise)
+ */
+ private ScheduledFuture> _ping;
+
+ /**
+ * Constructs the handler from the {@link Bridge}
+ *
+ * @param bridge a non-null {@link Bridge} the handler is for
+ */
+ public RioSystemHandler(Bridge bridge) {
+ super(bridge);
+ }
+
+ /**
+ * Overrides the base method since we are the source of the {@link SocketSession}.
+ *
+ * @return the {@link SocketSession} once initialized. Null if not initialized or disposed of
+ */
+ @Override
+ public SocketSession getSocketSession() {
+ return _session;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Handles commands to specific channels. This implementation will offload much of its work to the
+ * {@link RioSystemProtocol}. Basically we validate the type of command for the channel then call the
+ * {@link RioSystemProtocol} 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 RioSystemProtocol} 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.warn("Called with a null channel id - ignoring");
+ return;
+ }
+
+ if (id.equals(RioConstants.CHANNEL_SYSLANG)) {
+ if (command instanceof StringType) {
+ getProtocolHandler().setSystemLanguage(((StringType) command).toString());
+ } else {
+ logger.error("Received a SYSTEM LANGUAGE channel command with a non StringType: {}", command);
+ }
+ } else if (id.equals(RioConstants.CHANNEL_SYSALLON)) {
+ if (command instanceof OnOffType) {
+ getProtocolHandler().setSystemAllOn(command == OnOffType.ON);
+ } else {
+ logger.error("Received a SYSTEM ALL ON channel command with a non OnOffType: {}", command);
+ }
+ } else {
+ logger.error("Unknown/Unsupported Channel id: {}", id);
+ }
+ }
+
+ /**
+ * Method that handles the {@link RefreshType} command specifically. Calls the {@link RioSystemProtocol} 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_SYSLANG)) {
+ getProtocolHandler().refreshSystemLanguage();
+
+ } else if (id.equals(RioConstants.CHANNEL_SYSSTATUS)) {
+ getProtocolHandler().refreshSystemStatus();
+
+ } else if (id.equals(RioConstants.CHANNEL_SYSVERSION)) {
+ getProtocolHandler().refreshVersion();
+ } else {
+ // Can't refresh any others...
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Initializes the handler. This initialization will read/validate the configuration, then will create the
+ * {@link SocketSession}, initialize the {@link RioSystemProtocol} and will attempt to connect to the (via
+ * {{@link #retryConnect()}.
+ */
+ @Override
+ public void initialize() {
+ final RioSystemConfig config = getRioConfig();
+
+ if (config == null) {
+ return;
+ }
+
+ if (config.getIpAddress() == null || config.getIpAddress().trim().length() == 0) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "IP Address of Russound is missing from configuration");
+ return;
+ }
+
+ _session = new SocketSession(config.getIpAddress(), 9621);
+ setProtocolHandler(new RioSystemProtocol(_session, new StatefulHandlerCallback(new RioHandlerCallback() {
+ @Override
+ public void statusChanged(ThingStatus status, ThingStatusDetail detail, String msg) {
+ updateStatus(status, detail, msg);
+ if (status != ThingStatus.ONLINE) {
+ disconnect(true);
+ }
+ }
+
+ @Override
+ public void stateChanged(String channelId, State state) {
+ updateState(channelId, state);
+ }
+
+ })));
+
+ // Try initial connection in a scheduled task
+ this.scheduler.schedule(new Runnable() {
+ @Override
+ public void run() {
+ connect();
+ }
+
+ }, 1, TimeUnit.SECONDS);
+ }
+
+ /**
+ * Attempts to connect to the system. If successfully connect, the {@link RioSystemProtocol#login()} will be
+ * called to log into the system (if needed). Once completed, a ping job will be created to keep the connection
+ * alive. If a connection cannot be established (or login failed), the connection attempt will be retried later (via
+ * {@link #retryConnect()})
+ */
+ private void connect() {
+ String response = "Server is offline - will try to reconnect later";
+ try {
+ _session.connect();
+
+ response = getProtocolHandler().login();
+ if (response == null) {
+ final RioSystemConfig config = getRioConfig();
+ if (config != null) {
+ _ping = this.scheduler.scheduleAtFixedRate(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ final ThingStatus status = getThing().getStatus();
+ if (status == ThingStatus.ONLINE) {
+ if (_session.isConnected()) {
+ getProtocolHandler().ping();
+ }
+ }
+ } catch (Exception e) {
+ logger.error("Exception while pinging: {}", e);
+ }
+ }
+ }, config.getPing(), config.getPing(), TimeUnit.SECONDS);
+
+ logger.info("Going online");
+ updateStatus(ThingStatus.ONLINE);
+ return;
+ } else {
+ logger.error("getRioConfig returned a null");
+ }
+ } else {
+ logger.error("Login return {}", response);
+ }
+
+ } catch (Exception e) {
+ logger.error("Error connecting: {}", e);
+ e.printStackTrace();
+ // do nothing
+ }
+
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, response);
+ retryConnect();
+ }
+
+ /**
+ * Attempts to disconnect from the session and will optionally retry the connection attempt. The {@link #_ping} will
+ * be cancelled and set to null. The {@link #_session} will be disconnected and a retry attempt will be created if
+ * not being disposed of (retryConnection==true).
+ *
+ * @param retryConnection true to retry connection attempts after the disconnect
+ */
+ private void disconnect(boolean retryConnection) {
+ // Cancel ping
+ if (_ping != null) {
+ _ping.cancel(true);
+ _ping = null;
+ }
+
+ if (getProtocolHandler() != null) {
+ getProtocolHandler().watchSystem(false);
+ if (!retryConnection) {
+ setProtocolHandler(null);
+ }
+ }
+ try {
+ _session.disconnect();
+ } catch (IOException e) {
+ // ignore - we don't care
+ }
+
+ if (retryConnection) {
+ retryConnect();
+ }
+ }
+
+ /**
+ * Retries the connection attempt - schedules a job in {@link RioSystemConfig#getRetryPolling()} seconds to
+ * call the {@link #connect()} method. If a retry attempt is pending, the request is ignored.
+ */
+ private void retryConnect() {
+ if (_retryConnection == null) {
+ final RioSystemConfig config = getRioConfig();
+ if (config != null) {
+
+ logger.info("Will try to reconnect in {} seconds", config.getRetryPolling());
+ _retryConnection = this.scheduler.schedule(new Runnable() {
+ @Override
+ public void run() {
+ _retryConnection = null;
+ try {
+ connect();
+ } catch (Exception e) {
+ logger.error("Exception connecting");
+ e.printStackTrace();
+ }
+ }
+
+ }, config.getRetryPolling(), TimeUnit.SECONDS);
+ }
+ } else {
+ logger.debug("RetryConnection called when a retry connection is pending - ignoring request");
+ }
+ }
+
+ /**
+ * Simple gets the {@link RioSystemConfig} from the {@link Thing} and will set the status to offline if not
+ * found.
+ *
+ * @return a possible null {@link RioSystemConfig}
+ */
+ private RioSystemConfig getRioConfig() {
+ if (_config == null) {
+ final RioSystemConfig config = getThing().getConfiguration().as(RioSystemConfig.class);
+
+ if (config == null) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Configuration file missing");
+ } else {
+ _config = config;
+ }
+ }
+
+ return _config;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Disposes of the handler. Will simply call {@link #disconnect(boolean)} to disconnect and NOT retry the
+ * connection
+ */
+ @Override
+ public void dispose() {
+ disconnect(false);
+ }
+}
diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/system/RioSystemProtocol.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/system/RioSystemProtocol.java
new file mode 100644
index 0000000000000..14054971abf6f
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/system/RioSystemProtocol.java
@@ -0,0 +1,266 @@
+/**
+ * Copyright (c) 2014-2016 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.rio.system;
+
+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.eclipse.smarthome.core.thing.ThingStatus;
+import org.eclipse.smarthome.core.thing.ThingStatusDetail;
+import org.openhab.binding.russound.internal.net.SocketSession;
+import org.openhab.binding.russound.internal.net.SocketSessionListener;
+import org.openhab.binding.russound.rio.AbstractRioProtocol;
+import org.openhab.binding.russound.rio.RioConstants;
+import org.openhab.binding.russound.rio.RioHandlerCallback;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This is the protocol handler for the Russound System. This handler will issue the protocol commands and will
+ * process the responses from the Russound system.
+ *
+ * @author Tim Roberts
+ *
+ */
+class RioSystemProtocol extends AbstractRioProtocol {
+ // Logger
+ private Logger logger = LoggerFactory.getLogger(RioSystemProtocol.class);
+
+ // Protocol Constants
+ private final static String SYS_VERSION = "VERSION"; // 12 max
+ private final static String SYS_STATUS = "status"; // 12 max
+ private final static 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+)=\"(.*)\"$");
+
+ /**
+ * This represents our ping command. There is no ping command in the protocol so we simply send an empty command to
+ * keep things alive (and not generate any errors)
+ */
+ private final static String CMD_PING = "";
+
+ /**
+ * Constructs the protocol handler from given parameters
+ *
+ * @param session a non-null {@link SocketSession} (may be connected or disconnected)
+ * @param callback a non-null {@link RioHandlerCallback} to callback
+ */
+ RioSystemProtocol(SocketSession session, RioHandlerCallback callback) {
+ super(session, callback);
+
+ }
+
+ /**
+ * Attempts to log into the system. The russound system requires no login, so we immediately execute any
+ * {@link #postLogin()} commands.
+ *
+ * @return always null to indicate a successful login
+ */
+ String login() {
+ postLogin();
+ return null;
+ }
+
+ /**
+ * Post successful login stuff - mark us online, start watching the system and refresh some attributes
+ */
+ private void postLogin() {
+ logger.info("Russound System now connected");
+ statusChanged(ThingStatus.ONLINE, ThingStatusDetail.NONE, null);
+ watchSystem(true);
+
+ refreshSystemStatus();
+ refreshVersion();
+ refreshSystemLanguage();
+ }
+
+ /**
+ * Pings the server with out ping command to keep the connection alive
+ */
+ void ping() {
+ sendCommand(CMD_PING);
+ }
+
+ /**
+ * Refreshes the firmware version of the system
+ */
+ void refreshVersion() {
+ sendCommand(SYS_VERSION);
+ }
+
+ /**
+ * Helper method to refresh a system keyname
+ *
+ * @param keyName a non-null, non-empty keyname
+ * @throws IllegalArgumentException if keyname is null or empty
+ */
+ private void refreshSystemKey(String keyName) {
+ if (keyName == null || keyName.trim().length() == 0) {
+ throw new IllegalArgumentException("keyName cannot be null or empty");
+ }
+
+ sendCommand("GET System." + keyName);
+ }
+
+ /**
+ * Refresh the system status
+ */
+ void refreshSystemStatus() {
+ refreshSystemKey(SYS_STATUS);
+ }
+
+ /**
+ * Refresh the system language
+ */
+ void refreshSystemLanguage() {
+ refreshSystemKey(SYS_LANG);
+ }
+
+ /**
+ * Turns on/off watching for system notifications
+ *
+ * @param on true to turn on, false to turn off
+ */
+ void watchSystem(boolean on) {
+ sendCommand("WATCH SYSTEM " + (on ? "ON" : "OFF"));
+ }
+
+ /**
+ * Sets all zones on
+ *
+ * @param on true to turn all zones on, false otherwise
+ */
+ void setSystemAllOn(boolean on) {
+ sendCommand("EVENT C[1].Z[1]!All" + (on ? "On" : "Off"));
+ }
+
+ /**
+ * Sets the system language (currently can only be english, chinese or russian). Case does not matter - will be
+ * converted to uppercase for the system.
+ *
+ * @param language a non-null, non-empty language to set
+ * @throws IllegalArgumentException if language is null, empty or not (english, chinese or russian).
+ */
+ void setSystemLanguage(String language) {
+ if (language == null || language.trim().length() == 0) {
+ throw new IllegalArgumentException("Language cannot be null or an empty string");
+ }
+
+ if ("|ENGLISH|CHINESE|RUSSIAN|".indexOf("|" + language + "|") == -1) {
+ throw new IllegalArgumentException("Language can only be ENGLISH, CHINESE or RUSSIAN: " + language);
+ }
+ sendCommand("SET System." + SYS_LANG + " " + language.toUpperCase());
+ }
+
+ /**
+ * Handles the version notification
+ *
+ * @param m a non-null matcher
+ * @param resp a possibly null, possibly empty response
+ */
+ void handleVersionNotification(Matcher m, String resp) {
+ if (m == null) {
+ throw new IllegalArgumentException("m (matcher) cannot be null");
+ }
+ if (m.groupCount() == 1) {
+ final String version = m.group(1);
+ stateChanged(RioConstants.CHANNEL_SYSVERSION, new StringType(version));
+ } else {
+ logger.error("Invalid System Notification response: '{}'", 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 handleSystemNotification(Matcher m, String resp) {
+ if (m == null) {
+ throw new IllegalArgumentException("m (matcher) cannot be null");
+ }
+ if (m.groupCount() == 2) {
+ final String key = m.group(1);
+ final String value = m.group(2);
+
+ switch (key) {
+ case SYS_LANG:
+ stateChanged(RioConstants.CHANNEL_SYSLANG, new StringType(value));
+ break;
+ case SYS_STATUS:
+ stateChanged(RioConstants.CHANNEL_SYSSTATUS, "ON".equals(value) ? OnOffType.ON : OnOffType.OFF);
+ break;
+
+ default:
+ logger.warn("Unknown system notification: '{}'", resp);
+ break;
+ }
+ } else {
+ logger.error("Invalid System Notification response: '{}'", resp);
+ }
+
+ }
+
+ /**
+ * Handles any error notifications returned by the russound system
+ *
+ * @param m a non-null matcher
+ * @param resp a possibly null, possibly empty response
+ */
+ private void handleFailureNotification(Matcher m, String resp) {
+ logger.info("Error: " + 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_VERSION.matcher(response);
+ if (m.matches()) {
+ handleVersionNotification(m, response);
+ return;
+ }
+
+ m = RSP_SYSTEMNOTIFICATION.matcher(response);
+ if (m.matches()) {
+ handleSystemNotification(m, response);
+ return;
+ }
+
+ m = RSP_FAILURE.matcher(response);
+ if (m.matches()) {
+ handleFailureNotification(m, response);
+ return;
+ }
+ }
+
+ /**
+ * Overrides the default implementation to turn watch off ({@link #watchSystem(boolean)}) before calling the dispose
+ */
+ @Override
+ public void dispose() {
+ watchSystem(false);
+ super.dispose();
+ }
+}
diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/zone/RioZoneConfig.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/zone/RioZoneConfig.java
new file mode 100644
index 0000000000000..15c72e53fd2e6
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/zone/RioZoneConfig.java
@@ -0,0 +1,39 @@
+/**
+ * Copyright (c) 2014-2016 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.rio.zone;
+
+/**
+ * Configuration class for the {@link RioZoneHandler}
+ *
+ * @author Tim Roberts
+ */
+public class RioZoneConfig {
+ /**
+ * ID of the zone
+ */
+ private int zone;
+
+ /**
+ * Gets the zone identifier
+ *
+ * @return the zone identifier
+ */
+ public int getZone() {
+ return zone;
+ }
+
+ /**
+ * Sets the zone identifier
+ *
+ * @param zoneId the zone identifier
+ */
+ public void setZone(int zoneId) {
+ this.zone = zoneId;
+ }
+}
diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/zone/RioZoneHandler.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/zone/RioZoneHandler.java
new file mode 100644
index 0000000000000..a0c90267ea71d
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/zone/RioZoneHandler.java
@@ -0,0 +1,384 @@
+/**
+ * Copyright (c) 2014-2016 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.rio.zone;
+
+import org.eclipse.smarthome.core.library.types.DecimalType;
+import org.eclipse.smarthome.core.library.types.IncreaseDecreaseType;
+import org.eclipse.smarthome.core.library.types.OnOffType;
+import org.eclipse.smarthome.core.library.types.PercentType;
+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.rio.AbstractBridgeHandler;
+import org.openhab.binding.russound.rio.RioConstants;
+import org.openhab.binding.russound.rio.RioHandlerCallback;
+import org.openhab.binding.russound.rio.StatefulHandlerCallback;
+import org.openhab.binding.russound.rio.controller.RioControllerHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The bridge handler for a Russound Zone. A zone is the main receiving area for music. This implementation must be
+ * attached to a {@link RioControllerHandler} bridge.
+ *
+ * @author Tim Roberts
+ */
+public class RioZoneHandler extends AbstractBridgeHandler {
+ // Logger
+ private Logger logger = LoggerFactory.getLogger(RioZoneHandler.class);
+
+ /**
+ * The controller identifier we are attached to
+ */
+ private int _controller;
+
+ /**
+ * The zone identifier for this instance
+ */
+ private int _zone;
+
+ /**
+ * Constructs the handler from the {@link Bridge}
+ *
+ * @param bridge a non-null {@link Bridge} the handler is for
+ */
+ public RioZoneHandler(Bridge bridge) {
+ super(bridge);
+ }
+
+ /**
+ * Returns the controller identifier
+ *
+ * @return the controller identifier
+ */
+ public int getController() {
+ return _controller;
+ }
+
+ /**
+ * Returns the zone identifier
+ *
+ * @return the zone identifier
+ */
+ public int getZone() {
+ return _zone;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Handles commands to specific channels. This implementation will offload much of its work to the
+ * {@link RioZoneProtocol}. Basically we validate the type of command for the channel then call the
+ * {@link RioZoneProtocol} 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 RioZoneProtocol} to handle the actual refresh
+ */
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+
+ if (command instanceof RefreshType) {
+ handleRefresh(channelUID.getId());
+ return;
+ }
+
+ // if (getThing().getStatus() != ThingStatus.ONLINE) {
+ // // Ignore any command if not online
+ // return;
+ // }
+
+ String id = channelUID.getId();
+
+ if (id == null) {
+ logger.warn("Called with a null channel id - ignoring");
+ return;
+ }
+
+ if (id.equals(RioConstants.CHANNEL_ZONEBASS)) {
+ if (command instanceof DecimalType) {
+ getProtocolHandler().setZoneBass(((DecimalType) command).intValue());
+ } else {
+ logger.error("Received a ZONE BASS channel command with a non DecimalType: {}", command);
+ }
+
+ } else if (id.equals(RioConstants.CHANNEL_ZONETREBLE)) {
+ if (command instanceof DecimalType) {
+ getProtocolHandler().setZoneTreble(((DecimalType) command).intValue());
+ } else {
+ logger.error("Received a ZONE TREBLE channel command with a non DecimalType: {}", command);
+ }
+
+ } else if (id.equals(RioConstants.CHANNEL_ZONEBALANCE)) {
+ if (command instanceof DecimalType) {
+ getProtocolHandler().setZoneBalance(((DecimalType) command).intValue());
+ } else {
+ logger.error("Received a ZONE BALANCE channel command with a non DecimalType: {}", command);
+ }
+
+ } else if (id.equals(RioConstants.CHANNEL_ZONETURNONVOLUME)) {
+ if (command instanceof DecimalType) {
+ getProtocolHandler().setZoneTurnOnVolume(((DecimalType) command).intValue());
+ } else {
+ logger.error("Received a ZONE TURN ON VOLUME channel command with a non DecimalType: {}", command);
+ }
+
+ } else if (id.equals(RioConstants.CHANNEL_ZONELOUDNESS)) {
+ if (command instanceof OnOffType) {
+ getProtocolHandler().setZoneLoudness(command == OnOffType.ON);
+ } else {
+ logger.error("Received a ZONE TURN ON VOLUME channel command with a non OnOffType: {}", command);
+ }
+
+ } else if (id.equals(RioConstants.CHANNEL_ZONESLEEPTIMEREMAINING)) {
+ if (command instanceof DecimalType) {
+ getProtocolHandler().setZoneSleepTimeRemaining(((DecimalType) command).intValue());
+ } else {
+ logger.error("Received a ZONE SLEEP TIME REMAINING channel command with a non DecimalType: {}",
+ command);
+ }
+ } else if (id.equals(RioConstants.CHANNEL_ZONESOURCE)) {
+ if (command instanceof DecimalType) {
+ getProtocolHandler().setZoneSource(((DecimalType) command).intValue());
+ } else {
+ logger.error("Received a ZONE SOURCE channel command with a non DecimalType: {}", command);
+ }
+
+ } else if (id.equals(RioConstants.CHANNEL_ZONESTATUS)) {
+ if (command instanceof OnOffType) {
+ getProtocolHandler().setZoneStatus(command == OnOffType.ON);
+ } else {
+ logger.error("Received a ZONE STATUS channel command with a non OnOffType: {}", command);
+ }
+ } else if (id.equals(RioConstants.CHANNEL_ZONEPARTYMODE)) {
+ if (command instanceof StringType) {
+ getProtocolHandler().setZonePartyMode(((StringType) command).toString());
+ } else {
+ logger.error("Received a ZONE PARTY MODE channel command with a non StringType: {}", command);
+ }
+
+ } else if (id.equals(RioConstants.CHANNEL_ZONEDONOTDISTURB)) {
+ if (command instanceof StringType) {
+ getProtocolHandler().setZoneDoNotDisturb(((StringType) command).toString());
+ } else {
+ logger.error("Received a ZONE DO NOT DISTURB channel command with a non StringType: {}", command);
+ }
+
+ } else if (id.equals(RioConstants.CHANNEL_ZONEMUTE)) {
+ if (command instanceof OnOffType) {
+ getProtocolHandler().toggleZoneMute();
+ } else {
+ logger.error("Received a ZONE MUTE channel command with a non OnOffType: {}", command);
+ }
+
+ } else if (id.equals(RioConstants.CHANNEL_ZONEREPEAT)) {
+ if (command instanceof OnOffType) {
+ getProtocolHandler().toggleZoneRepeat();
+ } else {
+ logger.error("Received a ZONE REPEAT channel command with a non OnOffType: {}", command);
+ }
+
+ } else if (id.equals(RioConstants.CHANNEL_ZONESHUFFLE)) {
+ if (command instanceof OnOffType) {
+ getProtocolHandler().toggleZoneShuffle();
+ } else {
+ logger.error("Received a ZONE SHUFFLE channel command with a non OnOffType: {}", command);
+ }
+
+ } else if (id.equals(RioConstants.CHANNEL_ZONEVOLUME)) {
+ if (command instanceof OnOffType) {
+ getProtocolHandler().setZoneStatus(command == OnOffType.ON);
+ } 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
+ } else {
+ logger.error(
+ "Received a ZONE VOLUME channel command with a non OnOffType/IncreaseDecreaseType/PercentType: {}",
+ command);
+ }
+
+ } else if (id.equals(RioConstants.CHANNEL_ZONERATING)) {
+ if (command instanceof OnOffType) {
+ getProtocolHandler().setZoneRating(command == OnOffType.ON);
+ } else {
+ logger.error("Received a ZONE RATING channel command with a non OnOffType: {}", command);
+ }
+
+ } else if (id.equals(RioConstants.CHANNEL_ZONEKEYPRESS)) {
+ if (command instanceof StringType) {
+ getProtocolHandler().sendKeyPress(((StringType) command).toString());
+ } else {
+ logger.error("Received a ZONE KEYPRESS channel command with a non StringType: {}", command);
+ }
+
+ } else if (id.equals(RioConstants.CHANNEL_ZONEKEYRELEASE)) {
+ if (command instanceof StringType) {
+ getProtocolHandler().sendKeyRelease(((StringType) command).toString());
+ } else {
+ logger.error("Received a ZONE KEYRELEASE channel command with a non StringType: {}", command);
+ }
+
+ } else if (id.equals(RioConstants.CHANNEL_ZONEKEYHOLD)) {
+ if (command instanceof StringType) {
+ getProtocolHandler().sendKeyHold(((StringType) command).toString());
+ } else {
+ logger.error("Received a ZONE KEYHOLD channel command with a non StringType: {}", command);
+ }
+
+ } else if (id.equals(RioConstants.CHANNEL_ZONEKEYCODE)) {
+ if (command instanceof StringType) {
+ getProtocolHandler().sendKeyCode(((StringType) command).toString());
+ } else {
+ logger.error("Received a ZONE KEYCODE channel command with a non StringType: {}", command);
+ }
+
+ } else if (id.equals(RioConstants.CHANNEL_ZONEEVENT)) {
+ if (command instanceof StringType) {
+ getProtocolHandler().sendEvent(((StringType) command).toString());
+ } else {
+ logger.error("Received a ZONE EVENT channel command with a non StringType: {}", command);
+ }
+
+ } else {
+ logger.error("Unknown/Unsupported Channel id: {}", id);
+ }
+ }
+
+ /**
+ * Method that handles the {@link RefreshType} command specifically. Calls the {@link RioZoneProtocol} 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_ZONENAME)) {
+ getProtocolHandler().refreshZoneName();
+ } else if (id.startsWith(RioConstants.CHANNEL_ZONESOURCE)) {
+ getProtocolHandler().refreshZoneSource();
+ } else if (id.startsWith(RioConstants.CHANNEL_ZONEBASS)) {
+ getProtocolHandler().refreshZoneBass();
+ } else if (id.startsWith(RioConstants.CHANNEL_ZONETREBLE)) {
+ getProtocolHandler().refreshZoneTreble();
+ } else if (id.startsWith(RioConstants.CHANNEL_ZONEBALANCE)) {
+ getProtocolHandler().refreshZoneBalance();
+ } else if (id.startsWith(RioConstants.CHANNEL_ZONELOUDNESS)) {
+ getProtocolHandler().refreshZoneLoudness();
+ } else if (id.startsWith(RioConstants.CHANNEL_ZONETURNONVOLUME)) {
+ getProtocolHandler().refreshZoneTurnOnVolume();
+ } else if (id.startsWith(RioConstants.CHANNEL_ZONEDONOTDISTURB)) {
+ getProtocolHandler().refreshZoneDoNotDisturb();
+ } else if (id.startsWith(RioConstants.CHANNEL_ZONEPARTYMODE)) {
+ getProtocolHandler().refreshZonePartyMode();
+ } else if (id.startsWith(RioConstants.CHANNEL_ZONESTATUS)) {
+ getProtocolHandler().refreshZoneStatus();
+ } else if (id.startsWith(RioConstants.CHANNEL_ZONEVOLUME)) {
+ getProtocolHandler().refreshZoneVolume();
+ } else if (id.startsWith(RioConstants.CHANNEL_ZONEMUTE)) {
+ getProtocolHandler().refreshZoneMute();
+ } else if (id.startsWith(RioConstants.CHANNEL_ZONEPAGE)) {
+ getProtocolHandler().refreshZonePage();
+ } else if (id.startsWith(RioConstants.CHANNEL_ZONESHAREDSOURCE)) {
+ getProtocolHandler().refreshZoneSharedSource();
+ } else if (id.startsWith(RioConstants.CHANNEL_ZONESLEEPTIMEREMAINING)) {
+ getProtocolHandler().refreshZoneSleepTimeRemaining();
+ } else if (id.startsWith(RioConstants.CHANNEL_ZONELASTERROR)) {
+ getProtocolHandler().refreshZoneLastError();
+ } else {
+ // Can't refresh any others...
+ }
+ }
+
+ /**
+ * Initializes the bridge. Confirms the configuration is valid and that our parent bridge is a
+ * {@link RioControllerHandler}. Once validated, a {@link RioZoneProtocol} is set via
+ * {@link #setProtocolHandler(RioZoneProtocol)} 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 RioControllerHandler)) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "Source must be attached to a controller bridge: " + handler.getClass());
+ return;
+ }
+
+ final RioZoneConfig config = getThing().getConfiguration().as(RioZoneConfig.class);
+ if (config == null) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Configuration file missing");
+ return;
+ }
+
+ _zone = config.getZone();
+ if (_zone < 1 || _zone > 6) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "Source must be between 1 and 8: " + _zone);
+ return;
+ }
+
+ _controller = ((RioControllerHandler) handler).getController();
+
+ // 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;
+ }
+
+ setProtocolHandler(new RioZoneProtocol(_zone, _controller, 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);
+ }
+ })));
+ updateStatus(ThingStatus.ONLINE);
+ getProtocolHandler().watchZone(true);
+ getProtocolHandler().refreshZoneEnabled();
+ }
+
+}
diff --git a/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/zone/RioZoneProtocol.java b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/zone/RioZoneProtocol.java
new file mode 100644
index 0000000000000..46773c7519102
--- /dev/null
+++ b/addons/binding/org.openhab.binding.russound/src/main/java/org/openhab/binding/russound/rio/zone/RioZoneProtocol.java
@@ -0,0 +1,657 @@
+/**
+ * Copyright (c) 2014-2016 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.rio.zone;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.eclipse.smarthome.core.library.types.DecimalType;
+import org.eclipse.smarthome.core.library.types.OnOffType;
+import org.eclipse.smarthome.core.library.types.PercentType;
+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.rio.AbstractRioProtocol;
+import org.openhab.binding.russound.rio.RioConstants;
+import org.openhab.binding.russound.rio.RioHandlerCallback;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * 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.
+ *
+ * @author Tim Roberts
+ *
+ */
+class RioZoneProtocol extends AbstractRioProtocol {
+ // logger
+ private Logger logger = LoggerFactory.getLogger(RioZoneProtocol.class);
+
+ /**
+ * The controller identifier
+ */
+ private int _controller;
+
+ /**
+ * The zone identifier
+ */
+ private int _zone;
+
+ // Zone constants
+ private final static String ZONE_NAME = "name"; // 12 max
+ private final static String ZONE_SOURCE = "currentSource"; // 1-8 or 1-12
+ private final static String ZONE_BASS = "bass"; // -10 to 10
+ private final static String ZONE_TREBLE = "treble"; // -10 to 10
+ private final static String ZONE_BALANCE = "balance"; // -10 to 10
+ private final static String ZONE_LOUDNESS = "loudness"; // OFF/ON
+ private final static String ZONE_TURNONVOLUME = "turnOnVolume"; // 0 to 50
+ private final static String ZONE_DONOTDISTURB = "doNotDisturb"; // OFF/ON/SLAVE
+ private final static String ZONE_PARTYMODE = "partyMode"; // OFF/ON/MASTER
+ private final static String ZONE_STATUS = "status"; // OFF/ON/MASTER
+ private final static String ZONE_VOLUME = "volume"; // 0 to 50
+ private final static String ZONE_MUTE = "mute"; // OFF/ON/MASTER
+ private final static String ZONE_PAGE = "page"; // OFF/ON/MASTER
+ private final static String ZONE_SHAREDSOURCE = "sharedSource"; // OFF/ON/MASTER
+ private final static String ZONE_SLEEPTIMEREMAINING = "sleepTimeRemaining"; // OFF/ON/MASTER
+ private final static String ZONE_LASTERROR = "lastError"; // OFF/ON/MASTER
+ private final static String ZONE_ENABLED = "enabled"; // OFF/ON
+
+ // Respone patterns
+ private final Pattern RSP_ZONENOTIFICATION = Pattern
+ .compile("^[SN] C\\[(\\d+)\\]\\.Z\\[(\\d+)\\]\\.(\\w+)=\"(.*)\"$");
+
+ /**
+ * Constructs the protocol handler from given parameters
+ *
+ * @param zone the zone identifier
+ * @param controller the controller identifier
+ * @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) {
+ super(session, callback);
+
+ if (controller < 1 || controller > 6) {
+ throw new IllegalArgumentException("Controller must be between 1-6: " + controller);
+ }
+ if (zone < 1 || zone > 8) {
+ throw new IllegalArgumentException("Zone must be between 1-6: " + zone);
+ }
+
+ _controller = controller;
+ _zone = zone;
+ }
+
+ /**
+ * Helper method to refresh a system keyname
+ *
+ * @param keyname a non-null, non-empty keyname
+ * @throws IllegalArgumentException if keyname is null or empty
+ */
+ private void refreshZoneKey(String keyname) {
+ if (keyname == null || keyname.trim().length() == 0) {
+ throw new IllegalArgumentException("keyName cannot be null or empty");
+ }
+
+ sendCommand("GET C[" + _controller + "].Z[" + _zone + "]." + keyname);
+ }
+
+ /**
+ * Refresh a zone name
+ */
+ void refreshZoneName() {
+ refreshZoneKey(ZONE_NAME);
+ }
+
+ /**
+ * Refresh the zone's source
+ */
+ void refreshZoneSource() {
+ refreshZoneKey(ZONE_SOURCE);
+ }
+
+ /**
+ * Refresh the zone's bass setting
+ */
+ void refreshZoneBass() {
+ refreshZoneKey(ZONE_BASS);
+ }
+
+ /**
+ * Refresh the zone's treble setting
+ */
+ void refreshZoneTreble() {
+ refreshZoneKey(ZONE_TREBLE);
+ }
+
+ /**
+ * Refresh the zone's balance setting
+ */
+ void refreshZoneBalance() {
+ refreshZoneKey(ZONE_BALANCE);
+ }
+
+ /**
+ * Refresh the zone's loudness setting
+ */
+ void refreshZoneLoudness() {
+ refreshZoneKey(ZONE_LOUDNESS);
+ }
+
+ /**
+ * Refresh the zone's turn on volume setting
+ */
+ void refreshZoneTurnOnVolume() {
+ refreshZoneKey(ZONE_TURNONVOLUME);
+ }
+
+ /**
+ * Refresh the zone's do not disturb setting
+ */
+ void refreshZoneDoNotDisturb() {
+ refreshZoneKey(ZONE_DONOTDISTURB);
+ }
+
+ /**
+ * Refresh the zone's party mode setting
+ */
+ void refreshZonePartyMode() {
+ refreshZoneKey(ZONE_PARTYMODE);
+ }
+
+ /**
+ * Refresh the zone's status
+ */
+ void refreshZoneStatus() {
+ refreshZoneKey(ZONE_STATUS);
+ }
+
+ /**
+ * Refresh the zone's volume setting
+ */
+ void refreshZoneVolume() {
+ refreshZoneKey(ZONE_VOLUME);
+ }
+
+ /**
+ * Refresh the zone's mute setting
+ */
+ void refreshZoneMute() {
+ refreshZoneKey(ZONE_MUTE);
+ }
+
+ /**
+ * Refresh the zone's paging setting
+ */
+ void refreshZonePage() {
+ refreshZoneKey(ZONE_PAGE);
+ }
+
+ /**
+ * Refresh the zone's shared source setting
+ */
+ void refreshZoneSharedSource() {
+ refreshZoneKey(ZONE_SHAREDSOURCE);
+ }
+
+ /**
+ * Refresh the zone's sleep time remaining setting
+ */
+ void refreshZoneSleepTimeRemaining() {
+ refreshZoneKey(ZONE_SLEEPTIMEREMAINING);
+ }
+
+ /**
+ * Refresh the zone's last error
+ */
+ void refreshZoneLastError() {
+ refreshZoneKey(ZONE_LASTERROR);
+ }
+
+ /**
+ * Refresh the zone's enabled setting
+ */
+ void refreshZoneEnabled() {
+ refreshZoneKey(ZONE_ENABLED);
+ }
+
+ /**
+ * 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"));
+ }
+
+ /**
+ * Set's the zone bass setting (from -10 to 10)
+ *
+ * @param bass the bass setting from -10 to 10
+ * @throws IllegalArgumentException if bass < -10 or > 10
+ */
+ 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 + "\"");
+ }
+
+ /**
+ * Set's the zone treble setting (from -10 to 10)
+ *
+ * @param treble the treble setting from -10 to 10
+ * @throws IllegalArgumentException if treble < -10 or > 10
+ */
+ 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 + "\"");
+ }
+
+ /**
+ * Set's the zone balance setting (from -10 [full left] to 10 [full right])
+ *
+ * @param balance the balance setting from -10 to 10
+ * @throws IllegalArgumentException if balance < -10 or > 10
+ */
+ 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 + "\"");
+ }
+
+ /**
+ * Set's the zone's loudness
+ *
+ * @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") + "\"");
+ }
+
+ /**
+ * Set's the zone turn on volume (from 0 to 50)
+ *
+ * @param volume the turn on volume (from 0 to 50)
+ * @throws IllegalArgumentException if volume < 0 or > 50
+ */
+ void setZoneTurnOnVolume(int volume) {
+ if (volume < 0 || volume > 50) {
+ throw new IllegalArgumentException("Volume must be between 0 and 50: " + volume);
+ }
+ sendCommand("SET C[" + _controller + "].Z[" + _zone + "]." + ZONE_TURNONVOLUME + "=\"" + volume + "\"");
+ }
+
+ /**
+ * Set's the zone sleep time remaining in seconds (from 0 to 60). Will be rounded to nearest 5 (37 will become 35,
+ * 38 will become 40).
+ *
+ * @param sleepTime the sleeptime in seconds
+ * @throws IllegalArgumentException if sleepTime < 0 or > 60
+ */
+ void setZoneSleepTimeRemaining(int sleepTime) {
+ if (sleepTime < 0 || sleepTime > 60) {
+ 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 + "\"");
+ }
+
+ /**
+ * Set's the zone source (physical source from 1 to 12)
+ *
+ * @param source the source (1 to 12)
+ * @throws IllegalArgumentException if source is < 1 or > 12
+ */
+ 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);
+ }
+
+ /**
+ * Set's the zone's status
+ *
+ * @param on true to turn on, false otherwise
+ */
+ void setZoneStatus(boolean on) {
+ sendCommand("EVENT C[" + _controller + "].Z[" + _zone + "]!Zone" + (on ? "On" : "Off"));
+ }
+
+ /**
+ * Set's the zone's partymode (supports on/off/master). Case does not matter - will be
+ * converted to uppercase for the system.
+ *
+ * @param partyMode a non-null, non-empty party mode
+ * @throws IllegalArgumentException if partymode is null, empty or not (on/off/master).
+ */
+ void setZonePartyMode(String partyMode) {
+ if (partyMode == null || partyMode.trim().length() == 0) {
+ throw new IllegalArgumentException("PartyMode cannot be null or empty");
+ }
+ if ("|on|off|master|".indexOf("|" + partyMode + "|") == -1) {
+ throw new IllegalArgumentException(
+ "Party mode can only be set to on, off or master: " + partyMode.toUpperCase());
+ }
+ sendCommand("EVENT C[" + _controller + "].Z[" + _zone + "]!PartyMode " + partyMode);
+ }
+
+ /**
+ * Set's the zone's do not disturb (supports on/off/slave). Case does not matter - will be
+ * converted to uppercase for the system. Please note that slave will be translated to "ON" but may be refreshed
+ * back to "SLAVE" if a master zone has been designated
+ *
+ * @param doNotDisturb a non-null, non-empty do not disturb mode
+ * @throws IllegalArgumentException if doNotDisturb is null, empty or not (on/off/slave).
+ */
+ void setZoneDoNotDisturb(String doNotDisturb) {
+ if (doNotDisturb == null || doNotDisturb.trim().length() == 0) {
+ throw new IllegalArgumentException("Do Not Disturb cannot be null or empty");
+ }
+ 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 "
+ + ("off".equals(doNotDisturb) ? "OFF" : "ON")); // translate "slave" to "on"
+ }
+
+ /**
+ * Sets the zone's volume level (0-50)
+ *
+ * @param volume the volume level (0-50)
+ * @throws IllegalArgumentException if volume is < 0 or > 50
+ */
+ void setZoneVolume(int volume) {
+ if (volume < 0 || volume > 50) {
+ throw new IllegalArgumentException("Volume must be between 0 and 50");
+ }
+ sendKeyPress("Volume " + volume);
+ }
+
+ /**
+ * Sets the volume up or down by 1
+ *
+ * @param increase true to increase by 1, false to decrease
+ */
+ void setZoneVolume(boolean increase) {
+ sendKeyPress("Volume" + (increase ? "Up" : "Down"));
+ }
+
+ /**
+ * Toggles the zone's mute
+ */
+ void toggleZoneMute() {
+ sendKeyRelease("Mute");
+ }
+
+ /**
+ * Toggles the zone's shuffle if the source supports shuffle mode
+ */
+ void toggleZoneShuffle() {
+ 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");
+ }
+
+ /**
+ * Assign a rating to the current song if the source supports a rating
+ *
+ * @param like true to like, false to dislike
+ */
+ void setZoneRating(boolean like) {
+ sendCommand("EVENT C[" + _controller + "].Z[" + _zone + "]!MMRate " + (like ? "hi" : "low"));
+ }
+
+ /**
+ * Sends a KeyPress instruction to the zone
+ *
+ * @param keyPress a non-null, non-empty string to send
+ * @throws IllegalArgumentException if keyPress is null or empty
+ */
+ 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);
+ }
+
+ /**
+ * Sends a KeyRelease instruction to the zone
+ *
+ * @param keyRelease a non-null, non-empty string to send
+ * @throws IllegalArgumentException if keyRelease is null or empty
+ */
+ 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);
+ }
+
+ /**
+ * Sends a KeyHold instruction to the zone
+ *
+ * @param keyHold a non-null, non-empty string to send
+ * @throws IllegalArgumentException if keyHold is null or empty
+ */
+ 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);
+ }
+
+ /**
+ * Sends a KeyCode instruction to the zone
+ *
+ * @param keyCode a non-null, non-empty string to send
+ * @throws IllegalArgumentException if keyCode is null or empty
+ */
+ 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);
+ }
+
+ /**
+ * Sends a EVENT instruction to the zone
+ *
+ * @param event a non-null, non-empty string to send
+ * @throws IllegalArgumentException if event is null or empty
+ */
+ 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);
+ }
+
+ /**
+ * Handles any zone 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() == 4) {
+ 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 String key = m.group(3);
+ final String value = m.group(4);
+
+ switch (key) {
+ case ZONE_NAME:
+ stateChanged(RioConstants.CHANNEL_ZONENAME, new StringType(value));
+ break;
+
+ case ZONE_SOURCE:
+ try {
+ final int nbr = Integer.parseInt(value);
+ stateChanged(RioConstants.CHANNEL_ZONESOURCE, new DecimalType(nbr));
+ } catch (NumberFormatException e) {
+ logger.warn("Invalid zone notification (source not parsable): '{}')", resp);
+ }
+ break;
+
+ case ZONE_BASS:
+ try {
+ final int nbr = Integer.parseInt(value);
+ stateChanged(RioConstants.CHANNEL_ZONEBASS, new DecimalType(nbr));
+ } catch (NumberFormatException e) {
+ logger.warn("Invalid zone notification (bass not parsable): '{}')", resp);
+ }
+ break;
+
+ case ZONE_TREBLE:
+ try {
+ final int nbr = Integer.parseInt(value);
+ stateChanged(RioConstants.CHANNEL_ZONETREBLE, new DecimalType(nbr));
+ } catch (NumberFormatException e) {
+ logger.warn("Invalid zone notification (treble not parsable): '{}')", resp);
+ }
+ break;
+
+ case ZONE_BALANCE:
+ try {
+ final int nbr = Integer.parseInt(value);
+ stateChanged(RioConstants.CHANNEL_ZONEBALANCE, new DecimalType(nbr));
+ } catch (NumberFormatException e) {
+ logger.warn("Invalid zone notification (balance not parsable): '{}')", resp);
+ }
+ break;
+
+ case ZONE_LOUDNESS:
+ stateChanged(RioConstants.CHANNEL_ZONELOUDNESS,
+ "ON".equals(value) ? OnOffType.ON : OnOffType.OFF);
+ break;
+
+ case ZONE_TURNONVOLUME:
+ try {
+ final int nbr = Integer.parseInt(value);
+ stateChanged(RioConstants.CHANNEL_ZONETURNONVOLUME, new DecimalType(nbr));
+ } catch (NumberFormatException e) {
+ logger.warn("Invalid zone notification (turnonvolume not parsable): '{}')", resp);
+ }
+ break;
+
+ case ZONE_DONOTDISTURB:
+ stateChanged(RioConstants.CHANNEL_ZONEDONOTDISTURB, new StringType(value));
+ break;
+
+ case ZONE_PARTYMODE:
+ stateChanged(RioConstants.CHANNEL_ZONEPARTYMODE, new StringType(value));
+ break;
+
+ case ZONE_STATUS:
+ stateChanged(RioConstants.CHANNEL_ZONESTATUS,
+ "ON".equals(value) ? OnOffType.ON : OnOffType.OFF);
+ break;
+ case ZONE_MUTE:
+ stateChanged(RioConstants.CHANNEL_ZONEMUTE, "ON".equals(value) ? OnOffType.ON : OnOffType.OFF);
+ break;
+
+ case ZONE_SHAREDSOURCE:
+ stateChanged(RioConstants.CHANNEL_ZONESHAREDSOURCE,
+ "ON".equals(value) ? OnOffType.ON : OnOffType.OFF);
+ break;
+
+ case ZONE_LASTERROR:
+ stateChanged(RioConstants.CHANNEL_ZONELASTERROR, new StringType(value));
+ break;
+
+ case ZONE_PAGE:
+ stateChanged(RioConstants.CHANNEL_ZONEPAGE, "ON".equals(value) ? OnOffType.ON : OnOffType.OFF);
+ break;
+
+ case ZONE_SLEEPTIMEREMAINING:
+ try {
+ final int nbr = Integer.parseInt(value);
+ stateChanged(RioConstants.CHANNEL_ZONESLEEPTIMEREMAINING, new DecimalType(nbr));
+ } catch (NumberFormatException e) {
+ logger.warn("Invalid zone notification (sleeptimeremaining not parsable): '{}')", resp);
+ }
+ break;
+
+ case ZONE_ENABLED:
+ stateChanged(RioConstants.CHANNEL_ZONEENABLED,
+ "ON".equals(value) ? OnOffType.ON : OnOffType.OFF);
+ break;
+
+ case ZONE_VOLUME:
+ try {
+ final int nbr = Integer.parseInt(value);
+ stateChanged(RioConstants.CHANNEL_ZONEVOLUME, new PercentType(nbr * 2));
+ } catch (NumberFormatException e) {
+ logger.warn("Invalid zone notification (volume not parsable): '{}')", resp);
+ }
+ break;
+
+ default:
+ logger.warn("Unknown zone notification: '{}'", resp);
+ break;
+ }
+ } catch (NumberFormatException e) {
+ logger.error("Invalid Zone Notification (controller/zone not a parsable integer): '{}')", resp);
+ }
+ } else {
+ logger.error("Invalid Zone 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 (response == null || response == "") {
+ return;
+ }
+
+ final Matcher m = RSP_ZONENOTIFICATION.matcher(response);
+ if (m.matches()) {
+ handleZoneNotification(m, response);
+ }
+ }
+
+ /**
+ * Overrides the default implementation to turn watch off ({@link #watchZone(boolean)}) before calling the dispose
+ */
+ @Override
+ public void dispose() {
+ watchZone(false);
+ super.dispose();
+ }
+}