diff --git a/CODEOWNERS b/CODEOWNERS
index 74655f003f83c..b57cd877da767 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -150,6 +150,7 @@
/bundles/org.openhab.binding.novafinedust/ @t2000
/bundles/org.openhab.binding.ntp/ @marcelrv
/bundles/org.openhab.binding.nuki/ @mkatter
+/bundles/org.openhab.binding.nuvo/ @mlobstein
/bundles/org.openhab.binding.oceanic/ @kgoderis
/bundles/org.openhab.binding.ojelectronics/ @EvilPingu
/bundles/org.openhab.binding.omnikinverter/ @hansbogert
diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml
index 8071dab4bbb5e..9bbe6a0793374 100644
--- a/bom/openhab-addons/pom.xml
+++ b/bom/openhab-addons/pom.xml
@@ -734,6 +734,11 @@
org.openhab.binding.nuki${project.version}
+
+ org.openhab.addons.bundles
+ org.openhab.binding.nuvo
+ ${project.version}
+ org.openhab.addons.bundlesorg.openhab.binding.oceanic
diff --git a/bundles/org.openhab.binding.nuvo/.classpath b/bundles/org.openhab.binding.nuvo/.classpath
new file mode 100644
index 0000000000000..d223a57c7967a
--- /dev/null
+++ b/bundles/org.openhab.binding.nuvo/.classpath
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bundles/org.openhab.binding.nuvo/.project b/bundles/org.openhab.binding.nuvo/.project
new file mode 100644
index 0000000000000..310fe01fb4c3b
--- /dev/null
+++ b/bundles/org.openhab.binding.nuvo/.project
@@ -0,0 +1,23 @@
+
+
+ org.openhab.binding.nuvo
+
+
+
+
+
+ org.eclipse.jdt.core.javabuilder
+
+
+
+
+ org.eclipse.m2e.core.maven2Builder
+
+
+
+
+
+ org.eclipse.jdt.core.javanature
+ org.eclipse.m2e.core.maven2Nature
+
+
diff --git a/bundles/org.openhab.binding.nuvo/NOTICE b/bundles/org.openhab.binding.nuvo/NOTICE
new file mode 100644
index 0000000000000..38d625e349232
--- /dev/null
+++ b/bundles/org.openhab.binding.nuvo/NOTICE
@@ -0,0 +1,13 @@
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-addons
diff --git a/bundles/org.openhab.binding.nuvo/README.md b/bundles/org.openhab.binding.nuvo/README.md
new file mode 100644
index 0000000000000..09a785a500cf8
--- /dev/null
+++ b/bundles/org.openhab.binding.nuvo/README.md
@@ -0,0 +1,397 @@
+# Nuvo Grand Concerto & Essentia G Binding
+
+
+
+This binding can be used to control the Nuvo Grand Concerto or Essentia G whole house multi-zone amplifier.
+Up to 20 keypad zones can be controlled when zone expansion modules are used (if not all zones on the amp are used they can be excluded via configuration).
+
+The binding supports two different kinds of connections:
+
+* serial connection,
+* serial over IP connection
+
+For users without a serial connector on the server side, you can use a serial to USB adapter.
+
+You don't need to have your Grand Concerto or Essentia G whole house amplifier device directly connected to your openHAB server.
+You can connect it for example to a Raspberry Pi and use [ser2net Linux tool](https://sourceforge.net/projects/ser2net/) to make the serial connection available on LAN (serial over IP).
+
+## Supported Things
+
+There is exactly one supported thing type, which represents the amplifier controller.
+It has the `amplifier` id.
+
+## Discovery
+
+Discovery is not supported.
+You have to add all things manually.
+
+## Binding Configuration
+
+There are no overall binding configuration settings that need to be set.
+All settings are through thing configuration parameters.
+
+## Thing Configuration
+
+The thing has the following configuration parameters:
+
+| Parameter Label | Parameter ID | Description | Accepted values |
+|-------------------------|--------------|------------------------------------------------------------------------------------------------------------------------------------|------------------------|
+| Serial Port | serialPort | Serial port to use for connecting to the Nuvo whole house amplifier device | a comm port name |
+| Address | host | Host name or IP address of the machine connected to the Nuvo whole house amplifier device (serial over IP) | host name or ip |
+| Port | port | Communication port (serial over IP). | ip port number |
+| Number of Zones | numZones | (Optional) Number of zones on the amplifier to utilize in the binding (up to 20 zones when zone expansion modules are used) | (1-20; default 6) |
+| Sync Clock on GConcerto | clockSync | (Optional) If set to true, the binding will sync the internal clock on the Grand Concerto to match the openHAB host's system clock | Boolean; default false |
+
+Some notes:
+
+* If a zone has a maximum volume limit configured by the Nuvo configurator, the volume slider will automatically drop back to that level if set above the configured limit.
+* Source display_line1 thru 4 can only be updated on non NuvoNet sources.
+* The track_position channel does not update continuously for NuvoNet sources. It only changes when the track changes or playback is paused/unpaused.
+
+* On Linux, you may get an error stating the serial port cannot be opened when the Nuvo binding tries to load.
+* You can get around this by adding the `openhab` user to the `dialout` group like this: `usermod -a -G dialout openhab`.
+* Also on Linux you may have issues with the USB if using two serial USB devices e.g. Nuvo and RFXcom. See the [general documentation about serial port configuration](/docs/administration/serial.html) for more on symlinking the USB ports.
+* Here is an example of ser2net.conf you can use to share your serial port /dev/ttyUSB0 on IP port 4444 using [ser2net Linux tool](https://sourceforge.net/projects/ser2net/) (take care, the baud rate is specific to the Nuvo amplifier):
+
+```
+4444:raw:0:/dev/ttyUSB0:57600 8DATABITS NONE 1STOPBIT LOCAL
+```
+
+## Channels
+
+The following channels are available:
+
+| Channel ID | Item Type | Description |
+|--------------------------------------|-------------|---------------------------------------------------------------------------------------------------------------|
+| system#alloff | Switch | Turn all zones off simultaneously |
+| system#allmute | Switch | Mute or unmute all zones simultaneously |
+| system#page | Switch | Turn on or off the Page All Zones feature (while on the amplifier switches to source 6) |
+| zoneN#power (where N= 1-20) | Switch | Turn the power for a zone on or off |
+| zoneN#source (where N= 1-20) | Number | Select the source input for a zone (1-6) |
+| zoneN#volume (where N= 1-20) | Dimmer | Control the volume for a zone (0-100%) [translates to 0-79] |
+| zoneN#mute (where N= 1-20) | Switch | Mute or unmute a zone |
+| zoneN#control (where N= 1-20) | Player | Simulate pressing the transport control buttons on the keypad e.g. play/pause/next/previous |
+| zoneN#treble (where N= 1-20) | Number | Adjust the treble control for a zone (-18 to 18 [in increments of 2]) -18=none, 0=flat, 18=full |
+| zoneN#bass (where N= 1-20) | Number | Adjust the bass control for a zone (-18 to 18 [in increments of 2]) -18=none, 0=flat, 18=full |
+| zoneN#balance (where N= 1-20) | Number | Adjust the balance control for a zone (-18 to 18 [in increments of 2]) -18=left, 0=center, 18=right |
+| zoneN#loudness (where N= 1-20) | Switch | Turn on or off the loudness compensation setting for the zone |
+| zoneN#dnd (where N= 1-20) | Switch | Turn on or off the Do Not Disturb for the zone (for when the amplifiers's Page All Zones feature is activated)|
+| zoneN#lock (where N= 1-20) | Contact | Indicates if this zone is currently locked |
+| zoneN#party (where N= 1-20) | Switch | Turn on or off the party mode feature with this zone as the host |
+| sourceN#display_line1 (where N= 1-6) | String | 1st line of text being displayed on the keypad. Can be updated for a non NuvoNet source |
+| sourceN#display_line2 (where N= 1-6) | String | 2nd line of text being displayed on the keypad. Can be updated for a non NuvoNet source |
+| sourceN#display_line3 (where N= 1-6) | String | 3rd line of text being displayed on the keypad. Can be updated for a non NuvoNet source |
+| sourceN#display_line4 (where N= 1-6) | String | 4th line of text being displayed on the keypad. Can be updated for a non NuvoNet source |
+| sourceN#play_mode (where N= 1-6) | String | The current playback mode of the source, ie: Playing, Paused, etc. (ReadOnly) See rules example for updating |
+| sourceN#track_length (where N= 1-6) | Number:Time | The total running time of the current playing track (ReadOnly) See rules example for updating |
+| sourceN#track_position (where N= 1-6)| Number:Time | The running time elapsed of the current playing track (ReadOnly) See rules example for updating |
+| sourceN#button_press (where N= 1-6) | String | Indicates the last button pressed on the keypad for a non NuvoNet source (ReadOnly) |
+
+## Full Example
+
+nuvo.things:
+
+```java
+//serial port connection
+nuvo:amplifier:myamp "Nuvo WHA" [ serialPort="COM5", numZones=6, clockSync=false]
+
+// serial over IP connection
+nuvo:amplifier:myamp "Nuvo WHA" [ host="192.168.0.10", port=4444, numZones=6, clockSync=false]
+
+```
+
+nuvo.items:
+
+```java
+// system
+Switch nuvo_system_alloff "All Zones Off" { channel="nuvo:amplifier:myamp:system#alloff" }
+Switch nuvo_system_allmute "All Zones Mute" { channel="nuvo:amplifier:myamp:system#allmute" }
+Switch nuvo_system_page "Page All Zones" { channel="nuvo:amplifier:myamp:system#page" }
+
+// zones
+Switch nuvo_z1_power "Power" { channel="nuvo:amplifier:myamp:zone1#power" }
+Number nuvo_z1_source "Source Input [%s]" { channel="nuvo:amplifier:myamp:zone1#source" }
+Dimmer nuvo_z1_volume "Volume [%d %%]" { channel="nuvo:amplifier:myamp:zone1#volume" }
+Switch nuvo_z1_mute "Mute" { channel="nuvo:amplifier:myamp:zone1#mute" }
+Player nuvo_z1_control "Control" { channel="nuvo:amplifier:myamp:zone1#control" }
+Number nuvo_z1_treble "Treble Adjustment [%s]" { channel="nuvo:amplifier:myamp:zone1#treble" }
+Number nuvo_z1_bass "Bass Adjustment [%s]" { channel="nuvo:amplifier:myamp:zone1#bass" }
+Number nuvo_z1_balance "Balance Adjustment [%s]" { channel="nuvo:amplifier:myamp:zone1#balance" }
+Switch nuvo_z1_loudness "Loudness" { channel="nuvo:amplifier:myamp:zone1#loudness" }
+Switch nuvo_z1_dnd "Do Not Disturb" { channel="nuvo:amplifier:myamp:zone1#dnd" }
+Switch nuvo_z1_lock "Zone Locked [%s]" { channel="nuvo:amplifier:myamp:zone1#lock" }
+Switch nuvo_z1_party "Party Mode" { channel="nuvo:amplifier:myamp:zone1#party" }
+
+// > repeat for zones 2-20 (substitute z1 and zone1) < //
+
+// sources
+String nuvo_s1_display_line1 "Line 1: [%s]" { channel="nuvo:amplifier:myamp:source1#display_line1" }
+String nuvo_s1_display_line2 "Line 2: [%s]" { channel="nuvo:amplifier:myamp:source1#display_line2" }
+String nuvo_s1_display_line3 "Line 3: [%s]" { channel="nuvo:amplifier:myamp:source1#display_line3" }
+String nuvo_s1_display_line4 "Line 4: [%s]" { channel="nuvo:amplifier:myamp:source1#display_line4" }
+String nuvo_s1_play_mode "Play Mode: [%s]" { channel="nuvo:amplifier:myamp:source1#play_mode" }
+Number:Time nuvo_s1_track_length "Track Length: [%s s]" { channel="nuvo:amplifier:myamp:source1#track_length" }
+Number:Time nuvo_s1_track_position "Track Position: [%s s]" { channel="nuvo:amplifier:myamp:source1#track_position" }
+String nuvo_s1_button_press "Button: [%s]" { channel="nuvo:amplifier:myamp:source1#button_press" }
+
+String nuvo_s2_display_line1 "Line 1: [%s]" { channel="nuvo:amplifier:myamp:source2#display_line1" }
+String nuvo_s2_display_line2 "Line 2: [%s]" { channel="nuvo:amplifier:myamp:source2#display_line2" }
+String nuvo_s2_display_line3 "Line 3: [%s]" { channel="nuvo:amplifier:myamp:source2#display_line3" }
+String nuvo_s2_display_line4 "Line 4: [%s]" { channel="nuvo:amplifier:myamp:source2#display_line4" }
+String nuvo_s2_play_mode "Play Mode: [%s]" { channel="nuvo:amplifier:myamp:source2#play_mode" }
+Number:Time nuvo_s2_track_length "Track Length: [%s s]" { channel="nuvo:amplifier:myamp:source2#track_length" }
+Number:Time nuvo_s2_track_position "Track Position: [%s s]" { channel="nuvo:amplifier:myamp:source2#track_position" }
+String nuvo_s2_button_press "Button: [%s]" { channel="nuvo:amplifier:myamp:source2#button_press" }
+
+String nuvo_s3_display_line1 "Line 1: [%s]" { channel="nuvo:amplifier:myamp:source3#display_line1" }
+String nuvo_s3_display_line2 "Line 2: [%s]" { channel="nuvo:amplifier:myamp:source3#display_line2" }
+String nuvo_s3_display_line3 "Line 3: [%s]" { channel="nuvo:amplifier:myamp:source3#display_line3" }
+String nuvo_s3_display_line4 "Line 4: [%s]" { channel="nuvo:amplifier:myamp:source3#display_line4" }
+String nuvo_s3_play_mode "Play Mode: [%s]" { channel="nuvo:amplifier:myamp:source3#play_mode" }
+Number:Time nuvo_s3_track_length "Track Length: [%s s]" { channel="nuvo:amplifier:myamp:source3#track_length" }
+Number:Time nuvo_s3_track_position "Track Position: [%s s]" { channel="nuvo:amplifier:myamp:source3#track_position" }
+String nuvo_s3_button_press "Button: [%s]" { channel="nuvo:amplifier:myamp:source3#button_press" }
+
+String nuvo_s4_display_line1 "Line 1: [%s]" { channel="nuvo:amplifier:myamp:source4#display_line1" }
+String nuvo_s4_display_line2 "Line 2: [%s]" { channel="nuvo:amplifier:myamp:source4#display_line2" }
+String nuvo_s4_display_line3 "Line 3: [%s]" { channel="nuvo:amplifier:myamp:source4#display_line3" }
+String nuvo_s4_display_line4 "Line 4: [%s]" { channel="nuvo:amplifier:myamp:source4#display_line4" }
+String nuvo_s4_play_mode "Play Mode: [%s]" { channel="nuvo:amplifier:myamp:source4#play_mode" }
+Number:Time nuvo_s4_track_length "Track Length: [%s s]" { channel="nuvo:amplifier:myamp:source4#track_length" }
+Number:Time nuvo_s4_track_position "Track Position: [%s s]" { channel="nuvo:amplifier:myamp:source4#track_position" }
+String nuvo_s4_button_press "Button: [%s]" { channel="nuvo:amplifier:myamp:source4#button_press" }
+
+String nuvo_s5_display_line1 "Line 1: [%s]" { channel="nuvo:amplifier:myamp:source5#display_line1" }
+String nuvo_s5_display_line2 "Line 2: [%s]" { channel="nuvo:amplifier:myamp:source5#display_line2" }
+String nuvo_s5_display_line3 "Line 3: [%s]" { channel="nuvo:amplifier:myamp:source5#display_line3" }
+String nuvo_s5_display_line4 "Line 4: [%s]" { channel="nuvo:amplifier:myamp:source5#display_line4" }
+String nuvo_s5_play_mode "Play Mode: [%s]" { channel="nuvo:amplifier:myamp:source5#play_mode" }
+Number:Time nuvo_s5_track_length "Track Length: [%s s]" { channel="nuvo:amplifier:myamp:source5#track_length" }
+Number:Time nuvo_s5_track_position "Track Position: [%s s]" { channel="nuvo:amplifier:myamp:source5#track_position" }
+String nuvo_s5_button_press "Button: [%s]" { channel="nuvo:amplifier:myamp:source5#button_press" }
+
+String nuvo_s6_display_line1 "Line 1: [%s]" { channel="nuvo:amplifier:myamp:source6#display_line1" }
+String nuvo_s6_display_line2 "Line 2: [%s]" { channel="nuvo:amplifier:myamp:source6#display_line2" }
+String nuvo_s6_display_line3 "Line 3: [%s]" { channel="nuvo:amplifier:myamp:source6#display_line3" }
+String nuvo_s6_display_line4 "Line 4: [%s]" { channel="nuvo:amplifier:myamp:source6#display_line4" }
+String nuvo_s6_play_mode "Play Mode: [%s]" { channel="nuvo:amplifier:myamp:source6#play_mode" }
+Number:Time nuvo_s6_track_length "Track Length: [%s s]" { channel="nuvo:amplifier:myamp:source6#track_length" }
+Number:Time nuvo_s6_track_position "Track Position: [%s s]" { channel="nuvo:amplifier:myamp:source6#track_position" }
+String nuvo_s6_button_press "Button: [%s]" { channel="nuvo:amplifier:myamp:source6#button_press" }
+
+```
+
+nuvo.sitemap:
+
+```perl
+sitemap nuvo label="Audio Control" {
+ Frame label="System" {
+ Switch item=nuvo_system_alloff mappings=[ON=" "]
+ Switch item=nuvo_system_allmute
+ Switch item=nuvo_system_page
+ }
+
+ Frame label="Zone 1"
+ Switch item=nuvo_z1_power visibility=[nuvo_z1_lock!="1"]
+ Selection item=nuvo_z1_source visibility=[nuvo_z1_power==ON] icon="player"
+ //Volume can be a Setpoint also
+ Slider item=nuvo_z1_volume minValue=0 maxValue=100 step=1 visibility=[nuvo_z1_power==ON] icon="soundvolume"
+ Switch item=nuvo_z1_mute visibility=[nuvo_z1_power==ON] icon="soundvolume_mute"
+ Default item=nuvo_z1_control visibility=[nuvo_z1_power==ON]
+
+ Text item=nuvo_s1_display_line1 visibility=[nuvo_z1_source=="1"] icon="zoom"
+ Text item=nuvo_s1_display_line2 visibility=[nuvo_z1_source=="1"] icon="zoom"
+ Text item=nuvo_s1_display_line3 visibility=[nuvo_z1_source=="1"] icon="zoom"
+ Text item=nuvo_s1_display_line4 visibility=[nuvo_z1_source=="1"] icon="zoom"
+ Text item=nuvo_s1_play_mode visibility=[nuvo_z1_source=="1"] icon="player"
+ Text item=nuvo_s1_track_length visibility=[nuvo_z1_source=="1"]
+ Text item=nuvo_s1_track_position visibility=[nuvo_z1_source=="1"]
+ Text item=nuvo_s1_button_press visibility=[nuvo_z1_source=="1"] icon="none"
+
+ Text item=nuvo_s2_display_line1 visibility=[nuvo_z1_source=="2"] icon="zoom"
+ Text item=nuvo_s2_display_line2 visibility=[nuvo_z1_source=="2"] icon="zoom"
+ Text item=nuvo_s2_display_line3 visibility=[nuvo_z1_source=="2"] icon="zoom"
+ Text item=nuvo_s2_display_line4 visibility=[nuvo_z1_source=="2"] icon="zoom"
+ Text item=nuvo_s2_play_mode visibility=[nuvo_z1_source=="2"] icon="player"
+ Text item=nuvo_s2_track_length visibility=[nuvo_z1_source=="2"]
+ Text item=nuvo_s2_track_position visibility=[nuvo_z1_source=="2"]
+ Text item=nuvo_s2_button_press visibility=[nuvo_z1_source=="2"] icon="none"
+
+ Text item=nuvo_s3_display_line1 visibility=[nuvo_z1_source=="3"] icon="zoom"
+ Text item=nuvo_s3_display_line2 visibility=[nuvo_z1_source=="3"] icon="zoom"
+ Text item=nuvo_s3_display_line3 visibility=[nuvo_z1_source=="3"] icon="zoom"
+ Text item=nuvo_s3_display_line4 visibility=[nuvo_z1_source=="3"] icon="zoom"
+ Text item=nuvo_s3_play_mode visibility=[nuvo_z1_source=="3"] icon="player"
+ Text item=nuvo_s3_track_length visibility=[nuvo_z1_source=="3"]
+ Text item=nuvo_s3_track_position visibility=[nuvo_z1_source=="3"]
+ Text item=nuvo_s3_button_press visibility=[nuvo_z1_source=="3"] icon="none"
+
+ Text item=nuvo_s4_display_line1 visibility=[nuvo_z1_source=="4"] icon="zoom"
+ Text item=nuvo_s4_display_line2 visibility=[nuvo_z1_source=="4"] icon="zoom"
+ Text item=nuvo_s4_display_line3 visibility=[nuvo_z1_source=="4"] icon="zoom"
+ Text item=nuvo_s4_display_line4 visibility=[nuvo_z1_source=="4"] icon="zoom"
+ Text item=nuvo_s4_play_mode visibility=[nuvo_z1_source=="4"] icon="player"
+ Text item=nuvo_s4_track_length visibility=[nuvo_z1_source=="4"]
+ Text item=nuvo_s4_track_position visibility=[nuvo_z1_source=="4"]
+ Text item=nuvo_s4_button_press visibility=[nuvo_z1_source=="4"] icon="none"
+
+ Text item=nuvo_s5_display_line1 visibility=[nuvo_z1_source=="5"] icon="zoom"
+ Text item=nuvo_s5_display_line2 visibility=[nuvo_z1_source=="5"] icon="zoom"
+ Text item=nuvo_s5_display_line3 visibility=[nuvo_z1_source=="5"] icon="zoom"
+ Text item=nuvo_s5_display_line4 visibility=[nuvo_z1_source=="5"] icon="zoom"
+ Text item=nuvo_s5_play_mode visibility=[nuvo_z1_source=="5"] icon="player"
+ Text item=nuvo_s5_track_length visibility=[nuvo_z1_source=="5"]
+ Text item=nuvo_s5_track_position visibility=[nuvo_z1_source=="5"]
+ Text item=nuvo_s5_button_press visibility=[nuvo_z1_source=="5"] icon="none"
+
+ Text item=nuvo_s6_display_line1 visibility=[nuvo_z1_source=="6"] icon="zoom"
+ Text item=nuvo_s6_display_line2 visibility=[nuvo_z1_source=="6"] icon="zoom"
+ Text item=nuvo_s6_display_line3 visibility=[nuvo_z1_source=="6"] icon="zoom"
+ Text item=nuvo_s6_display_line4 visibility=[nuvo_z1_source=="6"] icon="zoom"
+ Text item=nuvo_s6_play_mode visibility=[nuvo_z1_source=="6"] icon="player"
+ Text item=nuvo_s6_track_length visibility=[nuvo_z1_source=="6"]
+ Text item=nuvo_s6_track_position visibility=[nuvo_z1_source=="6"]
+ Text item=nuvo_s6_button_press visibility=[nuvo_z1_source=="6"] icon="none"
+
+ Setpoint item=nuvo_z1_treble label="Treble Adjustment [%d]" minValue=-18 maxValue=18 step=2 visibility=[nuvo_z1_power==ON]
+ Setpoint item=nuvo_z1_bass label="Bass Adjustment [%d]" minValue=-18 maxValue=18 step=2 visibility=[nuvo_z1_power==ON]
+ Setpoint item=nuvo_z1_balance label="Balance Adjustment [%d]" minValue=-18 maxValue=18 step=2 visibility=[nuvo_z1_power==ON]
+ Switch item=nuvo_z1_loudness visibility=[nuvo_z1_power==ON]
+ Switch item=nuvo_z1_dnd visibility=[nuvo_z1_power==ON]
+ Text item=nuvo_z1_lock label="Zone Locked: [%s]" icon="lock"
+ Switch item=nuvo_z1_party visibility=[nuvo_z1_power==ON]
+ }
+
+ //repeat for zones 2-20 (substitute z1)
+}
+
+```
+
+nuvo.rules:
+
+```java
+import java.text.Normalizer
+
+val actions = getActions("nuvo","nuvo:amplifier:myamp")
+
+// send command a custom command to the Nuvo Amplifier
+// see 'NuVo Grand Concerto Serial Control Protocol.pdf' for more command examples
+// https://www.legrand.us/-/media/brands/nuvo/nuvo/catalog/softwaredownloads-new/i8g_e6g_control_protocol.ashx
+// commands send through the binding do not need the leading '*'
+
+rule "Nuvo Custom Command example"
+when
+ Item SomeItemTrigger received command
+then
+ if(null === actions) {
+ logInfo("actions", "Actions not found, check thing ID")
+ return
+ }
+
+ // Send a message to Source 3
+ //actions.sendNuvoCommand("S3MSG\"Hello World\",0,0")
+
+ // Send a message to Zone 11
+ //actions.sendNuvoCommand("Z11MSG\"Hello World\",0,0")
+
+ // Select a Favorite (1-12) for Zone 2
+ //actions.sendNuvoCommand("Z2FAV1")
+
+end
+
+// In the below examples, a method for maintaing Metadata information
+// for a hypothetical non NuvoNet Source 3 is demonstrated
+
+// Item_Containing_TrackLength should get a 'received update' when the track changes
+// ('changed' is not sufficient if two consecutive tracks are the same length)
+
+rule "Load track play info for Source 3"
+when
+ Item Item_Containing_TrackLength received update
+then
+ if(null === actions) {
+ logInfo("actions", "Actions not found, check thing ID")
+ return
+ }
+ // strip off any non-numeric characters and multiply seconds by 10 (Nuvo expects tenths of a second)
+ var int trackLength = Integer::parseInt(Item_Containing_TrackLength.state.toString.replaceAll("[\\D]", "")) * 10
+
+ // '0' indicates the track is just starting (at position 0), '2' indicates to Nuvo that the track is playing
+ // The Nuvo keypad will now begin counting up the elapsed time displayed (starting from 0)
+ actions.sendNuvoCommand("S3DISPINFO," + trackLength.toString() + ",0,2")
+
+end
+
+rule "Load track name for Source 3"
+when
+ Item Item_Containing_TrackName changed
+then
+ // The Nuvo keypad cannot display extended ASCII characters (accent, umulat, etc.)
+ // Below we transform extended ASCII chars into their basic counterparts
+ // example: 'La Touché' becomes 'La Touche' and 'Nöel' becomes 'Noel'
+ var trackName = Normalizer::normalize(Item_Containing_TrackName.state.toString, Normalizer.Form.NFD).replaceAll("[^\\p{ASCII}]", "")
+
+ nuvo_s3_display_line4.sendCommand(trackName)
+ nuvo_s3_display_line1.sendCommand("")
+
+end
+
+rule "Load album name for Source 3"
+when
+ Item Item_Containing_AlbumName changed
+then
+ // fix extended ASCII chars
+ var albumName = Normalizer::normalize(Item_Containing_AlbumName.state.toString, Normalizer.Form.NFD).replaceAll("[^\\p{ASCII}]", "")
+
+ nuvo_s3_display_line2.sendCommand(albumName)
+end
+
+rule "Load artist name for Source 3"
+when
+ Item Item_Containing_ArtistName changed
+then
+ // fix extended ASCII chars
+ var artistName = Normalizer::normalize(Item_Containing_ArtistName.state.toString, Normalizer.Form.NFD).replaceAll("[^\\p{ASCII}]", "")
+
+ nuvo_s3_display_line3.sendCommand(artistName)
+end
+
+// In this rule we have three items: Item_Containing_PlayMode, Item_Containing_TrackLength & Item_Containing_TrackPosition
+// Item_Containing_PlayMode reports the playing state of the music source as a string such as 'Playing' or 'Paused'
+// Item_Containing_TrackLength reports the length of the track in seconds
+// Item_Containing_TrackPosition report the current playback position of the track in seconds
+
+rule "Update play state info for Source 3"
+when
+ Item Item_Containing_PlayMode changed
+then
+ var playMode = Item_Containing_PlayMode.state.toString()
+
+ // strip off any non-numeric characters and multiply seconds by 10 (Nuvo expects tenths of a second)
+ var int trackLength = Integer::parseInt(Item_Containing_TrackLength.state.toString.replaceAll("[\\D]", "")) * 10
+ var int trackPosition = Integer::parseInt(Item_Containing_TrackPosition.state.toString.replaceAll("[\\D]", "")) * 10
+
+ if(null === actions) {
+ logInfo("actions", "Actions not found, check thing ID")
+ return
+ }
+
+ switch playMode {
+ case "Nothing playing": {
+ // when idle, '1' tells Nuvo to display 'idle' on the keypad
+ actions.sendNuvoCommand("S3DISPINFO,0,0,1")
+ }
+ case "Playing": {
+ // when playback starts or resumes, '2' tells Nuvo to display 'playing' on the keypad
+ // trackPosition does not need to be updated continuously, Nuvo will automatically count up the elapsed time displayed on the keypad
+ actions.sendNuvoCommand("S3DISPINFO," + trackLength.toString() + "," + trackPosition.toString() + ",2")
+ }
+ case "Paused": {
+ // when playback is paused, '3' tells Nuvo to display 'paused' on the keypad and stop counting up the elapsed time
+ // trackPosition should indicate the time elapsed of the track when playback was paused
+ actions.sendNuvoCommand("S3DISPINFO," + trackLength.toString() + "," + trackPosition.toString() + ",3")
+ }
+ }
+end
+
+```
diff --git a/bundles/org.openhab.binding.nuvo/doc/nuvo_logo.png b/bundles/org.openhab.binding.nuvo/doc/nuvo_logo.png
new file mode 100644
index 0000000000000..88264aa680a54
Binary files /dev/null and b/bundles/org.openhab.binding.nuvo/doc/nuvo_logo.png differ
diff --git a/bundles/org.openhab.binding.nuvo/pom.xml b/bundles/org.openhab.binding.nuvo/pom.xml
new file mode 100644
index 0000000000000..fd4e0cec8442e
--- /dev/null
+++ b/bundles/org.openhab.binding.nuvo/pom.xml
@@ -0,0 +1,17 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.addons.bundles
+ org.openhab.addons.reactor.bundles
+ 2.5.9-SNAPSHOT
+
+
+ org.openhab.binding.nuvo
+
+ openHAB Add-ons :: Bundles :: Nuvo Binding
+
+
diff --git a/bundles/org.openhab.binding.nuvo/src/main/feature/feature.xml b/bundles/org.openhab.binding.nuvo/src/main/feature/feature.xml
new file mode 100644
index 0000000000000..41cc3dcb76511
--- /dev/null
+++ b/bundles/org.openhab.binding.nuvo/src/main/feature/feature.xml
@@ -0,0 +1,10 @@
+
+
+ mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features
+
+
+ openhab-runtime-base
+ openhab-transport-serial
+ mvn:org.openhab.addons.bundles/org.openhab.binding.nuvo/${project.version}
+
+
diff --git a/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/INuvoThingActions.java b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/INuvoThingActions.java
new file mode 100644
index 0000000000000..8bec3d20d3a05
--- /dev/null
+++ b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/INuvoThingActions.java
@@ -0,0 +1,27 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nuvo.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link INuvoThingActions} defines the interface for all thing actions supported by the binding.
+ * These methods, parameters, and return types are explained in {@link NuvoThingActions}.
+ *
+ * @author Michael Lobstein - Initial contribution
+ */
+@NonNullByDefault
+public interface INuvoThingActions {
+
+ void sendNuvoCommand(String rawCommand);
+}
diff --git a/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/NuvoBindingConstants.java b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/NuvoBindingConstants.java
new file mode 100644
index 0000000000000..e5648e3d25b10
--- /dev/null
+++ b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/NuvoBindingConstants.java
@@ -0,0 +1,85 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nuvo.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.smarthome.core.thing.ThingTypeUID;
+
+/**
+ * The {@link NuvoBindingConstants} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author Michael Lobstein - Initial contribution
+ */
+@NonNullByDefault
+public class NuvoBindingConstants {
+
+ public static final String BINDING_ID = "nuvo";
+
+ // List of all Thing Type UIDs
+ public static final ThingTypeUID THING_TYPE_AMP = new ThingTypeUID(BINDING_ID, "amplifier");
+
+ // List of all Channel types
+ // system
+ public static final String CHANNEL_TYPE_ALLOFF = "alloff";
+ public static final String CHANNEL_TYPE_ALLMUTE = "allmute";
+ public static final String CHANNEL_TYPE_PAGE = "page";
+ public static final String CHANNEL_TYPE_SENDCMD = "sendcmd";
+
+ // zone
+ public static final String CHANNEL_TYPE_POWER = "power";
+ public static final String CHANNEL_TYPE_SOURCE = "source";
+ public static final String CHANNEL_TYPE_VOLUME = "volume";
+ public static final String CHANNEL_TYPE_MUTE = "mute";
+ public static final String CHANNEL_TYPE_CONTROL = "control";
+ public static final String CHANNEL_TYPE_TREBLE = "treble";
+ public static final String CHANNEL_TYPE_BASS = "bass";
+ public static final String CHANNEL_TYPE_BALANCE = "balance";
+ public static final String CHANNEL_TYPE_LOUDNESS = "loudness";
+ public static final String CHANNEL_TYPE_DND = "dnd";
+ public static final String CHANNEL_TYPE_LOCK = "lock";
+ public static final String CHANNEL_TYPE_PARTY = "party";
+
+ // source
+ public static final String CHANNEL_DISPLAY_LINE = "display_line";
+ public static final String CHANNEL_DISPLAY_LINE1 = "display_line1";
+ public static final String CHANNEL_DISPLAY_LINE2 = "display_line2";
+ public static final String CHANNEL_DISPLAY_LINE3 = "display_line3";
+ public static final String CHANNEL_DISPLAY_LINE4 = "display_line4";
+ public static final String CHANNEL_PLAY_MODE = "play_mode";
+ public static final String CHANNEL_TRACK_LENGTH = "track_length";
+ public static final String CHANNEL_TRACK_POSITION = "track_position";
+ public static final String CHANNEL_BUTTON_PRESS = "button_press";
+
+ // Message types
+ public static final String TYPE_VERSION = "version";
+ public static final String TYPE_ALLOFF = "alloff";
+ public static final String TYPE_ALLMUTE = "allmute";
+ public static final String TYPE_PAGE = "page";
+ public static final String TYPE_SOURCE_UPDATE = "source_update";
+ public static final String TYPE_ZONE_UPDATE = "zone_update";
+ public static final String TYPE_ZONE_BUTTON = "zone_button";
+ public static final String TYPE_ZONE_CONFIG = "zone_config";
+
+ // misc
+ public static final String ON = "ON";
+ public static final String OFF = "OFF";
+ public static final String ONE = "1";
+ public static final String ZERO = "0";
+ public static final String BLANK = "";
+ public static final String DISPLINE = "DISPLINE";
+ public static final String DISPINFO = "DISPINFO,"; // yes comma here
+ public static final String NAME_QUOTE = "NAME\"";
+ public static final String MUTE = "MUTE";
+ public static final String VOL = "VOL";
+}
diff --git a/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/NuvoException.java b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/NuvoException.java
new file mode 100644
index 0000000000000..26b18f7620c81
--- /dev/null
+++ b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/NuvoException.java
@@ -0,0 +1,36 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nuvo.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link NuvoException} class is used for any exception thrown by the binding
+ *
+ * @author Michael Lobstein - Initial contribution
+ */
+@NonNullByDefault
+public class NuvoException extends Exception {
+ private static final long serialVersionUID = 1L;
+
+ public NuvoException() {
+ }
+
+ public NuvoException(String message, Throwable t) {
+ super(message, t);
+ }
+
+ public NuvoException(String message) {
+ super(message);
+ }
+}
diff --git a/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/NuvoHandlerFactory.java b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/NuvoHandlerFactory.java
new file mode 100644
index 0000000000000..d7913c4b34483
--- /dev/null
+++ b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/NuvoHandlerFactory.java
@@ -0,0 +1,71 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nuvo.internal;
+
+import static org.openhab.binding.nuvo.internal.NuvoBindingConstants.*;
+
+import java.util.Collections;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+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.eclipse.smarthome.core.thing.binding.ThingHandlerFactory;
+import org.eclipse.smarthome.io.transport.serial.SerialPortManager;
+import org.openhab.binding.nuvo.internal.handler.NuvoHandler;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * The {@link NuvoHandlerFactory} is responsible for creating things and thing
+ * handlers.
+ *
+ * @author Michael Lobstein - Initial contribution
+ */
+@NonNullByDefault
+@Component(configurationPid = "binding.nuvo", service = ThingHandlerFactory.class)
+public class NuvoHandlerFactory extends BaseThingHandlerFactory {
+
+ private static final Set SUPPORTED_THING_TYPES_UIDS = Collections.singleton(THING_TYPE_AMP);
+
+ private final SerialPortManager serialPortManager;
+
+ private final NuvoStateDescriptionOptionProvider stateDescriptionProvider;
+
+ @Override
+ public boolean supportsThingType(ThingTypeUID thingTypeUID) {
+ return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
+ }
+
+ @Activate
+ public NuvoHandlerFactory(final @Reference NuvoStateDescriptionOptionProvider provider,
+ final @Reference SerialPortManager serialPortManager) {
+ this.stateDescriptionProvider = provider;
+ this.serialPortManager = serialPortManager;
+ }
+
+ @Override
+ protected @Nullable ThingHandler createHandler(Thing thing) {
+ ThingTypeUID thingTypeUID = thing.getThingTypeUID();
+
+ if (SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID)) {
+ return new NuvoHandler(thing, stateDescriptionProvider, serialPortManager);
+ }
+
+ return null;
+ }
+}
diff --git a/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/NuvoStateDescriptionOptionProvider.java b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/NuvoStateDescriptionOptionProvider.java
new file mode 100644
index 0000000000000..f231af318223e
--- /dev/null
+++ b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/NuvoStateDescriptionOptionProvider.java
@@ -0,0 +1,41 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nuvo.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.smarthome.core.thing.binding.BaseDynamicStateDescriptionProvider;
+import org.eclipse.smarthome.core.thing.i18n.ChannelTypeI18nLocalizationService;
+import org.eclipse.smarthome.core.thing.type.DynamicStateDescriptionProvider;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * Dynamic provider of state options while leaving other state description fields as original.
+ *
+ * @author Michael Lobstein - Initial contribution
+ */
+@Component(service = { DynamicStateDescriptionProvider.class, NuvoStateDescriptionOptionProvider.class })
+@NonNullByDefault
+public class NuvoStateDescriptionOptionProvider extends BaseDynamicStateDescriptionProvider {
+
+ @Reference
+ protected void setChannelTypeI18nLocalizationService(
+ final ChannelTypeI18nLocalizationService channelTypeI18nLocalizationService) {
+ this.channelTypeI18nLocalizationService = channelTypeI18nLocalizationService;
+ }
+
+ protected void unsetChannelTypeI18nLocalizationService(
+ final ChannelTypeI18nLocalizationService channelTypeI18nLocalizationService) {
+ this.channelTypeI18nLocalizationService = null;
+ }
+}
diff --git a/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/NuvoThingActions.java b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/NuvoThingActions.java
new file mode 100644
index 0000000000000..4dda8cd1aaf3a
--- /dev/null
+++ b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/NuvoThingActions.java
@@ -0,0 +1,88 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nuvo.internal;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.smarthome.core.thing.binding.ThingActions;
+import org.eclipse.smarthome.core.thing.binding.ThingActionsScope;
+import org.eclipse.smarthome.core.thing.binding.ThingHandler;
+import org.openhab.binding.nuvo.internal.handler.NuvoHandler;
+import org.openhab.core.automation.annotation.ActionInput;
+import org.openhab.core.automation.annotation.RuleAction;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Some automation actions to be used with a {@link NuvoThingActions}
+ *
+ * @author Michael Lobstein - initial contribution
+ *
+ */
+@ThingActionsScope(name = "nuvo")
+@NonNullByDefault
+public class NuvoThingActions implements ThingActions, INuvoThingActions {
+
+ private final Logger logger = LoggerFactory.getLogger(NuvoThingActions.class);
+
+ private @Nullable NuvoHandler handler;
+
+ @RuleAction(label = "sendNuvoCommand", description = "Action that sends raw command to the amplifer")
+ public void sendNuvoCommand(@ActionInput(name = "sendNuvoCommand") String rawCommand) {
+ NuvoHandler localHandler = handler;
+ if (localHandler != null) {
+ localHandler.handleRawCommand(rawCommand);
+ logger.debug("sendNuvoCommand called with raw command: {}", rawCommand);
+ } else {
+ logger.warn("unable to send command, NuvoHandler was null");
+ }
+ }
+
+ /** Static alias to support the old DSL rules engine and make the action available there. */
+ public static void sendNuvoCommand(@Nullable ThingActions actions, String rawCommand)
+ throws IllegalArgumentException {
+ invokeMethodOf(actions).sendNuvoCommand(rawCommand);
+ }
+
+ @Override
+ public void setThingHandler(@Nullable ThingHandler handler) {
+ this.handler = (NuvoHandler) handler;
+ }
+
+ @Override
+ public @Nullable ThingHandler getThingHandler() {
+ return this.handler;
+ }
+
+ private static INuvoThingActions invokeMethodOf(@Nullable ThingActions actions) {
+ if (actions == null) {
+ throw new IllegalArgumentException("actions cannot be null");
+ }
+ if (actions.getClass().getName().equals(NuvoThingActions.class.getName())) {
+ if (actions instanceof NuvoThingActions) {
+ return (INuvoThingActions) actions;
+ } else {
+ return (INuvoThingActions) Proxy.newProxyInstance(INuvoThingActions.class.getClassLoader(),
+ new Class[] { INuvoThingActions.class }, (Object proxy, Method method, Object[] args) -> {
+ Method m = actions.getClass().getDeclaredMethod(method.getName(),
+ method.getParameterTypes());
+ return m.invoke(actions, args);
+ });
+ }
+ }
+ throw new IllegalArgumentException("Actions is not an instance of NuvoThingActions");
+ }
+}
diff --git a/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoCommand.java b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoCommand.java
new file mode 100644
index 0000000000000..ac43e707eeeba
--- /dev/null
+++ b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoCommand.java
@@ -0,0 +1,72 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nuvo.internal.communication;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Represents the different kinds of commands
+ *
+ * @author Michael Lobstein - Initial contribution
+ */
+@NonNullByDefault
+public enum NuvoCommand {
+ GET_CONTROLLER_VERSION("VER"),
+ ALLMUTE_ON("MUTE1"),
+ ALLMUTE_OFF("MUTE0"),
+ ALLOFF("ALLOFF"),
+ PAGE_ON("PAGE1"),
+ PAGE_OFF("PAGE0"),
+ CFGTIME("CFGTIME"),
+ STATUS("STATUS"),
+ EQ_QUERY("EQ?"),
+ DISPINFO("DISPINFO"),
+ DISPLINE("DISPLINE"),
+ DISPLINE1("DISPLINE1"),
+ DISPLINE2("DISPLINE2"),
+ DISPLINE3("DISPLINE3"),
+ DISPLINE4("DISPLINE4"),
+ NAME("NAME"),
+ ON("ON"),
+ OFF("OFF"),
+ SOURCE("SRC"),
+ VOLUME("VOL"),
+ MUTE_ON("MUTEON"),
+ MUTE_OFF("MUTEOFF"),
+ TREBLE("TREB"),
+ BASS("BASS"),
+ BALANCE("BAL"),
+ LOUDNESS("LOUDCMP"),
+ PLAYPAUSE("PLAYPAUSE"),
+ PREV("PREV"),
+ NEXT("NEXT"),
+ DND_ON("DNDON"),
+ DND_OFF("DNDOFF"),
+ PARTY_ON("PARTY1"),
+ PARTY_OFF("PARTY0");
+
+ private final String value;
+
+ NuvoCommand(String value) {
+ this.value = value;
+ }
+
+ /**
+ * Get the command name
+ *
+ * @return the command name
+ */
+ public String getValue() {
+ return value;
+ }
+}
diff --git a/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoConnector.java b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoConnector.java
new file mode 100644
index 0000000000000..e47db3229f34c
--- /dev/null
+++ b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoConnector.java
@@ -0,0 +1,376 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nuvo.internal.communication;
+
+import static org.openhab.binding.nuvo.internal.NuvoBindingConstants.*;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nuvo.internal.NuvoException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Abstract class for communicating with the Nuvo device
+ *
+ * @author Laurent Garnier - Initial contribution
+ * @author Michael Lobstein - Adapted for the Nuvo binding
+ */
+@NonNullByDefault
+public abstract class NuvoConnector {
+ private static final String COMMAND_OK = "#OK";
+ private static final String BEGIN_CMD = "*";
+ private static final String END_CMD = "\r";
+ private static final String QUERY = "?";
+ private static final String VER_STR = "#VER\"NV-";
+ private static final String ALL_OFF = "#ALLOFF";
+ private static final String MUTE = "#MUTE";
+ private static final String PAGE = "#PAGE";
+
+ private static final byte[] WAKE_STR = "\r".getBytes(StandardCharsets.US_ASCII);
+
+ private static final Pattern SRC_PATTERN = Pattern.compile("^#S(\\d{1})(.*)$");
+ private static final Pattern ZONE_PATTERN = Pattern.compile("^#Z(\\d{1,2}),(.*)$");
+ private static final Pattern ZONE_BUTTON_PATTERN = Pattern.compile("^#Z(\\d{1,2})S(\\d{1})(.*)$");
+ private static final Pattern ZONE_CFG_PATTERN = Pattern.compile("^#ZCFG(\\d{1,2}),(.*)$");
+
+ private final Logger logger = LoggerFactory.getLogger(NuvoConnector.class);
+
+ protected static final String COMMAND_ERROR = "#?";
+
+ /** The output stream */
+ protected @Nullable OutputStream dataOut;
+
+ /** The input stream */
+ protected @Nullable InputStream dataIn;
+
+ /** true if the connection is established, false if not */
+ private boolean connected;
+
+ private @Nullable Thread readerThread;
+
+ private List listeners = new ArrayList<>();
+
+ private boolean isEssentia = true;
+
+ /**
+ * Get whether the connection is established or not
+ *
+ * @return true if the connection is established
+ */
+ public boolean isConnected() {
+ return connected;
+ }
+
+ /**
+ * Set whether the connection is established or not
+ *
+ * @param connected true if the connection is established
+ */
+ protected void setConnected(boolean connected) {
+ this.connected = connected;
+ }
+
+ /**
+ * Tell the connector if the device is an Essentia G or not
+ *
+ * @param true if the device is an Essentia G
+ */
+ public void setEssentia(boolean isEssentia) {
+ this.isEssentia = isEssentia;
+ }
+
+ /**
+ * Set the thread that handles the feedback messages
+ *
+ * @param readerThread the thread
+ */
+ protected void setReaderThread(Thread readerThread) {
+ this.readerThread = readerThread;
+ }
+
+ /**
+ * Open the connection with the Nuvo device
+ *
+ * @throws NuvoException - In case of any problem
+ */
+ public abstract void open() throws NuvoException;
+
+ /**
+ * Close the connection with the Nuvo device
+ */
+ public abstract void close();
+
+ /**
+ * Stop the thread that handles the feedback messages and close the opened input and output streams
+ */
+ protected void cleanup() {
+ Thread readerThread = this.readerThread;
+ OutputStream dataOut = this.dataOut;
+ if (dataOut != null) {
+ try {
+ dataOut.close();
+ } catch (IOException e) {
+ logger.debug("Error closing dataOut: {}", e.getMessage());
+ }
+ this.dataOut = null;
+ }
+ InputStream dataIn = this.dataIn;
+ if (dataIn != null) {
+ try {
+ dataIn.close();
+ } catch (IOException e) {
+ logger.debug("Error closing dataIn: {}", e.getMessage());
+ }
+ this.dataIn = null;
+ }
+ if (readerThread != null) {
+ readerThread.interrupt();
+ this.readerThread = null;
+ try {
+ readerThread.join(3000);
+ } catch (InterruptedException e) {
+ logger.warn("Error joining readerThread: {}", e.getMessage());
+ }
+ }
+ }
+
+ /**
+ * Reads some number of bytes from the input stream and stores them into the buffer array b. The number of bytes
+ * actually read is returned as an integer.
+ *
+ * @param dataBuffer the buffer into which the data is read.
+ *
+ * @return the total number of bytes read into the buffer, or -1 if there is no more data because the end of the
+ * stream has been reached.
+ *
+ * @throws NuvoException - If the input stream is null, if the first byte cannot be read for any reason
+ * other than the end of the file, if the input stream has been closed, or if some other I/O error
+ * occurs.
+ */
+ protected int readInput(byte[] dataBuffer) throws NuvoException {
+ InputStream dataIn = this.dataIn;
+ if (dataIn == null) {
+ throw new NuvoException("readInput failed: input stream is null");
+ }
+ try {
+ return dataIn.read(dataBuffer);
+ } catch (IOException e) {
+ throw new NuvoException("readInput failed: " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Request the Nuvo controller to execute an inquiry command
+ *
+ * @param zone the zone for which the command is to be run
+ * @param cmd the command to execute
+ *
+ * @throws NuvoException - In case of any problem
+ */
+ public void sendQuery(NuvoEnum zone, NuvoCommand cmd) throws NuvoException {
+ sendCommand(zone.getId() + cmd.getValue() + QUERY);
+ }
+
+ /**
+ * Request the Nuvo controller to execute a command for a zone that takes no arguments (ie power on, power off,
+ * etc.)
+ *
+ * @param zone the zone for which the command is to be run
+ * @param cmd the command to execute
+ *
+ * @throws NuvoException - In case of any problem
+ */
+ public void sendCommand(NuvoEnum zone, NuvoCommand cmd) throws NuvoException {
+ sendCommand(zone.getId() + cmd.getValue());
+ }
+
+ /**
+ * Request the Nuvo controller to execute a command for a zone and pass in a value
+ *
+ * @param zone the zone for which the command is to be run
+ * @param cmd the command to execute
+ * @param value the string value to consider for volume, source, etc.
+ *
+ * @throws NuvoException - In case of any problem
+ */
+ public void sendCommand(NuvoEnum zone, NuvoCommand cmd, @Nullable String value) throws NuvoException {
+ sendCommand(zone.getId() + cmd.getValue() + value);
+ }
+
+ /**
+ * Request the Nuvo controller to execute a configuration command for a zone and pass in a value
+ *
+ * @param zone the zone for which the command is to be run
+ * @param cmd the command to execute
+ * @param value the string value to consider for bass, treble, balance, etc.
+ *
+ * @throws NuvoException - In case of any problem
+ */
+ public void sendCfgCommand(NuvoEnum zone, NuvoCommand cmd, @Nullable String value) throws NuvoException {
+ sendCommand(zone.getConfigId() + cmd.getValue() + value);
+ }
+
+ /**
+ * Request the Nuvo controller to execute a system command the does not specify a zone or value
+ *
+ * @param cmd the command to execute
+ *
+ * @throws NuvoException - In case of any problem
+ */
+ public void sendCommand(NuvoCommand cmd) throws NuvoException {
+ sendCommand(cmd.getValue());
+ }
+
+ /**
+ * Request the Nuvo controller to execute a raw command string
+ *
+ * @param command the command string to run
+ *
+ * @throws NuvoException - In case of any problem
+ */
+ public void sendCommand(@Nullable String command) throws NuvoException {
+ String messageStr = BEGIN_CMD + command + END_CMD;
+
+ logger.debug("sending command: {}", messageStr);
+
+ OutputStream dataOut = this.dataOut;
+ if (dataOut == null) {
+ throw new NuvoException("Send command \"" + messageStr + "\" failed: output stream is null");
+ }
+ try {
+ // Essentia G needs time to wake up when in standby mode
+ // I don't want to track that in the binding, so just do this always
+ if (this.isEssentia) {
+ dataOut.write(WAKE_STR);
+ dataOut.flush();
+ }
+ dataOut.write(messageStr.getBytes(StandardCharsets.US_ASCII));
+ dataOut.flush();
+ } catch (IOException e) {
+ throw new NuvoException("Send command \"" + command + "\" failed: " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Add a listener to the list of listeners to be notified with events
+ *
+ * @param listener the listener
+ */
+ public void addEventListener(NuvoMessageEventListener listener) {
+ listeners.add(listener);
+ }
+
+ /**
+ * Remove a listener from the list of listeners to be notified with events
+ *
+ * @param listener the listener
+ */
+ public void removeEventListener(NuvoMessageEventListener listener) {
+ listeners.remove(listener);
+ }
+
+ /**
+ * Analyze an incoming message and dispatch corresponding (type, key, value) to the event listeners
+ *
+ * @param incomingMessage the received message
+ */
+ public void handleIncomingMessage(byte[] incomingMessage) {
+ String message = new String(incomingMessage, StandardCharsets.US_ASCII).trim();
+
+ logger.debug("handleIncomingMessage: {}", message);
+
+ if (COMMAND_ERROR.equals(message) || COMMAND_OK.equals(message)) {
+ // ignore
+ return;
+ }
+
+ if (message.contains(VER_STR)) {
+ // example: #VER"NV-E6G FWv2.66 HWv0"
+ // split on " and return the version number
+ dispatchKeyValue(TYPE_VERSION, "", message.split("\"")[1]);
+ return;
+ }
+
+ if (message.equals(ALL_OFF)) {
+ dispatchKeyValue(TYPE_ALLOFF, BLANK, BLANK);
+ return;
+ }
+
+ if (message.contains(MUTE)) {
+ dispatchKeyValue(TYPE_ALLMUTE, BLANK, message.substring(message.length() - 1));
+ return;
+ }
+
+ if (message.contains(PAGE)) {
+ dispatchKeyValue(TYPE_PAGE, BLANK, message.substring(message.length() - 1));
+ return;
+ }
+
+ // Amp controller send a source update ie: #S2DISPINFO,DUR3380,POS3090,STATUS2
+ // or #S2DISPLINE1,"1 of 17"
+ Matcher matcher = SRC_PATTERN.matcher(message);
+ if (matcher.find()) {
+ // pull out the source id and the remainder of the message
+ dispatchKeyValue(TYPE_SOURCE_UPDATE, matcher.group(1), matcher.group(2));
+ return;
+ }
+
+ // Amp controller send a zone update ie: #Z11,ON,SRC3,VOL63,DND0,LOCK0
+ matcher = ZONE_PATTERN.matcher(message);
+ if (matcher.find()) {
+ // pull out the zone id and the remainder of the message
+ dispatchKeyValue(TYPE_ZONE_UPDATE, matcher.group(1), matcher.group(2));
+ return;
+ }
+
+ // Amp controller send a zone button press event ie: #Z11S3PLAYPAUSE
+ matcher = ZONE_BUTTON_PATTERN.matcher(message);
+ if (matcher.find()) {
+ // pull out the source id and the remainder of the message, ignore the zone id
+ dispatchKeyValue(TYPE_ZONE_BUTTON, matcher.group(2), matcher.group(3));
+ return;
+ }
+
+ // Amp controller send a zone configuration response ie: #ZCFG1,BASS1,TREB-2,BALR2,LOUDCMP1
+ matcher = ZONE_CFG_PATTERN.matcher(message);
+ if (matcher.find()) {
+ // pull out the zone id and the remainder of the message
+ dispatchKeyValue(TYPE_ZONE_CONFIG, matcher.group(1), matcher.group(2));
+ return;
+ }
+
+ logger.debug("unhandled message: {}", message);
+ }
+
+ /**
+ * Dispatch an event (type, key, value) to the event listeners
+ *
+ * @param type the type
+ * @param key the key
+ * @param value the value
+ */
+ private void dispatchKeyValue(String type, String key, String value) {
+ NuvoMessageEvent event = new NuvoMessageEvent(this, type, key, value);
+ listeners.forEach(l -> l.onNewMessageEvent(event));
+ }
+}
diff --git a/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoDefaultConnector.java b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoDefaultConnector.java
new file mode 100644
index 0000000000000..f9fc45dbf0ac5
--- /dev/null
+++ b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoDefaultConnector.java
@@ -0,0 +1,41 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nuvo.internal.communication;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.nuvo.internal.NuvoException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Class to create a default NuvoDefaultConnector before initialization is complete.
+ *
+ * @author Laurent Garnier - Initial contribution
+ * @author Michael Lobstein - Adapted for the Nuvo binding
+ */
+@NonNullByDefault
+public class NuvoDefaultConnector extends NuvoConnector {
+
+ private final Logger logger = LoggerFactory.getLogger(NuvoDefaultConnector.class);
+
+ @Override
+ public void open() throws NuvoException {
+ logger.warn("Nuvo binding incorrectly configured. Please configure for Serial or IP over serial connection");
+ setConnected(false);
+ }
+
+ @Override
+ public void close() {
+ setConnected(false);
+ }
+}
diff --git a/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoEnum.java b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoEnum.java
new file mode 100644
index 0000000000000..a52bfa3f1f73a
--- /dev/null
+++ b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoEnum.java
@@ -0,0 +1,85 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nuvo.internal.communication;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Represents the different internal zone and source IDs of the Nuvo Whole House Amplifier
+ *
+ * @author Michael Lobstein - Initial contribution
+ */
+@NonNullByDefault
+public enum NuvoEnum {
+ SYSTEM("SYSTEM", "SYSTEM"),
+ ZONE1("Z1", "ZCFG1"),
+ ZONE2("Z2", "ZCFG2"),
+ ZONE3("Z3", "ZCFG3"),
+ ZONE4("Z4", "ZCFG4"),
+ ZONE5("Z5", "ZCFG5"),
+ ZONE6("Z6", "ZCFG6"),
+ ZONE7("Z7", "ZCFG7"),
+ ZONE8("Z8", "ZCFG8"),
+ ZONE9("Z9", "ZCFG9"),
+ ZONE10("Z10", "ZCFG10"),
+ ZONE11("Z11", "ZCFG11"),
+ ZONE12("Z12", "ZCFG12"),
+ ZONE13("Z13", "ZCFG13"),
+ ZONE14("Z14", "ZCFG14"),
+ ZONE15("Z15", "ZCFG15"),
+ ZONE16("Z16", "ZCFG16"),
+ ZONE17("Z17", "ZCFG17"),
+ ZONE18("Z18", "ZCFG18"),
+ ZONE19("Z19", "ZCFG19"),
+ ZONE20("Z20", "ZCFG20"),
+ SOURCE1("S1", "SCFG1"),
+ SOURCE2("S2", "SCFG2"),
+ SOURCE3("S3", "SCFG3"),
+ SOURCE4("S4", "SCFG4"),
+ SOURCE5("S5", "SCFG5"),
+ SOURCE6("S6", "SCFG6");
+
+ private final String id;
+ private final String cfgId;
+
+ // make a list of all valid source ids
+ public static final List VALID_SOURCES = Arrays.stream(values()).map(NuvoEnum::name)
+ .filter(s -> s.contains("SOURCE")).collect(Collectors.toList());
+
+ NuvoEnum(String id, String cfgId) {
+ this.id = id;
+ this.cfgId = cfgId;
+ }
+
+ /**
+ * Get the id
+ *
+ * @return the id
+ */
+ public String getId() {
+ return id;
+ }
+
+ /**
+ * Get the config id
+ *
+ * @return the config id
+ */
+ public String getConfigId() {
+ return cfgId;
+ }
+}
diff --git a/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoIpConnector.java b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoIpConnector.java
new file mode 100644
index 0000000000000..d4a51fc7264a2
--- /dev/null
+++ b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoIpConnector.java
@@ -0,0 +1,127 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nuvo.internal.communication;
+
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.Socket;
+import java.net.SocketTimeoutException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nuvo.internal.NuvoException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Class for communicating with the Nuvo device through a serial over IP connection
+ *
+ * @author Laurent Garnier - Initial contribution
+ * @author Michael Lobstein - Adapted for the Nuvo binding
+ */
+@NonNullByDefault
+public class NuvoIpConnector extends NuvoConnector {
+
+ private final Logger logger = LoggerFactory.getLogger(NuvoIpConnector.class);
+
+ private @Nullable final String address;
+ private final int port;
+ private final String uid;
+
+ private @Nullable Socket clientSocket;
+
+ /**
+ * Constructor
+ *
+ * @param address the IP address of the serial over ip adapter
+ * @param port the TCP port to be used
+ * @param uid the thing uid string
+ */
+ public NuvoIpConnector(@Nullable String address, int port, String uid) {
+ this.address = address;
+ this.port = port;
+ this.uid = uid;
+ }
+
+ @Override
+ public synchronized void open() throws NuvoException {
+ logger.debug("Opening IP connection on IP {} port {}", this.address, this.port);
+ try {
+ Socket clientSocket = new Socket(this.address, this.port);
+ clientSocket.setSoTimeout(100);
+
+ dataOut = new DataOutputStream(clientSocket.getOutputStream());
+ dataIn = new DataInputStream(clientSocket.getInputStream());
+
+ Thread thread = new NuvoReaderThread(this, this.uid, this.address + "." + this.port);
+ setReaderThread(thread);
+ thread.start();
+
+ this.clientSocket = clientSocket;
+
+ setConnected(true);
+
+ logger.debug("IP connection opened");
+ } catch (IOException | SecurityException | IllegalArgumentException e) {
+ setConnected(false);
+ throw new NuvoException("Opening IP connection failed: " + e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public synchronized void close() {
+ logger.debug("Closing IP connection");
+ super.cleanup();
+ Socket clientSocket = this.clientSocket;
+ if (clientSocket != null) {
+ try {
+ clientSocket.close();
+ } catch (IOException e) {
+ }
+ this.clientSocket = null;
+ }
+ setConnected(false);
+ logger.debug("IP connection closed");
+ }
+
+ /**
+ * Reads some number of bytes from the input stream and stores them into the buffer array b. The number of bytes
+ * actually read is returned as an integer.
+ * In case of socket timeout, the returned value is 0.
+ *
+ * @param dataBuffer the buffer into which the data is read.
+ *
+ * @return the total number of bytes read into the buffer, or -1 if there is no more data because the end of the
+ * stream has been reached.
+ *
+ * @throws NuvoException - If the input stream is null, if the first byte cannot be read for any reason
+ * other than the end of the file, if the input stream has been closed, or if some other I/O error
+ * occurs.
+ */
+ @Override
+ protected int readInput(byte[] dataBuffer) throws NuvoException {
+ InputStream dataIn = this.dataIn;
+ if (dataIn == null) {
+ throw new NuvoException("readInput failed: input stream is null");
+ }
+ try {
+ return dataIn.read(dataBuffer);
+ } catch (SocketTimeoutException e) {
+ return 0;
+ } catch (IOException e) {
+ throw new NuvoException("readInput failed: " + e.getMessage(), e);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoMessageEvent.java b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoMessageEvent.java
new file mode 100644
index 0000000000000..64b334c8283b0
--- /dev/null
+++ b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoMessageEvent.java
@@ -0,0 +1,49 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nuvo.internal.communication;
+
+import java.util.EventObject;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * NuvoMessageEvent event used to notify changes coming from messages received from the Nuvo device
+ *
+ * @author Michael Lobstein - Initial contribution
+ */
+@NonNullByDefault
+public class NuvoMessageEvent extends EventObject {
+ private static final long serialVersionUID = 1L;
+ private final String type;
+ private final String key;
+ private final String value;
+
+ public NuvoMessageEvent(Object source, String type, String key, String value) {
+ super(source);
+ this.type = type;
+ this.key = key;
+ this.value = value;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public String getKey() {
+ return key;
+ }
+
+ public String getValue() {
+ return value;
+ }
+}
diff --git a/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoMessageEventListener.java b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoMessageEventListener.java
new file mode 100644
index 0000000000000..481166666bccd
--- /dev/null
+++ b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoMessageEventListener.java
@@ -0,0 +1,33 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nuvo.internal.communication;
+
+import java.util.EventListener;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Nuvo Event Listener interface. Handles incoming Nuvo message events
+ *
+ * @author Michael Lobstein - Initial contribution
+ */
+@NonNullByDefault
+public interface NuvoMessageEventListener extends EventListener {
+
+ /**
+ * Event handler method for incoming Nuvo message events
+ *
+ * @param event the NuvoMessageEvent object
+ */
+ public void onNewMessageEvent(NuvoMessageEvent event);
+}
diff --git a/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoReaderThread.java b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoReaderThread.java
new file mode 100644
index 0000000000000..3e6b8f39cd2a9
--- /dev/null
+++ b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoReaderThread.java
@@ -0,0 +1,89 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nuvo.internal.communication;
+
+import java.util.Arrays;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.nuvo.internal.NuvoException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A class that reads messages from the Nuvo device in a dedicated thread
+ *
+ * @author Laurent Garnier - Initial contribution
+ * @author Michael Lobstein - Adapted for the Nuvo binding
+ */
+@NonNullByDefault
+public class NuvoReaderThread extends Thread {
+
+ private final Logger logger = LoggerFactory.getLogger(NuvoReaderThread.class);
+
+ private static final int READ_BUFFER_SIZE = 16;
+ private static final int SIZE = 256;
+
+ private static final char TERM_CHAR = '\r';
+
+ private NuvoConnector connector;
+
+ /**
+ * Constructor
+ *
+ * @param connector the object that should handle the received message
+ * @param uid the thing uid string
+ * @param connectionId a string that uniquely identifies the particular connection
+ */
+ public NuvoReaderThread(NuvoConnector connector, String uid, String connectionId) {
+ super("OH-binding-" + uid + "-" + connectionId);
+ this.connector = connector;
+ setDaemon(true);
+ }
+
+ @Override
+ public void run() {
+ logger.debug("Data listener started");
+
+ byte[] readDataBuffer = new byte[READ_BUFFER_SIZE];
+ byte[] dataBuffer = new byte[SIZE];
+ int index = 0;
+
+ try {
+ while (!Thread.interrupted()) {
+ int len = connector.readInput(readDataBuffer);
+ if (len > 0) {
+ for (int i = 0; i < len; i++) {
+
+ if (index < SIZE) {
+ dataBuffer[index++] = readDataBuffer[i];
+ }
+ if (readDataBuffer[i] == TERM_CHAR) {
+ if (index >= SIZE) {
+ dataBuffer[index - 1] = (byte) TERM_CHAR;
+ }
+ byte[] msg = Arrays.copyOf(dataBuffer, index);
+ connector.handleIncomingMessage(msg);
+ index = 0;
+ }
+
+ }
+ }
+ }
+ } catch (NuvoException e) {
+ logger.debug("Reading failed: {}", e.getMessage(), e);
+ connector.handleIncomingMessage(NuvoConnector.COMMAND_ERROR.getBytes());
+ }
+
+ logger.debug("Data listener stopped");
+ }
+}
diff --git a/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoSerialConnector.java b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoSerialConnector.java
new file mode 100644
index 0000000000000..51dd3214e749c
--- /dev/null
+++ b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoSerialConnector.java
@@ -0,0 +1,133 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nuvo.internal.communication;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.smarthome.io.transport.serial.PortInUseException;
+import org.eclipse.smarthome.io.transport.serial.SerialPort;
+import org.eclipse.smarthome.io.transport.serial.SerialPortIdentifier;
+import org.eclipse.smarthome.io.transport.serial.SerialPortManager;
+import org.eclipse.smarthome.io.transport.serial.UnsupportedCommOperationException;
+import org.openhab.binding.nuvo.internal.NuvoException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Class for communicating with the Nuvo device through a serial connection
+ *
+ * @author Laurent Garnier - Initial contribution
+ * @author Michael Lobstein - Adapted for the Nuvo binding
+ */
+@NonNullByDefault
+public class NuvoSerialConnector extends NuvoConnector {
+
+ private final Logger logger = LoggerFactory.getLogger(NuvoSerialConnector.class);
+
+ private final String serialPortName;
+ private final SerialPortManager serialPortManager;
+ private final String uid;
+
+ private @Nullable SerialPort serialPort;
+
+ /**
+ * Constructor
+ *
+ * @param serialPortManager the serial port manager
+ * @param serialPortName the serial port name to be used
+ * @param uid the thing uid string
+ */
+ public NuvoSerialConnector(SerialPortManager serialPortManager, String serialPortName, String uid) {
+ this.serialPortManager = serialPortManager;
+ this.serialPortName = serialPortName;
+ this.uid = uid;
+ }
+
+ @Override
+ public synchronized void open() throws NuvoException {
+ logger.debug("Opening serial connection on port {}", serialPortName);
+ try {
+ SerialPortIdentifier portIdentifier = serialPortManager.getIdentifier(serialPortName);
+ if (portIdentifier == null) {
+ setConnected(false);
+ logger.warn("Opening serial connection failed: No Such Port: {}", serialPortName);
+ throw new NuvoException("Opening serial connection failed: No Such Port");
+ }
+
+ SerialPort commPort = portIdentifier.open(this.getClass().getName(), 2000);
+
+ commPort.setSerialPortParams(57600, SerialPort.DATABITS_8, SerialPort.STOPBITS_1, SerialPort.PARITY_NONE);
+ commPort.enableReceiveThreshold(1);
+ commPort.enableReceiveTimeout(100);
+ commPort.setFlowControlMode(SerialPort.FLOWCONTROL_NONE);
+
+ InputStream dataIn = commPort.getInputStream();
+ OutputStream dataOut = commPort.getOutputStream();
+
+ if (dataOut != null) {
+ dataOut.flush();
+ }
+ if (dataIn != null && dataIn.markSupported()) {
+ try {
+ dataIn.reset();
+ } catch (IOException e) {
+ }
+ }
+
+ Thread thread = new NuvoReaderThread(this, this.uid, this.serialPortName);
+ setReaderThread(thread);
+ thread.start();
+
+ this.serialPort = commPort;
+ this.dataIn = dataIn;
+ this.dataOut = dataOut;
+
+ setConnected(true);
+
+ logger.debug("Serial connection opened");
+ } catch (PortInUseException e) {
+ setConnected(false);
+ throw new NuvoException("Opening serial connection failed: Port in Use Exception", e);
+ } catch (UnsupportedCommOperationException e) {
+ setConnected(false);
+ throw new NuvoException("Opening serial connection failed: Unsupported Comm Operation Exception", e);
+ } catch (UnsupportedEncodingException e) {
+ setConnected(false);
+ throw new NuvoException("Opening serial connection failed: Unsupported Encoding Exception", e);
+ } catch (IOException e) {
+ setConnected(false);
+ throw new NuvoException("Opening serial connection failed: IO Exception", e);
+ }
+ }
+
+ @Override
+ public synchronized void close() {
+ logger.debug("Closing serial connection");
+ SerialPort serialPort = this.serialPort;
+ if (serialPort != null) {
+ serialPort.removeEventListener();
+ }
+ super.cleanup();
+ if (serialPort != null) {
+ serialPort.close();
+ this.serialPort = null;
+ }
+ setConnected(false);
+ logger.debug("Serial connection closed");
+ }
+}
diff --git a/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoStatusCodes.java b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoStatusCodes.java
new file mode 100644
index 0000000000000..2cbd33c7ed729
--- /dev/null
+++ b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/communication/NuvoStatusCodes.java
@@ -0,0 +1,82 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nuvo.internal.communication;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Provides mapping of various Nuvo status codes to plain language meanings
+ *
+ * @author Michael Lobstein - Initial contribution
+ */
+
+@NonNullByDefault
+public class NuvoStatusCodes {
+ private static final String L = "L";
+ private static final String C = "C";
+ private static final String R = "R";
+ private static final String DASH = "-";
+ private static final String ZERO = "0";
+
+ // map to lookup play mode
+ public static final Map PLAY_MODE = new HashMap<>();
+ static {
+ PLAY_MODE.put("0", "Normal");
+ PLAY_MODE.put("1", "Idle");
+ PLAY_MODE.put("2", "Playing");
+ PLAY_MODE.put("3", "Paused");
+ PLAY_MODE.put("4", "Fast Forward");
+ PLAY_MODE.put("5", "Rewind");
+ PLAY_MODE.put("6", "Play Shuffle");
+ PLAY_MODE.put("7", "Play Repeat");
+ PLAY_MODE.put("8", "Play Shuffle Repeat");
+ PLAY_MODE.put("9", "unknown-9");
+ PLAY_MODE.put("10", "unknown-10");
+ PLAY_MODE.put("11", "Radio"); // undocumented
+ PLAY_MODE.put("12", "unknown-12");
+ }
+
+ /*
+ * This looks broken because the controller is seriously broken...
+ * On the keypad when adjusting the balance to "Left 18", the serial data reports R18 ¯\_(ツ)_/¯
+ * So on top of the weird translation, the value needs to be reversed by the binding
+ * to ensure that it will match what is displayed on the keypad.
+ * For display purposes we want -18 to be full left, 0 = center, and +18 to be full right
+ */
+ public static String getBalanceFromStr(String value) {
+ // example L2; return 2 | C; return 0 | R10; return -10
+ if (value.substring(0, 1).equals(L)) {
+ return (value.substring(1));
+ } else if (value.equals(C)) {
+ return ZERO;
+ } else if (value.substring(0, 1).equals(R)) {
+ return (DASH + value.substring(1));
+ }
+ return ZERO;
+ }
+
+ // see above comment
+ public static String getBalanceFromInt(Integer value) {
+ if (value < 0) {
+ return (L + Math.abs(value));
+ } else if (value == 0) {
+ return C;
+ } else if (value > 0) {
+ return (R + value);
+ }
+ return C;
+ }
+}
diff --git a/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/configuration/NuvoThingConfiguration.java b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/configuration/NuvoThingConfiguration.java
new file mode 100644
index 0000000000000..75367dc79f488
--- /dev/null
+++ b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/configuration/NuvoThingConfiguration.java
@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nuvo.internal.configuration;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link NuvoThingConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @author Michael Lobstein - Initial contribution
+ */
+@NonNullByDefault
+public class NuvoThingConfiguration {
+
+ public @Nullable String serialPort;
+ public @Nullable String host;
+ public @Nullable Integer port;
+ public @Nullable Integer numZones;
+ public boolean clockSync;
+}
diff --git a/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/handler/NuvoHandler.java b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/handler/NuvoHandler.java
new file mode 100644
index 0000000000000..97f653407814b
--- /dev/null
+++ b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/handler/NuvoHandler.java
@@ -0,0 +1,793 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nuvo.internal.handler;
+
+import static org.openhab.binding.nuvo.internal.NuvoBindingConstants.*;
+
+import java.math.BigDecimal;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import javax.measure.Unit;
+import javax.measure.quantity.Time;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.smarthome.core.library.types.DecimalType;
+import org.eclipse.smarthome.core.library.types.NextPreviousType;
+import org.eclipse.smarthome.core.library.types.OnOffType;
+import org.eclipse.smarthome.core.library.types.OpenClosedType;
+import org.eclipse.smarthome.core.library.types.PercentType;
+import org.eclipse.smarthome.core.library.types.PlayPauseType;
+import org.eclipse.smarthome.core.library.types.QuantityType;
+import org.eclipse.smarthome.core.library.types.StringType;
+import org.eclipse.smarthome.core.library.unit.SmartHomeUnits;
+import org.eclipse.smarthome.core.thing.Channel;
+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.BaseThingHandler;
+import org.eclipse.smarthome.core.thing.binding.ThingHandlerService;
+import org.eclipse.smarthome.core.types.Command;
+import org.eclipse.smarthome.core.types.State;
+import org.eclipse.smarthome.core.types.StateOption;
+import org.eclipse.smarthome.core.types.UnDefType;
+import org.eclipse.smarthome.io.transport.serial.SerialPortManager;
+import org.openhab.binding.nuvo.internal.NuvoException;
+import org.openhab.binding.nuvo.internal.NuvoStateDescriptionOptionProvider;
+import org.openhab.binding.nuvo.internal.NuvoThingActions;
+import org.openhab.binding.nuvo.internal.communication.NuvoCommand;
+import org.openhab.binding.nuvo.internal.communication.NuvoConnector;
+import org.openhab.binding.nuvo.internal.communication.NuvoDefaultConnector;
+import org.openhab.binding.nuvo.internal.communication.NuvoEnum;
+import org.openhab.binding.nuvo.internal.communication.NuvoIpConnector;
+import org.openhab.binding.nuvo.internal.communication.NuvoMessageEvent;
+import org.openhab.binding.nuvo.internal.communication.NuvoMessageEventListener;
+import org.openhab.binding.nuvo.internal.communication.NuvoSerialConnector;
+import org.openhab.binding.nuvo.internal.communication.NuvoStatusCodes;
+import org.openhab.binding.nuvo.internal.configuration.NuvoThingConfiguration;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link NuvoHandler} is responsible for handling commands, which are sent to one of the channels.
+ *
+ * Based on the Rotel binding by Laurent Garnier
+ *
+ * @author Michael Lobstein - Initial contribution
+ */
+@NonNullByDefault
+public class NuvoHandler extends BaseThingHandler implements NuvoMessageEventListener {
+ private static final long RECON_POLLING_INTERVAL_SEC = 60;
+ private static final long POLLING_INTERVAL_SEC = 30;
+ private static final long CLOCK_SYNC_INTERVAL_SEC = 3600;
+ private static final long INITIAL_POLLING_DELAY_SEC = 30;
+ private static final long INITIAL_CLOCK_SYNC_DELAY_SEC = 10;
+ // spec says wait 50ms, min is 100
+ private static final long SLEEP_BETWEEN_CMD_MS = 100;
+ private static final Unit