]> git.basschouten.com Git - openhab-addons.git/commitdiff
[emotiva] Initial contribution (#16499)
authorEspen Fossen <espenaf@junta.no>
Thu, 13 Jun 2024 20:33:14 +0000 (22:33 +0200)
committerGitHub <noreply@github.com>
Thu, 13 Jun 2024 20:33:14 +0000 (22:33 +0200)
* [emotiva] Initial contribution

Signed-off-by: Espen Fossen <espenaf@junta.no>
73 files changed:
CODEOWNERS
bom/openhab-addons/pom.xml
bundles/org.openhab.binding.emotiva/NOTICE [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/README.md [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/pom.xml [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/feature/feature.xml [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaBindingConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaCommandHelper.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaHandlerFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaProcessorHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaTranslationProvider.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaUdpBroadcastService.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaUdpReceivingService.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaUdpSendingService.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/InputStateOptionProvider.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/discovery/EmotivaDiscoveryService.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/AbstractJAXBElementDTO.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/AbstractNotificationDTO.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/ControlDTO.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaAckDTO.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaBarNotifyDTO.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaBarNotifyWrapper.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaCommandDTO.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaControlDTO.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaMenuCol.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaMenuNotifyDTO.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaMenuProgress.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaMenuRow.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaNotifyDTO.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaNotifyWrapper.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaPingDTO.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaPropertyDTO.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaSubscriptionDTO.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaSubscriptionRequest.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaSubscriptionResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaTransponderDTO.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaUnsubscribeDTO.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaUpdateRequest.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaUpdateResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaCommandType.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaControlCommands.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaControlRequest.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaDataType.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaPropertyStatus.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaProtocolVersion.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaSubscriptionTags.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaUdpResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaXmlUtils.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/OHChannelToEmotivaCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/resources/OH-INF/addon/addon.xml [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/resources/OH-INF/config/config.xml [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/resources/OH-INF/i18n/emotiva.properties [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/main/resources/OH-INF/thing/thing-types.xml [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/AbstractDTOTestBase.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/EmotivaCommandHelperTest.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaAckDTOTest.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaBarNotifyDTOTest.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaCommandDTOTest.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaControlDTOTest.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaMenuNotifyDTOTest.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaNotifyWrapperTest.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaPingDTOTest.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaPropertyDTOTest.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaSubscriptionRequestTest.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaSubscriptionResponseTest.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaTransponderDTOTest.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaUnsubscriptionTest.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaUpdateRequestTest.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaUpdateResponseTest.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/protocol/EmotivaControlRequestTest.java [new file with mode: 0644]
bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/protocol/EmotivaXmlUtilsTest.java [new file with mode: 0644]
bundles/pom.xml

index fbe2c959274aeb6379a3fa104ddaaddd606dfb55..49fc1684d31ab8c17377b63d760f0f905c6a49b9 100644 (file)
@@ -96,6 +96,7 @@
 /bundles/org.openhab.binding.electroluxair/ @jannegpriv
 /bundles/org.openhab.binding.elerotransmitterstick/ @vbier
 /bundles/org.openhab.binding.elroconnects/ @mherwege
+/bundles/org.openhab.binding.emotiva/ @espenaf
 /bundles/org.openhab.binding.energenie/ @hmerk
 /bundles/org.openhab.binding.energidataservice/ @jlaur
 /bundles/org.openhab.binding.enigma2/ @gdolfen
index 86fba9265726397da1e06985e53c4151ca2f9f41..f97b4b7010c7ef55f1f892dfe369d28d93e76c03 100644 (file)
       <artifactId>org.openhab.binding.elroconnects</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>org.openhab.addons.bundles</groupId>
+      <artifactId>org.openhab.binding.emotiva</artifactId>
+      <version>${project.version}</version>
+    </dependency>
     <dependency>
       <groupId>org.openhab.addons.bundles</groupId>
       <artifactId>org.openhab.binding.energenie</artifactId>
diff --git a/bundles/org.openhab.binding.emotiva/NOTICE b/bundles/org.openhab.binding.emotiva/NOTICE
new file mode 100644 (file)
index 0000000..38d625e
--- /dev/null
@@ -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.emotiva/README.md b/bundles/org.openhab.binding.emotiva/README.md
new file mode 100644 (file)
index 0000000..1008c66
--- /dev/null
@@ -0,0 +1,190 @@
+# Emotiva Binding
+
+This binding integrates Emotiva AV processors by using the Emotiva Network Remote Control protocol.
+
+## Supported Things
+
+This binding supports Emotiva processors with Emotiva Network Remote Control protocol support.
+The thing type for all of them is `processor`.
+
+Tested models: Emotiva XMC-2
+
+## Discovery
+
+The binding automatically discovers devices on your network.
+
+## Thing Configuration
+
+The Emotiva Processor thing requires the `ipAddress` it can connect to.
+There are more parameters which all have defaults set.
+
+| Parameter             | Values                                                        | Default |
+|-----------------------|---------------------------------------------------------------|---------|
+| ipAddress             | IP address of the processor                                   | -       |
+| controlPort           | port number, e.g. 7002                                        | 7002    |
+| notifyPort            | port number, e.g. 7003                                        | 7003    |
+| infoPort              | port number, e.g. 7004                                        | 7004    |
+| setupPortTCP          | port number, e.g. 7100                                        | 7100    |
+| menuNotifyPort        | port number, e.g. 7005                                        | 7005    |
+| protocolVersion       | Emotiva Network Protocol version, e.g. 3.0                    | 2.0     |
+| keepAlive             | Time between notification update from device, in milliseconds | 7500    |
+| retryConnectInMinutes | Time between connection retry, in minutes                     | 2       |
+
+
+## Channels
+
+The Emotiva Processor supports the following channels (some channels are model specific):
+
+| Channel Type ID                    | Item Type          | Description                                                |
+|------------------------------------|--------------------|------------------------------------------------------------|
+| _Main zone_                        |                    |                                                            |
+| main-zone#power                    | Switch (RW)        | Main zone power on/off                                     |      
+| main-zone#volume                   | Dimmer (RW)        | Main zone volume in percentage (0 to 100)                  |             
+| main-zone#volume-db                | Number (RW)        | Main zone volume in dB (-96 to 15)                         | 
+| main-zone#mute                     | Switch (RW)        | Main zone mute                                             | 
+| main-zone#source                   | String (RW)        | Main zone input (HDMI1, TUNER, ARC, ...)                   | 
+| _Zone 2_                           |                    |                                                            |
+| zone2#power                        | Switch (RW)        | Zone 2 power on/off                                        | 
+| zone2#volume                       | Dimmer (RW)        | Zone 2 volume in percentage (0 to 100)                     | 
+| zone2#volume-db                    | Number (RW)        | Zone 2 volume in dB (-80 offset)                           | 
+| zone2#mute                         | Switch (RW)        | Zone 2 mute                                                |
+| zone2#input                        | String (RW)        | Zone 2 input                                               |
+| _General_                          |                    |                                                            |
+| general#power                      | Switch (RW)        | Power on/off                                               |
+| general#standby                    | String (W)         | Set in standby mode                                        |
+| general#menu                       | String (RW)        | Enter or exit menu                                         |
+| general#menu-control               | String (W)         | Control menu via string commands                           |
+| general#up                         | String (W)         | Menu up                                                    |
+| general#down                       | String (W)         | Menu down                                                  |
+| general#left                       | String (W)         | Menu left                                                  |
+| general#right                      | String (W)         | Menu right                                                 |
+| general#enter                      | String (W)         | Menu enter                                                 |
+| general#dim                        | Switch (RW)        | Cycle through FP dimness settings                          |
+| general#mode                       | String (RW)        | Select audio mode (auto, dts, ...)                         |
+| general#info                       | String (W)         | Show info screen                                           |
+| general#speaker-preset             | String (RW)        | Select speaker presets (preset1, preset2)                  |
+| general#center                     | Number (RW)        | Center Volume increment up/down (0.5 step)                 |
+| general#subwoofer                  | Number (RW)        | Subwoofer Volume increment up/down (0.5 step)              |
+| general#surround                   | Number (RW)        | Surround Volume increment up/down (0.5 step)               |
+| general#back                       | Number (RW)        | Back Volume increment up/down (0.5 step)                   |
+| general#loudness                   | Switch (RW)        | Loudness on/off                                            |
+| general#treble                     | Number (RW)        | Treble Volume increment up/down (0.5 step)                 |
+| general#bass                       | Number (RW)        | Bass Volume increment up/down (0.5 step)                   |
+| general#frequenncy                 | Rollershutter (W)  | Frequency up/down, (100 kHz step)                          |
+| general#seek                       | Rollershutter (W)  | Seek signal up/down                                        |
+| general#channel                    | Rollershutter (W)  | Channel up/down                                            |
+| general#tuner-band                 | String (R)         | Tuner band, (AM, FM)                                       |
+| general#tuner-channel              | String (RW)        | User–assigned station name                                 |
+| general#tuner-signal               | String (R)         | Tuner signal quality                                       |
+| general#tuner-program              | String (R)         | Tuner program: "Country", "Rock", ...                      |
+| general#tuner-RDS                  | String (R)         | Tuner RDS string                                           |
+| general#audio-input                | String (R)         | Audio input source                                         |
+| general#audio-bitstream            | String (R)         | Audio input bitstream type: "PCM 2.0", "ATMOS", etc.       |
+| general#audio-bits                 | String (R)         | Audio input bits: "32kHZ 24bits", etc.                     |
+| general#video-input                | String (R)         | Video input source                                         |
+| general#video-format               | String (R)         | Video input format: "1920x1080i/60", "3840x2160p/60", etc. |
+| general#video-space                | String (R)         | Video input space: "YcbCr 8bits", etc.                     |
+| general#input-[1-8]                | String (R)         | User assigned input names                                  |
+| general#selected-mode              | String (R)         | User selected mode for the main zone                       |
+| general#selected-movie-music       | String (R)         | User selected movie or music mode for main zone            |
+| general#mode-ref-stereo            | String (R)         | Label for mode: Reference Stereo                           |
+| general#mode-stereo                | String (R)         | Label for mode: Stereo                                     |
+| general#mode-music                 | String (R)         | Label for mode: Music                                      |
+| general#mode-movie                 | String (R)         | Label for mode: Movie                                      |
+| general#mode-direct                | String (R)         | Label for mode: Direct                                     |
+| general#mode-dolby                 | String (R)         | Label for mode: Dolby                                      |
+| general#mode-dts                   | String (R)         | Label for mode: DTS                                        |
+| general#mode-all-stereo            | String (R)         | Label for mode: All Stereo                                 |
+| general#mode-auto                  | String (R)         | Label for mode: Auto                                       |
+| general#mode-surround              | String (RW)        | Select audio mode (Auto, Stereo, Dolby, ...)               |
+| general#width                      | Number (RW)        | Width Volume increment up/down (0.5 step)                  |
+| general#height                     | Number (RW)        | Height Volume increment up/down (0.5 step)                 |
+| general#bar                        | String (R)         | Text displayed on front panel bar of device                |
+| general#menu-display-highlight     | String (R)         | Menu Panel Display: Value in focus                         |
+| general#menu-display-top-start     | String (R)         | Menu Panel Display: Top bar, start cell                    |
+| general#menu-display-top-center    | String (R)         | Menu Panel Display: Top bar, center cell                   |
+| general#menu-display-top-end       | String (R)         | Menu Panel Display: Top bar, end cell                      |
+| general#menu-display-middle-start  | String (R)         | Menu Panel Display: Middle bar, start cell                 |
+| general#menu-display-middle-center | String (R)         | Menu Panel Display: Middle bar, center cell                |
+| general#menu-display-middle-end    | String (R)         | Menu Panel Display: Middle bar, end cell                   |
+| general#menu-display-bottom-start  | String (R)         | Menu Panel Display: Bottom bar, start cell                 |
+| general#menu-display-bottom-center | String (R)         | Menu Panel Display: Bottom bar, center cell                |
+| general#menu-display-bottom-end    | String (R)         | Menu Panel Display: Bottom bar, end cell                   |
+
+(R)  = read-only (no updates possible)
+(W)  = write-only
+(RW) = read-write
+
+## Full Example
+
+### `.things` file:
+
+```perl
+Thing emotiva:processor:1 "XMC-2" @ "Living room" [ipAddress="10.0.0.100", protocolVersion="3.0"]
+```
+
+### `.items` file:
+
+```perl
+Switch                  emotiva-power               "Processor"                     {channel="emotiva:processor:1:general#power"}
+Dimmer                  emotiva-volume              "Volume [%d %%]"                {channel="emotiva:processor:1:main-zone#volume"}
+Number:Dimensionless    emotiva-volume-db           "Volume [%d dB]"                {channel="emotiva:processor:1:main-zone#volume-db"}
+Switch                  emotiva-mute                "Mute"                          {channel="emotiva:processor:1:main-zone#mute"}
+String                  emotiva-source              "Source [%s]"                   {channel="emotiva:processor:1:main-zone#input"}
+String                  emotiva-mode-surround       "Surround Mode: [%s]"           {channel="emotiva:processor:1:general#mode-surround"}
+Number:Dimensionless    emotiva-speakers-center     "Center Trim [%.1f dB]"         {channel="emotiva:processor:1:general#center"}
+Switch                  emotiva-zone2power          "Zone 2"                        {channel="emotiva:processor:1:zone2#power"}
+String                  emotiva-front-panel-bar     "Bar Text"                      {channel="emotiva:processor:1:general#bar"}
+String                  emotiva-menu-control        "Menu Control"                  {channel="emotiva:processor:1:general#menu-control"}
+String                  emotiva-menu-hightlight     "Menu field focus"              {channel="emotiva:processor:1:general#menu-display-highlight"}
+String                  emotiva-menu-top-start      ""                      <none>  {channel="emotiva:processor:1:general#menu-display-top-start"}
+String                  emotiva-menu-top-center     ""                      <none>  {channel="emotiva:processor:1:general#menu-display-top-center"}
+String                  emotiva-menu-top-end        ""                      <none>  {channel="emotiva:processor:1:general#menu-display-top-end"}
+String                  emotiva-menu-middle-start   ""                      <none>  {channel="emotiva:processor:1:general#menu-display-middle-start"}
+String                  emotiva-menu-middle-center  ""                      <none>  {channel="emotiva:processor:1:general#menu-display-middle-center"}
+String                  emotiva-menu-middle-end     ""                      <none>  {channel="emotiva:processor:1:general#menu-display-middle-end"}
+String                  emotiva-menu-tottom-start   ""                      <none>  {channel="emotiva:processor:1:general#menu-display-bottom-start"}
+String                  emotiva-menu-tottom-center  ""                      <none>  {channel="emotiva:processor:1:general#menu-display-bottom-center"}
+String                  emotiva-menu-tottom-end     ""                      <none>  {channel="emotiva:processor:1:general#menu-display-bottom-end"}
+```
+
+### `.sitemap` file:
+
+```perl
+Group item=emotiva-input label="Processor" icon="receiver" {
+    Default   item=emotiva-power
+    Default   item=emotiva-mute             
+    Setpoint  item=emotiva-volume           
+    Default   item=emotiva-volume-db        step=2 minValue=-96.0 maxValue=15.0 
+    Selection item=emotiva-source           
+    Text      item=emotiva-mode-surround    
+    Setpoint  item=emotiva-speakers-center  step=0.5 minValue=-12.0 maxValue=12.0
+    Default   item=emotiva-zone2power
+}
+Frame label="Front Panel" {
+    Text item=emotiva-front-panel-bar
+    Text item=emotiva-menu-highlight
+    Frame label="" {
+        Text item=emotiva-menu-top-start
+        Text item=emotiva-menu-top-center
+        Text item=emotiva-menu-top-end
+    }
+    Frame label="" {
+        Text item=emotiva-menu-middle-start
+        Text item=emotiva-menu-middle-center
+        Text item=emotiva-menu-middle-end
+    }
+    Frame label="" {
+        Text item=emotiva-menu-bottom-start
+        Text item=emotiva-menu-bottom-center
+        Text item=emotiva-menu-bottom-end
+    }
+    Buttongrid label="Menu Control" staticIcon=material:control-camera item=emotiva-menu_control buttons=[1:1:POWER="Power"=switch-off , 1:2:MENU="Menu", 1:3:INFO="Info" , 2:2:UP="Up"=f7:arrowtriangle_up , 4:2:DOWN="Down"=f7:arrowtriangle_down , 3:1:LEFT="Left"=f7:arrowtriangle_left , 3:3:RIGHT="Right"=f7:arrowtriangle_right , 3:2:ENTER="Select" ]
+}
+```
+
+## Network Remote Control Protocol Reference
+
+These resources can be useful to learn what to send using the `command` channel:
+
+- [Emotiva Remote Interface Description](https://www.dropbox.com/sh/lvo9lbhu89jqfdb/AACa4iguvWK3I6ONjIpyM5Zca/Emotiva_Remote_Interface_Description%20V3.1.docx)
diff --git a/bundles/org.openhab.binding.emotiva/pom.xml b/bundles/org.openhab.binding.emotiva/pom.xml
new file mode 100644 (file)
index 0000000..9cdd681
--- /dev/null
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>org.openhab.addons.bundles</groupId>
+    <artifactId>org.openhab.addons.reactor.bundles</artifactId>
+    <version>4.2.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>org.openhab.binding.emotiva</artifactId>
+
+  <name>openHAB Add-ons :: Bundles :: Emotiva Binding</name>
+
+</project>
diff --git a/bundles/org.openhab.binding.emotiva/src/main/feature/feature.xml b/bundles/org.openhab.binding.emotiva/src/main/feature/feature.xml
new file mode 100644 (file)
index 0000000..df08780
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<features name="org.openhab.binding.emotiva-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
+       <repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
+
+       <feature name="openhab-binding-emotiva" description="Emotiva Binding" version="${project.version}">
+               <feature>openhab-runtime-base</feature>
+               <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.emotiva/${project.version}</bundle>
+       </feature>
+</features>
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaBindingConstants.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaBindingConstants.java
new file mode 100644 (file)
index 0000000..33d9fd0
--- /dev/null
@@ -0,0 +1,171 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.thing.ThingTypeUID;
+
+/**
+ * The {@link EmotivaBindingConstants} class defines common constants, which are used across the whole binding.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public class EmotivaBindingConstants {
+
+    public static final String BINDING_ID = "emotiva";
+
+    /** Property name to uniquely identify (discovered) things. */
+    static final String UNIQUE_PROPERTY_NAME = "ip4Address";
+
+    /** Default port used to discover Emotiva devices. */
+    static final int DEFAULT_PING_PORT = 7000;
+
+    /** Default port used to receive transponder (discovered) Emotiva devices. */
+    static final int DEFAULT_TRANSPONDER_PORT = 7001;
+
+    /** Default timeout in milliseconds for sending UDP packets. */
+    static final int DEFAULT_UDP_SENDING_TIMEOUT = 1000;
+
+    /** Number of connection attempts, set OFFLINE if no success and a retry job is then started. */
+    static final int DEFAULT_CONNECTION_RETRIES = 3;
+
+    /** Connection retry interval in minutes */
+    static final int DEFAULT_RETRY_INTERVAL_MINUTES = 2;
+
+    /**
+     * Default Emotiva device keep alive in milliseconds. {@link org.openhab.binding.emotiva.internal.dto.ControlDTO}
+     */
+    static final int DEFAULT_KEEP_ALIVE_IN_MILLISECONDS = 7500;
+
+    /** State name for storing keepAlive timestamp messages */
+    public static final String LAST_SEEN_STATE_NAME = "no-channel#last-seen";
+
+    /**
+     * Default Emotiva device considered list in milliseconds.
+     * {@link org.openhab.binding.emotiva.internal.dto.ControlDTO}
+     */
+    static final int DEFAULT_KEEP_ALIVE_CONSIDERED_LOST_IN_MILLISECONDS = 30000;
+
+    /** Default Emotiva control message value **/
+    public static final String DEFAULT_CONTROL_MESSAGE_SET_DEFAULT_VALUE = "0";
+
+    /** Default value for ack property in Emotiva control messages **/
+    public static final String DEFAULT_CONTROL_ACK_VALUE = "yes";
+
+    /** Default discovery timeout in seconds **/
+    public static final int DISCOVERY_TIMEOUT_SECONDS = 5;
+
+    /** Default discovery broadcast address **/
+    public static final String DISCOVERY_BROADCAST_ADDRESS = "255.255.255.255";
+
+    /** List of all Thing Type UIDs **/
+    static final ThingTypeUID THING_PROCESSOR = new ThingTypeUID(BINDING_ID, "processor");
+
+    public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = new HashSet<>(List.of(THING_PROCESSOR));
+
+    /** Default values for Emotiva channels **/
+    public static final String DEFAULT_EMOTIVA_PROTOCOL_VERSION = "2.0";
+    public static final int DEFAULT_VOLUME_MIN_DECIBEL = -96;
+    public static final int DEFAULT_VOLUME_MAX_DECIBEL = 15;
+    public static final int DEFAULT_TRIM_MIN_DECIBEL = -12;
+    public static final int DEFAULT_TRIM_MAX_DECIBEL = 12;
+    public static final String MAP_SOURCES_MAIN_ZONE = "sources";
+    public static final String MAP_SOURCES_ZONE_2 = "zone2-sources";
+
+    /** Miscellaneous Constants **/
+    public static final int PROTOCOL_V3_LEVEL_MULTIPLIER = 2;
+    public static final String TRIM_SET_COMMAND_SUFFIX = "_trim_set";
+    static final String MENU_PANEL_CHECKBOX_ON = "on";
+    static final String MENU_PANEL_HIGHLIGHTED = "true";
+    static final String EMOTIVA_SOURCE_COMMAND_PREFIX = "source_";
+
+    /** Emotiva Protocol V1 channels **/
+    public static final String CHANNEL_STANDBY = "general#standby";
+    public static final String CHANNEL_MAIN_ZONE_POWER = "main-zone#power";
+    public static final String CHANNEL_SOURCE = "main-zone#source";
+    public static final String CHANNEL_MENU = "general#menu";
+    public static final String CHANNEL_MENU_CONTROL = "general#menu-control";
+    public static final String CHANNEL_MENU_UP = "general#up";
+    public static final String CHANNEL_MENU_DOWN = "general#down";
+    public static final String CHANNEL_MENU_LEFT = "general#left";
+    public static final String CHANNEL_MENU_RIGHT = "general#right";
+    public static final String CHANNEL_MENU_ENTER = "general#enter";
+    public static final String CHANNEL_MUTE = "main-zone#mute";
+    public static final String CHANNEL_DIM = "general#dim";
+    public static final String CHANNEL_MODE = "general#mode";
+    public static final String CHANNEL_CENTER = "general#center";
+    public static final String CHANNEL_SUBWOOFER = "general#subwoofer";
+    public static final String CHANNEL_SURROUND = "general#surround";
+    public static final String CHANNEL_BACK = "general#back";
+    public static final String CHANNEL_MODE_SURROUND = "general#mode-surround";
+    public static final String CHANNEL_SPEAKER_PRESET = "general#speaker-preset";
+    public static final String CHANNEL_MAIN_VOLUME = "main-zone#volume";
+    public static final String CHANNEL_MAIN_VOLUME_DB = "main-zone#volume_db";
+    public static final String CHANNEL_LOUDNESS = "general#loudness";
+    public static final String CHANNEL_ZONE2_POWER = "zone2#power";
+    public static final String CHANNEL_ZONE2_VOLUME = "zone2#volume";
+    public static final String CHANNEL_ZONE2_VOLUME_DB = "zone2#volume-db";
+    public static final String CHANNEL_ZONE2_MUTE = "zone2#mute";
+    public static final String CHANNEL_ZONE2_SOURCE = "zone2#source";
+    public static final String CHANNEL_FREQUENCY = "general#frequency";
+    public static final String CHANNEL_SEEK = "general#seek";
+    public static final String CHANNEL_CHANNEL = "general#channel";
+    public static final String CHANNEL_TUNER_BAND = "general#tuner-band";
+    public static final String CHANNEL_TUNER_CHANNEL = "general#tuner-channel";
+    public static final String CHANNEL_TUNER_CHANNEL_SELECT = "general#tuner-channel-select";
+    public static final String CHANNEL_TUNER_SIGNAL = "general#tuner-signal";
+    public static final String CHANNEL_TUNER_PROGRAM = "general#tuner-program";
+    public static final String CHANNEL_TUNER_RDS = "general#tuner-RDS";
+    public static final String CHANNEL_AUDIO_INPUT = "general#audio-input";
+    public static final String CHANNEL_AUDIO_BITSTREAM = "general#audio-bitstream";
+    public static final String CHANNEL_AUDIO_BITS = "general#audio-bits";
+    public static final String CHANNEL_VIDEO_INPUT = "general#video-input";
+    public static final String CHANNEL_VIDEO_FORMAT = "general#video-format";
+    public static final String CHANNEL_VIDEO_SPACE = "general#video-space";
+    public static final String CHANNEL_INPUT1 = "general#input-1";
+    public static final String CHANNEL_INPUT2 = "general#input-2";
+    public static final String CHANNEL_INPUT3 = "general#input-3";
+    public static final String CHANNEL_INPUT4 = "general#input-4";
+    public static final String CHANNEL_INPUT5 = "general#input-5";
+    public static final String CHANNEL_INPUT6 = "general#input-6";
+    public static final String CHANNEL_INPUT7 = "general#input-7";
+    public static final String CHANNEL_INPUT8 = "general#input-8";
+    public static final String CHANNEL_MODE_REF_STEREO = "general#mode-ref-stereo";
+    public static final String CHANNEL_SURROUND_MODE = "general#surround-mode";
+    public static final String CHANNEL_MODE_STEREO = "general#mode-stereo";
+    public static final String CHANNEL_MODE_MUSIC = "general#mode-music";
+    public static final String CHANNEL_MODE_MOVIE = "general#mode-movie";
+    public static final String CHANNEL_MODE_DIRECT = "general#mode-direct";
+    public static final String CHANNEL_MODE_DOLBY = "general#mode-dolby";
+    public static final String CHANNEL_MODE_DTS = "general#mode-dts";
+    public static final String CHANNEL_MODE_ALL_STEREO = "general#mode-all-stereo";
+    public static final String CHANNEL_MODE_AUTO = "general#mode-auto";
+
+    /** Emotiva Protocol V2 channels **/
+    public static final String CHANNEL_SELECTED_MODE = "general#selected-mode";
+    public static final String CHANNEL_SELECTED_MOVIE_MUSIC = "general#selected-movie-music";
+
+    /** Emotiva Protocol V3 channels **/
+    public static final String CHANNEL_TREBLE = "general#treble";
+    public static final String CHANNEL_BASS = "general#bass";
+    public static final String CHANNEL_WIDTH = "general#width";
+    public static final String CHANNEL_HEIGHT = "general#height";
+    public static final String CHANNEL_BAR = "general#bar";
+    public static final String CHANNEL_MENU_DISPLAY_PREFIX = "general#menu-display";
+    public static final String CHANNEL_MENU_DISPLAY_HIGHLIGHT = "general#menu-display-highlight";
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaCommandHelper.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaCommandHelper.java
new file mode 100644 (file)
index 0000000..ec79969
--- /dev/null
@@ -0,0 +1,112 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal;
+
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.*;
+
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaControlRequest;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaProtocolVersion;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags;
+import org.openhab.binding.emotiva.internal.protocol.OHChannelToEmotivaCommand;
+import org.openhab.core.library.types.PercentType;
+
+/**
+ * Helper class for Emotiva commands.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public class EmotivaCommandHelper {
+
+    public static PercentType volumeDecibelToPercentage(String volumeInDecibel) {
+        String volumeTrimmed = volumeInDecibel.replace("dB", "").trim();
+        int clampedValue = clamp(volumeTrimmed, DEFAULT_VOLUME_MIN_DECIBEL, DEFAULT_VOLUME_MAX_DECIBEL);
+        return new PercentType(Math.round((100 - ((float) Math.abs(clampedValue - DEFAULT_VOLUME_MAX_DECIBEL)
+                / Math.abs(DEFAULT_VOLUME_MIN_DECIBEL - DEFAULT_VOLUME_MAX_DECIBEL)) * 100)));
+    }
+
+    public static double integerToPercentage(int integer) {
+        int clampedValue = clamp(integer, 0, 100);
+        return Math.round((100 - ((float) Math.abs(clampedValue - 100) / Math.abs(-100)) * 100));
+    }
+
+    public static int volumePercentageToDecibel(int volumeInPercentage) {
+        int clampedValue = clamp(volumeInPercentage, 0, 100);
+        return (clampedValue * (DEFAULT_VOLUME_MAX_DECIBEL - DEFAULT_VOLUME_MIN_DECIBEL) / 100)
+                + DEFAULT_VOLUME_MIN_DECIBEL;
+    }
+
+    public static int volumePercentageToDecibel(String volumeInPercentage) {
+        String volumeInPercentageTrimmed = volumeInPercentage.replace("%", "").trim();
+        int clampedValue = clamp(volumeInPercentageTrimmed, 0, 100);
+        return (clampedValue * (DEFAULT_VOLUME_MAX_DECIBEL - DEFAULT_VOLUME_MIN_DECIBEL) / 100)
+                + DEFAULT_VOLUME_MIN_DECIBEL;
+    }
+
+    public static double clamp(Number value, double min, double max) {
+        return Math.min(Math.max(value.intValue(), min), max);
+    }
+
+    private static int clamp(String volumeInPercentage, int min, int max) {
+        return Math.min(Math.max(Double.valueOf(volumeInPercentage.trim()).intValue(), min), max);
+    }
+
+    private static int clamp(int volumeInPercentage, int min, int max) {
+        return Math.min(Math.max(Double.valueOf(volumeInPercentage).intValue(), min), max);
+    }
+
+    public static EmotivaControlRequest channelToControlRequest(String id,
+            Map<String, Map<EmotivaControlCommands, String>> commandMaps, EmotivaProtocolVersion protocolVersion) {
+        EmotivaSubscriptionTags channelSubscription = EmotivaSubscriptionTags.fromChannelUID(id);
+        EmotivaControlCommands channelFromCommand = OHChannelToEmotivaCommand.fromChannelUID(id);
+        return new EmotivaControlRequest(id, channelSubscription, channelFromCommand, commandMaps, protocolVersion);
+    }
+
+    public static String getMenuPanelRowLabel(int row) {
+        return switch (row) {
+            case 4 -> "top";
+            case 5 -> "middle";
+            case 6 -> "bottom";
+            default -> "";
+        };
+    }
+
+    public static String getMenuPanelColumnLabel(int column) {
+        return switch (column) {
+            case 0 -> "start";
+            case 1 -> "center";
+            case 2 -> "end";
+            default -> "";
+        };
+    }
+
+    public static String updateProgress(double progressPercentage) {
+        final int width = 30;
+        StringBuilder sb = new StringBuilder();
+
+        sb.append("[");
+        int i = 0;
+        for (; i <= (int) (progressPercentage * width); i++) {
+            sb.append(".");
+        }
+        for (; i < width; i++) {
+            sb.append(" ");
+        }
+        sb.append("]");
+        return sb.toString();
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaConfiguration.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaConfiguration.java
new file mode 100644 (file)
index 0000000..0d54d35
--- /dev/null
@@ -0,0 +1,36 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal;
+
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.*;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link EmotivaConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public class EmotivaConfiguration {
+
+    public String ipAddress = "";
+    public int controlPort = 7002;
+    public int notifyPort = 7003;
+    public int infoPort = 7004;
+    public int setupPortTCP = 7100;
+    public int menuNotifyPort = 7005;
+    public String protocolVersion = DEFAULT_EMOTIVA_PROTOCOL_VERSION;
+    public int keepAlive = DEFAULT_KEEP_ALIVE_IN_MILLISECONDS;
+    public int retryConnectInMinutes = DEFAULT_RETRY_INTERVAL_MINUTES;
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaHandlerFactory.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaHandlerFactory.java
new file mode 100644 (file)
index 0000000..52f9e1d
--- /dev/null
@@ -0,0 +1,72 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal;
+
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.THING_PROCESSOR;
+
+import java.util.Set;
+
+import javax.xml.bind.JAXBException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.binding.BaseThingHandlerFactory;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerFactory;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link org.openhab.core.thing.binding.ThingHandlerFactory} is responsible for creating things and thing
+ * handlers.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+@Component(configurationPid = "binding.emotiva", service = ThingHandlerFactory.class)
+public class EmotivaHandlerFactory extends BaseThingHandlerFactory {
+
+    private final Logger logger = LoggerFactory.getLogger(EmotivaHandlerFactory.class);
+    private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_PROCESSOR);
+    private final EmotivaTranslationProvider i18nProvider;
+
+    @Activate
+    public EmotivaHandlerFactory(final @Reference EmotivaTranslationProvider i18nProvider) {
+        this.i18nProvider = i18nProvider;
+    }
+
+    @Override
+    public boolean supportsThingType(ThingTypeUID thingTypeUID) {
+        return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
+    }
+
+    @Override
+    protected @Nullable ThingHandler createHandler(Thing thing) {
+        ThingTypeUID thingTypeUID = thing.getThingTypeUID();
+
+        if (THING_PROCESSOR.equals(thingTypeUID)) {
+            try {
+                return new EmotivaProcessorHandler(thing, i18nProvider);
+            } catch (JAXBException e) {
+                logger.debug("Could not create Emotiva Process Handler", e);
+            }
+        }
+
+        return null;
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaProcessorHandler.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaProcessorHandler.java
new file mode 100644 (file)
index 0000000..9dceae4
--- /dev/null
@@ -0,0 +1,786 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal;
+
+import static java.lang.String.format;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.*;
+import static org.openhab.binding.emotiva.internal.EmotivaCommandHelper.channelToControlRequest;
+import static org.openhab.binding.emotiva.internal.EmotivaCommandHelper.getMenuPanelColumnLabel;
+import static org.openhab.binding.emotiva.internal.EmotivaCommandHelper.getMenuPanelRowLabel;
+import static org.openhab.binding.emotiva.internal.EmotivaCommandHelper.updateProgress;
+import static org.openhab.binding.emotiva.internal.EmotivaCommandHelper.volumeDecibelToPercentage;
+import static org.openhab.binding.emotiva.internal.EmotivaCommandHelper.volumePercentageToDecibel;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.band_am;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.band_fm;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.channel_1;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.none;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.power_on;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaDataType.STRING;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaPropertyStatus.NOT_VALID;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaProtocolVersion.protocolFromConfig;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags.noSubscriptionToChannel;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags.tuner_band;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags.tuner_channel;
+
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumMap;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import javax.measure.quantity.Frequency;
+import javax.xml.bind.JAXBException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.emotiva.internal.dto.AbstractNotificationDTO;
+import org.openhab.binding.emotiva.internal.dto.EmotivaAckDTO;
+import org.openhab.binding.emotiva.internal.dto.EmotivaBarNotifyDTO;
+import org.openhab.binding.emotiva.internal.dto.EmotivaBarNotifyWrapper;
+import org.openhab.binding.emotiva.internal.dto.EmotivaControlDTO;
+import org.openhab.binding.emotiva.internal.dto.EmotivaMenuNotifyDTO;
+import org.openhab.binding.emotiva.internal.dto.EmotivaNotifyDTO;
+import org.openhab.binding.emotiva.internal.dto.EmotivaNotifyWrapper;
+import org.openhab.binding.emotiva.internal.dto.EmotivaPropertyDTO;
+import org.openhab.binding.emotiva.internal.dto.EmotivaSubscriptionResponse;
+import org.openhab.binding.emotiva.internal.dto.EmotivaUpdateResponse;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaCommandType;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaControlRequest;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaDataType;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaUdpResponse;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaXmlUtils;
+import org.openhab.core.common.NamedThreadFactory;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.openhab.core.types.State;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The EmotivaProcessorHandler is responsible for handling OpenHAB commands, which are
+ * sent to one of the channels.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public class EmotivaProcessorHandler extends BaseThingHandler {
+
+    private final Logger logger = LoggerFactory.getLogger(EmotivaProcessorHandler.class);
+
+    private final Map<String, State> stateMap = Collections.synchronizedMap(new HashMap<>());
+
+    private final EmotivaConfiguration config;
+
+    /**
+     * Emotiva devices have trouble with too many subscriptions in same request, so subscriptions are dividing into
+     * those general group channels, and the rest.
+     */
+    private final EmotivaSubscriptionTags[] generalSubscription = EmotivaSubscriptionTags.generalChannels();
+    private final EmotivaSubscriptionTags[] nonGeneralSubscriptions = EmotivaSubscriptionTags.nonGeneralChannels();
+
+    private final EnumMap<EmotivaControlCommands, String> sourcesMainZone;
+    private final EnumMap<EmotivaControlCommands, String> sourcesZone2;
+    private final EnumMap<EmotivaSubscriptionTags, String> modes;
+    private final Map<String, Map<EmotivaControlCommands, String>> commandMaps = new ConcurrentHashMap<>();
+    private final EmotivaTranslationProvider i18nProvider;
+
+    private @Nullable ScheduledFuture<?> pollingJob;
+    private @Nullable ScheduledFuture<?> connectRetryJob;
+    private @Nullable EmotivaUdpSendingService sendingService;
+    private @Nullable EmotivaUdpReceivingService notifyListener;
+    private @Nullable EmotivaUdpReceivingService menuNotifyListener;
+
+    private final int retryConnectInMinutes;
+
+    /**
+     * Thread factory for menu progress bar
+     */
+    private final NamedThreadFactory listeningThreadFactory = new NamedThreadFactory(BINDING_ID, true);
+
+    private final EmotivaXmlUtils xmlUtils = new EmotivaXmlUtils();
+
+    private boolean udpSenderActive = false;
+
+    public EmotivaProcessorHandler(Thing thing, EmotivaTranslationProvider i18nProvider) throws JAXBException {
+        super(thing);
+        this.i18nProvider = i18nProvider;
+        this.config = getConfigAs(EmotivaConfiguration.class);
+        this.retryConnectInMinutes = config.retryConnectInMinutes;
+
+        sourcesMainZone = new EnumMap<>(EmotivaControlCommands.class);
+        commandMaps.put(MAP_SOURCES_MAIN_ZONE, sourcesMainZone);
+
+        sourcesZone2 = new EnumMap<>(EmotivaControlCommands.class);
+        commandMaps.put(MAP_SOURCES_ZONE_2, sourcesZone2);
+
+        EnumMap<EmotivaControlCommands, String> channels = new EnumMap<>(
+                Map.ofEntries(Map.entry(channel_1, channel_1.getLabel()),
+                        Map.entry(EmotivaControlCommands.channel_2, EmotivaControlCommands.channel_2.getLabel()),
+                        Map.entry(EmotivaControlCommands.channel_3, EmotivaControlCommands.channel_3.getLabel()),
+                        Map.entry(EmotivaControlCommands.channel_4, EmotivaControlCommands.channel_4.getLabel()),
+                        Map.entry(EmotivaControlCommands.channel_5, EmotivaControlCommands.channel_5.getLabel()),
+                        Map.entry(EmotivaControlCommands.channel_6, EmotivaControlCommands.channel_6.getLabel()),
+                        Map.entry(EmotivaControlCommands.channel_7, EmotivaControlCommands.channel_7.getLabel()),
+                        Map.entry(EmotivaControlCommands.channel_8, EmotivaControlCommands.channel_8.getLabel()),
+                        Map.entry(EmotivaControlCommands.channel_9, EmotivaControlCommands.channel_9.getLabel()),
+                        Map.entry(EmotivaControlCommands.channel_10, EmotivaControlCommands.channel_10.getLabel()),
+                        Map.entry(EmotivaControlCommands.channel_11, EmotivaControlCommands.channel_11.getLabel()),
+                        Map.entry(EmotivaControlCommands.channel_12, EmotivaControlCommands.channel_12.getLabel()),
+                        Map.entry(EmotivaControlCommands.channel_13, EmotivaControlCommands.channel_13.getLabel()),
+                        Map.entry(EmotivaControlCommands.channel_14, EmotivaControlCommands.channel_14.getLabel()),
+                        Map.entry(EmotivaControlCommands.channel_15, EmotivaControlCommands.channel_15.getLabel()),
+                        Map.entry(EmotivaControlCommands.channel_16, EmotivaControlCommands.channel_16.getLabel()),
+                        Map.entry(EmotivaControlCommands.channel_17, EmotivaControlCommands.channel_17.getLabel()),
+                        Map.entry(EmotivaControlCommands.channel_18, EmotivaControlCommands.channel_18.getLabel()),
+                        Map.entry(EmotivaControlCommands.channel_19, EmotivaControlCommands.channel_19.getLabel()),
+                        Map.entry(EmotivaControlCommands.channel_20, EmotivaControlCommands.channel_20.getLabel())));
+        commandMaps.put(tuner_channel.getEmotivaName(), channels);
+
+        EnumMap<EmotivaControlCommands, String> bands = new EnumMap<>(
+                Map.of(band_am, band_am.getLabel(), band_fm, band_fm.getLabel()));
+        commandMaps.put(tuner_band.getEmotivaName(), bands);
+
+        modes = new EnumMap<>(EmotivaSubscriptionTags.class);
+    }
+
+    @Override
+    public void initialize() {
+        logger.debug("Initialize: '{}'", getThing().getUID());
+        updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, "@text/message.processor.connecting");
+        if (config.controlPort < 0) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    "@text/message.processor.connection.error.port");
+            return;
+        }
+        if (config.ipAddress.trim().isEmpty()) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    "@text/message.processor.connection.error.address-empty");
+            return;
+        } else {
+            try {
+                // noinspection ResultOfMethodCallIgnored
+                InetAddress.getByName(config.ipAddress);
+            } catch (UnknownHostException ignored) {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                        "@text/message.processor.connection.error.address-invalid");
+                return;
+            }
+        }
+
+        scheduler.execute(this::connect);
+    }
+
+    private synchronized void connect() {
+        final EmotivaConfiguration localConfig = config;
+        try {
+            final EmotivaUdpReceivingService notifyListener = new EmotivaUdpReceivingService(localConfig.notifyPort,
+                    localConfig, scheduler);
+            this.notifyListener = notifyListener;
+            notifyListener.connect(this::handleStatusUpdate, true);
+
+            final EmotivaUdpSendingService sendConnector = new EmotivaUdpSendingService(localConfig, scheduler);
+            sendingService = sendConnector;
+            sendConnector.connect(this::handleStatusUpdate, true);
+
+            // Simple retry mechanism to handle minor network issues, if this fails a retry job is created
+            for (int attempt = 1; attempt <= DEFAULT_CONNECTION_RETRIES && !udpSenderActive; attempt++) {
+                try {
+                    logger.debug("Connection attempt '{}'", attempt);
+                    sendConnector.sendSubscription(generalSubscription, config);
+                    sendConnector.sendSubscription(nonGeneralSubscriptions, config);
+                } catch (IOException e) {
+                    // network or socket failure, also wait 2 sec and try again
+                }
+
+                for (int delay = 0; delay < 10 && !udpSenderActive; delay++) {
+                    Thread.sleep(200); // wait 10 x 200ms = 2sec
+                }
+            }
+
+            if (udpSenderActive) {
+                updateStatus(ThingStatus.ONLINE);
+
+                final EmotivaUdpReceivingService menuListenerConnector = new EmotivaUdpReceivingService(
+                        localConfig.menuNotifyPort, localConfig, scheduler);
+                this.menuNotifyListener = menuListenerConnector;
+                menuListenerConnector.connect(this::handleStatusUpdate, true);
+
+                startPollingKeepAlive();
+            } else {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR,
+                        "@text/message.processor.connection.failed");
+                disconnect();
+                scheduleConnectRetry(retryConnectInMinutes);
+            }
+        } catch (InterruptedException e) {
+            // OH shutdown - don't log anything, Framework will call dispose()
+        } catch (Exception e) {
+            logger.error("Connection to '{}' failed", localConfig.ipAddress, e);
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR,
+                    "@text/message.processor.connection.failed");
+            disconnect();
+            scheduleConnectRetry(retryConnectInMinutes);
+        }
+    }
+
+    private void scheduleConnectRetry(long waitMinutes) {
+        logger.debug("Scheduling connection retry in '{}' minutes", waitMinutes);
+        connectRetryJob = scheduler.schedule(this::connect, waitMinutes, TimeUnit.MINUTES);
+    }
+
+    /**
+     * Starts a polling job for connection to th device, adds the
+     * {@link EmotivaBindingConstants#DEFAULT_KEEP_ALIVE_IN_MILLISECONDS} as a time buffer for checking, to avoid
+     * flapping state or minor network issues.
+     */
+    private void startPollingKeepAlive() {
+        final ScheduledFuture<?> localRefreshJob = this.pollingJob;
+        if (localRefreshJob == null || localRefreshJob.isCancelled()) {
+            logger.debug("Start polling");
+
+            int delay = stateMap.get(EmotivaSubscriptionTags.keepAlive.name()) != null
+                    && stateMap.get(EmotivaSubscriptionTags.keepAlive.name()) instanceof Number keepAlive
+                            ? keepAlive.intValue()
+                            : config.keepAlive;
+            pollingJob = scheduler.scheduleWithFixedDelay(this::checkKeepAliveTimestamp,
+                    delay + DEFAULT_KEEP_ALIVE_IN_MILLISECONDS, delay + DEFAULT_KEEP_ALIVE_IN_MILLISECONDS,
+                    TimeUnit.MILLISECONDS);
+        }
+    }
+
+    private void checkKeepAliveTimestamp() {
+        if (ThingStatus.ONLINE.equals(getThing().getStatusInfo().getStatus())) {
+            State state = stateMap.get(LAST_SEEN_STATE_NAME);
+            if (state instanceof Number value) {
+                Instant lastKeepAliveMessageTimestamp = Instant.ofEpochSecond(value.longValue());
+                Instant deviceGoneGracePeriod = Instant.now().minus(config.keepAlive, ChronoUnit.MILLIS)
+                        .minus(DEFAULT_KEEP_ALIVE_CONSIDERED_LOST_IN_MILLISECONDS, ChronoUnit.MILLIS);
+                if (lastKeepAliveMessageTimestamp.isBefore(deviceGoneGracePeriod)) {
+                    logger.debug(
+                            "Last KeepAlive message received '{}', over grace-period by '{}', consider '{}' gone, setting OFFLINE and disposing",
+                            lastKeepAliveMessageTimestamp,
+                            Duration.between(lastKeepAliveMessageTimestamp, deviceGoneGracePeriod),
+                            thing.getThingTypeUID());
+                    updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+                            "@text/message.processor.connection.error.keep-alive");
+                    // Connection lost, avoid sending unsubscription messages
+                    udpSenderActive = false;
+                    disconnect();
+                    scheduleConnectRetry(retryConnectInMinutes);
+                }
+            }
+        } else if (ThingStatus.OFFLINE.equals(getThing().getStatusInfo().getStatus())) {
+            logger.debug("Keep alive pool job, '{}' is '{}'", getThing().getThingTypeUID(),
+                    getThing().getStatusInfo().getStatus());
+        }
+    }
+
+    private void handleStatusUpdate(EmotivaUdpResponse emotivaUdpResponse) {
+        udpSenderActive = true;
+        logger.debug("Received data from '{}' with length '{}'", emotivaUdpResponse.ipAddress(),
+                emotivaUdpResponse.answer().length());
+
+        Object object;
+        try {
+            object = xmlUtils.unmarshallToEmotivaDTO(emotivaUdpResponse.answer());
+        } catch (JAXBException e) {
+            logger.debug("Could not unmarshal answer from '{}' with length '{}' and content '{}'",
+                    emotivaUdpResponse.ipAddress(), emotivaUdpResponse.answer().length(), emotivaUdpResponse.answer(),
+                    e);
+            return;
+        }
+
+        if (object instanceof EmotivaAckDTO answerDto) {
+            // Currently not supported to revert a failed command update, just used for logging for now.
+            logger.trace("Processing received '{}' with '{}'", EmotivaAckDTO.class.getSimpleName(), answerDto);
+
+        } else if (object instanceof EmotivaBarNotifyWrapper answerDto) {
+            logger.trace("Processing received '{}' with '{}'", EmotivaBarNotifyWrapper.class.getSimpleName(),
+                    emotivaUdpResponse.answer());
+
+            List<EmotivaBarNotifyDTO> emotivaBarNotifies = xmlUtils.unmarshallToBarNotify(answerDto.getTags());
+
+            if (!emotivaBarNotifies.isEmpty()) {
+                if (emotivaBarNotifies.get(0).getType() != null) {
+                    findChannelDatatypeAndUpdateChannel(CHANNEL_BAR, emotivaBarNotifies.get(0).formattedMessage(),
+                            STRING);
+                }
+            }
+        } else if (object instanceof EmotivaNotifyWrapper answerDto) {
+            logger.trace("Processing received '{}' with '{}'", EmotivaNotifyWrapper.class.getSimpleName(),
+                    emotivaUdpResponse.answer());
+            handleNotificationUpdate(answerDto);
+        } else if (object instanceof EmotivaUpdateResponse answerDto) {
+            logger.trace("Processing received '{}' with '{}'", EmotivaUpdateResponse.class.getSimpleName(),
+                    emotivaUdpResponse.answer());
+            handleNotificationUpdate(answerDto);
+        } else if (object instanceof EmotivaMenuNotifyDTO answerDto) {
+            logger.trace("Processing received '{}' with '{}'", EmotivaMenuNotifyDTO.class.getSimpleName(),
+                    emotivaUdpResponse.answer());
+
+            if (answerDto.getRow() != null) {
+                handleMenuNotify(answerDto);
+            } else if (answerDto.getProgress() != null && answerDto.getProgress().getTime() != null) {
+                logger.trace("Processing received '{}' with '{}'", EmotivaMenuNotifyDTO.class.getSimpleName(),
+                        emotivaUdpResponse.answer());
+                listeningThreadFactory
+                        .newThread(() -> handleMenuNotifyProgressMessage(answerDto.getProgress().getTime())).start();
+            }
+        } else if (object instanceof EmotivaSubscriptionResponse answerDto) {
+            logger.trace("Processing received '{}' with '{}'", EmotivaSubscriptionResponse.class.getSimpleName(),
+                    emotivaUdpResponse.answer());
+            // Populates static input sources, except input
+            sourcesMainZone.putAll(EmotivaControlCommands.getCommandsFromType(EmotivaCommandType.SOURCE_MAIN_ZONE));
+            sourcesMainZone.remove(EmotivaControlCommands.input);
+            commandMaps.put(MAP_SOURCES_MAIN_ZONE, sourcesMainZone);
+
+            sourcesZone2.putAll(EmotivaControlCommands.getCommandsFromType(EmotivaCommandType.SOURCE_ZONE2));
+            sourcesZone2.remove(EmotivaControlCommands.zone2_input);
+            commandMaps.put(MAP_SOURCES_ZONE_2, sourcesZone2);
+
+            if (answerDto.getProperties() == null) {
+                for (EmotivaNotifyDTO dto : xmlUtils.unmarshallToNotification(answerDto.getTags())) {
+                    handleChannelUpdate(dto.getName(), dto.getValue(), dto.getVisible(), dto.getAck());
+                }
+            } else {
+                for (EmotivaPropertyDTO property : answerDto.getProperties()) {
+                    handleChannelUpdate(property.getName(), property.getValue(), property.getVisible(),
+                            property.getStatus());
+                }
+            }
+        }
+    }
+
+    private void handleMenuNotify(EmotivaMenuNotifyDTO answerDto) {
+        String highlightValue = "";
+
+        for (var row = 4; row <= 6; row++) {
+            var emotivaMenuRow = answerDto.getRow().get(row);
+            logger.debug("Checking row '{}' with '{}' columns", row, emotivaMenuRow.getCol().size());
+            for (var column = 0; column <= 2; column++) {
+                var emotivaMenuCol = emotivaMenuRow.getCol().get(column);
+                String cellValue = "";
+                if (emotivaMenuCol.getValue() != null) {
+                    cellValue = emotivaMenuCol.getValue();
+                }
+
+                if (emotivaMenuCol.getCheckbox() != null) {
+                    cellValue = MENU_PANEL_CHECKBOX_ON.equalsIgnoreCase(emotivaMenuCol.getCheckbox().trim()) ? "☑"
+                            : "☐";
+                }
+
+                if (emotivaMenuCol.getHighlight() != null
+                        && MENU_PANEL_HIGHLIGHTED.equalsIgnoreCase(emotivaMenuCol.getHighlight().trim())) {
+                    logger.debug("Highlight is at row '{}' column '{}' value '{}'", row, column, cellValue);
+                    highlightValue = cellValue;
+                }
+
+                var channelName = format("%s-%s-%s", CHANNEL_MENU_DISPLAY_PREFIX, getMenuPanelRowLabel(row),
+                        getMenuPanelColumnLabel(column));
+                updateChannelState(channelName, new StringType(cellValue));
+            }
+        }
+        updateChannelState(CHANNEL_MENU_DISPLAY_HIGHLIGHT, new StringType(highlightValue));
+    }
+
+    private void handleMenuNotifyProgressMessage(String progressBarTimeInSeconds) {
+        try {
+            var seconds = Integer.parseInt(progressBarTimeInSeconds);
+            for (var count = 0; seconds >= count; count++) {
+                updateChannelState(CHANNEL_MENU_DISPLAY_HIGHLIGHT,
+                        new StringType(updateProgress(EmotivaCommandHelper.integerToPercentage(count))));
+            }
+        } catch (NumberFormatException e) {
+            logger.debug("Menu progress bar time value '{}' is not a valid integer", progressBarTimeInSeconds);
+        }
+    }
+
+    private void resetMenuPanelChannels() {
+        logger.debug("Resetting Menu Panel Display");
+        for (var row = 4; row <= 6; row++) {
+            for (var column = 0; column <= 2; column++) {
+                var channelName = format("%s-%s-%s", CHANNEL_MENU_DISPLAY_PREFIX, getMenuPanelRowLabel(row),
+                        getMenuPanelColumnLabel(column));
+                updateChannelState(channelName, new StringType(""));
+            }
+        }
+        updateChannelState(CHANNEL_MENU_DISPLAY_HIGHLIGHT, new StringType(""));
+    }
+
+    private void sendEmotivaUpdate(EmotivaControlCommands tags) {
+        EmotivaUdpSendingService localSendingService = sendingService;
+        if (localSendingService != null) {
+            try {
+                localSendingService.sendUpdate(tags, config);
+            } catch (InterruptedIOException e) {
+                logger.debug("Interrupted during sending of EmotivaUpdate message to device '{}'",
+                        this.getThing().getThingTypeUID(), e);
+            } catch (IOException e) {
+                logger.error("Failed to send EmotivaUpdate message to device '{}'", this.getThing().getThingTypeUID(),
+                        e);
+            }
+        }
+    }
+
+    private void handleNotificationUpdate(AbstractNotificationDTO answerDto) {
+        if (answerDto.getProperties() == null) {
+            for (EmotivaNotifyDTO tag : xmlUtils.unmarshallToNotification(answerDto.getTags())) {
+                try {
+                    EmotivaSubscriptionTags tagName = EmotivaSubscriptionTags.valueOf(tag.getName());
+                    if (EmotivaSubscriptionTags.hasChannel(tag.getName())) {
+                        findChannelDatatypeAndUpdateChannel(tagName.getChannel(), tag.getValue(),
+                                tagName.getDataType());
+                    }
+                } catch (IllegalArgumentException e) {
+                    logger.debug("Subscription name '{}' could not be mapped to a channel", tag.getName());
+                }
+            }
+        } else {
+            for (EmotivaPropertyDTO property : answerDto.getProperties()) {
+                handleChannelUpdate(property.getName(), property.getValue(), property.getVisible(),
+                        property.getStatus());
+            }
+        }
+    }
+
+    private void handleChannelUpdate(String emotivaSubscriptionName, String value, String visible, String status) {
+        logger.debug("Handling channel update for '{}' with value '{}'", emotivaSubscriptionName, value);
+
+        if (status.equals(NOT_VALID.name())) {
+            logger.debug("Subscription property '{}' not present in device, skipping", emotivaSubscriptionName);
+            return;
+        }
+
+        if ("None".equals(value)) {
+            logger.debug("No value present for channel {}, usually means a speaker is not enabled",
+                    emotivaSubscriptionName);
+            return;
+        }
+
+        try {
+            EmotivaSubscriptionTags.hasChannel(emotivaSubscriptionName);
+        } catch (IllegalArgumentException e) {
+            logger.debug("Subscription property '{}' is not know to the binding, might need updating",
+                    emotivaSubscriptionName);
+            return;
+        }
+
+        if (noSubscriptionToChannel().contains(EmotivaSubscriptionTags.valueOf(emotivaSubscriptionName))) {
+            logger.debug("Initial subscription status update for {}, skipping, only want notifications",
+                    emotivaSubscriptionName);
+            return;
+        }
+
+        try {
+            EmotivaSubscriptionTags subscriptionTag;
+            try {
+                subscriptionTag = EmotivaSubscriptionTags.valueOf(emotivaSubscriptionName);
+            } catch (IllegalArgumentException e) {
+                logger.debug("Property '{}' could not be mapped subscription tag, skipping", emotivaSubscriptionName);
+                return;
+            }
+
+            if (subscriptionTag.getChannel().isEmpty()) {
+                logger.debug("Subscription property '{}' does not have a corresponding channel, skipping",
+                        emotivaSubscriptionName);
+                return;
+            }
+
+            String trimmedValue = value.trim();
+
+            logger.debug("Found subscription '{}' for '{}' and value '{}'", subscriptionTag, emotivaSubscriptionName,
+                    trimmedValue);
+
+            // Add/Update user assigned name for inputs
+            if (subscriptionTag.getChannel().startsWith(CHANNEL_INPUT1.substring(0, CHANNEL_INPUT1.indexOf("-") + 1))
+                    && "true".equals(visible)) {
+                logger.debug("Adding '{}' to dynamic source input list", trimmedValue);
+                sourcesMainZone.put(EmotivaControlCommands.matchToInput(subscriptionTag.name()), trimmedValue);
+                commandMaps.put(MAP_SOURCES_MAIN_ZONE, sourcesMainZone);
+
+                logger.debug("sources list is now {}", sourcesMainZone.size());
+            }
+
+            // Add/Update audio modes
+            if (subscriptionTag.getChannel().startsWith(CHANNEL_MODE + "-") && "true".equals(visible)) {
+                String modeName = i18nProvider.getText("channel-type.emotiva.selected-mode.option."
+                        + subscriptionTag.getChannel().substring(subscriptionTag.getChannel().indexOf("_") + 1));
+                logger.debug("Adding '{} ({})' from channel '{}' to dynamic mode list", trimmedValue, modeName,
+                        subscriptionTag.getChannel());
+                modes.put(EmotivaSubscriptionTags.fromChannelUID(subscriptionTag.getChannel()), trimmedValue);
+            }
+
+            findChannelDatatypeAndUpdateChannel(subscriptionTag.getChannel(), trimmedValue,
+                    subscriptionTag.getDataType());
+        } catch (IllegalArgumentException e) {
+            logger.debug("Error updating subscription property '{}'", emotivaSubscriptionName, e);
+        }
+    }
+
+    private void findChannelDatatypeAndUpdateChannel(String channelName, String value, EmotivaDataType dataType) {
+        switch (dataType) {
+            case DIMENSIONLESS_DECIBEL -> {
+                var trimmedString = value.replaceAll("[ +]", "");
+                logger.debug("Update channel '{}' to '{}:{}'", channelName, QuantityType.class.getSimpleName(),
+                        trimmedString);
+                if (channelName.equals(CHANNEL_MAIN_VOLUME)) {
+                    updateVolumeChannels(trimmedString, CHANNEL_MUTE, channelName, CHANNEL_MAIN_VOLUME_DB);
+                } else if (channelName.equals(CHANNEL_ZONE2_VOLUME)) {
+                    updateVolumeChannels(trimmedString, CHANNEL_ZONE2_MUTE, channelName, CHANNEL_ZONE2_VOLUME_DB);
+                } else {
+                    if (trimmedString.equals("None")) {
+                        updateChannelState(channelName, QuantityType.valueOf(0, Units.DECIBEL));
+                    } else {
+                        updateChannelState(channelName,
+                                QuantityType.valueOf(Double.parseDouble(trimmedString), Units.DECIBEL));
+                    }
+                }
+            }
+            case DIMENSIONLESS_PERCENT -> {
+                var trimmedString = value.replaceAll("[ +]", "");
+                logger.debug("Update channel '{}' to '{}:{}'", channelName, PercentType.class.getSimpleName(), value);
+                updateChannelState(channelName, PercentType.valueOf(trimmedString));
+            }
+            case FREQUENCY_HERTZ -> {
+                logger.debug("Update channel '{}' to '{}:{}'", channelName, Units.HERTZ.getClass().getSimpleName(),
+                        value);
+                if (!value.isEmpty()) {
+                    // Getting rid of characters and empty space leaves us with the raw frequency
+                    try {
+                        String frequencyString = value.replaceAll("[a-zA-Z ]", "");
+                        QuantityType<Frequency> hz = QuantityType.valueOf(0, Units.HERTZ);
+                        if (value.contains("AM")) {
+                            hz = QuantityType.valueOf(Double.parseDouble(frequencyString) * 1000, Units.HERTZ);
+                        } else if (value.contains("FM")) {
+                            hz = QuantityType.valueOf(Double.parseDouble(frequencyString) * 1000000, Units.HERTZ);
+                        }
+                        updateChannelState(CHANNEL_TUNER_CHANNEL, hz);
+                    } catch (NumberFormatException e) {
+                        logger.debug("Could not extract radio tuner frequency from '{}'", value);
+                    }
+                }
+            }
+            case GOODBYE -> {
+                logger.info(
+                        "Received goodbye notification from '{}'; disconnecting and scheduling av connection retry in '{}' minutes",
+                        getThing().getUID(), DEFAULT_RETRY_INTERVAL_MINUTES);
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.GONE, "@text/message.processor.goodbye");
+
+                // Device gone, sending unsubscription messages not needed
+                udpSenderActive = false;
+                disconnect();
+                scheduleConnectRetry(retryConnectInMinutes);
+            }
+            case NUMBER_TIME -> {
+                logger.debug("Update channel '{}' to '{}:{}'", channelName, Number.class.getSimpleName(), value);
+                long nowEpochSecond = Instant.now().getEpochSecond();
+                updateChannelState(channelName, new QuantityType<>(nowEpochSecond, Units.SECOND));
+            }
+            case ON_OFF -> {
+                logger.debug("Update channel '{}' to '{}:{}'", channelName, OnOffType.class.getSimpleName(), value);
+                OnOffType switchValue = OnOffType.from(value.trim().toUpperCase());
+                updateChannelState(channelName, switchValue);
+                if (switchValue.equals(OnOffType.OFF) && CHANNEL_MENU.equals(channelName)) {
+                    resetMenuPanelChannels();
+                }
+            }
+            case STRING -> {
+                logger.debug("Update channel '{}' to '{}:{}'", channelName, StringType.class.getSimpleName(), value);
+                updateChannelState(channelName, StringType.valueOf(value));
+            }
+            case UNKNOWN -> // Do nothing, types not connect to channels
+                logger.debug("Channel '{}' with UNKNOWN type and value '{}' was not updated", channelName, value);
+            default -> {
+                // datatypes not connect to a channel, so do nothing
+            }
+        }
+    }
+
+    private void updateChannelState(String channelID, State state) {
+        stateMap.put(channelID, state);
+        logger.trace("Updating channel '{}' with '{}'", channelID, state);
+        updateState(channelID, state);
+    }
+
+    private void updateVolumeChannels(String value, String muteChannel, String volumeChannel, String volumeDbChannel) {
+        if ("Mute".equals(value)) {
+            updateChannelState(muteChannel, OnOffType.ON);
+        } else {
+            updateChannelState(volumeChannel, volumeDecibelToPercentage(value));
+            updateChannelState(volumeDbChannel, QuantityType.valueOf(Double.parseDouble(value), Units.DECIBEL));
+        }
+    }
+
+    @Override
+    public void handleCommand(ChannelUID channelUID, Command ohCommand) {
+        logger.debug("Handling ohCommand '{}:{}' for '{}'", channelUID.getId(), ohCommand, channelUID.getThingUID());
+        EmotivaUdpSendingService localSendingService = sendingService;
+
+        if (localSendingService != null) {
+            EmotivaControlRequest emotivaRequest = channelToControlRequest(channelUID.getId(), commandMaps,
+                    protocolFromConfig(config.protocolVersion));
+            if (ohCommand instanceof RefreshType) {
+                stateMap.remove(channelUID.getId());
+
+                if (emotivaRequest.getDefaultCommand().equals(none)) {
+                    logger.debug("Found controlCommand 'none' for request '{}' from channel '{}' with RefreshType",
+                            emotivaRequest.getName(), channelUID);
+                } else {
+                    logger.debug("Sending EmotivaUpdate for '{}'", emotivaRequest);
+                    sendEmotivaUpdate(emotivaRequest.getDefaultCommand());
+                }
+            } else {
+                try {
+                    EmotivaControlDTO dto = emotivaRequest.createDTO(ohCommand, stateMap.get(channelUID.getId()));
+                    localSendingService.send(dto);
+
+                    if (emotivaRequest.getName().equals(EmotivaControlCommands.volume.name())) {
+                        if (ohCommand instanceof PercentType value) {
+                            updateChannelState(CHANNEL_MAIN_VOLUME_DB,
+                                    QuantityType.valueOf(volumePercentageToDecibel(value.intValue()), Units.DECIBEL));
+                        } else if (ohCommand instanceof QuantityType<?> value) {
+                            updateChannelState(CHANNEL_MAIN_VOLUME, volumeDecibelToPercentage(value.toString()));
+                        }
+                    } else if (emotivaRequest.getName().equals(EmotivaControlCommands.zone2_volume.name())) {
+                        if (ohCommand instanceof PercentType value) {
+                            updateChannelState(CHANNEL_ZONE2_VOLUME_DB,
+                                    QuantityType.valueOf(volumePercentageToDecibel(value.intValue()), Units.DECIBEL));
+                        } else if (ohCommand instanceof QuantityType<?> value) {
+                            updateChannelState(CHANNEL_ZONE2_VOLUME, volumeDecibelToPercentage(value.toString()));
+                        }
+                    } else if (ohCommand instanceof OnOffType value) {
+                        if (value.equals(OnOffType.ON) && emotivaRequest.getOnCommand().equals(power_on)) {
+                            localSendingService.sendUpdate(EmotivaSubscriptionTags.speakerChannels(), config);
+                        }
+                    }
+                } catch (InterruptedIOException e) {
+                    logger.debug("Interrupted during updating state for channel: '{}:{}:{}'", channelUID.getId(),
+                            emotivaRequest.getName(), emotivaRequest.getDataType(), e);
+                } catch (IOException e) {
+                    logger.error("Failed updating state for channel '{}:{}:{}'", channelUID.getId(),
+                            emotivaRequest.getName(), emotivaRequest.getDataType(), e);
+                }
+            }
+        }
+    }
+
+    @Override
+    public void dispose() {
+        logger.debug("Disposing '{}'", getThing().getUID());
+
+        disconnect();
+        super.dispose();
+    }
+
+    private synchronized void disconnect() {
+        final EmotivaUdpSendingService localSendingService = sendingService;
+        if (localSendingService != null) {
+            logger.debug("Disposing active sender");
+            if (udpSenderActive) {
+                try {
+                    // Unsubscribe before disconnect
+                    localSendingService.sendUnsubscribe(generalSubscription);
+                    localSendingService.sendUnsubscribe(nonGeneralSubscriptions);
+                } catch (IOException e) {
+                    logger.debug("Failed to unsubscribe for '{}'", config.ipAddress, e);
+                }
+            }
+
+            sendingService = null;
+            try {
+                localSendingService.disconnect();
+                logger.debug("Disconnected udp send connector");
+            } catch (Exception e) {
+                logger.debug("Failed to close socket connection for '{}'", config.ipAddress, e);
+            }
+        }
+        udpSenderActive = false;
+
+        final EmotivaUdpReceivingService notifyConnector = notifyListener;
+        if (notifyConnector != null) {
+            notifyListener = null;
+            try {
+                notifyConnector.disconnect();
+                logger.debug("Disconnected notify connector");
+            } catch (Exception e) {
+                logger.error("Failed to close socket connection for: '{}:{}'", config.ipAddress, config.notifyPort, e);
+            }
+        }
+
+        final EmotivaUdpReceivingService menuConnector = menuNotifyListener;
+        if (menuConnector != null) {
+            menuNotifyListener = null;
+            try {
+                menuConnector.disconnect();
+                logger.debug("Disconnected menu notify connector");
+            } catch (Exception e) {
+                logger.error("Failed to close socket connection for: '{}:{}'", config.ipAddress, config.notifyPort, e);
+            }
+        }
+
+        ScheduledFuture<?> localConnectRetryJob = this.connectRetryJob;
+        if (localConnectRetryJob != null) {
+            localConnectRetryJob.cancel(true);
+            this.connectRetryJob = null;
+        }
+
+        ScheduledFuture<?> localPollingJob = this.pollingJob;
+        if (localPollingJob != null) {
+            localPollingJob.cancel(true);
+            this.pollingJob = null;
+            logger.debug("Polling job canceled");
+        }
+    }
+
+    @Override
+    public Collection<Class<? extends ThingHandlerService>> getServices() {
+        return Set.of(InputStateOptionProvider.class);
+    }
+
+    public EnumMap<EmotivaControlCommands, String> getSourcesMainZone() {
+        return sourcesMainZone;
+    }
+
+    public EnumMap<EmotivaControlCommands, String> getSourcesZone2() {
+        return sourcesZone2;
+    }
+
+    public EnumMap<EmotivaSubscriptionTags, String> getModes() {
+        return modes;
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaTranslationProvider.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaTranslationProvider.java
new file mode 100644 (file)
index 0000000..3724d8c
--- /dev/null
@@ -0,0 +1,66 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal;
+
+import java.util.Locale;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.i18n.LocaleProvider;
+import org.openhab.core.i18n.TranslationProvider;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.FrameworkUtil;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * This class provides translated texts.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = EmotivaTranslationProvider.class)
+public class EmotivaTranslationProvider {
+
+    private final Bundle bundle;
+    private final TranslationProvider i18nProvider;
+    private final LocaleProvider localeProvider;
+
+    @Activate
+    public EmotivaTranslationProvider(@Reference TranslationProvider i18nProvider,
+            @Reference LocaleProvider localeProvider) {
+        this.bundle = FrameworkUtil.getBundle(this.getClass());
+        this.i18nProvider = i18nProvider;
+        this.localeProvider = localeProvider;
+    }
+
+    public EmotivaTranslationProvider(final EmotivaTranslationProvider other) {
+        this.bundle = other.bundle;
+        this.i18nProvider = other.i18nProvider;
+        this.localeProvider = other.localeProvider;
+    }
+
+    public String getText(String key, @Nullable Object... arguments) {
+        Locale locale = localeProvider.getLocale();
+        String message = i18nProvider.getText(bundle, key, this.getDefaultText(key), locale, arguments);
+        if (message != null) {
+            return message;
+        }
+        return key;
+    }
+
+    public @Nullable String getDefaultText(String key) {
+        return i18nProvider.getText(bundle, key, key, Locale.ENGLISH);
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaUdpBroadcastService.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaUdpBroadcastService.java
new file mode 100644 (file)
index 0000000..c41cfcb
--- /dev/null
@@ -0,0 +1,195 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal;
+
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.*;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaProtocolVersion.PROTOCOL_V3;
+
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.net.DatagramPacket;
+import java.net.DatagramSocket;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.SocketTimeoutException;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.Objects;
+import java.util.Optional;
+
+import javax.xml.bind.JAXBException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.emotiva.internal.dto.EmotivaPingDTO;
+import org.openhab.binding.emotiva.internal.dto.EmotivaTransponderDTO;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaXmlUtils;
+import org.openhab.core.common.AbstractUID;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.thing.ThingUID;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This service is used for discovering Emotiva devices via sending an EmotivaPing UDP message.
+ *
+ * @author Hilbrand Bouwkamp - Initial contribution
+ * @author Espen Fossen - Adapted to Emotiva binding
+ */
+@NonNullByDefault
+public class EmotivaUdpBroadcastService {
+
+    private final Logger logger = LoggerFactory.getLogger(EmotivaUdpBroadcastService.class);
+    private static final int MAX_PACKET_SIZE = 512;
+    @Nullable
+    private DatagramSocket discoverSocket;
+    private final EmotivaXmlUtils xmlUtils = new EmotivaXmlUtils();
+
+    /**
+     * The address to broadcast EmotivaPing message to.
+     */
+    private final String broadcastAddress;
+
+    public EmotivaUdpBroadcastService(String broadcastAddress) throws IllegalArgumentException, JAXBException {
+        if (broadcastAddress.trim().isEmpty()) {
+            throw new IllegalArgumentException("Missing broadcast address");
+        }
+        this.broadcastAddress = broadcastAddress;
+    }
+
+    /**
+     * Performs the actual discovery of Emotiva devices (things).
+     */
+    public Optional<DiscoveryResult> discoverThings() {
+        try {
+            final DatagramPacket receivePacket = new DatagramPacket(new byte[MAX_PACKET_SIZE], MAX_PACKET_SIZE);
+            // No need to call close first, because the caller of this method already has done it.
+
+            discoverSocket = new DatagramSocket(
+                    new InetSocketAddress(EmotivaBindingConstants.DEFAULT_TRANSPONDER_PORT));
+            final InetAddress broadcast = InetAddress.getByName(broadcastAddress);
+
+            byte[] emotivaPingDTO = xmlUtils.marshallEmotivaDTO(new EmotivaPingDTO(PROTOCOL_V3.name()))
+                    .getBytes(Charset.defaultCharset());
+            final DatagramPacket discoverPacket = new DatagramPacket(emotivaPingDTO, emotivaPingDTO.length, broadcast,
+                    EmotivaBindingConstants.DEFAULT_PING_PORT);
+
+            DatagramSocket localDatagramSocket = discoverSocket;
+            while (localDatagramSocket != null && discoverSocket != null) {
+                localDatagramSocket.setBroadcast(true);
+                localDatagramSocket.setSoTimeout(DEFAULT_UDP_SENDING_TIMEOUT);
+                localDatagramSocket.send(discoverPacket);
+                if (logger.isTraceEnabled()) {
+                    logger.trace("Discovery package sent: {}",
+                            new String(discoverPacket.getData(), StandardCharsets.UTF_8));
+                }
+
+                // Runs until the socket call gets a timeout and throws an exception. When a timeout is triggered it
+                // means
+                // no data was present and nothing new to discover.
+                while (true) {
+                    // Set packet length in case a previous call reduced the size.
+                    receivePacket.setLength(MAX_PACKET_SIZE);
+                    if (discoverSocket == null) {
+                        break;
+                    } else {
+                        localDatagramSocket.receive(receivePacket);
+                    }
+                    logger.debug("Emotiva device discovery returned package with length '{}'",
+                            receivePacket.getLength());
+                    if (receivePacket.getLength() > 0) {
+                        return thingDiscovered(receivePacket);
+                    }
+                }
+            }
+        } catch (SocketTimeoutException e) {
+            logger.debug("Discovering poller timeout...");
+        } catch (InterruptedIOException e) {
+            logger.debug("Interrupted during discovery: {}", e.getMessage());
+        } catch (IOException e) {
+            logger.debug("Error during discovery: {}", e.getMessage());
+        } finally {
+            closeDiscoverSocket();
+        }
+        return Optional.empty();
+    }
+
+    /**
+     * Closes the discovery socket and cleans the value. No need for synchronization as this method is called from a
+     * synchronized context.
+     */
+    public void closeDiscoverSocket() {
+        final DatagramSocket localDiscoverSocket = discoverSocket;
+        if (localDiscoverSocket != null) {
+            discoverSocket = null;
+            if (!localDiscoverSocket.isClosed()) {
+                localDiscoverSocket.close(); // this interrupts and terminates the listening thread
+            }
+        }
+    }
+
+    /**
+     * Register a device (thing) with the discovered properties.
+     *
+     * @param packet containing data of detected device
+     */
+    private Optional<DiscoveryResult> thingDiscovered(DatagramPacket packet) {
+        final String ipAddress = packet.getAddress().getHostAddress();
+        String udpResponse = new String(packet.getData(), 0, packet.getLength() - 1, StandardCharsets.UTF_8);
+
+        Object object;
+        try {
+            object = xmlUtils.unmarshallToEmotivaDTO(udpResponse);
+        } catch (JAXBException e) {
+            logger.debug("Could not unmarshal '{}:{}'", ipAddress, udpResponse.length());
+            return Optional.empty();
+        }
+
+        if (object instanceof EmotivaTransponderDTO answerDto) {
+            logger.trace("Processing Received '{}' with '{}' ", EmotivaTransponderDTO.class.getSimpleName(),
+                    udpResponse);
+            final ThingUID thingUid = new ThingUID(
+                    THING_PROCESSOR + AbstractUID.SEPARATOR + ipAddress.replace(".", ""));
+            final DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUid)
+                    .withThingType(THING_PROCESSOR).withProperty("ipAddress", ipAddress)
+                    .withProperty("controlPort", answerDto.getControl().getControlPort())
+                    .withProperty("notifyPort", answerDto.getControl().getNotifyPort())
+                    .withProperty("infoPort", answerDto.getControl().getInfoPort())
+                    .withProperty("setupPortTCP", answerDto.getControl().getSetupPortTCP())
+                    .withProperty("menuNotifyPort", answerDto.getControl().getMenuNotifyPort())
+                    .withProperty("model", answerDto.getModel())
+                    .withProperty("revision", Objects.requireNonNullElse(answerDto.getRevision(), ""))
+                    .withProperty("dataRevision", Objects.requireNonNullElse(answerDto.getDataRevision(), ""))
+                    .withProperty("protocolVersion",
+                            Objects.requireNonNullElse(answerDto.getControl().getVersion(),
+                                    DEFAULT_EMOTIVA_PROTOCOL_VERSION))
+                    .withProperty("keepAlive", answerDto.getControl().getKeepAlive())
+                    .withProperty(EmotivaBindingConstants.UNIQUE_PROPERTY_NAME, ipAddress)
+                    .withLabel(answerDto.getName())
+                    .withRepresentationProperty(EmotivaBindingConstants.UNIQUE_PROPERTY_NAME).build();
+            try {
+                logger.debug("Adding newly discovered thing '{}:{}' with properties '{}'", THING_PROCESSOR, ipAddress,
+                        discoveryResult.getProperties());
+
+                return Optional.of(discoveryResult);
+            } catch (Exception e) {
+                logger.debug("Failed adding discovered thing '{}:{}' with properties '{}'", THING_PROCESSOR, ipAddress,
+                        discoveryResult.getProperties(), e);
+            }
+        } else {
+            logger.debug("Received message of unknown type in message '{}'", udpResponse);
+        }
+        return Optional.empty();
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaUdpReceivingService.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaUdpReceivingService.java
new file mode 100644 (file)
index 0000000..954af61
--- /dev/null
@@ -0,0 +1,224 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal;
+
+import java.net.DatagramPacket;
+import java.net.DatagramSocket;
+import java.net.SocketException;
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.concurrent.ExecutorService;
+import java.util.function.Consumer;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaUdpResponse;
+import org.openhab.core.common.NamedThreadFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This service is used for receiving UDP message from Emotiva devices.
+ *
+ * @author Patrick Koenemann - Initial contribution
+ * @author Espen Fossen - Adapted to Emotiva binding
+ */
+@NonNullByDefault
+public class EmotivaUdpReceivingService {
+
+    private final Logger logger = LoggerFactory.getLogger(EmotivaUdpReceivingService.class);
+
+    /**
+     * Buffer for incoming UDP packages.
+     */
+    private static final int MAX_PACKET_SIZE = 10240;
+
+    /**
+     * The device IP this connector is listening to / sends to.
+     */
+    private final String ipAddress;
+
+    /**
+     * The port this connector is listening to notify message.
+     */
+    private final int receivingPort;
+
+    /**
+     * Service to spawn new threads for handling status updates.
+     */
+    private final ExecutorService executorService;
+
+    /**
+     * Thread factory for UDP listening thread.
+     */
+    private final NamedThreadFactory listeningThreadFactory = new NamedThreadFactory(EmotivaBindingConstants.BINDING_ID,
+            true);
+
+    /**
+     * Socket for receiving Notify UDP packages.
+     */
+    private @Nullable DatagramSocket receivingSocket = null;
+
+    /**
+     * The listener that gets notified upon newly received messages.
+     */
+    private @Nullable Consumer<EmotivaUdpResponse> listener;
+
+    private int receiveNotifyFailures = 0;
+    private boolean listenerNotifyActive = false;
+
+    /**
+     * Create a listener to an Emotiva device via the given configuration.
+     *
+     * @param receivingPort listening port
+     * @param config Emotiva configuration values
+     */
+    public EmotivaUdpReceivingService(int receivingPort, EmotivaConfiguration config, ExecutorService executorService) {
+        if (receivingPort <= 0) {
+            throw new IllegalArgumentException("Invalid receivingPort: " + receivingPort);
+        }
+        if (config.ipAddress.trim().isEmpty()) {
+            throw new IllegalArgumentException("Missing ipAddress");
+        }
+        this.ipAddress = config.ipAddress;
+        this.receivingPort = receivingPort;
+        this.executorService = executorService;
+    }
+
+    /**
+     * Initialize socket connection to the UDP receive port for the given listener.
+     *
+     * @throws SocketException Is only thrown if <code>logNotThrowException = false</code>.
+     * @throws InterruptedException Typically happens during shutdown.
+     */
+    public void connect(Consumer<EmotivaUdpResponse> listener, boolean logNotThrowException)
+            throws SocketException, InterruptedException {
+        if (receivingSocket == null) {
+            try {
+                receivingSocket = new DatagramSocket(receivingPort);
+
+                this.listener = listener;
+
+                listeningThreadFactory.newThread(this::listen).start();
+
+                // wait for the listening thread to be active
+                for (int i = 0; i < 20 && !listenerNotifyActive; i++) {
+                    Thread.sleep(100); // wait at most 20 * 100ms = 2sec for the listener to be active
+                }
+                if (!listenerNotifyActive) {
+                    logger.warn(
+                            "Listener thread started but listener is not yet active after 2sec; something seems to be wrong with the JVM thread handling?!");
+                }
+            } catch (SocketException e) {
+                if (logNotThrowException) {
+                    logger.warn("Failed to open socket connection on port '{}'", receivingPort);
+                }
+
+                disconnect();
+
+                if (!logNotThrowException) {
+                    throw e;
+                }
+            }
+        } else if (!Objects.equals(this.listener, listener)) {
+            throw new IllegalStateException("A listening thread is already running");
+        }
+    }
+
+    private void listen() {
+        try {
+            listenUnhandledInterruption();
+        } catch (InterruptedException e) {
+            // OH shutdown - don't log anything, just quit
+        }
+    }
+
+    private void listenUnhandledInterruption() throws InterruptedException {
+        logger.debug("Emotiva listener started for: '{}:{}'", ipAddress, receivingPort);
+
+        final Consumer<EmotivaUdpResponse> localListener = listener;
+        final DatagramSocket localReceivingSocket = receivingSocket;
+        while (localListener != null && localReceivingSocket != null && receivingSocket != null) {
+            try {
+                final DatagramPacket answer = new DatagramPacket(new byte[MAX_PACKET_SIZE], MAX_PACKET_SIZE);
+
+                listenerNotifyActive = true;
+                localReceivingSocket.receive(answer); // receive packet (blocking call)
+                listenerNotifyActive = false;
+
+                final byte[] receivedData = Arrays.copyOfRange(answer.getData(), 0, answer.getLength() - 1);
+
+                if (receivedData.length == 0) {
+                    if (isConnected()) {
+                        logger.debug("Nothing received, this may happen during shutdown or some unknown error");
+                    }
+                    continue;
+                }
+                receiveNotifyFailures = 0; // message successfully received, unset failure counter
+
+                handleReceivedData(answer, receivedData, localListener);
+            } catch (Exception e) {
+                listenerNotifyActive = false;
+
+                if (receivingSocket == null) {
+                    logger.debug("Socket closed; stopping listener on port '{}'", receivingPort);
+                } else {
+                    logger.debug("Checkin receiveFailures count {}", receiveNotifyFailures);
+                    // if we get 3 errors in a row, we should better add a delay to stop spamming the log!
+                    if (receiveNotifyFailures++ > EmotivaBindingConstants.DEFAULT_CONNECTION_RETRIES) {
+                        logger.debug(
+                                "Unexpected error while listening on port '{}'; waiting 10sec before the next attempt to listen on that port",
+                                receivingPort, e);
+                        for (int i = 0; i < 50 && receivingSocket != null; i++) {
+                            Thread.sleep(200); // 50 * 200ms = 10sec
+                        }
+                    } else {
+                        logger.debug("Unexpected error while listening on port '{}'", receivingPort, e);
+                    }
+                }
+            }
+        }
+    }
+
+    private void handleReceivedData(DatagramPacket answer, byte[] receivedData,
+            Consumer<EmotivaUdpResponse> localListener) {
+        // log & notify listener in new thread (so that listener loop continues immediately)
+        executorService.execute(() -> {
+            if (answer.getAddress() != null && answer.getLength() > 0) {
+                logger.trace("Received data on port '{}': {}", answer.getPort(), receivedData);
+                EmotivaUdpResponse emotivaUdpResponse = new EmotivaUdpResponse(
+                        new String(answer.getData(), 0, answer.getLength()), answer.getAddress().getHostAddress());
+                localListener.accept(emotivaUdpResponse);
+            }
+        });
+    }
+
+    /**
+     * Close the socket connection.
+     */
+    public void disconnect() {
+        logger.debug("Emotiva listener stopped for: '{}:{}'", ipAddress, receivingPort);
+        listener = null;
+        final DatagramSocket localReceivingSocket = receivingSocket;
+        if (localReceivingSocket != null) {
+            receivingSocket = null;
+            if (!localReceivingSocket.isClosed()) {
+                localReceivingSocket.close(); // this interrupts and terminates the listening thread
+            }
+        }
+    }
+
+    public boolean isConnected() {
+        return receivingSocket != null;
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaUdpSendingService.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaUdpSendingService.java
new file mode 100644 (file)
index 0000000..5b91a68
--- /dev/null
@@ -0,0 +1,213 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal;
+
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.DEFAULT_UDP_SENDING_TIMEOUT;
+
+import java.io.IOException;
+import java.net.DatagramPacket;
+import java.net.DatagramSocket;
+import java.net.InetAddress;
+import java.net.SocketException;
+import java.nio.charset.Charset;
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.concurrent.ExecutorService;
+import java.util.function.Consumer;
+
+import javax.xml.bind.JAXBException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.emotiva.internal.dto.EmotivaControlDTO;
+import org.openhab.binding.emotiva.internal.dto.EmotivaSubscriptionRequest;
+import org.openhab.binding.emotiva.internal.dto.EmotivaUnsubscribeDTO;
+import org.openhab.binding.emotiva.internal.dto.EmotivaUpdateRequest;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaUdpResponse;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaXmlUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This service handles sending UDP message to Emotiva devices.
+ *
+ * @author Patrick Koenemann - Initial contribution
+ * @author Espen Fossen - Adapted to Emotiva binding
+ */
+@NonNullByDefault
+public class EmotivaUdpSendingService {
+
+    private final Logger logger = LoggerFactory.getLogger(EmotivaUdpSendingService.class);
+
+    /**
+     * Buffer for incoming UDP packages.
+     */
+    private static final int MAX_PACKET_SIZE = 10240;
+
+    /**
+     * The device IP this connector is listening to / sends to.
+     */
+    private final String ipAddress;
+
+    /**
+     * The port this connector is sending to.
+     */
+    private final int sendingControlPort;
+
+    /**
+     * Service to spawn new threads for handling status updates.
+     */
+    private final ExecutorService executorService;
+
+    /**
+     * Socket for sending UDP packages.
+     */
+    private @Nullable DatagramSocket sendingSocket = null;
+
+    /**
+     * Sending response listener.
+     */
+    private @Nullable Consumer<EmotivaUdpResponse> listener;
+
+    private final EmotivaXmlUtils emotivaXmlUtils;
+
+    /**
+     * Create a socket for sending message to Emotiva device via the given configuration.
+     *
+     * @param config Emotiva configuration values
+     */
+    public EmotivaUdpSendingService(EmotivaConfiguration config, ExecutorService executorService) throws JAXBException {
+        if (config.controlPort <= 0) {
+            throw new IllegalArgumentException("Invalid udpSendingControlPort: " + config.controlPort);
+        }
+        if (config.ipAddress.trim().isEmpty()) {
+            throw new IllegalArgumentException("Missing ipAddress");
+        }
+        this.ipAddress = config.ipAddress;
+        this.sendingControlPort = config.controlPort;
+        this.executorService = executorService;
+        this.emotivaXmlUtils = new EmotivaXmlUtils();
+    }
+
+    /**
+     * Initialize socket connection to the UDP sending port
+     *
+     * @throws SocketException Is only thrown if <code>logNotThrowException = false</code>.
+     * @throws InterruptedException Typically happens during shutdown.
+     */
+    public void connect(Consumer<EmotivaUdpResponse> listener, boolean logNotThrowException)
+            throws SocketException, InterruptedException {
+        try {
+            sendingSocket = new DatagramSocket(sendingControlPort);
+
+            this.listener = listener;
+        } catch (SocketException e) {
+            disconnect();
+
+            if (!logNotThrowException) {
+                throw e;
+            }
+        }
+    }
+
+    private void handleReceivedData(DatagramPacket answer, byte[] receivedData,
+            Consumer<EmotivaUdpResponse> localListener) {
+        // log & notify listener in new thread (so that listener loop continues immediately)
+        executorService.execute(() -> {
+            if (answer.getAddress() != null && answer.getLength() > 0) {
+                logger.trace("Received data on port '{}': {}", answer.getPort(), receivedData);
+                EmotivaUdpResponse emotivaUdpResponse = new EmotivaUdpResponse(
+                        new String(answer.getData(), 0, answer.getLength()), answer.getAddress().getHostAddress());
+                localListener.accept(emotivaUdpResponse);
+            }
+        });
+    }
+
+    /**
+     * Close the socket connection.
+     */
+    public void disconnect() {
+        logger.debug("Emotiva sender stopped for '{}'", ipAddress);
+        listener = null;
+        final DatagramSocket localSendingSocket = sendingSocket;
+        if (localSendingSocket != null) {
+            synchronized (this) {
+                if (Objects.equals(sendingSocket, localSendingSocket)) {
+                    sendingSocket = null;
+                    if (!localSendingSocket.isClosed()) {
+                        localSendingSocket.close();
+                    }
+                }
+            }
+        }
+    }
+
+    public void send(EmotivaControlDTO dto) throws IOException {
+        send(emotivaXmlUtils.marshallJAXBElementObjects(dto));
+    }
+
+    public void sendSubscription(EmotivaSubscriptionTags[] tags, EmotivaConfiguration config) throws IOException {
+        send(emotivaXmlUtils.marshallJAXBElementObjects(new EmotivaSubscriptionRequest(tags, config.protocolVersion)));
+    }
+
+    public void sendUpdate(EmotivaControlCommands defaultCommand, EmotivaConfiguration config) throws IOException {
+        send(emotivaXmlUtils
+                .marshallJAXBElementObjects(new EmotivaUpdateRequest(defaultCommand, config.protocolVersion)));
+    }
+
+    public void sendUpdate(EmotivaSubscriptionTags[] tags, EmotivaConfiguration config) throws IOException {
+        send(emotivaXmlUtils.marshallJAXBElementObjects(new EmotivaUpdateRequest(tags, config.protocolVersion)));
+    }
+
+    public void sendUnsubscribe(EmotivaSubscriptionTags[] defaultCommand) throws IOException {
+        send(emotivaXmlUtils.marshallJAXBElementObjects(new EmotivaUnsubscribeDTO(defaultCommand)));
+    }
+
+    public void send(String msg) throws IOException {
+        logger.trace("Sending message '{}' to {}:{}", msg, ipAddress, sendingControlPort);
+        if (msg.isEmpty()) {
+            throw new IllegalArgumentException("Message must not be empty");
+        }
+
+        final InetAddress ipAddress = InetAddress.getByName(this.ipAddress);
+        byte[] buf = msg.getBytes(Charset.defaultCharset());
+        DatagramPacket packet = new DatagramPacket(buf, buf.length, ipAddress, sendingControlPort);
+
+        // make sure we are not interrupted by a disconnect while sending this message
+        synchronized (this) {
+            DatagramSocket localDatagramSocket = this.sendingSocket;
+            final DatagramPacket answer = new DatagramPacket(new byte[MAX_PACKET_SIZE], MAX_PACKET_SIZE);
+            final Consumer<EmotivaUdpResponse> localListener = listener;
+            if (localDatagramSocket != null && !localDatagramSocket.isClosed()) {
+                localDatagramSocket.setSoTimeout(DEFAULT_UDP_SENDING_TIMEOUT);
+                localDatagramSocket.send(packet);
+                logger.debug("Sending successful");
+
+                localDatagramSocket.receive(answer);
+                final byte[] receivedData = Arrays.copyOfRange(answer.getData(), 0, answer.getLength() - 1);
+
+                if (receivedData.length == 0) {
+                    logger.debug("Nothing received, this may happen during shutdown or some unknown error");
+                }
+
+                if (localListener != null) {
+                    handleReceivedData(answer, receivedData, localListener);
+                }
+            } else {
+                throw new SocketException("Datagram Socket closed or not initialized");
+            }
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/InputStateOptionProvider.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/InputStateOptionProvider.java
new file mode 100644 (file)
index 0000000..c20b2c6
--- /dev/null
@@ -0,0 +1,106 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal;
+
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.BINDING_ID;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_MODE;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_SOURCE;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_ZONE2_SOURCE;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.EMOTIVA_SOURCE_COMMAND_PREFIX;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Locale;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.binding.BaseDynamicStateDescriptionProvider;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.openhab.core.thing.type.ChannelTypeUID;
+import org.openhab.core.types.StateDescription;
+import org.openhab.core.types.StateOption;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This class provides the list of valid inputs for a source or audio mode.
+ *
+ * @author Kai Kreuzer - Initial contribution
+ * @author Espen Fossen - Adapted to Emotiva binding
+ */
+@NonNullByDefault
+public class InputStateOptionProvider extends BaseDynamicStateDescriptionProvider implements ThingHandlerService {
+
+    private final Logger logger = LoggerFactory.getLogger(InputStateOptionProvider.class);
+
+    private @Nullable EmotivaProcessorHandler handler;
+
+    @Override
+    public void setThingHandler(ThingHandler handler) {
+        this.handler = (EmotivaProcessorHandler) handler;
+    }
+
+    @Override
+    public @Nullable ThingHandler getThingHandler() {
+        return handler;
+    }
+
+    @Override
+    public @Nullable StateDescription getStateDescription(Channel channel, @Nullable StateDescription original,
+            @Nullable Locale locale) {
+        ChannelTypeUID typeUID = channel.getChannelTypeUID();
+        if (typeUID == null || !BINDING_ID.equals(typeUID.getBindingId()) || original == null) {
+            return null;
+        }
+
+        List<StateOption> options = new ArrayList<>();
+        EmotivaProcessorHandler localHandler = handler;
+        if (localHandler != null) {
+            if (channel.getUID().getId().equals(CHANNEL_SOURCE)) {
+                setStateOptionsForSource(channel, options, localHandler.getSourcesMainZone());
+            } else if (channel.getUID().getId().equals(CHANNEL_ZONE2_SOURCE)) {
+                setStateOptionsForSource(channel, options, localHandler.getSourcesZone2());
+            } else if (channel.getUID().getId().equals(CHANNEL_MODE)) {
+                EnumMap<EmotivaSubscriptionTags, String> modes = localHandler.getModes();
+                Collection<EmotivaSubscriptionTags> modeKeys = modes.keySet();
+                for (EmotivaSubscriptionTags modeKey : modeKeys) {
+                    options.add(new StateOption(modeKey.name(), modes.get(modeKey)));
+                }
+                logger.debug("Updated '{}' with '{}'", CHANNEL_MODE, options);
+                setStateOptions(channel.getUID(), options);
+            }
+        }
+
+        return super.getStateDescription(channel, original, locale);
+    }
+
+    private void setStateOptionsForSource(Channel channel, List<StateOption> options,
+            EnumMap<EmotivaControlCommands, String> sources) {
+        Collection<EmotivaControlCommands> sourceKeys = sources.keySet();
+        for (EmotivaControlCommands sourceKey : sourceKeys) {
+            if (sourceKey.name().startsWith(EMOTIVA_SOURCE_COMMAND_PREFIX)) {
+                options.add(new StateOption(sourceKey.name(), sources.get(sourceKey)));
+            } else {
+                options.add(new StateOption(sourceKey.name(), sourceKey.getLabel()));
+            }
+        }
+        logger.debug("Updated '{}' with '{}'", channel.getUID().getId(), options);
+        setStateOptions(channel.getUID(), options);
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/discovery/EmotivaDiscoveryService.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/discovery/EmotivaDiscoveryService.java
new file mode 100644 (file)
index 0000000..3323e86
--- /dev/null
@@ -0,0 +1,68 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.discovery;
+
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.*;
+
+import java.util.Objects;
+
+import javax.xml.bind.JAXBException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.emotiva.internal.EmotivaUdpBroadcastService;
+import org.openhab.core.config.discovery.AbstractDiscoveryService;
+import org.openhab.core.config.discovery.DiscoveryService;
+import org.osgi.service.component.annotations.Component;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Discovery service for Emotiva devices.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = DiscoveryService.class, configurationPid = "discovery.emotiva")
+public class EmotivaDiscoveryService extends AbstractDiscoveryService {
+
+    private final Logger logger = LoggerFactory.getLogger(EmotivaDiscoveryService.class);
+
+    @Nullable
+    private final EmotivaUdpBroadcastService broadcastService = new EmotivaUdpBroadcastService(
+            DISCOVERY_BROADCAST_ADDRESS);
+
+    public EmotivaDiscoveryService() throws IllegalArgumentException, JAXBException {
+        super(SUPPORTED_THING_TYPES_UIDS, DISCOVERY_TIMEOUT_SECONDS, false);
+    }
+
+    @Override
+    protected void startScan() {
+        logger.debug("Start scan for Emotiva devices");
+        EmotivaUdpBroadcastService localBroadcastService = broadcastService;
+        if (localBroadcastService != null) {
+            try {
+                localBroadcastService.discoverThings().ifPresent(this::thingDiscovered);
+            } finally {
+                removeOlderResults(getTimestampOfLastScan());
+            }
+        }
+    }
+
+    @Override
+    protected void stopScan() {
+        logger.debug("Stop scan for Emotiva devices");
+        Objects.requireNonNull(broadcastService).closeDiscoverSocket();
+        super.stopScan();
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/AbstractJAXBElementDTO.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/AbstractJAXBElementDTO.java
new file mode 100644 (file)
index 0000000..cb5e33e
--- /dev/null
@@ -0,0 +1,50 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import java.util.List;
+
+import javax.xml.bind.JAXBElement;
+import javax.xml.bind.annotation.XmlAnyElement;
+import javax.xml.bind.annotation.XmlTransient;
+import javax.xml.namespace.QName;
+
+/**
+ * Defines elements used by common request DTO classes.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+public class AbstractJAXBElementDTO {
+
+    @XmlTransient
+    protected List<EmotivaCommandDTO> commands;
+
+    @XmlAnyElement
+    protected List<JAXBElement<String>> jaxbElements;
+
+    public List<EmotivaCommandDTO> getCommands() {
+        return commands;
+    }
+
+    public void setCommands(List<EmotivaCommandDTO> commands) {
+        this.commands = commands;
+    }
+
+    public void setJaxbElements(List<JAXBElement<String>> jaxbElements) {
+        this.jaxbElements = jaxbElements;
+    }
+
+    public JAXBElement<String> createJAXBElement(QName name) {
+        return new JAXBElement<String>(name, String.class, null);
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/AbstractNotificationDTO.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/AbstractNotificationDTO.java
new file mode 100644 (file)
index 0000000..afe72d6
--- /dev/null
@@ -0,0 +1,42 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import java.util.List;
+
+import javax.xml.bind.annotation.XmlAnyElement;
+import javax.xml.bind.annotation.XmlElement;
+
+/**
+ * Defines elements used by common notification DTO classes.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+public class AbstractNotificationDTO {
+
+    // Only present with PROTOCOL_V2 or older
+    @XmlAnyElement(lax = true)
+    List<Object> tags;
+
+    // Only present with PROTOCOL_V3 or newer
+    @XmlElement(name = "property")
+    List<EmotivaPropertyDTO> properties;
+
+    public List<EmotivaPropertyDTO> getProperties() {
+        return properties;
+    }
+
+    public List<Object> getTags() {
+        return tags;
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/ControlDTO.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/ControlDTO.java
new file mode 100644 (file)
index 0000000..ed6de5a
--- /dev/null
@@ -0,0 +1,80 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.XmlRootElement;
+
+/**
+ * Emotiva Control XML object, which is part of the {@link EmotivaTransponderDTO} response.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@XmlRootElement(name = "control")
+@XmlAccessorType(XmlAccessType.FIELD)
+public class ControlDTO {
+
+    @XmlElement(name = "version")
+    String version;
+    @XmlElement(name = "controlPort")
+    int controlPort;
+    @XmlElement(name = "notifyPort")
+    int notifyPort;
+    @XmlElement(name = "infoPort")
+    int infoPort;
+    @XmlElement(name = "menuNotifyPort")
+    int menuNotifyPort;
+    @XmlElement(name = "setupPortTCP")
+    int setupPortTCP;
+    @XmlElement(name = "setupXMLVersion")
+    int setupXMLVersion;
+    @XmlElement(name = "keepAlive")
+    int keepAlive;
+
+    public ControlDTO() {
+    }
+
+    public String getVersion() {
+        return version;
+    }
+
+    public int getControlPort() {
+        return controlPort;
+    }
+
+    public int getNotifyPort() {
+        return notifyPort;
+    }
+
+    public int getInfoPort() {
+        return infoPort;
+    }
+
+    public int getMenuNotifyPort() {
+        return menuNotifyPort;
+    }
+
+    public int getSetupPortTCP() {
+        return setupPortTCP;
+    }
+
+    public int getSetupXMLVersion() {
+        return setupXMLVersion;
+    }
+
+    public int getKeepAlive() {
+        return keepAlive;
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaAckDTO.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaAckDTO.java
new file mode 100644 (file)
index 0000000..92d76d5
--- /dev/null
@@ -0,0 +1,51 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import java.util.List;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlAnyElement;
+import javax.xml.bind.annotation.XmlRootElement;
+
+/**
+ * The EmotivaAck message type. Received from Emotiva device whenever a {@link EmotivaControlDTO} with
+ * {@link EmotivaCommandDTO} is sent.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@XmlRootElement(name = "emotivaAck")
+@XmlAccessorType(XmlAccessType.FIELD)
+public class EmotivaAckDTO {
+
+    @XmlAnyElement(lax = true)
+    private List<Object> commands;
+
+    @SuppressWarnings("unused")
+    public EmotivaAckDTO() {
+    }
+
+    public List<Object> getCommands() {
+        return commands;
+    }
+
+    public void setCommands(List<Object> commands) {
+        this.commands = commands;
+    }
+
+    @Override
+    public String toString() {
+        return "EmotivaAckDTO{" + "commands=" + commands + '}';
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaBarNotifyDTO.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaBarNotifyDTO.java
new file mode 100644 (file)
index 0000000..e94250b
--- /dev/null
@@ -0,0 +1,137 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlAttribute;
+import javax.xml.bind.annotation.XmlRootElement;
+import javax.xml.bind.annotation.XmlValue;
+
+/**
+ * The EmotivaBarNotify message type. Received from a device if subscribed to the
+ * {@link org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags#bar_update} type. Uses the
+ * {@link EmotivaBarNotifyWrapper} to handle unmarshalling.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@XmlRootElement(name = "property")
+@XmlAccessorType(XmlAccessType.FIELD)
+public class EmotivaBarNotifyDTO {
+
+    @XmlValue
+    private String name = "bar";
+
+    // Possible values “bar”, “centerBar”, “bigText’, “off”
+    @XmlAttribute
+    private String type;
+    @XmlAttribute
+    private String text;
+    @XmlAttribute
+    private String units;
+    @XmlAttribute
+    private String value;
+    @XmlAttribute
+    private String min;
+    @XmlAttribute
+    private String max;
+
+    @SuppressWarnings("unused")
+    public EmotivaBarNotifyDTO() {
+    }
+
+    public EmotivaBarNotifyDTO(String name) {
+        this.name = name;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    public String getType() {
+        return type;
+    }
+
+    public void setType(String type) {
+        this.type = type;
+    }
+
+    public String getText() {
+        return text;
+    }
+
+    public void setText(String text) {
+        this.text = text;
+    }
+
+    public String getUnits() {
+        return units;
+    }
+
+    public void setUnits(String units) {
+        this.units = units;
+    }
+
+    public String getValue() {
+        return value;
+    }
+
+    public void setValue(String value) {
+        this.value = value;
+    }
+
+    public String getMin() {
+        return min;
+    }
+
+    public void setMin(String min) {
+        this.min = min;
+    }
+
+    public String getMax() {
+        return max;
+    }
+
+    public void setMax(String max) {
+        this.max = max;
+    }
+
+    public String formattedMessage() {
+        StringBuilder sb = new StringBuilder();
+
+        if (type != null) {
+            if (!"off".equals(type)) {
+                if (text != null) {
+                    sb.append(text);
+                }
+                if (value != null) {
+                    sb.append(" ");
+                    try {
+                        Double doubleValue = Double.valueOf(value);
+                        sb.append(String.format("%.1f", doubleValue));
+                    } catch (NumberFormatException e) {
+                        sb.append(value);
+                    }
+                }
+                if (units != null) {
+                    sb.append(" ").append(units);
+                }
+            }
+        }
+        return sb.toString();
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaBarNotifyWrapper.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaBarNotifyWrapper.java
new file mode 100644 (file)
index 0000000..eb35615
--- /dev/null
@@ -0,0 +1,46 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import java.util.List;
+
+import javax.xml.bind.annotation.XmlAnyElement;
+import javax.xml.bind.annotation.XmlAttribute;
+import javax.xml.bind.annotation.XmlRootElement;
+
+/**
+ * A helper class for receiving {@link EmotivaBarNotifyDTO} messages.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@XmlRootElement(name = "emotivaBarNotify")
+public class EmotivaBarNotifyWrapper {
+
+    @XmlAttribute
+    private String sequence;
+
+    @XmlAnyElement(lax = true)
+    List<Object> tags;
+
+    @SuppressWarnings("unused")
+    public EmotivaBarNotifyWrapper() {
+    }
+
+    public String getSequence() {
+        return sequence;
+    }
+
+    public List<Object> getTags() {
+        return tags;
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaCommandDTO.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaCommandDTO.java
new file mode 100644 (file)
index 0000000..9d65b65
--- /dev/null
@@ -0,0 +1,146 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.DEFAULT_CONTROL_ACK_VALUE;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlAttribute;
+import javax.xml.bind.annotation.XmlRootElement;
+import javax.xml.bind.annotation.XmlValue;
+
+import org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags;
+
+/**
+ * The EmotivaCommand DTO. Use by multiple message types to control commands in Emotiva devices.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@XmlRootElement(name = "property")
+@XmlAccessorType(XmlAccessType.FIELD)
+public class EmotivaCommandDTO {
+
+    @XmlValue
+    private String commandName;
+    @XmlAttribute
+    private String value;
+    @XmlAttribute
+    private String visible;
+    @XmlAttribute
+    private String status;
+    @XmlAttribute
+    private String ack;
+
+    @SuppressWarnings("unused")
+    public EmotivaCommandDTO() {
+    }
+
+    public EmotivaCommandDTO(EmotivaControlCommands command) {
+        this.commandName = command.name();
+    }
+
+    public EmotivaCommandDTO(EmotivaSubscriptionTags tag) {
+        this.commandName = tag.name();
+    }
+
+    public EmotivaCommandDTO(EmotivaControlCommands command, String value) {
+        this.commandName = command.name();
+        this.value = value;
+    }
+
+    public EmotivaCommandDTO(EmotivaControlCommands commandName, String value, String ack) {
+        this(commandName, value);
+        this.ack = ack;
+    }
+
+    /**
+     * Creates a new instance based on command. Primarily used by EmotivaControl messages.
+     *
+     * @return EmotivaCommandDTO with ack=yes always added
+     */
+    public static EmotivaCommandDTO fromTypeWithAck(EmotivaControlCommands command) {
+        EmotivaCommandDTO emotivaCommandDTO = new EmotivaCommandDTO(command);
+        emotivaCommandDTO.setAck(DEFAULT_CONTROL_ACK_VALUE);
+        return emotivaCommandDTO;
+    }
+
+    /**
+     * Creates a new instance based on command and value. Primarily used by EmotivaControl messages.
+     *
+     * @return EmotivaCommandDTO with ack=yes always added
+     */
+    public static EmotivaCommandDTO fromTypeWithAck(EmotivaControlCommands command, String value) {
+        EmotivaCommandDTO emotivaCommandDTO = new EmotivaCommandDTO(command);
+        if (value != null) {
+            emotivaCommandDTO.setValue(value);
+        }
+        emotivaCommandDTO.setAck(DEFAULT_CONTROL_ACK_VALUE);
+        return emotivaCommandDTO;
+    }
+
+    public static EmotivaCommandDTO fromType(EmotivaControlCommands command) {
+        return new EmotivaCommandDTO(command);
+    }
+
+    public static EmotivaCommandDTO fromType(EmotivaSubscriptionTags command) {
+        return new EmotivaCommandDTO(command);
+    }
+
+    public static EmotivaCommandDTO fromTypeWithAck(EmotivaSubscriptionTags command) {
+        EmotivaCommandDTO emotivaCommandDTO = new EmotivaCommandDTO(command);
+        emotivaCommandDTO.setAck(DEFAULT_CONTROL_ACK_VALUE);
+        return emotivaCommandDTO;
+    }
+
+    public String getName() {
+        return commandName;
+    }
+
+    public String getValue() {
+        return value;
+    }
+
+    public String getVisible() {
+        return visible;
+    }
+
+    public String getStatus() {
+        return status;
+    }
+
+    public String getAck() {
+        return ack;
+    }
+
+    public void setName(String name) {
+        this.commandName = name;
+    }
+
+    public void setValue(String value) {
+        this.value = value;
+    }
+
+    public void setVisible(String visible) {
+        this.visible = visible;
+    }
+
+    public void setStatus(String status) {
+        this.status = status;
+    }
+
+    public void setAck(String ack) {
+        this.ack = ack;
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaControlDTO.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaControlDTO.java
new file mode 100644 (file)
index 0000000..3c4e7e1
--- /dev/null
@@ -0,0 +1,59 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.DEFAULT_CONTROL_MESSAGE_SET_DEFAULT_VALUE;
+
+import java.util.List;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlRootElement;
+
+import org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands;
+
+/**
+ * The EmotivaControl message type. Use to send commands via {@link EmotivaCommandDTO} to Emotiva devices.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@XmlRootElement(name = "emotivaControl")
+@XmlAccessorType(XmlAccessType.FIELD)
+public class EmotivaControlDTO extends AbstractJAXBElementDTO {
+
+    @SuppressWarnings("unused")
+    public EmotivaControlDTO() {
+    }
+
+    public EmotivaControlDTO(List<EmotivaCommandDTO> commands) {
+        this.commands = commands;
+    }
+
+    public static EmotivaControlDTO create(EmotivaControlCommands command) {
+        return new EmotivaControlDTO(
+                List.of(EmotivaCommandDTO.fromTypeWithAck(command, DEFAULT_CONTROL_MESSAGE_SET_DEFAULT_VALUE)));
+    }
+
+    public static EmotivaControlDTO create(EmotivaControlCommands command, int value) {
+        return new EmotivaControlDTO(List.of(EmotivaCommandDTO.fromTypeWithAck(command, String.valueOf(value))));
+    }
+
+    public static EmotivaControlDTO create(EmotivaControlCommands command, double value) {
+        return new EmotivaControlDTO(
+                List.of(EmotivaCommandDTO.fromTypeWithAck(command, String.valueOf(Math.round(value * 2) / 2.0))));
+    }
+
+    public static EmotivaControlDTO create(EmotivaControlCommands command, String value) {
+        return new EmotivaControlDTO(List.of(EmotivaCommandDTO.fromTypeWithAck(command, value)));
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaMenuCol.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaMenuCol.java
new file mode 100644 (file)
index 0000000..1061680
--- /dev/null
@@ -0,0 +1,112 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlAttribute;
+import javax.xml.bind.annotation.XmlRootElement;
+
+/**
+ * Data field use by {@link EmotivaMenuNotifyDTO}.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@XmlRootElement(name = "col")
+@XmlAccessorType(XmlAccessType.FIELD)
+public class EmotivaMenuCol {
+
+    @XmlAttribute
+    private String arrow;
+    @XmlAttribute
+    private String checkbox;
+    @XmlAttribute
+    private String fixed;
+    @XmlAttribute
+    private String fixedWidth;
+    @XmlAttribute
+    private String highlight;
+    @XmlAttribute
+    private String offset;
+    @XmlAttribute
+    private String number;
+    @XmlAttribute
+    private String value;
+
+    public EmotivaMenuCol() {
+    }
+
+    public String getArrow() {
+        return arrow;
+    }
+
+    public void setArrow(String arrow) {
+        this.arrow = arrow;
+    }
+
+    public String getCheckbox() {
+        return checkbox;
+    }
+
+    public void setCheckbox(String checkbox) {
+        this.checkbox = checkbox;
+    }
+
+    public String getFixed() {
+        return fixed;
+    }
+
+    public void setFixed(String fixed) {
+        this.fixed = fixed;
+    }
+
+    public String getFixedWidth() {
+        return fixedWidth;
+    }
+
+    public void setFixedWidth(String fixedWidth) {
+        this.fixedWidth = fixedWidth;
+    }
+
+    public String getHighlight() {
+        return highlight;
+    }
+
+    public void setHighlight(String highlight) {
+        this.highlight = highlight;
+    }
+
+    public String getOffset() {
+        return offset;
+    }
+
+    public void setOffset(String offset) {
+        this.offset = offset;
+    }
+
+    public String getNumber() {
+        return number;
+    }
+
+    public void setNumber(String number) {
+        this.number = number;
+    }
+
+    public String getValue() {
+        return value;
+    }
+
+    public void setValue(String value) {
+        this.value = value;
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaMenuNotifyDTO.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaMenuNotifyDTO.java
new file mode 100644 (file)
index 0000000..24d0cad
--- /dev/null
@@ -0,0 +1,68 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import java.util.List;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlAttribute;
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.XmlRootElement;
+
+/**
+ * The EmotivaMenuNotify message type. Received from a device if subscribed to the
+ * 
+ * @link EmotivaSubscriptionTags#menu_update} type.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@XmlRootElement(name = "emotivaMenuNotify")
+@XmlAccessorType(XmlAccessType.FIELD)
+public class EmotivaMenuNotifyDTO {
+
+    @XmlAttribute
+    private String sequence;
+
+    @XmlElement
+    private List<EmotivaMenuRow> row;
+    @XmlElement
+    private EmotivaMenuProgress progress;
+
+    public EmotivaMenuNotifyDTO() {
+    }
+
+    public String getSequence() {
+        return sequence;
+    }
+
+    public void setSequence(String sequence) {
+        this.sequence = sequence;
+    }
+
+    public List<EmotivaMenuRow> getRow() {
+        return row;
+    }
+
+    public void setRow(List<EmotivaMenuRow> row) {
+        this.row = row;
+    }
+
+    public EmotivaMenuProgress getProgress() {
+        return progress;
+    }
+
+    public void setProgress(EmotivaMenuProgress progress) {
+        this.progress = progress;
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaMenuProgress.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaMenuProgress.java
new file mode 100644 (file)
index 0000000..f068ffb
--- /dev/null
@@ -0,0 +1,42 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlAttribute;
+import javax.xml.bind.annotation.XmlRootElement;
+
+/**
+ * Data field use by {@link EmotivaMenuNotifyDTO}.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@XmlRootElement(name = "progress")
+@XmlAccessorType(XmlAccessType.FIELD)
+public class EmotivaMenuProgress {
+
+    @XmlAttribute
+    private String time;
+
+    public EmotivaMenuProgress() {
+    }
+
+    public String getTime() {
+        return time;
+    }
+
+    public void setTime(String time) {
+        this.time = time;
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaMenuRow.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaMenuRow.java
new file mode 100644 (file)
index 0000000..8683c91
--- /dev/null
@@ -0,0 +1,56 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import java.util.List;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlAttribute;
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.XmlRootElement;
+
+/**
+ * Data field use by {@link EmotivaMenuNotifyDTO}.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@XmlRootElement(name = "row")
+@XmlAccessorType(XmlAccessType.FIELD)
+public class EmotivaMenuRow {
+
+    @XmlAttribute
+    private String number;
+
+    @XmlElement
+    private List<EmotivaMenuCol> col;
+
+    public EmotivaMenuRow() {
+    }
+
+    public String getNumber() {
+        return number;
+    }
+
+    public void setNumber(String number) {
+        this.number = number;
+    }
+
+    public List<EmotivaMenuCol> getCol() {
+        return col;
+    }
+
+    public void setCol(List<EmotivaMenuCol> col) {
+        this.col = col;
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaNotifyDTO.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaNotifyDTO.java
new file mode 100644 (file)
index 0000000..af9bb0b
--- /dev/null
@@ -0,0 +1,90 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlAttribute;
+import javax.xml.bind.annotation.XmlRootElement;
+import javax.xml.bind.annotation.XmlValue;
+
+/**
+ * The EmotivaNotify message type. Received from a device if subscribed to
+ * {@link org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags} values. Uses
+ * the {@link EmotivaNotifyWrapper} to handle unmarshalling.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@XmlRootElement(name = "property")
+@XmlAccessorType(XmlAccessType.FIELD)
+public class EmotivaNotifyDTO {
+
+    @XmlValue
+    private String tagName;
+    @XmlAttribute
+    private String value;
+    @XmlAttribute
+    private String visible;
+    @XmlAttribute
+    private String status;
+    @XmlAttribute
+    private String ack;
+
+    @SuppressWarnings("unused")
+    public EmotivaNotifyDTO() {
+    }
+
+    public EmotivaNotifyDTO(String tag) {
+        this.tagName = tag;
+    }
+
+    public String getName() {
+        return tagName;
+    }
+
+    public String getValue() {
+        return value;
+    }
+
+    public String getVisible() {
+        return visible;
+    }
+
+    public String getStatus() {
+        return status;
+    }
+
+    public void setName(String name) {
+        this.tagName = name;
+    }
+
+    public void setValue(String value) {
+        this.value = value;
+    }
+
+    public void setVisible(String visible) {
+        this.visible = visible;
+    }
+
+    public void setStatus(String status) {
+        this.status = status;
+    }
+
+    public String getAck() {
+        return ack;
+    }
+
+    public void setAck(String ack) {
+        this.ack = ack;
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaNotifyWrapper.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaNotifyWrapper.java
new file mode 100644 (file)
index 0000000..2d08fc4
--- /dev/null
@@ -0,0 +1,47 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import java.util.List;
+
+import javax.xml.bind.annotation.XmlAttribute;
+import javax.xml.bind.annotation.XmlRootElement;
+
+/**
+ * Emotiva Notify message type. 2.x version of protocol uses command type as prefix in each line in the body, while 3.x
+ * users property as prefix with name="commandType". 2.x is handled as a element with a special handler unmarshall
+ * handler in {@link org.openhab.binding.emotiva.internal.protocol.EmotivaXmlUtils}, while 3.x qualifies as a proper xml
+ * element and can be properly unmarshalled by
+ * JAXB.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@XmlRootElement(name = "emotivaNotify")
+public class EmotivaNotifyWrapper extends AbstractNotificationDTO {
+
+    @XmlAttribute
+    private String sequence;
+
+    @SuppressWarnings("unused")
+    public EmotivaNotifyWrapper() {
+    }
+
+    public EmotivaNotifyWrapper(String sequence, List<EmotivaPropertyDTO> properties) {
+        this.sequence = sequence;
+        this.properties = properties;
+    }
+
+    public String getSequence() {
+        return sequence;
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaPingDTO.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaPingDTO.java
new file mode 100644 (file)
index 0000000..e1469ad
--- /dev/null
@@ -0,0 +1,39 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import javax.xml.bind.annotation.XmlAttribute;
+import javax.xml.bind.annotation.XmlRootElement;
+
+/**
+ * The EmotivaPing message type. Use to discover Emotiva devices.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@XmlRootElement(name = "emotivaPing")
+public class EmotivaPingDTO {
+
+    @XmlAttribute
+    private String protocol;
+
+    public EmotivaPingDTO() {
+    }
+
+    public EmotivaPingDTO(String protocol) {
+        this.protocol = protocol;
+    }
+
+    public String getProtocol() {
+        return protocol;
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaPropertyDTO.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaPropertyDTO.java
new file mode 100644 (file)
index 0000000..688d411
--- /dev/null
@@ -0,0 +1,72 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import java.util.Objects;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlAttribute;
+import javax.xml.bind.annotation.XmlRootElement;
+
+/**
+ * The EmotivaProperty DTO. Use by multiple message types to get updates from Emotiva devices.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@XmlRootElement(name = "property")
+@XmlAccessorType(XmlAccessType.FIELD)
+public class EmotivaPropertyDTO {
+
+    @XmlAttribute
+    private String name;
+    @XmlAttribute
+    private String value;
+    @XmlAttribute
+    private String visible;
+    @XmlAttribute
+    private String status;
+
+    @SuppressWarnings("unused")
+    public EmotivaPropertyDTO() {
+    }
+
+    public EmotivaPropertyDTO(String name, String value, String visible) {
+        this.name = name;
+        this.value = value;
+        this.visible = visible;
+    }
+
+    public EmotivaPropertyDTO(String name, String value, String visible, String status) {
+        this.name = name;
+        this.value = value;
+        this.visible = visible;
+        this.status = status;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public String getValue() {
+        return Objects.requireNonNullElse(value, "");
+    }
+
+    public String getVisible() {
+        return Objects.requireNonNullElse(visible, "false");
+    }
+
+    public String getStatus() {
+        return Objects.requireNonNullElse(status, "");
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaSubscriptionDTO.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaSubscriptionDTO.java
new file mode 100644 (file)
index 0000000..6bc1536
--- /dev/null
@@ -0,0 +1,50 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlRootElement;
+import javax.xml.bind.annotation.XmlValue;
+
+import org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags;
+
+/**
+ * The EmotivaSubscriptionDTO message type. Used to send commands via {@link EmotivaSubscriptionRequest} to Emotiva
+ * devices.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@XmlRootElement(name = "property")
+@XmlAccessorType(XmlAccessType.FIELD)
+public class EmotivaSubscriptionDTO {
+
+    @XmlValue
+    private String propertyName;
+
+    @SuppressWarnings("unused")
+    public EmotivaSubscriptionDTO() {
+    }
+
+    public EmotivaSubscriptionDTO(EmotivaSubscriptionTags property) {
+        this.propertyName = property.name();
+    }
+
+    public static EmotivaSubscriptionDTO fromType(EmotivaSubscriptionTags tag) {
+        return new EmotivaSubscriptionDTO(tag);
+    }
+
+    public String getName() {
+        return propertyName;
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaSubscriptionRequest.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaSubscriptionRequest.java
new file mode 100644 (file)
index 0000000..faff313
--- /dev/null
@@ -0,0 +1,63 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaProtocolVersion.PROTOCOL_V2;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.xml.bind.annotation.XmlAttribute;
+import javax.xml.bind.annotation.XmlRootElement;
+
+import org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags;
+
+/**
+ * A helper class for sending {@link EmotivaSubscriptionDTO} messages.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@XmlRootElement(name = "emotivaSubscription")
+public class EmotivaSubscriptionRequest extends AbstractJAXBElementDTO {
+
+    @XmlAttribute
+    private String protocol = PROTOCOL_V2.value();
+
+    @SuppressWarnings("unused")
+    public EmotivaSubscriptionRequest() {
+    }
+
+    public EmotivaSubscriptionRequest(List<EmotivaCommandDTO> commands, String protocol) {
+        this.protocol = protocol;
+        this.commands = commands;
+    }
+
+    public EmotivaSubscriptionRequest(EmotivaSubscriptionTags[] emotivaCommandTypes, String protocol) {
+        this.protocol = protocol;
+        List<EmotivaCommandDTO> list = new ArrayList<>();
+        for (EmotivaSubscriptionTags commandType : emotivaCommandTypes) {
+            list.add(EmotivaCommandDTO.fromTypeWithAck(commandType));
+        }
+        this.commands = list;
+    }
+
+    public EmotivaSubscriptionRequest(EmotivaSubscriptionTags tag) {
+        this.commands = List.of(EmotivaCommandDTO.fromTypeWithAck(tag));
+    }
+
+    public EmotivaSubscriptionRequest(EmotivaControlCommands commandType, String protocol) {
+        this.protocol = protocol;
+        this.commands = List.of(EmotivaCommandDTO.fromTypeWithAck(commandType));
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaSubscriptionResponse.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaSubscriptionResponse.java
new file mode 100644 (file)
index 0000000..e2b87f6
--- /dev/null
@@ -0,0 +1,52 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import java.util.List;
+
+import javax.xml.bind.annotation.XmlAnyElement;
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.XmlRootElement;
+
+/**
+ * A helper class for receiving {@link EmotivaSubscriptionDTO} messages.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@XmlRootElement(name = "emotivaSubscription")
+public class EmotivaSubscriptionResponse {
+
+    // Only present with PROTOCOL_V2 or older
+    @XmlAnyElement(lax = true)
+    List<Object> tags;
+
+    // Only present with PROTOCOL_V3 or newer
+    @XmlElement(name = "property")
+    List<EmotivaPropertyDTO> properties;
+
+    @SuppressWarnings("unused")
+    public EmotivaSubscriptionResponse() {
+    }
+
+    public EmotivaSubscriptionResponse(List<EmotivaPropertyDTO> properties) {
+        this.properties = properties;
+    }
+
+    public List<EmotivaPropertyDTO> getProperties() {
+        return properties;
+    }
+
+    public List<Object> getTags() {
+        return tags;
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaTransponderDTO.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaTransponderDTO.java
new file mode 100644 (file)
index 0000000..d31e65d
--- /dev/null
@@ -0,0 +1,57 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.XmlRootElement;
+
+/**
+ * The EmotivaTransponder message type. Received from a device if after a successful device discovery via the
+ * {@link EmotivaPingDTO} message type.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@XmlRootElement(name = "emotivaTransponder")
+public class EmotivaTransponderDTO {
+
+    @XmlElement(name = "model")
+    private String model;
+    @XmlElement(name = "revision")
+    private String revision;
+    @XmlElement(name = "dataRevision")
+    private String dataRevision;
+    @XmlElement(name = "name")
+    private String name;
+    @XmlElement(name = "control")
+    private ControlDTO control;
+
+    public java.lang.String getModel() {
+        return model;
+    }
+
+    public java.lang.String getRevision() {
+        return revision;
+    }
+
+    public String getDataRevision() {
+        return dataRevision;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public ControlDTO getControl() {
+        return control;
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaUnsubscribeDTO.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaUnsubscribeDTO.java
new file mode 100644 (file)
index 0000000..6a97e53
--- /dev/null
@@ -0,0 +1,56 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.xml.bind.annotation.XmlRootElement;
+
+import org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags;
+
+/**
+ * The EmotivaUnsubscriptionDTO message type. Use to remove subscription after registration via {
+ * 
+ * @link EmotivaSubscriptionRequest}.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@XmlRootElement(name = "emotivaUnsubscribe")
+public class EmotivaUnsubscribeDTO extends AbstractJAXBElementDTO {
+
+    @SuppressWarnings("unused")
+    public EmotivaUnsubscribeDTO() {
+    }
+
+    public EmotivaUnsubscribeDTO(List<EmotivaCommandDTO> commands) {
+        this.commands = commands;
+    }
+
+    public EmotivaUnsubscribeDTO(EmotivaSubscriptionTags[] emotivaCommandTypes) {
+        List<EmotivaCommandDTO> list = new ArrayList<>();
+        for (EmotivaSubscriptionTags commandType : emotivaCommandTypes) {
+            list.add(EmotivaCommandDTO.fromType(commandType));
+        }
+        this.commands = list;
+    }
+
+    public EmotivaUnsubscribeDTO(EmotivaSubscriptionTags tag) {
+        this.commands = List.of(EmotivaCommandDTO.fromType(tag));
+    }
+
+    public EmotivaUnsubscribeDTO(EmotivaControlCommands commandType) {
+        this.commands = List.of(EmotivaCommandDTO.fromType(commandType));
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaUpdateRequest.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaUpdateRequest.java
new file mode 100644 (file)
index 0000000..0fb3c9f
--- /dev/null
@@ -0,0 +1,66 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.xml.bind.annotation.XmlAttribute;
+import javax.xml.bind.annotation.XmlRootElement;
+
+import org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags;
+
+/**
+ * A helper class for sending EmotivaUpdate messages with {@link EmotivaCommandDTO} commands.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@XmlRootElement(name = "emotivaUpdate")
+public class EmotivaUpdateRequest extends AbstractJAXBElementDTO {
+
+    @XmlAttribute
+    private String protocol;
+
+    @SuppressWarnings("unused")
+    public EmotivaUpdateRequest() {
+    }
+
+    public EmotivaUpdateRequest(List<EmotivaCommandDTO> commands) {
+        this.commands = commands;
+    }
+
+    public EmotivaUpdateRequest(EmotivaControlCommands command, String protocol) {
+        this.protocol = protocol;
+        List<EmotivaCommandDTO> list = new ArrayList<>();
+        list.add(EmotivaCommandDTO.fromType(command));
+        this.commands = list;
+    }
+
+    public EmotivaUpdateRequest(EmotivaSubscriptionTags tag) {
+        this.commands = List.of(EmotivaCommandDTO.fromType(tag));
+    }
+
+    public EmotivaUpdateRequest(EmotivaSubscriptionTags[] tags, String protocol) {
+        this.protocol = protocol;
+        List<EmotivaCommandDTO> list = new ArrayList<>();
+        for (EmotivaSubscriptionTags tag : tags) {
+            list.add(EmotivaCommandDTO.fromType(tag));
+        }
+        this.commands = list;
+    }
+
+    public EmotivaUpdateRequest(EmotivaControlCommands commandType) {
+        this.commands = List.of(EmotivaCommandDTO.fromType(commandType));
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaUpdateResponse.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/dto/EmotivaUpdateResponse.java
new file mode 100644 (file)
index 0000000..5b7b5a7
--- /dev/null
@@ -0,0 +1,37 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import java.util.List;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlRootElement;
+
+/**
+ * The EmotivaUpdate message type. Received if an {@link EmotivaUpdateRequest} sent to a device.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@XmlRootElement(name = "emotivaUpdate")
+@XmlAccessorType(XmlAccessType.FIELD)
+public class EmotivaUpdateResponse extends AbstractNotificationDTO {
+
+    @SuppressWarnings("unused")
+    public EmotivaUpdateResponse() {
+    }
+
+    public EmotivaUpdateResponse(List<EmotivaPropertyDTO> properties) {
+        this.properties = properties;
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaCommandType.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaCommandType.java
new file mode 100644 (file)
index 0000000..2ffda5d
--- /dev/null
@@ -0,0 +1,40 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.protocol;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Enum types for commands to send to Emotiva devices. Used by {@link EmotivaControlRequest} to create correct
+ * {@link org.openhab.binding.emotiva.internal.dto.EmotivaControlDTO} command message.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public enum EmotivaCommandType {
+
+    CYCLE, // Cycles to multiple states
+    NONE, // Unknown or not in use commands
+    NUMBER,
+    MENU_CONTROL,
+    MODE, // Audio mode
+    SET, // Sets a specific number or string value
+    SOURCE_MAIN_ZONE, // Main Zone sources
+    SOURCE_USER, // Source with possible user assigned name
+    SOURCE_ZONE2, // Zone 2 sources
+    SPEAKER_PRESET, // Speaker preset
+    TOGGLE, // Two state toggle
+    UP_DOWN_SINGLE, // +1/-1
+    UP_DOWN_HALF // +0.5/-0.5
+
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaControlCommands.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaControlCommands.java
new file mode 100644 (file)
index 0000000..ef78e54
--- /dev/null
@@ -0,0 +1,240 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.protocol;
+
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaCommandType.CYCLE;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaCommandType.MENU_CONTROL;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaCommandType.MODE;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaCommandType.NONE;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaCommandType.NUMBER;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaCommandType.SET;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaCommandType.SOURCE_MAIN_ZONE;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaCommandType.SOURCE_USER;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaCommandType.SOURCE_ZONE2;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaCommandType.SPEAKER_PRESET;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaCommandType.TOGGLE;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaCommandType.UP_DOWN_HALF;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaCommandType.UP_DOWN_SINGLE;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaDataType.DIMENSIONLESS_DECIBEL;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaDataType.DIMENSIONLESS_PERCENT;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaDataType.NOT_IMPLEMENTED;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaDataType.ON_OFF;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaDataType.STRING;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaDataType.UNKNOWN;
+
+import java.util.EnumMap;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Emotiva command name with corresponding command type and UoM data type.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public enum EmotivaControlCommands {
+    none("", NONE, UNKNOWN),
+    standby("", TOGGLE, ON_OFF),
+    source_tuner("Tuner", SOURCE_USER, STRING),
+    source_1("Input 1", SOURCE_USER, STRING),
+    source_2("Input 2", SOURCE_USER, STRING),
+    source_3("Input 3", SOURCE_USER, STRING),
+    source_4("Input 4", SOURCE_USER, STRING),
+    source_5("Input 5", SOURCE_USER, STRING),
+    source_6("Input 6", SOURCE_USER, STRING),
+    source_7("Input 7", SOURCE_USER, STRING),
+    source_8("Input 8", SOURCE_USER, STRING),
+    menu("", SET, STRING),
+    menu_control("", MENU_CONTROL, STRING), // Not an Emotiva command, just a placeholder
+    up("", SET, STRING),
+    down("", SET, STRING),
+    left("", SET, STRING),
+    right("", SET, STRING),
+    enter("", SET, STRING),
+    dim("", CYCLE, DIMENSIONLESS_PERCENT),
+    mode("", MODE, STRING),
+    info("", SET, UNKNOWN),
+    mute("", SET, ON_OFF),
+    mute_off("", SET, ON_OFF),
+    mute_on("", SET, ON_OFF),
+    music("", SET, STRING),
+    movie("", SET, STRING),
+    center("", SET, DIMENSIONLESS_DECIBEL),
+    subwoofer("", SET, DIMENSIONLESS_DECIBEL),
+    surround("", SET, DIMENSIONLESS_DECIBEL),
+    back("", SET, DIMENSIONLESS_DECIBEL),
+    input("", NONE, STRING),
+    input_up("", SET, STRING),
+    input_down("", SET, STRING),
+    power("", TOGGLE, ON_OFF), // Not an Emotiva command, just a placeholder
+    power_on("", SET, ON_OFF),
+    power_off("", SET, ON_OFF),
+    volume("", SET, DIMENSIONLESS_DECIBEL),
+    set_volume("", NUMBER, DIMENSIONLESS_DECIBEL),
+    loudness_on("", SET, ON_OFF),
+    loudness_off("", SET, ON_OFF),
+    loudness("", TOGGLE, ON_OFF),
+    speaker_preset("", SPEAKER_PRESET, STRING),
+    mode_up("", SET, STRING),
+    mode_down("", SET, STRING),
+    bass("", UP_DOWN_HALF, DIMENSIONLESS_DECIBEL), // Not an Emotiva command, just a placeholder
+    bass_up("", UP_DOWN_HALF, DIMENSIONLESS_DECIBEL),
+    bass_down("", UP_DOWN_HALF, DIMENSIONLESS_DECIBEL),
+    treble("", UP_DOWN_HALF, DIMENSIONLESS_DECIBEL), // Not an Emotiva command, just a placeholder
+    treble_up("", UP_DOWN_HALF, DIMENSIONLESS_DECIBEL),
+    treble_down("", UP_DOWN_HALF, DIMENSIONLESS_DECIBEL),
+    zone2_power("", TOGGLE, ON_OFF),
+    zone2_power_off("", SET, ON_OFF),
+    zone2_power_on("", SET, ON_OFF),
+    zone2_volume("", SET, DIMENSIONLESS_DECIBEL),
+    zone2_set_volume("", NUMBER, STRING),
+    zone2_input("", NONE, STRING),
+    zone1_band("", TOGGLE, STRING),
+    band_am("", SET, STRING),
+    band_fm("", SET, STRING),
+    zone2_mute("", TOGGLE, ON_OFF),
+    zone2_mute_off("", SET, ON_OFF),
+    zone2_mute_on("", SET, ON_OFF),
+    zone2_band("", SET, NOT_IMPLEMENTED),
+    frequency("", UP_DOWN_SINGLE, ON_OFF),
+    seek("", UP_DOWN_SINGLE, ON_OFF),
+    channel("", UP_DOWN_SINGLE, ON_OFF),
+    stereo("", SET, STRING),
+    direct("", SET, STRING),
+    dolby("", SET, STRING),
+    dts("", SET, STRING),
+    all_stereo("", SET, STRING),
+    auto("", SET, STRING),
+    reference_stereo("", SET, STRING),
+    surround_mode("", SET, STRING),
+    preset1("Preset 1", SET, STRING),
+    preset2("Preset 2", SET, STRING),
+    dirac("Dirac", SET, STRING),
+    hdmi1("HDMI 1", SOURCE_MAIN_ZONE, STRING),
+    hdmi2("HDMI 2", SOURCE_MAIN_ZONE, STRING),
+    hdmi3("HDMI 3", SOURCE_MAIN_ZONE, STRING),
+    hdmi4("HDMI 4", SOURCE_MAIN_ZONE, STRING),
+    hdmi5("HDMI 5", SOURCE_MAIN_ZONE, STRING),
+    hdmi6("HDMI 6", SOURCE_MAIN_ZONE, STRING),
+    hdmi7("HDMI 7", SOURCE_MAIN_ZONE, STRING),
+    hdmi8("HDMI 8", SOURCE_MAIN_ZONE, STRING),
+    analog1("Analog 1", SOURCE_MAIN_ZONE, STRING),
+    analog2("Analog 2", SOURCE_MAIN_ZONE, STRING),
+    analog3("Analog 3", SOURCE_MAIN_ZONE, STRING),
+    analog4("Analog 4", SOURCE_MAIN_ZONE, STRING),
+    analog5("Analog 5", SOURCE_MAIN_ZONE, STRING),
+    analog71("Analog 7.1", SOURCE_MAIN_ZONE, STRING),
+    ARC("Audio Return Channel", SOURCE_MAIN_ZONE, STRING),
+    coax1("Coax 1", SOURCE_MAIN_ZONE, STRING),
+    coax2("Coax 2", SOURCE_MAIN_ZONE, STRING),
+    coax3("Coax 3", SOURCE_MAIN_ZONE, STRING),
+    coax4("Coax 4", SOURCE_MAIN_ZONE, STRING),
+    front_in("Front In", SOURCE_MAIN_ZONE, STRING),
+    optical1("Optical 1", SOURCE_MAIN_ZONE, STRING),
+    optical2("Optical 2", SOURCE_MAIN_ZONE, STRING),
+    optical3("Optical 3", SOURCE_MAIN_ZONE, STRING),
+    optical4("Optical 4", SOURCE_MAIN_ZONE, STRING),
+    tuner("Tuner 1", SOURCE_MAIN_ZONE, STRING),
+    usb_stream("USB Stream", SOURCE_MAIN_ZONE, STRING),
+    center_trim_set("", NUMBER, DIMENSIONLESS_DECIBEL),
+    subwoofer_trim_set("", NUMBER, DIMENSIONLESS_DECIBEL),
+    surround_trim_set("", NUMBER, DIMENSIONLESS_DECIBEL),
+    back_trim_set("", NUMBER, DIMENSIONLESS_DECIBEL),
+    width_trim_set("", NUMBER, DIMENSIONLESS_DECIBEL),
+    height_trim_set("", NUMBER, DIMENSIONLESS_DECIBEL),
+    zone2_analog1("Analog 1", SOURCE_ZONE2, STRING),
+    zone2_analog2("Analog 2", SOURCE_ZONE2, STRING),
+    zone2_analog3("Analog 3", SOURCE_ZONE2, STRING),
+    zone2_analog4("Analog 4", SOURCE_ZONE2, STRING),
+    zone2_analog5("Analog 5", SOURCE_ZONE2, STRING),
+    zone2_analog71("Analog 7.1", SOURCE_ZONE2, STRING),
+    zone2_analog8("Analog 8", SOURCE_ZONE2, STRING),
+    zone2_ARC("Audio Return Channel", SOURCE_ZONE2, STRING),
+    zone2_coax1("Coax 1", SOURCE_ZONE2, STRING),
+    zone2_coax2("Coax 2", SOURCE_ZONE2, STRING),
+    zone2_coax3("Coax 3", SOURCE_ZONE2, STRING),
+    zone2_coax4("Coax 4", SOURCE_ZONE2, STRING),
+    zone2_ethernet("Ethernet", SOURCE_ZONE2, STRING),
+    zone2_follow_main("Follow Main", SOURCE_ZONE2, STRING),
+    zone2_front_in("Front In", SOURCE_ZONE2, STRING),
+    zone2_optical1("Optical 1", SOURCE_ZONE2, STRING),
+    zone2_optical2("Optical 2", SOURCE_ZONE2, STRING),
+    zone2_optical3("Optical 3", SOURCE_ZONE2, STRING),
+    zone2_optical4("Optical 4", SOURCE_ZONE2, STRING),
+    channel_1("Channel 1", SET, STRING),
+    channel_2("Channel 2", SET, STRING),
+    channel_3("Channel 3", SET, STRING),
+    channel_4("Channel 4", SET, STRING),
+    channel_5("Channel 5", SET, STRING),
+    channel_6("Channel 6", SET, STRING),
+    channel_7("Channel 7", SET, STRING),
+    channel_8("Channel 8", SET, STRING),
+    channel_9("Channel 9", SET, STRING),
+    channel_10("Channel 10", SET, STRING),
+    channel_11("Channel 11", SET, STRING),
+    channel_12("Channel 12", SET, STRING),
+    channel_13("Channel 13", SET, STRING),
+    channel_14("Channel 14", SET, STRING),
+    channel_15("Channel 15", SET, STRING),
+    channel_16("Channel 16", SET, STRING),
+    channel_17("Channel 17", SET, STRING),
+    channel_18("Channel 18", SET, STRING),
+    channel_19("Channel 19", SET, STRING),
+    channel_20("Channel 20", SET, STRING);
+
+    private final String label;
+    private final EmotivaCommandType commandType;
+    private final EmotivaDataType dataType;
+
+    EmotivaControlCommands(String label, EmotivaCommandType commandType, EmotivaDataType dataType) {
+        this.label = label;
+        this.commandType = commandType;
+        this.dataType = dataType;
+    }
+
+    public static EmotivaControlCommands matchToInput(String inputName) {
+        for (EmotivaControlCommands value : values()) {
+            if (inputName.toLowerCase().equals(value.name())) {
+                return value;
+            }
+        }
+        if (inputName.startsWith("input_")) {
+            return valueOf(inputName.replace("input_", "source_"));
+        }
+        return none;
+    }
+
+    public String getLabel() {
+        return label;
+    }
+
+    public EmotivaCommandType getCommandType() {
+        return commandType;
+    }
+
+    public EmotivaDataType getDataType() {
+        return dataType;
+    }
+
+    public static EnumMap<EmotivaControlCommands, String> getCommandsFromType(EmotivaCommandType filter) {
+        EnumMap<EmotivaControlCommands, String> commands = new EnumMap<>(EmotivaControlCommands.class);
+        for (EmotivaControlCommands value : values()) {
+            if (value.getCommandType().equals(filter)) {
+                StringBuilder sb = new StringBuilder(value.name());
+                sb.setCharAt(0, Character.toUpperCase(value.name().charAt(0)));
+                commands.put(value, sb.toString());
+            }
+        }
+        return commands;
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaControlRequest.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaControlRequest.java
new file mode 100644 (file)
index 0000000..ab529c7
--- /dev/null
@@ -0,0 +1,475 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.protocol;
+
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.*;
+import static org.openhab.binding.emotiva.internal.EmotivaCommandHelper.clamp;
+import static org.openhab.binding.emotiva.internal.EmotivaCommandHelper.volumePercentageToDecibel;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaCommandType.*;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaDataType.FREQUENCY_HERTZ;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags.tuner_band;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags.tuner_channel;
+
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.emotiva.internal.dto.EmotivaControlDTO;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.types.UpDownType;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Binds channels to a given command with datatype.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public class EmotivaControlRequest {
+    private final Logger logger = LoggerFactory.getLogger(EmotivaControlRequest.class);
+    private String name;
+    private final EmotivaDataType dataType;
+    private String channel;
+    private final EmotivaControlCommands defaultCommand;
+    private final EmotivaControlCommands setCommand;
+    private final EmotivaControlCommands onCommand;
+    private final EmotivaControlCommands offCommand;
+    private final EmotivaControlCommands upCommand;
+    private final EmotivaControlCommands downCommand;
+    private double maxValue;
+    private double minValue;
+    private final Map<String, Map<EmotivaControlCommands, String>> commandMaps;
+    private final EmotivaProtocolVersion protocolVersion;
+
+    public EmotivaControlRequest(String channel, EmotivaSubscriptionTags channelSubscription,
+            EmotivaControlCommands controlCommand, Map<String, Map<EmotivaControlCommands, String>> commandMaps,
+            EmotivaProtocolVersion protocolVersion) {
+        if (channelSubscription.equals(EmotivaSubscriptionTags.unknown)) {
+            if (controlCommand.equals(EmotivaControlCommands.none)) {
+                this.defaultCommand = EmotivaControlCommands.none;
+                this.onCommand = EmotivaControlCommands.none;
+                this.offCommand = EmotivaControlCommands.none;
+                this.setCommand = EmotivaControlCommands.none;
+                this.upCommand = EmotivaControlCommands.none;
+                this.downCommand = EmotivaControlCommands.none;
+            } else {
+                this.defaultCommand = controlCommand;
+                this.onCommand = resolveOnCommand(controlCommand);
+                this.offCommand = resolveOffCommand(controlCommand);
+                this.setCommand = resolveSetCommand(controlCommand);
+                this.upCommand = resolveUpCommand(controlCommand);
+                this.downCommand = resolveDownCommand(controlCommand);
+            }
+        } else {
+            this.defaultCommand = resolveControlCommand(channelSubscription.getEmotivaName(), controlCommand);
+            if (controlCommand.equals(EmotivaControlCommands.none)) {
+                this.onCommand = resolveOnCommand(defaultCommand);
+                this.offCommand = resolveOffCommand(defaultCommand);
+                this.setCommand = resolveSetCommand(defaultCommand);
+                this.upCommand = resolveUpCommand(defaultCommand);
+                this.downCommand = resolveDownCommand(defaultCommand);
+            } else {
+                this.onCommand = controlCommand;
+                this.offCommand = controlCommand;
+                this.setCommand = controlCommand;
+                this.upCommand = controlCommand;
+                this.downCommand = controlCommand;
+            }
+        }
+        this.name = defaultCommand.name();
+        this.dataType = defaultCommand.getDataType();
+        this.channel = channel;
+        this.commandMaps = commandMaps;
+        this.protocolVersion = protocolVersion;
+        if (name.equals(EmotivaControlCommands.volume.name())
+                || name.equals(EmotivaControlCommands.zone2_volume.name())) {
+            minValue = DEFAULT_VOLUME_MIN_DECIBEL;
+            maxValue = DEFAULT_VOLUME_MAX_DECIBEL;
+        } else if (setCommand.name().endsWith(TRIM_SET_COMMAND_SUFFIX)) {
+            minValue = DEFAULT_TRIM_MIN_DECIBEL * 2;
+            maxValue = DEFAULT_TRIM_MAX_DECIBEL * 2;
+        }
+    }
+
+    public EmotivaControlDTO createDTO(Command ohCommand, @Nullable State previousState) {
+        switch (defaultCommand.getCommandType()) {
+            case CYCLE -> {
+                return EmotivaControlDTO.create(defaultCommand);
+            }
+            case MENU_CONTROL -> {
+                if (ohCommand instanceof StringType value) {
+                    try {
+                        return EmotivaControlDTO.create(EmotivaControlCommands.valueOf(value.toString().toLowerCase()));
+                    } catch (IllegalArgumentException e) {
+                        return EmotivaControlDTO.create(EmotivaControlCommands.none);
+                    }
+                }
+            }
+            case MODE -> {
+                if (ohCommand instanceof StringType value) {
+                    // Check if value can be interpreted as a mode-<command>
+                    try {
+                        OHChannelToEmotivaCommand ohChannelToEmotivaCommand = OHChannelToEmotivaCommand
+                                .valueOf(value.toString());
+                        return EmotivaControlDTO.create(ohChannelToEmotivaCommand.getCommand());
+                    } catch (IllegalArgumentException e) {
+                        if ("1".equals(value.toString())) {
+                            return EmotivaControlDTO.create(getUpCommand(), 1);
+                        } else if ("-1".equals(value.toString())) {
+                            return EmotivaControlDTO.create(getDownCommand(), -1);
+                        }
+                        return EmotivaControlDTO.create(EmotivaControlCommands.none);
+                    }
+                } else if (ohCommand instanceof Number value) {
+                    if (value.intValue() >= 1) {
+                        return EmotivaControlDTO.create(getUpCommand(), 1);
+                    } else if (value.intValue() <= -1) {
+                        return EmotivaControlDTO.create(getDownCommand(), -1);
+                    }
+                }
+            }
+            case NUMBER -> {
+                if (ohCommand instanceof Number value) {
+                    return handleNumberTypes(getSetCommand(), ohCommand, value);
+                } else {
+                    logger.debug("Could not create EmotivaControlDTO for {}:{}:{}, ohCommand is {}", channel, name,
+                            NUMBER, ohCommand.getClass().getSimpleName());
+                    return EmotivaControlDTO.create(EmotivaControlCommands.none);
+                }
+            }
+            case NONE -> {
+                switch (channel) {
+                    case CHANNEL_TUNER_BAND -> {
+                        return matchToCommandMap(ohCommand, tuner_band.getEmotivaName());
+                    }
+                    case CHANNEL_TUNER_CHANNEL_SELECT -> {
+                        return matchToCommandMap(ohCommand, tuner_channel.getEmotivaName());
+                    }
+                    case CHANNEL_SOURCE -> {
+                        return matchToCommandMap(ohCommand, MAP_SOURCES_MAIN_ZONE);
+                    }
+                    case CHANNEL_ZONE2_SOURCE -> {
+                        return matchToCommandMap(ohCommand, MAP_SOURCES_ZONE_2);
+                    }
+                    default -> {
+                        return EmotivaControlDTO.create(EmotivaControlCommands.none);
+                    }
+                }
+            }
+            case SET -> {
+                if (ohCommand instanceof StringType value) {
+                    return EmotivaControlDTO.create(getSetCommand(), value.toString());
+                } else if (ohCommand instanceof Number value) {
+                    return handleNumberTypes(getSetCommand(), ohCommand, value);
+                } else if (ohCommand instanceof OnOffType value) {
+                    if (value.equals(OnOffType.ON)) {
+                        return EmotivaControlDTO.create(getOnCommand());
+                    } else {
+                        return EmotivaControlDTO.create(getOffCommand());
+                    }
+                } else {
+                    logger.debug("Could not create EmotivaControlDTO for {}:{}:{}, ohCommand is {}", channel, name, SET,
+                            ohCommand.getClass().getSimpleName());
+                    return EmotivaControlDTO.create(EmotivaControlCommands.none);
+                }
+            }
+            case SPEAKER_PRESET -> {
+                if (ohCommand instanceof StringType value) {
+                    try {
+                        return EmotivaControlDTO.create(EmotivaControlCommands.valueOf(value.toString()));
+                    } catch (IllegalArgumentException e) {
+                        // No match found for preset command, default to cycling
+                        return EmotivaControlDTO.create(defaultCommand);
+                    }
+                } else {
+                    return EmotivaControlDTO.create(defaultCommand);
+                }
+            }
+            case TOGGLE -> {
+                if (ohCommand instanceof OnOffType value) {
+                    if (value.equals(OnOffType.ON)) {
+                        return EmotivaControlDTO.create(getOnCommand());
+                    } else {
+                        return EmotivaControlDTO.create(getOffCommand());
+                    }
+                } else {
+                    logger.debug("Could not create EmotivaControlDTO for {}:{}:{}, ohCommand is {}", channel, name,
+                            TOGGLE, ohCommand.getClass().getSimpleName());
+                    return EmotivaControlDTO.create(EmotivaControlCommands.none);
+                }
+            }
+            case UP_DOWN_SINGLE -> {
+                if (ohCommand instanceof Number value) {
+                    if (dataType.equals(FREQUENCY_HERTZ)) {
+                        if (previousState instanceof Number pre) {
+                            if (value.doubleValue() > pre.doubleValue()) {
+                                return EmotivaControlDTO.create(getUpCommand(), 1);
+                            } else if (value.doubleValue() < pre.doubleValue()) {
+                                return EmotivaControlDTO.create(getDownCommand(), -1);
+                            }
+                        }
+                    }
+                    if (value.intValue() <= maxValue || value.intValue() >= minValue) {
+                        if (value.intValue() >= 1) {
+                            return EmotivaControlDTO.create(getUpCommand(), 1);
+                        } else if (value.intValue() <= -1) {
+                            return EmotivaControlDTO.create(getDownCommand(), -1);
+                        }
+                    }
+                    // Reached max or min value, not sending anything
+                    return EmotivaControlDTO.create(EmotivaControlCommands.none);
+                } else if (ohCommand instanceof StringType value) {
+                    if ("1".equals(value.toString())) {
+                        return EmotivaControlDTO.create(getUpCommand(), 1);
+                    } else if ("-1".equals(value.toString())) {
+                        return EmotivaControlDTO.create(getDownCommand(), -1);
+                    }
+                } else if (ohCommand instanceof UpDownType value) {
+                    if (value.equals(UpDownType.UP)) {
+                        return EmotivaControlDTO.create(getUpCommand(), 1);
+                    } else {
+                        return EmotivaControlDTO.create(getDownCommand(), -1);
+                    }
+                } else {
+                    logger.debug("Could not create EmotivaControlDTO for {}:{}:{}, ohCommand is {}", channel, name,
+                            UP_DOWN_SINGLE, ohCommand.getClass().getSimpleName());
+                }
+                return EmotivaControlDTO.create(EmotivaControlCommands.none);
+            }
+            case UP_DOWN_HALF -> {
+                if (ohCommand instanceof Number value) {
+                    if (value.intValue() <= maxValue || value.intValue() >= minValue) {
+                        Number pre = (Number) previousState;
+                        if (pre == null) {
+                            if (value.doubleValue() > 0) {
+                                return EmotivaControlDTO.create(getUpCommand());
+                            } else if (value.doubleValue() < 0) {
+                                return EmotivaControlDTO.create(getDownCommand());
+                            }
+                        } else {
+                            if (value.doubleValue() > pre.doubleValue()) {
+                                return EmotivaControlDTO.create(getUpCommand());
+                            } else if (value.doubleValue() < pre.doubleValue()) {
+                                return EmotivaControlDTO.create(getDownCommand());
+                            }
+                        }
+                    }
+                } else {
+                    logger.debug("Could not create EmotivaControlDTO for {}:{}:{}, ohCommand is {}", channel, name,
+                            UP_DOWN_HALF, ohCommand.getClass().getSimpleName());
+                    return EmotivaControlDTO.create(EmotivaControlCommands.none);
+                }
+            }
+            default -> {
+                return EmotivaControlDTO.create(EmotivaControlCommands.none);
+            }
+        }
+        return EmotivaControlDTO.create(EmotivaControlCommands.none);
+    }
+
+    private EmotivaControlDTO matchToCommandMap(Command ohCommand, String mapName) {
+        if (ohCommand instanceof StringType value) {
+            Map<EmotivaControlCommands, String> commandMap = commandMaps.get(mapName);
+            if (commandMap != null) {
+                for (EmotivaControlCommands command : commandMap.keySet()) {
+                    String map = commandMap.get(command);
+                    if (map != null && map.equals(value.toString())) {
+                        return EmotivaControlDTO.create(EmotivaControlCommands.matchToInput(command.toString()));
+                    } else if (command.name().equalsIgnoreCase(value.toString())) {
+                        return EmotivaControlDTO.create(command);
+                    }
+                }
+            }
+        }
+        return EmotivaControlDTO.create(EmotivaControlCommands.none);
+    }
+
+    private EmotivaControlDTO handleNumberTypes(EmotivaControlCommands setCommand, Command ohCommand, Number value) {
+        switch (dataType) {
+            case DIMENSIONLESS_PERCENT -> {
+                if (name.equals(EmotivaControlCommands.volume.name())) {
+                    return EmotivaControlDTO.create(EmotivaControlCommands.set_volume,
+                            volumePercentageToDecibel(value.intValue()));
+                } else if (name.equals(EmotivaControlCommands.zone2_set_volume.name())) {
+                    return EmotivaControlDTO.create(EmotivaControlCommands.zone2_set_volume,
+                            volumePercentageToDecibel(value.intValue()));
+                } else {
+                    return EmotivaControlDTO.create(setCommand, value.intValue());
+                }
+            }
+            case DIMENSIONLESS_DECIBEL -> {
+                if (name.equals(EmotivaControlCommands.volume.name())) {
+                    return createForVolumeSetCommand(ohCommand, value, EmotivaControlCommands.set_volume);
+                } else if (name.equals(EmotivaControlCommands.zone2_volume.name())) {
+                    return createForVolumeSetCommand(ohCommand, value, EmotivaControlCommands.zone2_set_volume);
+                } else {
+                    double doubleValue = setCommand.name().endsWith(TRIM_SET_COMMAND_SUFFIX)
+                            ? value.doubleValue() * PROTOCOL_V3_LEVEL_MULTIPLIER
+                            : value.doubleValue();
+                    if (doubleValue >= maxValue) {
+                        return EmotivaControlDTO.create(getSetCommand(), maxValue);
+                    } else if (doubleValue <= minValue) {
+                        return EmotivaControlDTO.create(getSetCommand(), minValue);
+                    } else {
+                        return EmotivaControlDTO.create(getSetCommand(), doubleValue);
+                    }
+                }
+            }
+            case FREQUENCY_HERTZ -> {
+                return EmotivaControlDTO.create(getDefaultCommand(), value.intValue());
+            }
+            default -> {
+                logger.debug("Could not create EmotivaControlDTO for {}:{}:{}, ohCommand is {}", channel, name,
+                        setCommand.getDataType(), ohCommand.getClass().getSimpleName());
+                return EmotivaControlDTO.create(EmotivaControlCommands.none);
+            }
+        }
+    }
+
+    private EmotivaControlDTO createForVolumeSetCommand(Command ohCommand, Number value,
+            EmotivaControlCommands emotivaControlCommands) {
+        if (ohCommand instanceof PercentType) {
+            return EmotivaControlDTO.create(emotivaControlCommands, volumePercentageToDecibel(value.intValue()));
+        } else {
+            return EmotivaControlDTO.create(emotivaControlCommands, clamp(value, minValue, maxValue));
+        }
+    }
+
+    private EmotivaControlCommands resolveUpCommand(EmotivaControlCommands controlCommand) {
+        try {
+            return EmotivaControlCommands.valueOf("%s_up".formatted(controlCommand.name()));
+        } catch (IllegalArgumentException e) {
+            // not found, setting original command
+            return controlCommand;
+        }
+    }
+
+    private EmotivaControlCommands resolveDownCommand(EmotivaControlCommands controlCommand) {
+        try {
+            return EmotivaControlCommands.valueOf("%s_down".formatted(controlCommand.name()));
+        } catch (IllegalArgumentException e) {
+            // not found, setting original command
+            return controlCommand;
+        }
+    }
+
+    private EmotivaControlCommands resolveControlCommand(String name, EmotivaControlCommands controlCommand) {
+        try {
+            return controlCommand.equals(EmotivaControlCommands.none) ? EmotivaControlCommands.valueOf(name)
+                    : controlCommand;
+        } catch (IllegalArgumentException e) {
+            // ignore
+        }
+        return EmotivaControlCommands.none;
+    }
+
+    private EmotivaControlCommands resolveOnCommand(EmotivaControlCommands controlCommand) {
+        try {
+            return EmotivaControlCommands.valueOf("%s_on".formatted(controlCommand.name()));
+        } catch (IllegalArgumentException e) {
+            // not found, setting original command
+            return controlCommand;
+        }
+    }
+
+    private EmotivaControlCommands resolveOffCommand(EmotivaControlCommands controlCommand) {
+        try {
+            return EmotivaControlCommands.valueOf("%s_off".formatted(controlCommand.name()));
+        } catch (IllegalArgumentException e) {
+            // not found, using original command
+            return controlCommand;
+        }
+    }
+
+    /**
+     * Checks for commands with _trim_set suffix, which indicate speaker trims with a fixed min/max value.
+     */
+    private EmotivaControlCommands resolveSetCommand(EmotivaControlCommands controlCommand) {
+        try {
+            return EmotivaControlCommands.valueOf("%s_trim_set".formatted(controlCommand.name()));
+        } catch (IllegalArgumentException e) {
+            // not found, using original command
+            return controlCommand;
+        }
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public EmotivaDataType getDataType() {
+        return dataType;
+    }
+
+    public String getChannel() {
+        return channel;
+    }
+
+    public EmotivaControlCommands getDefaultCommand() {
+        return defaultCommand;
+    }
+
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    public void setChannel(String channel) {
+        this.channel = channel;
+    }
+
+    public EmotivaControlCommands getSetCommand() {
+        return setCommand;
+    }
+
+    public EmotivaControlCommands getOnCommand() {
+        return onCommand;
+    }
+
+    public EmotivaControlCommands getOffCommand() {
+        return offCommand;
+    }
+
+    public EmotivaControlCommands getUpCommand() {
+        return upCommand;
+    }
+
+    public EmotivaControlCommands getDownCommand() {
+        return downCommand;
+    }
+
+    public double getMaxValue() {
+        return maxValue;
+    }
+
+    public double getMinValue() {
+        return minValue;
+    }
+
+    public EmotivaProtocolVersion getProtocolVersion() {
+        return protocolVersion;
+    }
+
+    @Override
+    public String toString() {
+        return "EmotivaControlRequest{" + "name='" + name + '\'' + ", dataType=" + dataType + ", channel='" + channel
+                + '\'' + ", defaultCommand=" + defaultCommand + ", setCommand=" + setCommand + ", onCommand="
+                + onCommand + ", offCommand=" + offCommand + ", upCommand=" + upCommand + ", downCommand=" + downCommand
+                + ", maxValue=" + maxValue + ", minValue=" + minValue + ", commandMaps=" + commandMaps
+                + ", protocolVersion=" + protocolVersion + '}';
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaDataType.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaDataType.java
new file mode 100644 (file)
index 0000000..4a29b0d
--- /dev/null
@@ -0,0 +1,56 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.protocol;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * This enum is used to describe the value types from Emotiva.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public enum EmotivaDataType {
+    DIMENSIONLESS_DECIBEL("decibel"),
+    DIMENSIONLESS_PERCENT("percent"),
+    FREQUENCY_HERTZ("hertz"),
+    NUMBER("number"),
+    NUMBER_TIME("number_time"),
+    GOODBYE("goodbye"),
+    NOT_IMPLEMENTED("not_implemented"),
+    ON_OFF("boolean"),
+    STRING("string"),
+    UNKNOWN("unknown");
+
+    private final String name;
+
+    EmotivaDataType(String name) {
+        this.name = name;
+    }
+
+    public static EmotivaDataType fromName(String name) {
+        EmotivaDataType result = EmotivaDataType.UNKNOWN;
+        for (EmotivaDataType m : EmotivaDataType.values()) {
+            if (m.name.equals(name)) {
+                result = m;
+                break;
+            }
+        }
+        return result;
+    }
+
+    @Override
+    public String toString() {
+        return name;
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaPropertyStatus.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaPropertyStatus.java
new file mode 100644 (file)
index 0000000..77f99e2
--- /dev/null
@@ -0,0 +1,37 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.protocol;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Status types for status fields of different message type.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public enum EmotivaPropertyStatus {
+
+    VALID("ack"),
+    NOT_VALID("nak");
+
+    private final String value;
+
+    EmotivaPropertyStatus(String value) {
+        this.value = value;
+    }
+
+    public String getValue() {
+        return value;
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaProtocolVersion.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaProtocolVersion.java
new file mode 100644 (file)
index 0000000..feb590d
--- /dev/null
@@ -0,0 +1,46 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.protocol;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Enum for mapping Emotiva Network Protocol versions.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public enum EmotivaProtocolVersion {
+
+    PROTOCOL_V2("2.0"),
+    PROTOCOL_V3("3.0");
+
+    private final String protocolVersion;
+
+    EmotivaProtocolVersion(String protocolVersion) {
+        this.protocolVersion = protocolVersion;
+    }
+
+    public static EmotivaProtocolVersion protocolFromConfig(String protocolVersion) {
+        for (EmotivaProtocolVersion value : values()) {
+            if (protocolVersion.equals(value.protocolVersion)) {
+                return value;
+            }
+        }
+        return PROTOCOL_V2;
+    }
+
+    public String value() {
+        return protocolVersion;
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaSubscriptionTags.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaSubscriptionTags.java
new file mode 100644 (file)
index 0000000..2e2d648
--- /dev/null
@@ -0,0 +1,186 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.protocol;
+
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.*;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaDataType.*;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Emotiva subscription tags with corresponding UoM data type and channel.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public enum EmotivaSubscriptionTags {
+
+    /* Protocol V1 notify tags */
+    power("power", ON_OFF, CHANNEL_MAIN_ZONE_POWER),
+    source("source", STRING, CHANNEL_SOURCE),
+    dim("dim", DIMENSIONLESS_PERCENT, CHANNEL_DIM),
+    mode("mode", STRING, CHANNEL_MODE),
+    speaker_preset("speaker-preset", STRING, CHANNEL_SPEAKER_PRESET),
+    center("center", DIMENSIONLESS_DECIBEL, CHANNEL_CENTER),
+    subwoofer("subwoofer", DIMENSIONLESS_DECIBEL, CHANNEL_SUBWOOFER),
+    surround("surround", DIMENSIONLESS_DECIBEL, CHANNEL_SURROUND),
+    back("back", DIMENSIONLESS_DECIBEL, CHANNEL_BACK),
+    volume("volume", DIMENSIONLESS_DECIBEL, CHANNEL_MAIN_VOLUME),
+    loudness("loudness", ON_OFF, CHANNEL_LOUDNESS),
+    treble("treble", DIMENSIONLESS_DECIBEL, CHANNEL_TREBLE),
+    bass("bass", DIMENSIONLESS_DECIBEL, CHANNEL_BASS),
+    zone2_power("zone2-power", ON_OFF, CHANNEL_ZONE2_POWER),
+    zone2_volume("zone2-volume", DIMENSIONLESS_DECIBEL, CHANNEL_ZONE2_VOLUME),
+    zone2_input("zone2-input", STRING, CHANNEL_ZONE2_SOURCE),
+    tuner_band("tuner-band", STRING, CHANNEL_TUNER_BAND),
+    tuner_channel("tuner-channel", FREQUENCY_HERTZ, CHANNEL_TUNER_CHANNEL),
+    tuner_signal("tuner-signal", STRING, CHANNEL_TUNER_SIGNAL),
+    tuner_program("tuner-program", STRING, CHANNEL_TUNER_PROGRAM),
+    tuner_RDS("tuner-RDS", STRING, CHANNEL_TUNER_RDS),
+    audio_input("audio-input", STRING, CHANNEL_AUDIO_INPUT),
+    audio_bitstream("audio-bitstream", STRING, CHANNEL_AUDIO_BITSTREAM),
+    audio_bits("audio-bits", STRING, CHANNEL_AUDIO_BITS),
+    video_input("video-input", STRING, CHANNEL_VIDEO_INPUT),
+    video_format("video-format", STRING, CHANNEL_VIDEO_FORMAT),
+    video_space("video-space", STRING, CHANNEL_VIDEO_SPACE),
+    input_1("input-1", STRING, CHANNEL_INPUT1),
+    input_2("input-2", STRING, CHANNEL_INPUT2),
+    input_3("input-3", STRING, CHANNEL_INPUT3),
+    input_4("input-4", STRING, CHANNEL_INPUT4),
+    input_5("input-5", STRING, CHANNEL_INPUT5),
+    input_6("input-6", STRING, CHANNEL_INPUT6),
+    input_7("input-7", STRING, CHANNEL_INPUT7),
+    input_8("input-8", STRING, CHANNEL_INPUT8),
+
+    /* Protocol V2 notify tags */
+    selected_mode("selected-mode", STRING, CHANNEL_SELECTED_MODE),
+    selected_movie_music("selected-movie-music", STRING, CHANNEL_SELECTED_MOVIE_MUSIC),
+    mode_ref_stereo("mode-ref-stereo", STRING, CHANNEL_MODE_REF_STEREO),
+    mode_stereo("mode-stereo", STRING, CHANNEL_MODE_STEREO),
+    mode_music("mode-music", STRING, CHANNEL_MODE_MUSIC),
+    mode_movie("mode-movie", STRING, CHANNEL_MODE_MOVIE),
+    mode_direct("mode-direct", STRING, CHANNEL_MODE_DIRECT),
+    mode_dolby("mode-dolby", STRING, CHANNEL_MODE_DOLBY),
+    mode_dts("mode-dts", STRING, CHANNEL_MODE_DTS),
+    mode_all_stereo("mode-all-stereo", STRING, CHANNEL_MODE_ALL_STEREO),
+    mode_auto("mode-auto", STRING, CHANNEL_MODE_AUTO),
+    mode_surround("mode-surround", STRING, CHANNEL_MODE_SURROUND),
+    menu("menu", ON_OFF, CHANNEL_MENU),
+    menu_update("menu-update", STRING, CHANNEL_MENU_DISPLAY_PREFIX),
+
+    /* Protocol V3 notify tags */
+    keepAlive("keepAlive", NUMBER_TIME, LAST_SEEN_STATE_NAME),
+    goodBye("goodBye", GOODBYE, ""),
+    bar_update("bar-update", STRING, CHANNEL_BAR),
+    width("width", DIMENSIONLESS_DECIBEL, CHANNEL_WIDTH),
+    height("height", DIMENSIONLESS_DECIBEL, CHANNEL_HEIGHT),
+
+    /* Notify tag not in the documentation */
+    source_tuner("source-tuner", ON_OFF, ""),
+
+    /* No match tag */
+    unknown("unknown", UNKNOWN, "");
+
+    private final Logger logger = LoggerFactory.getLogger(EmotivaSubscriptionTags.class);
+
+    /* For error handling */
+    public static final String UNKNOWN_TAG = "unknown";
+
+    private final String name;
+    private final EmotivaDataType dataType;
+    private final String channel;
+
+    EmotivaSubscriptionTags(String name, EmotivaDataType dataType, String channel) {
+        this.name = name;
+        this.dataType = dataType;
+        this.channel = channel;
+    }
+
+    public static boolean hasChannel(String name) {
+        try {
+            EmotivaSubscriptionTags type = EmotivaSubscriptionTags.valueOf(name);
+            if (!type.channel.isEmpty()) {
+                return true;
+            }
+        } catch (IllegalArgumentException e) {
+            // do nothing
+        }
+        return false;
+    }
+
+    public static EmotivaSubscriptionTags fromChannelUID(String id) {
+        for (EmotivaSubscriptionTags value : values()) {
+            if (id.equals(value.getChannel())) {
+                return value;
+            }
+        }
+        return EmotivaSubscriptionTags.unknown;
+    }
+
+    public static EmotivaSubscriptionTags[] generalChannels() {
+        List<EmotivaSubscriptionTags> tags = new ArrayList<>();
+        for (EmotivaSubscriptionTags value : values()) {
+            if (value.channel.startsWith("general")) {
+                tags.add(value);
+            }
+        }
+        return tags.toArray(new EmotivaSubscriptionTags[0]);
+    }
+
+    public static EmotivaSubscriptionTags[] nonGeneralChannels() {
+        List<EmotivaSubscriptionTags> tags = new ArrayList<>();
+        for (EmotivaSubscriptionTags value : values()) {
+            if (!value.channel.startsWith("general")) {
+                tags.add(value);
+            }
+        }
+        return tags.toArray(new EmotivaSubscriptionTags[0]);
+    }
+
+    public static EmotivaSubscriptionTags[] speakerChannels() {
+        List<EmotivaSubscriptionTags> tags = new ArrayList<>();
+        for (EmotivaSubscriptionTags value : values()) {
+            if (value.getDataType().equals(DIMENSIONLESS_DECIBEL)) {
+                tags.add(value);
+            }
+        }
+        return tags.toArray(new EmotivaSubscriptionTags[0]);
+    }
+
+    public static List<EmotivaSubscriptionTags> noSubscriptionToChannel() {
+        return List.of(goodBye);
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public String getEmotivaName() {
+        String retVal = name.replaceAll("-", "_");
+        logger.debug("Converting OH channel '{}' to Emotiva command '{}'", name, retVal);
+        return retVal;
+    }
+
+    public EmotivaDataType getDataType() {
+        return dataType;
+    }
+
+    public String getChannel() {
+        return channel;
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaUdpResponse.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaUdpResponse.java
new file mode 100644 (file)
index 0000000..0027c9f
--- /dev/null
@@ -0,0 +1,34 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.protocol;
+
+/**
+ * The class {@link EmotivaUdpResponse} represents UDP response we expect.
+ *
+ * @author Andi Bräu - Initial contribution
+ * @author Espen Fossen - Adpated to Emotiva binding
+ */
+public record EmotivaUdpResponse(String answer, String ipAddress) {
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (o == null || getClass() != o.getClass()) {
+            return false;
+        }
+        EmotivaUdpResponse that = (EmotivaUdpResponse) o;
+        return answer.equals(that.answer) && ipAddress.equals(that.ipAddress);
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaXmlUtils.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaXmlUtils.java
new file mode 100644 (file)
index 0000000..abbc66c
--- /dev/null
@@ -0,0 +1,298 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.protocol;
+
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags.UNKNOWN_TAG;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.StringReader;
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import javax.xml.bind.JAXBContext;
+import javax.xml.bind.JAXBElement;
+import javax.xml.bind.JAXBException;
+import javax.xml.bind.Marshaller;
+import javax.xml.bind.Unmarshaller;
+import javax.xml.namespace.QName;
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.transform.stream.StreamSource;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.emotiva.internal.dto.AbstractJAXBElementDTO;
+import org.openhab.binding.emotiva.internal.dto.EmotivaAckDTO;
+import org.openhab.binding.emotiva.internal.dto.EmotivaBarNotifyDTO;
+import org.openhab.binding.emotiva.internal.dto.EmotivaBarNotifyWrapper;
+import org.openhab.binding.emotiva.internal.dto.EmotivaCommandDTO;
+import org.openhab.binding.emotiva.internal.dto.EmotivaControlDTO;
+import org.openhab.binding.emotiva.internal.dto.EmotivaMenuNotifyDTO;
+import org.openhab.binding.emotiva.internal.dto.EmotivaNotifyDTO;
+import org.openhab.binding.emotiva.internal.dto.EmotivaNotifyWrapper;
+import org.openhab.binding.emotiva.internal.dto.EmotivaPingDTO;
+import org.openhab.binding.emotiva.internal.dto.EmotivaPropertyDTO;
+import org.openhab.binding.emotiva.internal.dto.EmotivaSubscriptionRequest;
+import org.openhab.binding.emotiva.internal.dto.EmotivaSubscriptionResponse;
+import org.openhab.binding.emotiva.internal.dto.EmotivaTransponderDTO;
+import org.openhab.binding.emotiva.internal.dto.EmotivaUnsubscribeDTO;
+import org.openhab.binding.emotiva.internal.dto.EmotivaUpdateRequest;
+import org.openhab.binding.emotiva.internal.dto.EmotivaUpdateResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.xml.sax.SAXException;
+
+/**
+ * Helper class for marshalling and unmarshalling Emotiva message types.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public class EmotivaXmlUtils {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(EmotivaXmlUtils.class);
+    Marshaller marshaller;
+
+    JAXBContext context;
+
+    public EmotivaXmlUtils() throws JAXBException {
+        context = JAXBContext.newInstance(EmotivaAckDTO.class, EmotivaBarNotifyWrapper.class, EmotivaBarNotifyDTO.class,
+                EmotivaCommandDTO.class, EmotivaControlDTO.class, EmotivaMenuNotifyDTO.class,
+                EmotivaNotifyWrapper.class, EmotivaPingDTO.class, EmotivaPropertyDTO.class,
+                EmotivaSubscriptionRequest.class, EmotivaSubscriptionResponse.class, EmotivaTransponderDTO.class,
+                EmotivaUnsubscribeDTO.class, EmotivaUpdateRequest.class, EmotivaUpdateResponse.class);
+        marshaller = context.createMarshaller();
+        marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true);
+    }
+
+    public String marshallEmotivaDTO(Object objectInstanceType) {
+        try {
+            StringWriter out = new StringWriter();
+            marshaller.marshal(objectInstanceType, out);
+            return out.toString();
+        } catch (JAXBException e) {
+            LOGGER.debug("Could not marshall class of type {}", objectInstanceType.getClass().getName(), e);
+        }
+        return "";
+    }
+
+    public String marshallJAXBElementObjects(AbstractJAXBElementDTO jaxbElementDTO) {
+        try {
+            StringWriter out = new StringWriter();
+
+            List<JAXBElement<String>> commandsAsJAXBElement = new ArrayList<>();
+
+            if (jaxbElementDTO.getCommands() != null) {
+                for (EmotivaCommandDTO command : jaxbElementDTO.getCommands()) {
+                    if (command.getName() != null) {
+                        StringBuilder sb = new StringBuilder();
+                        if (command.getValue() != null) {
+                            sb.append(" value=\"").append(command.getValue()).append("\"");
+                        }
+                        if (command.getStatus() != null) {
+                            sb.append(" status=\"").append(command.getStatus()).append("\"");
+                        }
+                        if (command.getVisible() != null) {
+                            sb.append(" visible=\"").append(command.getVisible()).append("\"");
+                        }
+                        if (command.getAck() != null) {
+                            sb.append(" ack=\"").append(command.getAck()).append("\"");
+                        }
+                        QName name = new QName("%s%s".formatted(command.getName().trim(), sb));
+                        commandsAsJAXBElement.add(jaxbElementDTO.createJAXBElement(name));
+                    }
+                }
+            }
+
+            // Replace commands with modified JaxbElements for Emotiva compatible marshalling
+            jaxbElementDTO.setJaxbElements(commandsAsJAXBElement);
+            jaxbElementDTO.setCommands(Collections.emptyList());
+
+            marshaller.marshal(jaxbElementDTO, out);
+
+            // Remove JAXB added xsi and xmlns data, not needed
+            return out.toString().replaceAll("xsi:nil=\"true\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"",
+                    "");
+        } catch (JAXBException e) {
+            LOGGER.debug("Could not marshall class of type {}", jaxbElementDTO.getClass().getName(), e);
+        }
+        return "";
+    }
+
+    public Object unmarshallToEmotivaDTO(String xmlAsString) throws JAXBException {
+        Object object;
+        Unmarshaller unmarshaller = context.createUnmarshaller();
+
+        if (xmlAsString.isEmpty()) {
+            throw new JAXBException("Could not unmarshall value, xml value is null or empty");
+        }
+
+        StringReader xmlAsStringReader = new StringReader(xmlAsString);
+        StreamSource xmlAsStringStream = new StreamSource(xmlAsStringReader);
+        object = unmarshaller.unmarshal(xmlAsStringStream);
+        return object;
+    }
+
+    public List<EmotivaCommandDTO> unmarshallXmlObjectsToControlCommands(List<Object> objects) {
+        List<EmotivaCommandDTO> commands = new ArrayList<>();
+        for (Object object : objects) {
+            try {
+                Element xmlElement = (Element) object;
+
+                try {
+                    EmotivaCommandDTO commandDTO = getEmotivaCommandDTO(xmlElement);
+                    commands.add(commandDTO);
+                } catch (IllegalArgumentException e) {
+                    LOGGER.debug("Notify tag {} is unknown or not defined, skipping.", xmlElement.getTagName(), e);
+                }
+            } catch (ClassCastException e) {
+                LOGGER.debug("Could not cast object to Element, object is of type {}", object.getClass());
+            }
+        }
+        return commands;
+    }
+
+    public List<EmotivaNotifyDTO> unmarshallToNotification(List<Object> objects) {
+        List<EmotivaNotifyDTO> commands = new ArrayList<>();
+        for (Object object : objects) {
+            try {
+                Element xmlElement = (Element) object;
+
+                try {
+                    EmotivaNotifyDTO tagDTO = getEmotivaNotifyTags(xmlElement);
+                    commands.add(tagDTO);
+                } catch (IllegalArgumentException e) {
+                    LOGGER.debug("Notify tag {} is unknown or not defined, skipping.", xmlElement.getTagName(), e);
+                }
+            } catch (ClassCastException e) {
+                LOGGER.debug("Could not cast object to Element, object is of type {}", object.getClass());
+            }
+        }
+        return commands;
+    }
+
+    public List<EmotivaBarNotifyDTO> unmarshallToBarNotify(List<Object> objects) {
+        List<EmotivaBarNotifyDTO> commands = new ArrayList<>();
+        for (Object object : objects) {
+            try {
+                Element xmlElement = (Element) object;
+
+                try {
+                    EmotivaBarNotifyDTO tagDTO = getEmotivaBarNotify(xmlElement);
+                    commands.add(tagDTO);
+                } catch (IllegalArgumentException e) {
+                    LOGGER.debug("Bar notify type {} is unknown or not defined, skipping.", xmlElement.getTagName(), e);
+                }
+            } catch (ClassCastException e) {
+                LOGGER.debug("Could not cast object to Element, object is of type {}", object.getClass());
+            }
+        }
+        return commands;
+    }
+
+    public List<EmotivaCommandDTO> unmarshallToCommands(String elementAsString) {
+        List<EmotivaCommandDTO> commands = new ArrayList<>();
+        try {
+            DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance();
+            DocumentBuilder db = builderFactory.newDocumentBuilder();
+
+            String[] lines = elementAsString.split("\n");
+            for (String line : lines) {
+
+                if (line.trim().startsWith("<") && line.trim().endsWith("/>")) {
+                    Document doc = db.parse(new ByteArrayInputStream(line.getBytes(StandardCharsets.UTF_8)));
+                    doc.getDocumentElement();
+                    EmotivaCommandDTO commandDTO = getEmotivaCommandDTO(doc.getDocumentElement());
+                    commands.add(commandDTO);
+                }
+            }
+        } catch (SAXException | IOException | ParserConfigurationException e) {
+            LOGGER.debug("Error unmarshall elements to commands", e);
+        }
+        return commands;
+    }
+
+    private static EmotivaCommandDTO getEmotivaCommandDTO(Element xmlElement) {
+        EmotivaControlCommands commandType;
+        try {
+            commandType = EmotivaControlCommands.valueOf(xmlElement.getTagName().trim());
+        } catch (IllegalArgumentException e) {
+            LOGGER.debug("Could not create EmotivaCommand, unknown command {}", xmlElement.getTagName());
+            commandType = EmotivaControlCommands.none;
+        }
+        EmotivaCommandDTO commandDTO = new EmotivaCommandDTO(commandType);
+        if (xmlElement.hasAttribute("status")) {
+            commandDTO.setStatus(xmlElement.getAttribute("status"));
+        }
+        if (xmlElement.hasAttribute("value")) {
+            commandDTO.setValue(xmlElement.getAttribute("value"));
+        }
+        if (xmlElement.hasAttribute("visible")) {
+            commandDTO.setVisible(xmlElement.getAttribute("visible"));
+        }
+        return commandDTO;
+    }
+
+    private static EmotivaBarNotifyDTO getEmotivaBarNotify(Element xmlElement) {
+        EmotivaBarNotifyDTO barNotify = new EmotivaBarNotifyDTO(xmlElement.getTagName().trim());
+        if (xmlElement.hasAttribute("type")) {
+            barNotify.setType(xmlElement.getAttribute("type"));
+        }
+        if (xmlElement.hasAttribute("text")) {
+            barNotify.setText(xmlElement.getAttribute("text"));
+        }
+        if (xmlElement.hasAttribute("units")) {
+            barNotify.setUnits(xmlElement.getAttribute("units"));
+        }
+        if (xmlElement.hasAttribute("value")) {
+            barNotify.setValue(xmlElement.getAttribute("value"));
+        }
+        if (xmlElement.hasAttribute("min")) {
+            barNotify.setMin(xmlElement.getAttribute("min"));
+        }
+        if (xmlElement.hasAttribute("max")) {
+            barNotify.setMax(xmlElement.getAttribute("max"));
+        }
+        return barNotify;
+    }
+
+    private static EmotivaNotifyDTO getEmotivaNotifyTags(Element xmlElement) {
+        String notifyTagName;
+        try {
+            notifyTagName = EmotivaSubscriptionTags.valueOf(xmlElement.getTagName().trim()).name();
+        } catch (IllegalArgumentException e) {
+            LOGGER.debug("Could not create EmotivaNotify, unknown subscription tag {}", xmlElement.getTagName());
+            notifyTagName = UNKNOWN_TAG;
+        }
+        EmotivaNotifyDTO commandDTO = new EmotivaNotifyDTO(notifyTagName);
+        if (xmlElement.hasAttribute("status")) {
+            commandDTO.setStatus(xmlElement.getAttribute("status"));
+        }
+        if (xmlElement.hasAttribute("value")) {
+            commandDTO.setValue(xmlElement.getAttribute("value"));
+        }
+        if (xmlElement.hasAttribute("visible")) {
+            commandDTO.setVisible(xmlElement.getAttribute("visible"));
+        }
+        if (xmlElement.hasAttribute("ack")) {
+            commandDTO.setAck(xmlElement.getAttribute("ack"));
+        }
+        return commandDTO;
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/OHChannelToEmotivaCommand.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/OHChannelToEmotivaCommand.java
new file mode 100644 (file)
index 0000000..51e4627
--- /dev/null
@@ -0,0 +1,115 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.protocol;
+
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_CHANNEL;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_FREQUENCY;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_HEIGHT;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_MAIN_VOLUME;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_MAIN_VOLUME_DB;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_MENU;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_MENU_CONTROL;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_MENU_DOWN;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_MENU_ENTER;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_MENU_LEFT;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_MENU_RIGHT;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_MENU_UP;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_MODE_ALL_STEREO;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_MODE_AUTO;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_MODE_DIRECT;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_MODE_DOLBY;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_MODE_DTS;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_MODE_MOVIE;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_MODE_MUSIC;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_MODE_REF_STEREO;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_MODE_STEREO;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_MODE_SURROUND;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_MUTE;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_SEEK;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_SOURCE;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_STANDBY;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_SURROUND_MODE;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_WIDTH;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_ZONE2_MUTE;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_ZONE2_SOURCE;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_ZONE2_VOLUME;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_ZONE2_VOLUME_DB;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Maps OH channels with only an indirect connection to an Emotiva command. Only handles 1:1 mappings.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public enum OHChannelToEmotivaCommand {
+
+    standby(CHANNEL_STANDBY, EmotivaControlCommands.standby),
+    source(CHANNEL_SOURCE, EmotivaControlCommands.input),
+    menu(CHANNEL_MENU, EmotivaControlCommands.menu),
+    menu_control(CHANNEL_MENU_CONTROL, EmotivaControlCommands.menu_control),
+    up(CHANNEL_MENU_UP, EmotivaControlCommands.up),
+    down(CHANNEL_MENU_DOWN, EmotivaControlCommands.down),
+    left(CHANNEL_MENU_LEFT, EmotivaControlCommands.left),
+    right(CHANNEL_MENU_RIGHT, EmotivaControlCommands.right),
+    enter(CHANNEL_MENU_ENTER, EmotivaControlCommands.enter),
+    mute(CHANNEL_MUTE, EmotivaControlCommands.mute),
+    volume(CHANNEL_MAIN_VOLUME, EmotivaControlCommands.volume),
+    volume_db(CHANNEL_MAIN_VOLUME_DB, EmotivaControlCommands.volume),
+    zone2_volume(CHANNEL_ZONE2_VOLUME, EmotivaControlCommands.zone2_volume),
+    zone2_volume_db(CHANNEL_ZONE2_VOLUME_DB, EmotivaControlCommands.zone2_volume),
+    zone2_mute(CHANNEL_ZONE2_MUTE, EmotivaControlCommands.zone2_mute),
+    zone2_source(CHANNEL_ZONE2_SOURCE, EmotivaControlCommands.zone2_input),
+    width(CHANNEL_WIDTH, EmotivaControlCommands.width_trim_set),
+    height(CHANNEL_HEIGHT, EmotivaControlCommands.height_trim_set),
+    frequency(CHANNEL_FREQUENCY, EmotivaControlCommands.frequency),
+    seek(CHANNEL_SEEK, EmotivaControlCommands.seek),
+    channel(CHANNEL_CHANNEL, EmotivaControlCommands.channel),
+    mode_ref_stereo(CHANNEL_MODE_REF_STEREO, EmotivaControlCommands.reference_stereo),
+    surround_mode(CHANNEL_SURROUND_MODE, EmotivaControlCommands.surround_mode),
+    mode_surround(CHANNEL_MODE_SURROUND, EmotivaControlCommands.surround_mode),
+    mode_stereo(CHANNEL_MODE_STEREO, EmotivaControlCommands.stereo),
+    mode_music(CHANNEL_MODE_MUSIC, EmotivaControlCommands.music),
+    mode_movie(CHANNEL_MODE_MOVIE, EmotivaControlCommands.movie),
+    mode_direct(CHANNEL_MODE_DIRECT, EmotivaControlCommands.direct),
+    mode_dolby(CHANNEL_MODE_DOLBY, EmotivaControlCommands.dolby),
+    mode_dts(CHANNEL_MODE_DTS, EmotivaControlCommands.dts),
+    mode_all_stereo(CHANNEL_MODE_ALL_STEREO, EmotivaControlCommands.all_stereo),
+    mode_auto(CHANNEL_MODE_AUTO, EmotivaControlCommands.auto);
+
+    private final String ohChannel;
+    private final EmotivaControlCommands command;
+
+    OHChannelToEmotivaCommand(String ohChannel, EmotivaControlCommands command) {
+        this.ohChannel = ohChannel;
+        this.command = command;
+    }
+
+    public String getChannel() {
+        return ohChannel;
+    }
+
+    public EmotivaControlCommands getCommand() {
+        return command;
+    }
+
+    public static EmotivaControlCommands fromChannelUID(String id) {
+        for (OHChannelToEmotivaCommand value : values()) {
+            if (id.equals(value.ohChannel)) {
+                return value.command;
+            }
+        }
+        return EmotivaControlCommands.none;
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.emotiva/src/main/resources/OH-INF/addon/addon.xml
new file mode 100644 (file)
index 0000000..3cec243
--- /dev/null
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<addon:addon id="emotiva" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
+
+       <type>binding</type>
+       <name>Emotiva Binding</name>
+       <description>This is the binding for devices from the Emotiva Audio Corporation.</description>
+       <connection>local</connection>
+
+       <discovery-methods>
+               <discovery-method>
+                       <service-type>ip</service-type>
+                       <discovery-parameters>
+                               <discovery-parameter>
+                                       <name>type</name>
+                                       <value>ipBroadcast</value>
+                               </discovery-parameter>
+                               <discovery-parameter>
+                                       <name>destPort</name>
+                                       <value>7001</value>
+                               </discovery-parameter>
+                               <discovery-parameter>
+                                       <name>timeoutMs</name>
+                                       <value>1000</value>
+                               </discovery-parameter>
+                       </discovery-parameters>
+               </discovery-method>
+       </discovery-methods>
+
+</addon:addon>
diff --git a/bundles/org.openhab.binding.emotiva/src/main/resources/OH-INF/config/config.xml b/bundles/org.openhab.binding.emotiva/src/main/resources/OH-INF/config/config.xml
new file mode 100644 (file)
index 0000000..c0477ae
--- /dev/null
@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<config-description:config-descriptions
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
+
+       <config-description uri="thing-type:processor:config">
+               <parameter name="ipAddress" type="text" required="true">
+                       <context>network-address</context>
+                       <label>Network Address</label>
+                       <description>IP Network Address where Emotiva device can be Reached.</description>
+               </parameter>
+               <parameter name="controlPort" type="integer" required="false">
+                       <context>control-port</context>
+                       <label>Control Port</label>
+                       <description>Network address port for control (UDP)</description>
+                       <default>7002</default>
+                       <advanced>true</advanced>
+               </parameter>
+               <parameter name="notifyPort" type="integer" required="false">
+                       <context>notify-port</context>
+                       <label>Notify Port</label>
+                       <description>Network address port for notifications (UDP)</description>
+                       <default>7003</default>
+                       <advanced>true</advanced>
+               </parameter>
+               <parameter name="infoPort" type="integer" required="false">
+                       <context>info-port</context>
+                       <label>Info Port</label>
+                       <description>Network address port for info (UDP)</description>
+                       <default>7004</default>
+                       <advanced>true</advanced>
+               </parameter>
+               <parameter name="menuNotifyPort" type="integer" required="false">
+                       <context>setup-port</context>
+                       <label>Menu Notify Port</label>
+                       <description>Network address port for menu notify port (UDP)</description>
+                       <default>7005</default>
+                       <advanced>true</advanced>
+               </parameter>
+               <parameter name="setupPortTCP" type="integer" required="false">
+                       <context>setup-port</context>
+                       <label>Setup Port</label>
+                       <description>Network address port for setup port (TCP)</description>
+                       <default>7100</default>
+                       <advanced>true</advanced>
+               </parameter>
+               <parameter name="protocolVersion" type="text" required="false">
+                       <context>protocol-revision</context>
+                       <label>Protocol Version</label>
+                       <description>Protocol version, only change if you know what your doing</description>
+                       <advanced>true</advanced>
+               </parameter>
+               <parameter name="retryConnectInMinutes" type="integer" required="false" unit="s">
+                       <label>Reconnect Interval</label>
+                       <description>The time to wait between reconnection attempts (in minutes)</description>
+                       <default>2</default>
+                       <advanced>true</advanced>
+               </parameter>
+       </config-description>
+</config-description:config-descriptions>
diff --git a/bundles/org.openhab.binding.emotiva/src/main/resources/OH-INF/i18n/emotiva.properties b/bundles/org.openhab.binding.emotiva/src/main/resources/OH-INF/i18n/emotiva.properties
new file mode 100644 (file)
index 0000000..6d7389e
--- /dev/null
@@ -0,0 +1,201 @@
+addon.emotiva.name = Emotiva Binding
+addon.emotiva.description = This is the binding for Emotiva Audio Corporation AV processors.
+
+# thing types
+
+thing-type.emotiva.processor.label = Processor
+thing-type.emotiva.processor.description = Control a Emotiva AV Processor.
+thing-type.emotiva.processor.group.main-zone.label = Main Zone Control
+thing-type.emotiva.processor.group.main-zone.description = Channels for the main zone of this device.
+thing-type.emotiva.processor.group.zone2.label = Zone 2 Control
+thing-type.emotiva.processor.group.zone2.description = Channels for Zone 2 of this device.
+
+# thing types config
+
+thing-type.config.emotiva.config.ipAddress.label = IP address
+thing-type.config.emotiva.config.ipAddress.description = IP address of the device
+thing-type.config.emotiva.config.controlPort = Control Port
+thing-type.config.emotiva.config.controlPort.description = UDP port to send commands to the device
+thing-type.config.emotiva.config.notifyPort.label = Notify Port
+thing-type.config.emotiva.config.notifyPort.description = UDP port to receive notifications from the device
+thing-type.config.emotiva.config.infoPort.label = Info Port
+thing-type.config.emotiva.config.infoPort.description = UDP port
+thing-type.config.emotiva.config.setupPortTCP.label = Setup TCP Port
+thing-type.config.emotiva.config.setupPortTCP.description = TCP port for remote setup
+thing-type.config.emotiva.config.menuNotifyPort.label = Menu Notify Port
+thing-type.config.emotiva.config.menuNotifyPort.description = UDP port to receive menu notifications from the device
+thing-type.config.emotiva.config.protocolVersion.label = Emotiva Protocol Version
+thing-type.config.emotiva.config.protocolVersion.description = Emotiva Network Remote Control protocol version
+thing-type.config.emotiva.config.keepAlive.label = Keep Alive Notification
+thing-type.config.emotiva.config.keepAlive.description = The interval, in milliseconds, at which the Emotiva Device will send a "keepAlive" notification
+
+# channel group types
+
+channel-group-type.emotiva.general.label = General Control
+channel-group-type.emotiva.general.description = General channels for this device.
+channel-group-type.emotiva.zone.label = Zone Control
+channel-group-type.emotiva.zone.description = Channels for a zone of this device.
+
+# channel types
+
+channel-type.emotiva.audio-input.label = Audio Input
+channel-type.emotiva.audio-input.description = Source for audio input
+channel-type.emotiva.audio-bitstream.label = Audio Input Bitstream Type
+channel-type.emotiva.audio-bitstream.description = Current audio bitstream, "PCM 2.0", "ATMOS", etc.
+channel-type.emotiva.audio-bits.label = Audio Input Bits
+channel-type.emotiva.audio-bits.description = Current audio input bits: "32kHZ 24bits", etc.
+channel-type.emotiva.bar.label = Front Panel Bar
+channel-type.emotiva.bar.description = Displays text from the front panel bar of the device
+channel-type.emotiva.channel.label = Radio Tuner Channel
+channel-type.emotiva.channel.description = Changes radio tuner channel a station at a time, up or down
+channel-type.emotiva.frequency.label = Radio Tuner Frequency
+channel-type.emotiva.frequency.description = Changes radio tuner frequency, up or down
+channel-type.emotiva.dim.label = Front Panel Dimness
+channel-type.emotiva.dim.description = Percentage of light on front panel
+channel-type.emotiva.input-name.label = Input Name
+channel-type.emotiva.input-name.description = User assigned name for input or mode
+channel-type.emotiva.loudness.label = Loudness
+channel-type.emotiva.loudness.description = Loudness ON/OFF
+channel-type.emotiva.mainPower.label = Power
+channel-type.emotiva.mainPower.description = Power ON/OFF the device
+channel-type.emotiva.menu.label = Menu
+channel-type.emotiva.menu.description = Controls the device menu
+channel-type.emotiva.mode.label = Mode
+channel-type.emotiva.mode.description = Sets main zone mode, "Stereo", "Direct", "Auto", etc.
+channel-type.emotiva.mode-surround.label = Surround Mode
+channel-type.emotiva.mode-surround.description = Select the surround mode for this zone of the device
+channel-type.emotiva.mute.label = Mute
+channel-type.emotiva.mute.description = Enable/Disable Mute on this zone of the device
+channel-type.emotiva.seek.label = Radio Tuner Seek
+channel-type.emotiva.seek.description = Enables seek of radio channel, up or down
+channel-type.emotiva.selected-mode.label = Selected Mode
+channel-type.emotiva.selected-mode.description = User selected mode for the main zone. An "Auto" value here might not mean the mode channel is in auto.
+channel-type.emotiva.selected-mode.state.option.all-stereo = All Stereo
+channel-type.emotiva.selected-mode.state.option.auto = Auto
+channel-type.emotiva.selected-mode.state.option.direct = Direct
+channel-type.emotiva.selected-mode.state.option.dolby = Dolby
+channel-type.emotiva.selected-mode.state.option.dts = DTS
+channel-type.emotiva.selected-mode.state.option.stereo = Stereo
+channel-type.emotiva.selected-mode.state.option.surround = Surround
+channel-type.emotiva.selected-mode.state.option.ref-stereo = Reference Stereo
+channel-type.emotiva.selected-movie-music.label = Selected Movie Music
+channel-type.emotiva.selected-movie-music.description = User-selected movie or music mode for main zone: "Movie" or "Music".
+channel-type.emotiva.selected-movie-music.state.option.movie = Movie
+channel-type.emotiva.selected-movie-music.state.option.music = Music
+channel-type.emotiva.speaker-preset.label = Speaker Preset
+channel-type.emotiva.speaker-preset.description = Speaker Preset Name
+channel-type.emotiva.speaker-preset.state.option.preset-1 = Speaker Preset 1
+channel-type.emotiva.speaker-preset.state.option.preset-2 = Speaker Preset 2
+channel-type.emotiva.source.label = Input Source
+channel-type.emotiva.source.description = Select the input source for this zone of the device
+channel-type.emotiva.standby.label = Standby
+channel-type.emotiva.standby.description = Set device in standby mode
+channel-type.emotiva.tuner-band.label = Radio Tuner Band
+channel-type.emotiva.tuner-band.description = Set radio tuner band, "AM" or "FM"
+channel-type.emotiva.tuner-band.state.option.band-am = AM
+channel-type.emotiva.tuner-band.state.option.band-fm = FM
+channel-type.emotiva.tuner-channel.label = Radio Tuner Channel Frequency
+channel-type.emotiva.tuner-channel.description = Frequency of user selected radio channel
+channel-type.emotiva.tuner-channel-select.label = Radio Tuner Channel Name
+channel-type.emotiva.tuner-channel-select.description = Name of user selected radio channel
+channel-type.emotiva.tuner-channel-select.state.option.channel-1 = Channel 1
+channel-type.emotiva.tuner-channel-select.state.option.channel-2 = Channel 2
+channel-type.emotiva.tuner-channel-select.state.option.channel-3 = Channel 3
+channel-type.emotiva.tuner-channel-select.state.option.channel-4 = Channel 4
+channel-type.emotiva.tuner-channel-select.state.option.channel-5 = Channel 5
+channel-type.emotiva.tuner-channel-select.state.option.channel-6 = Channel 6
+channel-type.emotiva.tuner-channel-select.state.option.channel-7 = Channel 7
+channel-type.emotiva.tuner-channel-select.state.option.channel-8 = Channel 8
+channel-type.emotiva.tuner-channel-select.state.option.channel-9 = Channel 9
+channel-type.emotiva.tuner-channel-select.state.option.channel-10 = Channel 10
+channel-type.emotiva.tuner-channel-select.state.option.channel-11 = Channel 11
+channel-type.emotiva.tuner-channel-select.state.option.channel-12 = Channel 12
+channel-type.emotiva.tuner-channel-select.state.option.channel-13 = Channel 13
+channel-type.emotiva.tuner-channel-select.state.option.channel-14 = Channel 14
+channel-type.emotiva.tuner-channel-select.state.option.channel-15 = Channel 15
+channel-type.emotiva.tuner-channel-select.state.option.channel-16 = Channel 16
+channel-type.emotiva.tuner-channel-select.state.option.channel-17 = Channel 17
+channel-type.emotiva.tuner-channel-select.state.option.channel-18 = Channel 18
+channel-type.emotiva.tuner-channel-select.state.option.channel-19 = Channel 19
+channel-type.emotiva.tuner-channel-select.state.option.channel-20 = Channel 20
+channel-type.emotiva.tuner-program.label = Radio Tuner Program
+channel-type.emotiva.tuner-program.description = Radio tuner program: "Country", "Rock", "Classical", etc.
+channel-type.emotiva.tuner-program.state.option.adult-hits = Adult Hits
+channel-type.emotiva.tuner-program.state.option.alarm = Alarm
+channel-type.emotiva.tuner-program.state.option.alarm-test = Alarm Test
+channel-type.emotiva.tuner-program.state.option.children-programmes = Children's Programmes
+channel-type.emotiva.tuner-program.state.option.classic-rock = Classic Rock
+channel-type.emotiva.tuner-program.state.option.classical = Classical
+channel-type.emotiva.tuner-program.state.option.college = College
+channel-type.emotiva.tuner-program.state.option.country-music = Country Music
+channel-type.emotiva.tuner-program.state.option.culture = Culture
+channel-type.emotiva.tuner-program.state.option.current-affairs = Current Affairs
+channel-type.emotiva.tuner-program.state.option.documentary = Documentary
+channel-type.emotiva.tuner-program.state.option.drama = Drama
+channel-type.emotiva.tuner-program.state.option.easy-listening = Easy Listening
+channel-type.emotiva.tuner-program.state.option.education = Education
+channel-type.emotiva.tuner-program.state.option.emergency = Emergency
+channel-type.emotiva.tuner-program.state.option.emergency-test = Emergency Test
+channel-type.emotiva.tuner-program.state.option.finance = Finance
+channel-type.emotiva.tuner-program.state.option.folk-music = Folk Music
+channel-type.emotiva.tuner-program.state.option.information = Information
+channel-type.emotiva.tuner-program.state.option.jazz = Jazz
+channel-type.emotiva.tuner-program.state.option.jazz-music = Jazz Music
+channel-type.emotiva.tuner-program.state.option.language = Language
+channel-type.emotiva.tuner-program.state.option.leisure = Leisure
+channel-type.emotiva.tuner-program.state.option.light-classical = Light Classical
+channel-type.emotiva.tuner-program.state.option.national-music = National Music
+channel-type.emotiva.tuner-program.state.option.news = News
+channel-type.emotiva.tuner-program.state.option.nostalgia = Nostalgia
+channel-type.emotiva.tuner-program.state.option.oldies = Oldies (Music)
+channel-type.emotiva.tuner-program.state.option.oldies-music = Oldies Music
+channel-type.emotiva.tuner-program.state.option.other-music = Other Music
+channel-type.emotiva.tuner-program.state.option.personality = Personality
+channel-type.emotiva.tuner-program.state.option.phone-in = Phone-in
+channel-type.emotiva.tuner-program.state.option.popular-music = Popular Music (Pop)
+channel-type.emotiva.tuner-program.state.option.public = Public
+channel-type.emotiva.tuner-program.state.option.religion = Religion
+channel-type.emotiva.tuner-program.state.option.religious-talk = Religious Talk
+channel-type.emotiva.tuner-program.state.option.rhythm-blues = Rhythm & Blues
+channel-type.emotiva.tuner-program.state.option.rock = Rock
+channel-type.emotiva.tuner-program.state.option.rock-music = Rock Music
+channel-type.emotiva.tuner-program.state.option.science = Science
+channel-type.emotiva.tuner-program.state.option.serious-classical = Serious Classical
+channel-type.emotiva.tuner-program.state.option.social-affairs = Social Affairs
+channel-type.emotiva.tuner-program.state.option.soft-music = Soft Music
+channel-type.emotiva.tuner-program.state.option.soft-rhythm-blues = Soft Rhythm & Blues
+channel-type.emotiva.tuner-program.state.option.soft-rock = Soft Rock
+channel-type.emotiva.tuner-program.state.option.sport = Sport
+channel-type.emotiva.tuner-program.state.option.talk = Talk
+channel-type.emotiva.tuner-program.state.option.top-40 = Top 40
+channel-type.emotiva.tuner-program.state.option.travel = Travel
+channel-type.emotiva.tuner-program.state.option.weather = Weather
+channel-type.emotiva.tuner-rds.label = Radio Tuner RDS
+channel-type.emotiva.tuner-rds.description = Message from Radio Data System (RDS) for selected channel
+channel-type.emotiva.tuner-signal.label = Radio Tuner Signal
+channel-type.emotiva.tuner-signal.description = Radio tuner signal quality
+channel-type.emotiva.video-format.label = Video Input Format
+channel-type.emotiva.video-format.description = Current video input format: "1920x1080i/60", "3840x2160p/60", etc.
+channel-type.emotiva.video-input.label = Video Input
+channel-type.emotiva.video-input.description = Source for video input
+channel-type.emotiva.video-space.label = Video Input Space
+channel-type.emotiva.video-space.description = Current video input space: "YcbCr 8bits", etc.
+channel-type.emotiva.volume.label = Volume
+channel-type.emotiva.volume.description = Set the volume level of this zone
+channel-type.emotiva.volume-db.label = Volume (dB)
+channel-type.emotiva.volume-db.description = Set the volume level (dB).
+channel-type.emotiva.volume-speaker-db.label = Speaker Trim
+channel-type.emotiva.volume-speaker-db.description = Increased/Reduced volume for the speaker, treble or bass, in +/-dB
+channel-type.emotiva.zonePower.label = Power (zone)
+channel-type.emotiva.zonePower.description = Power ON/OFF this zone of the unit
+
+
+# User Messages
+message.processor.connecting = Connecting
+message.processor.connection.failed = Failed to connect, check network connectivity and configuration
+message.processor.connection.error.keep-alive = Failed to receive keepAlive message from device, check network connectivity!
+message.processor.connection.error.port = portNumber is invalid!
+message.processor.connection.error.address-empty = IP Address must be configured!
+message.processor.connection.error.address-invalid = IP Address is not valid!
+message.processor.notfound = Could not find device with ipAddress {0}
+message.processor.goodbye = Device was shutdown
diff --git a/bundles/org.openhab.binding.emotiva/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.emotiva/src/main/resources/OH-INF/thing/thing-types.xml
new file mode 100644 (file)
index 0000000..83101e3
--- /dev/null
@@ -0,0 +1,532 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="emotiva"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <thing-type id="processor">
+               <label>Emotiva Processor</label>
+               <description>Emotiva Processor Thing for Emotiva Binding</description>
+
+               <channel-groups>
+                       <channel-group id="general" typeId="general"/>
+                       <channel-group id="main-zone" typeId="zone">
+                               <label>Main Zone Control</label>
+                               <description>Channels for the main zone of this processor</description>
+                       </channel-group>
+                       <channel-group id="zone2" typeId="zone">
+                               <label>Zone 2 Control</label>
+                               <description>Channels for zone2 of this processor</description>
+                       </channel-group>
+               </channel-groups>
+
+               <properties>
+                       <property name="model">Unknown Model</property>
+                       <property name="revision">Unknown Model Revision</property>
+                       <property name="dataRevision">Unknown Data Revision</property>
+               </properties>
+
+               <representation-property>ipAddress</representation-property>
+
+               <config-description-ref uri="thing-type:processor:config"/>
+       </thing-type>
+
+       <channel-group-type id="general">
+               <label>General Control</label>
+               <description>General channels for this processor</description>
+               <channels>
+                       <channel id="power" typeId="mainPower"/>
+                       <channel id="standby" typeId="standby"/>
+                       <channel id="menu" typeId="menu"/>
+                       <channel id="menu-control" typeId="menu-control"/>
+                       <channel id="up" typeId="up"/>
+                       <channel id="down" typeId="up"/>
+                       <channel id="left" typeId="up"/>
+                       <channel id="right" typeId="up"/>
+                       <channel id="enter" typeId="up"/>
+                       <channel id="dim" typeId="dim"/>
+                       <channel id="mode" typeId="mode"/>
+                       <channel id="info" typeId="info"/>
+                       <channel id="speaker-preset" typeId="speaker-preset"/>
+                       <channel id="center" typeId="volume-speaker-db"/>
+                       <channel id="subwoofer" typeId="volume-speaker-db"/>
+                       <channel id="surround" typeId="volume-speaker-db"/>
+                       <channel id="back" typeId="volume-speaker-db"/>
+                       <channel id="loudness" typeId="loudness"/>
+                       <channel id="treble" typeId="volume-speaker-db"/>
+                       <channel id="bass" typeId="volume-speaker-db"/>
+                       <channel id="frequency" typeId="frequency"/>
+                       <channel id="seek" typeId="seek"/>
+                       <channel id="channel" typeId="channel"/>
+                       <channel id="tuner-band" typeId="tuner-band"/>
+                       <channel id="tuner-channel" typeId="tuner-channel"/>
+                       <channel id="tuner-channel-select" typeId="tuner-channel-select"/>
+                       <channel id="tuner-signal" typeId="tuner-signal"/>
+                       <channel id="tuner-program" typeId="tuner-program"/>
+                       <channel id="tuner-rds" typeId="tuner-rds"/>
+                       <channel id="audio-input" typeId="audio-input"/>
+                       <channel id="audio-bitstream" typeId="audio-bitstream"/>
+                       <channel id="audio-bits" typeId="audio-bits"/>
+                       <channel id="video-input" typeId="video-input"/>
+                       <channel id="video-format" typeId="video-format"/>
+                       <channel id="video-space" typeId="video-space"/>
+                       <channel id="input-1" typeId="input-name"/>
+                       <channel id="input-2" typeId="input-name"/>
+                       <channel id="input-3" typeId="input-name"/>
+                       <channel id="input-4" typeId="input-name"/>
+                       <channel id="input-5" typeId="input-name"/>
+                       <channel id="input-6" typeId="input-name"/>
+                       <channel id="input-7" typeId="input-name"/>
+                       <channel id="input-8" typeId="input-name"/>
+
+                       <!-- Channels requiring protocol V2 -->
+                       <channel id="selected-mode" typeId="selected-mode"/>
+                       <channel id="selected-movie-music" typeId="selected-movie-music"/>
+                       <channel id="mode-ref-stereo" typeId="input-name"/>
+                       <channel id="mode-stereo" typeId="input-name"/>
+                       <channel id="mode-music" typeId="input-name"/>
+                       <channel id="mode-movie" typeId="input-name"/>
+                       <channel id="mode-direct" typeId="input-name"/>
+                       <channel id="mode-dolby" typeId="input-name"/>
+                       <channel id="mode-dts" typeId="mode"/>
+                       <channel id="mode-all-stereo" typeId="mode"/>
+                       <channel id="mode-auto" typeId="mode"/>
+                       <channel id="mode-surround" typeId="mode-surround"/>
+                       <channel id="menu-display-highlight" typeId="menu-display"/>
+                       <channel id="menu-display-top-start" typeId="menu-display"/>
+                       <channel id="menu-display-top-center" typeId="menu-display"/>
+                       <channel id="menu-display-top-end" typeId="menu-display"/>
+                       <channel id="menu-display-middle-start" typeId="menu-display"/>
+                       <channel id="menu-display-middle-center" typeId="menu-display"/>
+                       <channel id="menu-display-middle-end" typeId="menu-display"/>
+                       <channel id="menu-display-bottom-start" typeId="menu-display"/>
+                       <channel id="menu-display-bottom-center" typeId="menu-display"/>
+                       <channel id="menu-display-bottom-end" typeId="menu-display"/>
+
+                       <!-- Channels requiring protocol V3 -->
+                       <channel id="width" typeId="volume-speaker-db"/>
+                       <channel id="height" typeId="volume-speaker-db"/>
+                       <channel id="bar" typeId="bar"/>
+
+               </channels>
+       </channel-group-type>
+
+       <channel-group-type id="zone">
+               <label>Zone Control</label>
+               <description>Channels for a zone of this processor</description>
+               <channels>
+                       <channel id="power" typeId="zonePower"/>
+                       <channel id="volume" typeId="volume"/>
+                       <channel id="volume-db" typeId="volume-db"/>
+                       <channel id="mute" typeId="mute"/>
+                       <channel id="source" typeId="source"/>
+               </channels>
+       </channel-group-type>
+
+       <channel-type id="mainPower">
+               <item-type>Switch</item-type>
+               <label>Power</label>
+               <description>Power ON/OFF the device</description>
+       </channel-type>
+
+       <channel-type id="zonePower">
+               <item-type>Switch</item-type>
+               <label>Power (zone)</label>
+               <description>Power ON/OFF this zone of the Processor</description>
+       </channel-type>
+
+       <channel-type id="volume">
+               <item-type>Dimmer</item-type>
+               <label>Volume</label>
+               <description>Set the volume level of this zone</description>
+               <category>SoundVolume</category>
+               <state min="0" max="100" pattern="%d %unit%"/>
+       </channel-type>
+
+       <channel-type id="volume-db" advanced="true">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Volume (dB)</label>
+               <description>Set the volume level (dB). Same as [mainVolume - 96]</description>
+               <category>SoundVolume</category>
+               <state min="-96" max="15" step="0.5" pattern="%.1f dB"/>
+       </channel-type>
+
+       <channel-type id="mute">
+               <item-type>Switch</item-type>
+               <label>Mute</label>
+               <description>Enable or Disable Mute on this zone of the Processor</description>
+               <category>SoundVolume</category>
+       </channel-type>
+
+       <channel-type id="source">
+               <item-type>String</item-type>
+               <label>Input Source</label>
+               <description>Select the input source for this zone of the Processor</description>
+               <autoUpdatePolicy>recommend</autoUpdatePolicy>
+       </channel-type>
+
+       <channel-type id="standby">
+               <item-type>Switch</item-type>
+               <label>On Standby</label>
+               <description>Set appliance on standby</description>
+               <category>Energy</category>
+       </channel-type>
+
+       <channel-type id="menu">
+               <item-type>String</item-type>
+               <label>Menu</label>
+               <description>Menu display ON/OFF for the device</description>
+       </channel-type>
+
+       <channel-type id="menu-control">
+               <item-type>String</item-type>
+               <label>Menu Control</label>
+               <description>Menu Control for emulating an Emotiva Remote control</description>
+       </channel-type>
+
+       <channel-type id="up">
+               <item-type>String</item-type>
+               <label>Menu Up</label>
+               <description>Menu Control Up</description>
+       </channel-type>
+
+       <channel-type id="down">
+               <item-type>String</item-type>
+               <label>Menu Down</label>
+               <description>Menu Control Down</description>
+       </channel-type>
+
+       <channel-type id="left">
+               <item-type>String</item-type>
+               <label>Menu Left</label>
+               <description>Menu Control Left</description>
+       </channel-type>
+
+       <channel-type id="right">
+               <item-type>String</item-type>
+               <label>Menu Right</label>
+               <description>Menu Control Right</description>
+       </channel-type>
+
+       <channel-type id="enter">
+               <item-type>String</item-type>
+               <label>Menu Enter</label>
+               <description>Menu Control Enter</description>
+       </channel-type>
+
+       <channel-type id="volume-speaker-db">
+               <item-type>Number</item-type>
+               <label>Volume Speaker</label>
+               <description>Increased/Reduced volume for a given speaker, in dB</description>
+               <category>SoundVolume</category>
+               <state min="-24" step="0.5" max="24" pattern="%.1f dB"/>
+       </channel-type>
+
+       <channel-type id="dim">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Front Panel Dimness</label>
+               <description>Percentage of dimness: "0", "20", "40", "60", "80", "100"</description>
+               <category>Light</category>
+               <state min="0" step="20" max="100" pattern="%d %unit%" readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="mode">
+               <item-type>String</item-type>
+               <label>Mode</label>
+               <description>Main zone mode: "Stereo", "Direct", "Auto", etc.</description>
+               <state>
+                       <options>
+                               <option value="all-stereo">all-stereo</option>
+                               <option value="auto">auto</option>
+                               <option value="direct">direct</option>
+                               <option value="dolby">dolby</option>
+                               <option value="dts">dts</option>
+                               <option value="stereo">stereo</option>
+                               <option value="surround">surround</option>
+                               <option value="ref-stereo">ref-stereo</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="info">
+               <item-type>String</item-type>
+               <label>Info Screen</label>
+               <description>Shown Info Screen</description>
+       </channel-type>
+
+       <channel-type id="speaker-preset">
+               <item-type>String</item-type>
+               <label>Speaker Preset</label>
+               <description>Speaker preset Name</description>
+               <state>
+                       <options>
+                               <option value="preset-1">preset-1</option>
+                               <option value="preset-2">preset-2</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="loudness">
+               <item-type>Switch</item-type>
+               <label>Mute</label>
+               <description>Enable/Disable Loudness on this zone of the Processor</description>
+       </channel-type>
+
+       <channel-type id="frequency">
+               <item-type>Rollershutter</item-type>
+               <label>Radio Tuner Frequency</label>
+               <description>Radio Tuner frequency</description>
+       </channel-type>
+
+       <channel-type id="seek">
+               <item-type>Rollershutter</item-type>
+               <label>Radio Tuner Seek</label>
+               <description>Radio Tuner seek</description>
+       </channel-type>
+
+       <channel-type id="channel">
+               <item-type>Rollershutter</item-type>
+               <label>Tuner Channel</label>
+               <description>Radio Tuner Channel</description>
+               <state min="1" max="20"/>
+       </channel-type>
+
+       <channel-type id="tuner-band">
+               <item-type>String</item-type>
+               <label>Radio Tuner Band</label>
+               <description>Radio tuner band: "AM" or "FM"</description>
+               <state>
+                       <options>
+                               <option value="band-am">band-am</option>
+                               <option value="band-fm">band-fm</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="tuner-channel">
+               <item-type>Number:Frequency</item-type>
+               <label>Radio Tuner Channel Frequency</label>
+               <description>User select radio tuner channel frequency"</description>
+               <state readOnly="true" min="535000" max="108000000" pattern="%d %unit%"/>
+       </channel-type>
+
+       <channel-type id="tuner-channel-select">
+               <item-type>String</item-type>
+               <label>Radio Tuner Channel Name</label>
+               <description>User select radio tuner channel name</description>
+               <state>
+                       <options>
+                               <option value="channel-1">channel-1</option>
+                               <option value="channel-2">channel-2</option>
+                               <option value="channel-3">channel-3</option>
+                               <option value="channel-4">channel-4</option>
+                               <option value="channel-5">channel-5</option>
+                               <option value="channel-6">channel-6</option>
+                               <option value="channel-7">channel-7</option>
+                               <option value="channel-8">channel-8</option>
+                               <option value="channel-9">channel-9</option>
+                               <option value="channel-10">channel-10</option>
+                               <option value="channel-11">channel-11</option>
+                               <option value="channel-12">channel-12</option>
+                               <option value="channel-13">channel-13</option>
+                               <option value="channel-14">channel-14</option>
+                               <option value="channel-15">channel-15</option>
+                               <option value="channel-16">channel-16</option>
+                               <option value="channel-17">channel-17</option>
+                               <option value="channel-18">channel-18</option>
+                               <option value="channel-19">channel-19</option>
+                               <option value="channel-20">channel-20</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="tuner-signal">
+               <item-type>String</item-type>
+               <label>Radio Tuner Signal</label>
+               <description>Radio tuner signal quality</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="tuner-program">
+               <item-type>String</item-type>
+               <label>Radio Tuner Program</label>
+               <description>Radio tuner program: "Country", "Rock", "Classical", etc.</description>
+               <state readOnly="true">
+                       <options>
+                               <option value="adult-hits">adult-hits</option>
+                               <option value="alarm-test">alarm-test</option>
+                               <option value="alarm">alarm</option>
+                               <option value="children-programmes">children-programmes</option>
+                               <option value="classic-rock">classic-rock</option>
+                               <option value="classical">classical</option>
+                               <option value="college">college</option>
+                               <option value="country-music">country-music</option>
+                               <option value="culture">culture</option>
+                               <option value="current-affairs">current-affairs</option>
+                               <option value="documentary">documentary</option>
+                               <option value="drama">drama</option>
+                               <option value="easy-listening">easy-listening</option>
+                               <option value="education">education</option>
+                               <option value="emergency-test">emergency-test</option>
+                               <option value="emergency">emergency</option>
+                               <option value="finance">finance</option>
+                               <option value="folk-music">folk-music</option>
+                               <option value="information">information</option>
+                               <option value="jazz-music">jazz-music</option>
+                               <option value="jazz">jazz</option>
+                               <option value="language">language</option>
+                               <option value="Leisure">leisure</option>
+                               <option value="Light Classical">light-classical</option>
+                               <option value="National Music">national-music</option>
+                               <option value="News">news</option>
+                               <option value="Nostalgia">nostalgia</option>
+                               <option value="no-program">no-program</option>
+                               <option value="oldies">oldies</option>
+                               <option value="oldies-music">oldies-music</option>
+                               <option value="other-music">other-music</option>
+                               <option value="personality">personality</option>
+                               <option value="Phone-in">phone-in</option>
+                               <option value="popular-music">popular-music</option>
+                               <option value="public">public</option>
+                               <option value="religion">religion</option>
+                               <option value="religious-talk">religious-talk</option>
+                               <option value="rhythm-blues">rhythm-blues</option>
+                               <option value="rock-music">rock-music</option>
+                               <option value="rock">rock</option>
+                               <option value="science">Science</option>
+                               <option value="serious-classical">serious-classical</option>
+                               <option value="social-affairs">social-affairs</option>
+                               <option value="soft-music">soft-music</option>
+                               <option value="soft-rhythm-blues">soft-rhythm-blues</option>
+                               <option value="soft-rock">soft-rock</option>
+                               <option value="sport">sport</option>
+                               <option value="talk">talk</option>
+                               <option value="top-40">top-40</option>
+                               <option value="travel">travel</option>
+                               <option value="weather">weather</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="tuner-rds">
+               <item-type>String</item-type>
+               <label>Radio Tuner RDS</label>
+               <description>Radio Data System (RDS) tuner string</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="audio-input">
+               <item-type>String</item-type>
+               <label>Audio Input</label>
+               <description>Input source for audio on main zone</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="audio-bitstream">
+               <item-type>String</item-type>
+               <label>Audio Input Bitstream Type</label>
+               <description>Audio input bitstream type: "PCM 2.0", "ATMOS", etc.</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="audio-bits">
+               <item-type>String</item-type>
+               <label>Audio Input Bits</label>
+               <description>Audio input bits: "32kHZ 24bits", etc.</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="video-input">
+               <item-type>String</item-type>
+               <label>Video Input Source</label>
+               <description>Input source for video on main zone</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="video-format">
+               <item-type>String</item-type>
+               <label>Video Input Format</label>
+               <description>Video input format: "1920x1080i/60", "3840x2160p/60", etc.</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="video-space">
+               <item-type>String</item-type>
+               <label>Video Input Space</label>
+               <description>Video input space: "YcbCr 8bits", etc.</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="input-name">
+               <item-type>String</item-type>
+               <label>Custom Input Name</label>
+               <description>Custom Input Name</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <!-- Channels requiring protocol V2 -->
+       <channel-type id="selected-mode">
+               <item-type>String</item-type>
+               <label>User Selected Mode</label>
+               <description>User selected mode for the main zone. An "Auto" value here might not mean the mode channel is in
+                       auto:
+                       "Stereo", "Direct", "Auto", etc.
+               </description>
+               <state readOnly="true">
+                       <options>
+                               <option value="all-stereo">all-stereo</option>
+                               <option value="auto">auto</option>
+                               <option value="direct">direct</option>
+                               <option value="dolby">dolby</option>
+                               <option value="dts">dts</option>
+                               <option value="stereo">stereo</option>
+                               <option value="surround">surround</option>
+                               <option value="ref-stereo">ref-stereo</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="selected-movie-music">
+               <item-type>String</item-type>
+               <label>Media Mode</label>
+               <description>User-selected movie or music mode for main zone: "Movie" or "Music"</description>
+               <state readOnly="true">
+                       <options>
+                               <option value="movie">movie</option>
+                               <option value="music">music</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="mode-surround">
+               <item-type>String</item-type>
+               <label>Mode Surround</label>
+               <description>Main zone surround mode: "Auto", "Stereo", "Dolby", ...</description>
+               <state readOnly="true">
+                       <options>
+                               <option value="all-stereo">all-stereo</option>
+                               <option value="auto">auto</option>
+                               <option value="direct">direct</option>
+                               <option value="dolby">dolby</option>
+                               <option value="dts">dts</option>
+                               <option value="stereo">stereo</option>
+                               <option value="surround">surround</option>
+                               <option value="ref-stereo">ref-stereo</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="bar">
+               <item-type>String</item-type>
+               <label>Front Panel Bar</label>
+               <description>Text displayed on front panel bar of device</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="menu-display">
+               <item-type>String</item-type>
+               <label>Menu Display</label>
+               <description>Text displayed on a specific menu row and column</description>
+       </channel-type>
+
+</thing:thing-descriptions>
diff --git a/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/AbstractDTOTestBase.java b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/AbstractDTOTestBase.java
new file mode 100644 (file)
index 0000000..d38faae
--- /dev/null
@@ -0,0 +1,311 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal;
+
+import javax.xml.bind.JAXBException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaXmlUtils;
+
+/**
+ * Abstract helper class for unit tests.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public class AbstractDTOTestBase {
+
+    protected EmotivaXmlUtils xmlUtils = new EmotivaXmlUtils();
+
+    protected String emotivaAckPowerOff = """
+            <?xml version="1.0"?>
+            <emotivaAck>
+              <power_off status="ack"/>
+            </emotivaAck>""";
+
+    protected String emotivaAckPowerOffAndNotRealCommand = """
+            <?xml version="1.0"?>
+            <emotivaAck>
+              <power_off status="ack"/>
+              <not_a_real_command status="ack"/>
+            </emotivaAck>""";
+
+    protected String emotivaAckPowerOffAndVolume = """
+            <?xml version="1.0"?>
+            <emotivaAck>
+              <power_off status="ack"/>
+              <volume status="ack"/>
+            </emotivaAck>""";
+
+    protected String emotivaCommandoPowerOn = """
+            <power_on status="ack"/>""";
+
+    protected String emotivaNotifyEmotivaPropertyPower = """
+            <property name="tuner_channel" value="FM 106.50MHz" visible="true"/>""";
+
+    protected String emotivaUpdateEmotivaPropertyPower = """
+            <property name="power" value="On" visible="true" status="ack"/>""";
+
+    protected String emotivaControlVolume = """
+            <emotivaControl>
+              <volume value="-1" ack="no" />
+            </emotivaControl>""";
+
+    protected String emotivaNotifyV2KeepAlive = """
+            <?xml version="1.0"?>
+            <emotivaNotify sequence="54062">
+              <keepAlive value="7500" visible="true"/>
+            </emotivaNotify>""";
+
+    protected String emotivaNotifyV2UnknownTag = """
+            <?xml version="1.0"?>
+            <emotivaNotify sequence="54062">
+              <unknownTag value="0" visible="false"/>
+            </emotivaNotify>""";
+
+    protected String emotivaNotifyV2KeepAliveSequence = "54062";
+
+    protected String emotivaNotifyV3KeepAlive = """
+            <?xml version="1.0"?>
+            <emotivaNotify sequence="54062">
+              <property name="keepAlive" value="7500" visible="true"/>
+            </emotivaNotify>""";
+
+    protected String emotivaNotifyV3EmptyMenuValue = """
+            <?xml version="1.0"?>
+            <emotivaNotify sequence="23929">
+              <property name="menu" value="" visible="true"/>
+            </emotivaNotify>
+            """;
+
+    protected String emotivaUpdateRequest = """
+            <?xml version="1.0" encoding="utf-8"?>
+            <emotivaUpdate protocol="3.0">
+              <power />
+              <source />
+              <volume />
+              <audio_bitstream />
+              <audio_bits />
+              <video_input />
+              <video_format />
+              <video_space />
+            </emotivaUpdate>""";
+
+    protected String emotivaMenuNotify = """
+            <?xml version="1.0"?>
+            <emotivaMenuNotify sequence="2378">
+              <row number="0">
+                <col number="0" value="" fixed="no" highlight="no" arrow="no"/>
+                <col number="1" value="Left Display" fixed="no" highlight="no" arrow="up"/>
+                <col number="2" value="Full Status" fixed="no" highlight="no" arrow="no"/>
+              </row>
+              <row number="1">
+                <col number="0" value="" fixed="no" highlight="no" arrow="no"/>
+                <col number="1" value="Right Display" fixed="no" highlight="no" arrow="no"/>
+                <col number="2" value="Volume" fixed="no" highlight="no" arrow="no"/>
+              </row>
+              <row number="2">
+                <col number="0" value="" fixed="no" highlight="no" arrow="no"/>
+                <col number="1" value="Menu Display" fixed="no" highlight="no" arrow="no"/>
+                <col number="2" value="Right" fixed="no" highlight="no" arrow="no"/>
+              </row>
+              <row number="3">
+                <col number="0" value="" fixed="no" highlight="no" arrow="no"/>
+                <col number="1" value="OSD Transparent" fixed="no" highlight="no" arrow="no"/>
+                <col number="2" value=" 37.5%" fixed="no" highlight="no" arrow="no"/>
+              </row>
+              <row number="4">
+                <col number="0" value="" fixed="no" highlight="no" arrow="no"/>
+                <col number="1" value="Friendly Name" fixed="no" highlight="no" arrow="up"/>
+                <col number="2" value="RMC-1" fixed="no" highlight="no" arrow="no"/>
+              </row>
+              <row number="5">
+                <col number="0" value="Preferences" fixed="no" highlight="no" arrow="left"/>
+                <col number="1" value="OSD Popups" fixed="no" highlight="yes" arrow="no"/>
+                <col number="2" value="All" fixed="no" highlight="no" arrow="right"/>
+              </row>
+              <row number="6">
+                <col number="0" value="" fixed="no" highlight="no" arrow="no"/>
+                <col number="1" value="LFE Level" fixed="no" highlight="no" arrow="down"/>
+                <col number="2" value="  0.0dB" fixed="no" highlight="no" arrow="no"/>
+              </row>
+              <row number="7">
+                <col number="0" value="" fixed="no" highlight="no" arrow="no"/>
+                <col number="1" value="Turn-On Input" fixed="no" highlight="no" arrow="no"/>
+                <col number="2" value="Last Used" fixed="no" highlight="no" arrow="no"/>
+              </row>
+              <row number="8">
+                <col number="0" value="" fixed="no" highlight="no" arrow="no"/>
+                <col number="1" value="Turn-On Volume" fixed="no" highlight="no" arrow="no"/>
+                <col number="2" value="Last Used" fixed="no" highlight="no" arrow="no"/>
+              </row>
+              <row number="9">
+                <col number="0" value="" fixed="no" highlight="no" arrow="no"/>
+                <col number="1" value="Max Volume" fixed="no" highlight="no" arrow="no"/>
+                <col number="2" value=" 11.0dB" fixed="no" highlight="no" arrow="no"/>
+              </row>
+              <row number="10">
+                <col number="0" value="" fixed="no" highlight="no" arrow="no"/>
+                <col number="1" value="Front Bright" fixed="no" highlight="no" arrow="no"/>
+                <col number="2" value="100%" fixed="no" highlight="no" arrow="no"/>
+              </row>
+            </emotivaMenuNotify>""";
+
+    protected String emotivaMenuNotifyWithCheckBox = """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <emotivaMenuNotify sequence="12129">
+              <row number="0">
+                <col number="0" value="" fixedWidth="false" highlight="false" arrow="no"/>
+                <col number="1" value="" fixedWidth="false" highlight="false" arrow="up"/>
+                <col number="2" value="" fixedWidth="false" highlight="false" arrow="no"/>
+              </row>
+              <row number="1">
+                <col number="0" value="" fixedWidth="false" highlight="false" arrow="no"/>
+                <col number="1" value="" fixedWidth="false" highlight="false" arrow="no"/>
+                <col number="2" value="" fixedWidth="false" highlight="false" arrow="no"/>
+              </row>
+              <row number="2">
+                <col number="0" value="" fixedWidth="false" highlight="false" arrow="no"/>
+                <col number="1" value="" fixedWidth="false" highlight="false" arrow="no"/>
+                <col number="2" value="" fixedWidth="false" highlight="false" arrow="no"/>
+              </row>
+              <row number="3">
+                <col number="0" value="" fixedWidth="false" highlight="false" arrow="no"/>
+                <col number="1" value="Input change" fixedWidth="false" highlight="false" arrow="no"/>
+                <col number="2" checkbox="off" highlight="false" arrow="no"/>
+              </row>
+              <row number="4">
+                <col number="0" value="" fixedWidth="false" highlight="false" arrow="no"/>
+                <col number="1" value="Volume" fixedWidth="false" highlight="false" arrow="up"/>
+                <col number="2" checkbox="on" highlight="false" arrow="no"/>
+              </row>
+              <row number="5">
+                <col number="0" value="HDMI CEC" fixedWidth="false" highlight="false" arrow="left"/>
+                <col number="1" value="Enable" fixedWidth="false" highlight="true" arrow="no"/>
+                <col number="2" checkbox="on" highlight="false" arrow="right"/>
+              </row>
+              <row number="6">
+                <col number="0" value="" fixedWidth="false" highlight="false" arrow="no"/>
+                <col number="1" value="Audio to TV" fixedWidth="false" highlight="false" arrow="down"/>
+                <col number="2" checkbox="off" highlight="false" arrow="no"/>
+              </row>
+              <row number="7">
+                <col number="0" value="" fixedWidth="false" highlight="false" arrow="no"/>
+                <col number="1" value="Power On" fixedWidth="false" highlight="false" arrow="no"/>
+                <col number="2" checkbox="on" highlight="false" arrow="no"/>
+              </row>
+              <row number="8">
+                <col number="0" value="" fixedWidth="false" highlight="false" arrow="no"/>
+                <col number="1" value="Power Off" fixedWidth="false" highlight="false" arrow="no"/>
+                <col number="2" checkbox="on" highlight="false" arrow="no"/>
+              </row>
+              <row number="9">
+                <col number="0" value="" fixedWidth="false" highlight="false" arrow="no"/>
+                <col number="1" value="" fixedWidth="false" highlight="false" arrow="no"/>
+                <col number="2" value="" fixedWidth="false" highlight="false" arrow="no"/>
+              </row>
+              <row number="10">
+                <col number="0" value="" fixedWidth="false" highlight="false" arrow="no"/>
+                <col number="1" value="" fixedWidth="false" highlight="false" arrow="no"/>
+                <col number="2" value="" fixedWidth="false" highlight="false" arrow="no"/>
+              </row>
+            </emotivaMenuNotify>""";
+
+    protected String emotivaMenuNotifyProgress = """
+            <?xml version="1.0"?>
+            <emotivaMenuNotify sequence="2405">
+              <progress time="15"/>
+            </emotivaMenuNotify>""";
+
+    protected String emotivaUpdateResponseV2 = """
+            <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+            <emotivaUpdate protocol="2.0">
+              <power value="On" visible="true" status="ack"/>
+              <source value="HDMI 1" visible="true" status="nak"/>
+              <noKnownTag ack="nak"/>
+            </emotivaUpdate>""";
+
+    protected String emotivaUpdateResponseV3 = """
+            <?xml version="1.0" encoding="utf-8"?>
+            <emotivaUpdate protocol="3.0">
+              <property name="power" value="On" visible="true" status="ack"/>
+              <property name="source" value="HDMI 1" visible="true" status="nak"/>
+              <property name="noKnownTag" ack="nak"/>
+            </emotivaUpdate>""";
+
+    protected String emotivaBarNotifyBigText = """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <emotivaBarNotify sequence="98">
+              <bar text="XBox One" type="bigText"/>
+            </emotivaBarNotify>""";
+
+    protected String emotivaSubscriptionRequest = """
+            <emotivaSubscription>
+              <selected_mode />
+              <power />
+              <noKnownTag />
+            </emotivaSubscription>""";
+
+    protected String emotivaSubscriptionResponse = """
+            <?xml version="1.0"?>
+            <emotivaSubscription>
+              <power status="ack"/>
+              <source value="SHIELD    " visible="true" status="ack"/>
+              <menu value="Off" visible="true" status="ack"/>
+              <treble ack="yes" value="+ 1.5" visible="true" status="ack"/>
+              <noKnownTag ack="no"/>
+            </emotivaSubscription>""";
+
+    protected String emotivaPingV2 = """
+            <?xml version="1.0" encoding="utf-8"?>
+            <emotivaPing />""";
+
+    protected String emotivaPingV3 = """
+            <?xml version="1.0" encoding="utf-8" ?>
+            <emotivaPing protocol="3.0"/>""";
+
+    protected String emotivaTransponderResponseV2 = """
+            <?xml version="1.0"?>
+            <emotivaTransponder>
+              <model>XMC-1</model>
+              <revision>2.0</revision>
+              <name>Living Room</name>
+              <control>
+                <version>2.0</version>
+                <controlPort>7002</controlPort>
+                <notifyPort>7003</notifyPort>
+                <infoPort>7004</infoPort>
+                <setupPortTCP>7100</setupPortTCP>
+                <keepAlive>10000</keepAlive>
+              </control>
+            </emotivaTransponder>""";
+
+    protected String emotivaTransponderResponseV3 = """
+            <?xml version="1.0"?>
+            <emotivaTransponder>
+              <model>XMC-2</model>
+              <revision>3.0</revision>
+              <name>Living Room</name>
+              <control>
+                <version>3.0</version>
+                <controlPort>7002</controlPort>
+                <notifyPort>7003</notifyPort>
+                <infoPort>7004</infoPort>
+                <setupPortTCP>7100</setupPortTCP>
+                <keepAlive>10000</keepAlive>
+              </control>
+            </emotivaTransponder>""";
+
+    public AbstractDTOTestBase() throws JAXBException {
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/EmotivaCommandHelperTest.java b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/EmotivaCommandHelperTest.java
new file mode 100644 (file)
index 0000000..b495509
--- /dev/null
@@ -0,0 +1,106 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_MAIN_VOLUME;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_MUTE;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_STANDBY;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_SURROUND;
+import static org.openhab.binding.emotiva.internal.EmotivaCommandHelper.volumeDecibelToPercentage;
+import static org.openhab.binding.emotiva.internal.EmotivaCommandHelper.volumePercentageToDecibel;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.mute;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.mute_off;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.mute_on;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.standby;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.surround;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.surround_trim_set;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.volume;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaDataType.DIMENSIONLESS_DECIBEL;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaDataType.ON_OFF;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaProtocolVersion.PROTOCOL_V2;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaProtocolVersion.PROTOCOL_V3;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaControlRequest;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaDataType;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaProtocolVersion;
+import org.openhab.core.library.types.PercentType;
+
+/**
+ * Unit tests for the EmotivaCommandHelper.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+class EmotivaCommandHelperTest {
+
+    @Test
+    void volumeToPercentage() {
+        assertThat(volumeDecibelToPercentage("-100 dB"), is(PercentType.valueOf("0")));
+        assertThat(volumeDecibelToPercentage(" -96"), is(PercentType.valueOf("0")));
+        assertThat(volumeDecibelToPercentage("-41 dB "), is(PercentType.valueOf("50")));
+        assertThat(volumeDecibelToPercentage("15"), is(PercentType.valueOf("100")));
+        assertThat(volumeDecibelToPercentage("20"), is(PercentType.valueOf("100")));
+    }
+
+    @Test
+    void volumeToDecibel() {
+        assertThat(volumePercentageToDecibel("-10"), is(-96));
+        assertThat(volumePercentageToDecibel("0%"), is(-96));
+        assertThat(volumePercentageToDecibel("50 %"), is(-41));
+        assertThat(volumePercentageToDecibel("100 % "), is(15));
+        assertThat(volumePercentageToDecibel("110"), is(15));
+    }
+
+    private static Stream<Arguments> channelToControlRequest() {
+        return Stream.of(
+                Arguments.of(CHANNEL_SURROUND, "surround", DIMENSIONLESS_DECIBEL, surround, surround, surround,
+                        surround_trim_set, PROTOCOL_V2, -24.0, 24.0),
+                Arguments.of(CHANNEL_SURROUND, "surround", DIMENSIONLESS_DECIBEL, surround, surround, surround,
+                        surround_trim_set, PROTOCOL_V3, -24.0, 24.0),
+                Arguments.of(CHANNEL_MUTE, "mute", ON_OFF, mute, mute_on, mute_off, mute, PROTOCOL_V2, 0, 0),
+                Arguments.of(CHANNEL_STANDBY, "standby", ON_OFF, standby, standby, standby, standby, PROTOCOL_V2, 0, 0),
+                Arguments.of(CHANNEL_MAIN_VOLUME, "volume", DIMENSIONLESS_DECIBEL, volume, volume, volume, volume,
+                        PROTOCOL_V2, -96, 15));
+    }
+
+    @ParameterizedTest
+    @MethodSource("channelToControlRequest")
+    void testChannelToControlRequest(String channel, String name, EmotivaDataType emotivaDataType,
+            EmotivaControlCommands defaultCommand, EmotivaControlCommands onCommand, EmotivaControlCommands offCommand,
+            EmotivaControlCommands setCommand, EmotivaProtocolVersion version, double min, double max) {
+        final Map<String, Map<EmotivaControlCommands, String>> commandMaps = new ConcurrentHashMap<>();
+
+        EmotivaControlRequest surround = EmotivaCommandHelper.channelToControlRequest(channel, commandMaps, version);
+        assertThat(surround.getName(), is(name));
+        assertThat(surround.getChannel(), is(channel));
+        assertThat(surround.getDataType(), is(emotivaDataType));
+        assertThat(surround.getDefaultCommand(), is(defaultCommand));
+        assertThat(surround.getOnCommand(), is(onCommand));
+        assertThat(surround.getOffCommand(), is(offCommand));
+        assertThat(surround.getSetCommand(), is(setCommand));
+        assertThat(surround.getMinValue(), is(min));
+        assertThat(surround.getMaxValue(), is(max));
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaAckDTOTest.java b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaAckDTOTest.java
new file mode 100644 (file)
index 0000000..1fc2c89
--- /dev/null
@@ -0,0 +1,64 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import java.util.List;
+
+import javax.xml.bind.JAXBException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.emotiva.internal.AbstractDTOTestBase;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands;
+
+/**
+ * Unit tests for EmotivaAck message type.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+class EmotivaAckDTOTest extends AbstractDTOTestBase {
+
+    public EmotivaAckDTOTest() throws JAXBException {
+    }
+
+    @Test
+    void unmarshallValidCommand() throws JAXBException {
+        EmotivaAckDTO dto = (EmotivaAckDTO) xmlUtils.unmarshallToEmotivaDTO(emotivaAckPowerOff);
+        assertThat(dto, is(notNullValue()));
+        assertThat(dto.getCommands().size(), is(1));
+    }
+
+    @Test
+    void unmarshallOneValidCommand() throws JAXBException {
+        EmotivaAckDTO dto = (EmotivaAckDTO) xmlUtils.unmarshallToEmotivaDTO(emotivaAckPowerOffAndNotRealCommand);
+        assertThat(dto, is(notNullValue()));
+        List<EmotivaCommandDTO> commands = xmlUtils.unmarshallXmlObjectsToControlCommands(dto.getCommands());
+        assertThat(commands.size(), is(2));
+
+        assertThat(commands.get(0), is(notNullValue()));
+        assertThat(commands.get(0).getName(), is(EmotivaControlCommands.power_off.name()));
+        assertThat(commands.get(0).getStatus(), is("ack"));
+        assertThat(commands.get(0).getVisible(), is(nullValue()));
+        assertThat(commands.get(0).getValue(), is(nullValue()));
+
+        assertThat(commands.get(1), is(notNullValue()));
+        assertThat(commands.get(1).getName(), is(EmotivaControlCommands.none.name()));
+        assertThat(commands.get(1).getStatus(), is("ack"));
+        assertThat(commands.get(1).getVisible(), is(nullValue()));
+        assertThat(commands.get(1).getValue(), is(nullValue()));
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaBarNotifyDTOTest.java b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaBarNotifyDTOTest.java
new file mode 100644 (file)
index 0000000..da61875
--- /dev/null
@@ -0,0 +1,51 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import java.util.List;
+
+import javax.xml.bind.JAXBException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.emotiva.internal.AbstractDTOTestBase;
+
+/**
+ * Unit tests for EmotivaBarNotify message type.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+class EmotivaBarNotifyDTOTest extends AbstractDTOTestBase {
+
+    public EmotivaBarNotifyDTOTest() throws JAXBException {
+    }
+
+    @Test
+    void testUnmarshall() throws JAXBException {
+        EmotivaBarNotifyWrapper dto = (EmotivaBarNotifyWrapper) xmlUtils
+                .unmarshallToEmotivaDTO(emotivaBarNotifyBigText);
+        assertThat(dto.getSequence(), is("98"));
+        assertThat(dto.getTags().size(), is(1));
+
+        List<EmotivaBarNotifyDTO> commands = xmlUtils.unmarshallToBarNotify(dto.getTags());
+        assertThat(commands.get(0).getType(), is("bigText"));
+        assertThat(commands.get(0).getText(), is("XBox One"));
+        assertThat(commands.get(0).getUnits(), is(nullValue()));
+        assertThat(commands.get(0).getMin(), is(nullValue()));
+        assertThat(commands.get(0).getMax(), is(nullValue()));
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaCommandDTOTest.java b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaCommandDTOTest.java
new file mode 100644 (file)
index 0000000..bf7f5f6
--- /dev/null
@@ -0,0 +1,75 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import java.util.List;
+
+import javax.xml.bind.JAXBException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.emotiva.internal.AbstractDTOTestBase;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands;
+
+/**
+ * Unit tests for EmotivaCommandDTO command types.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+class EmotivaCommandDTOTest extends AbstractDTOTestBase {
+
+    public EmotivaCommandDTOTest() throws JAXBException {
+    }
+
+    @Test
+    void unmarshallElements() {
+        List<EmotivaCommandDTO> commandDTO = xmlUtils.unmarshallToCommands(emotivaCommandoPowerOn);
+        assertThat(commandDTO, is(notNullValue()));
+        assertThat(commandDTO.size(), is(1));
+        assertThat(commandDTO.get(0).getName(), is(EmotivaControlCommands.power_on.name()));
+    }
+
+    @Test
+    void unmarshallFromEmotivaAckWithMissingEnumType() {
+        List<EmotivaCommandDTO> commandDTO = xmlUtils.unmarshallToCommands(emotivaAckPowerOffAndNotRealCommand);
+        assertThat(commandDTO, is(notNullValue()));
+        assertThat(commandDTO.size(), is(2));
+        assertThat(commandDTO.get(0).getName(), is(EmotivaControlCommands.power_off.name()));
+        assertThat(commandDTO.get(0).getStatus(), is("ack"));
+        assertThat(commandDTO.get(0).getValue(), is(nullValue()));
+        assertThat(commandDTO.get(0).getVisible(), is(nullValue()));
+        assertThat(commandDTO.get(1).getName(), is(EmotivaControlCommands.none.name()));
+        assertThat(commandDTO.get(1).getStatus(), is("ack"));
+        assertThat(commandDTO.get(1).getValue(), is(nullValue()));
+        assertThat(commandDTO.get(1).getVisible(), is(nullValue()));
+    }
+
+    @Test
+    void unmarshallFromEmotivaAck() {
+        List<EmotivaCommandDTO> commandDTO = xmlUtils.unmarshallToCommands(emotivaAckPowerOffAndVolume);
+        assertThat(commandDTO, is(notNullValue()));
+        assertThat(commandDTO.size(), is(2));
+        assertThat(commandDTO.get(0).getName(), is(EmotivaControlCommands.power_off.name()));
+        assertThat(commandDTO.get(0).getStatus(), is("ack"));
+        assertThat(commandDTO.get(0).getValue(), is(nullValue()));
+        assertThat(commandDTO.get(0).getVisible(), is(nullValue()));
+        assertThat(commandDTO.get(1).getName(), is(EmotivaControlCommands.volume.name()));
+        assertThat(commandDTO.get(1).getStatus(), is("ack"));
+        assertThat(commandDTO.get(1).getValue(), is(nullValue()));
+        assertThat(commandDTO.get(1).getVisible(), is(nullValue()));
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaControlDTOTest.java b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaControlDTOTest.java
new file mode 100644 (file)
index 0000000..a27e2ab
--- /dev/null
@@ -0,0 +1,76 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import java.util.Collections;
+import java.util.List;
+
+import javax.xml.bind.JAXBException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.emotiva.internal.AbstractDTOTestBase;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands;
+
+/**
+ * Unit tests for EmotivaControl message type.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+class EmotivaControlDTOTest extends AbstractDTOTestBase {
+
+    public EmotivaControlDTOTest() throws JAXBException {
+    }
+
+    @Test
+    void marshalWithNoCommand() {
+        EmotivaControlDTO control = new EmotivaControlDTO(null);
+        String xmlString = xmlUtils.marshallJAXBElementObjects(control);
+        assertThat(xmlString, containsString("<emotivaControl/>"));
+        assertThat(xmlString, not(containsString("<property")));
+        assertThat(xmlString, not(containsString("</emotivaControl>")));
+    }
+
+    @Test
+    void marshalNoCommand() {
+        EmotivaControlDTO control = new EmotivaControlDTO(Collections.emptyList());
+        String xmlString = xmlUtils.marshallJAXBElementObjects(control);
+        assertThat(xmlString, containsString("<emotivaControl/>"));
+    }
+
+    @Test
+    void marshalCommand() {
+        EmotivaCommandDTO command = EmotivaCommandDTO.fromTypeWithAck(EmotivaControlCommands.set_volume, "10");
+        EmotivaControlDTO control = new EmotivaControlDTO(List.of(command));
+        String xmlString = xmlUtils.marshallJAXBElementObjects(control);
+        assertThat(xmlString, containsString("<emotivaControl>"));
+        assertThat(xmlString, containsString("<set_volume value=\"10\" ack=\"yes\" />"));
+        assertThat(xmlString, endsWith("</emotivaControl>\n"));
+    }
+
+    @Test
+    void marshalWithTwoCommands() {
+        EmotivaControlDTO control = new EmotivaControlDTO(
+                List.of(EmotivaCommandDTO.fromTypeWithAck(EmotivaControlCommands.power_on),
+                        EmotivaCommandDTO.fromTypeWithAck(EmotivaControlCommands.hdmi1)));
+        String xmlString = xmlUtils.marshallJAXBElementObjects(control);
+        assertThat(xmlString, containsString("<emotivaControl>"));
+        assertThat(xmlString, containsString("<power_on ack=\"yes\" />"));
+        assertThat(xmlString, containsString("<hdmi1 ack=\"yes\" />"));
+        assertThat(xmlString, endsWith("</emotivaControl>\n"));
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaMenuNotifyDTOTest.java b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaMenuNotifyDTOTest.java
new file mode 100644 (file)
index 0000000..254a2dd
--- /dev/null
@@ -0,0 +1,65 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import javax.xml.bind.JAXBException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.emotiva.internal.AbstractDTOTestBase;
+
+/**
+ * Unit tests for EmotivaMenuNotify message type.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+class EmotivaMenuNotifyDTOTest extends AbstractDTOTestBase {
+
+    public EmotivaMenuNotifyDTOTest() throws JAXBException {
+    }
+
+    @Test
+    void testUnmarshallMenu() throws JAXBException {
+        EmotivaMenuNotifyDTO dto = (EmotivaMenuNotifyDTO) xmlUtils.unmarshallToEmotivaDTO(emotivaMenuNotify);
+        assertThat(dto.getProgress(), is(nullValue()));
+        assertThat(dto.getSequence(), is("2378"));
+        assertThat(dto.getRow().size(), is(11));
+        assertThat(dto.getRow().size(), is(11));
+        assertThat(dto.getRow().get(0).getNumber(), is("0"));
+        assertThat(dto.getRow().get(0).getCol().size(), is(3));
+        assertThat(dto.getRow().get(0).getCol().get(0).getNumber(), is("0"));
+        assertThat(dto.getRow().get(0).getCol().get(0).getValue(), is(""));
+        assertThat(dto.getRow().get(0).getCol().get(0).getHighlight(), is("no"));
+        assertThat(dto.getRow().get(0).getCol().get(0).getArrow(), is("no"));
+        assertThat(dto.getRow().get(0).getCol().get(1).getNumber(), is("1"));
+        assertThat(dto.getRow().get(0).getCol().get(1).getValue(), is("Left Display"));
+        assertThat(dto.getRow().get(0).getCol().get(1).getHighlight(), is("no"));
+        assertThat(dto.getRow().get(0).getCol().get(1).getArrow(), is("up"));
+        assertThat(dto.getRow().get(0).getCol().get(2).getNumber(), is("2"));
+        assertThat(dto.getRow().get(0).getCol().get(2).getValue(), is("Full Status"));
+        assertThat(dto.getRow().get(0).getCol().get(2).getHighlight(), is("no"));
+        assertThat(dto.getRow().get(0).getCol().get(2).getArrow(), is("no"));
+    }
+
+    @Test
+    void testUnmarshallProgress() throws JAXBException {
+        EmotivaMenuNotifyDTO dto = (EmotivaMenuNotifyDTO) xmlUtils.unmarshallToEmotivaDTO(emotivaMenuNotifyProgress);
+        assertThat(dto.getSequence(), is("2405"));
+        assertThat(dto.getRow(), is(nullValue()));
+        assertThat(dto.getProgress().getTime(), is("15"));
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaNotifyWrapperTest.java b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaNotifyWrapperTest.java
new file mode 100644 (file)
index 0000000..bdee59e
--- /dev/null
@@ -0,0 +1,108 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import java.util.Collections;
+import java.util.List;
+
+import javax.xml.bind.JAXBException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.emotiva.internal.AbstractDTOTestBase;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags;
+import org.w3c.dom.Element;
+
+/**
+ * Unit tests for EmotivaNotify wrapper.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+class EmotivaNotifyWrapperTest extends AbstractDTOTestBase {
+
+    public EmotivaNotifyWrapperTest() throws JAXBException {
+    }
+
+    @Test
+    void marshallWithNoProperty() {
+        EmotivaNotifyWrapper dto = new EmotivaNotifyWrapper(emotivaNotifyV2KeepAliveSequence, Collections.emptyList());
+        String xmlAsString = xmlUtils.marshallEmotivaDTO(dto);
+        assertThat(xmlAsString,
+                containsString("<emotivaNotify sequence=\"" + emotivaNotifyV2KeepAliveSequence + "\"/>"));
+        assertThat(xmlAsString, not(containsString("<property")));
+        assertThat(xmlAsString, not(containsString("</emotivaNotify>")));
+    }
+
+    @Test
+    void marshallWithOneProperty() {
+        List<EmotivaPropertyDTO> keepAliveProperty = List.of(new EmotivaPropertyDTO("keepAlive", "7500", "true"));
+        EmotivaNotifyWrapper dto = new EmotivaNotifyWrapper(emotivaNotifyV2KeepAliveSequence, keepAliveProperty);
+
+        String xmlAsString = xmlUtils.marshallEmotivaDTO(dto);
+        assertThat(xmlAsString,
+                containsString("<emotivaNotify sequence=\"" + emotivaNotifyV2KeepAliveSequence + "\">"));
+        assertThat(xmlAsString, containsString("<property name=\"keepAlive\" value=\"7500\" visible=\"true\"/>"));
+        assertThat(xmlAsString, containsString("</emotivaNotify>"));
+    }
+
+    @Test
+    void testUnmarshallV2() throws JAXBException {
+        EmotivaNotifyWrapper dto = (EmotivaNotifyWrapper) xmlUtils.unmarshallToEmotivaDTO(emotivaNotifyV2KeepAlive);
+        assertThat(dto.getSequence(), is(emotivaNotifyV2KeepAliveSequence));
+        assertThat(dto.getTags().size(), is(1));
+        assertThat(dto.getTags().get(0), instanceOf(Element.class));
+        Element keepAlive = (Element) dto.getTags().get(0);
+        assertThat(keepAlive.getTagName(), is(EmotivaSubscriptionTags.keepAlive.name()));
+        assertThat(keepAlive.hasAttribute("value"), is(true));
+        assertThat(keepAlive.getAttribute("value"), is("7500"));
+        assertThat(keepAlive.hasAttribute("visible"), is(true));
+        assertThat(keepAlive.getAttribute("visible"), is("true"));
+        assertThat(dto.getProperties(), is(nullValue()));
+    }
+
+    @Test
+    void testUnmarshallV2UnknownProperty() throws JAXBException {
+        EmotivaNotifyWrapper dto = (EmotivaNotifyWrapper) xmlUtils.unmarshallToEmotivaDTO(emotivaNotifyV2UnknownTag);
+        assertThat(dto.getSequence(), is(emotivaNotifyV2KeepAliveSequence));
+        assertThat(dto.getTags().size(), is(1));
+        assertThat(dto.getTags().get(0), instanceOf(Element.class));
+        Element unknownCommand = (Element) dto.getTags().get(0);
+        assertThat(unknownCommand.getTagName(), is("unknownTag"));
+        assertThat(dto.getProperties(), is(nullValue()));
+    }
+
+    @Test
+    void testUnmarshallV3() throws JAXBException {
+        EmotivaNotifyWrapper dto = (EmotivaNotifyWrapper) xmlUtils.unmarshallToEmotivaDTO(emotivaNotifyV3KeepAlive);
+        assertThat(dto.getSequence(), is(emotivaNotifyV2KeepAliveSequence));
+        assertThat(dto.getProperties().size(), is(1));
+        assertThat(dto.getTags(), is(nullValue()));
+    }
+
+    @Test
+    void testUnmarshallV3EmptyValue() throws JAXBException {
+        EmotivaNotifyWrapper dto = (EmotivaNotifyWrapper) xmlUtils
+                .unmarshallToEmotivaDTO(emotivaNotifyV3EmptyMenuValue);
+        assertThat(dto.getSequence(), is("23929"));
+        assertThat(dto.getProperties().size(), is(1));
+        assertThat(dto.getProperties().get(0).getName(), is("menu"));
+        assertThat(dto.getProperties().get(0).getValue(), is(""));
+        assertThat(dto.getProperties().get(0).getVisible(), is("true"));
+        assertThat(dto.getProperties().get(0).getStatus(), is(notNullValue()));
+        assertThat(dto.getTags(), is(nullValue()));
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaPingDTOTest.java b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaPingDTOTest.java
new file mode 100644 (file)
index 0000000..8a7f7ee
--- /dev/null
@@ -0,0 +1,67 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import javax.xml.bind.JAXBException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.emotiva.internal.AbstractDTOTestBase;
+
+/**
+ * Unit tests for EmotivaPing message type.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+class EmotivaPingDTOTest extends AbstractDTOTestBase {
+
+    public EmotivaPingDTOTest() throws JAXBException {
+    }
+
+    @Test
+    void marshallPlain() {
+        EmotivaPingDTO dto = new EmotivaPingDTO();
+        String xmlAsString = xmlUtils.marshallEmotivaDTO(dto);
+        assertThat(xmlAsString, containsString("<emotivaPing/>"));
+        assertThat(xmlAsString, not(containsString("<property")));
+        assertThat(xmlAsString, not(containsString("</emotivaPing>")));
+    }
+
+    @Test
+    void marshallWithProtocol() {
+        EmotivaPingDTO dto = new EmotivaPingDTO("3.0");
+        String xmlAsString = xmlUtils.marshallEmotivaDTO(dto);
+        assertThat(xmlAsString, containsString("<emotivaPing protocol=\"3.0\"/>"));
+        assertThat(xmlAsString, not(containsString("<property")));
+        assertThat(xmlAsString, not(containsString("</emotivaPing>")));
+    }
+
+    @Test
+    void unmarshallV2() throws JAXBException {
+        EmotivaPingDTO dto = (EmotivaPingDTO) xmlUtils.unmarshallToEmotivaDTO(emotivaPingV2);
+        assertThat(dto, is(notNullValue()));
+        assertThat(dto.getProtocol(), is(nullValue()));
+    }
+
+    @Test
+    void unmarshallV3() throws JAXBException {
+        EmotivaPingDTO dto = (EmotivaPingDTO) xmlUtils.unmarshallToEmotivaDTO(emotivaPingV3);
+        assertThat(dto, is(notNullValue()));
+        assertThat(dto.getProtocol(), is(notNullValue()));
+        assertThat(dto.getProtocol(), is("3.0"));
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaPropertyDTOTest.java b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaPropertyDTOTest.java
new file mode 100644 (file)
index 0000000..d9cc821
--- /dev/null
@@ -0,0 +1,59 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaPropertyStatus.VALID;
+
+import javax.xml.bind.JAXBException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.emotiva.internal.AbstractDTOTestBase;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags;
+
+/**
+ * Unit tests for EmotivaCommandDTO command types.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+class EmotivaPropertyDTOTest extends AbstractDTOTestBase {
+
+    public EmotivaPropertyDTOTest() throws JAXBException {
+    }
+
+    @Test
+    void unmarshallFromEmotivaNotify() throws JAXBException {
+        EmotivaPropertyDTO commandDTO = (EmotivaPropertyDTO) xmlUtils
+                .unmarshallToEmotivaDTO(emotivaNotifyEmotivaPropertyPower);
+        assertThat(commandDTO, is(notNullValue()));
+        assertThat(commandDTO.getName(), is(EmotivaSubscriptionTags.tuner_channel.name()));
+        assertThat(commandDTO.getValue(), is("FM 106.50MHz"));
+        assertThat(commandDTO.getVisible(), is("true"));
+        assertThat(commandDTO.getStatus(), is(notNullValue()));
+    }
+
+    @Test
+    void unmarshallFromEmotivaUpdate() throws JAXBException {
+        EmotivaPropertyDTO commandDTO = (EmotivaPropertyDTO) xmlUtils
+                .unmarshallToEmotivaDTO(emotivaUpdateEmotivaPropertyPower);
+        assertThat(commandDTO, is(notNullValue()));
+        assertThat(commandDTO.getName(), is(EmotivaControlCommands.power.name()));
+        assertThat(commandDTO.getValue(), is("On"));
+        assertThat(commandDTO.getVisible(), is("true"));
+        assertThat(commandDTO.getStatus(), is(VALID.getValue()));
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaSubscriptionRequestTest.java b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaSubscriptionRequestTest.java
new file mode 100644 (file)
index 0000000..8f3c9a8
--- /dev/null
@@ -0,0 +1,92 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_TUNER_RDS;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaProtocolVersion.PROTOCOL_V2;
+
+import java.util.List;
+
+import javax.xml.bind.JAXBException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.emotiva.internal.AbstractDTOTestBase;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags;
+
+/**
+ * Unit tests for EmotivaSubscription requests.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+class EmotivaSubscriptionRequestTest extends AbstractDTOTestBase {
+
+    public EmotivaSubscriptionRequestTest() throws JAXBException {
+    }
+
+    @Test
+    void marshalFromChannelUID() {
+        EmotivaSubscriptionTags subscriptionChannel = EmotivaSubscriptionTags.fromChannelUID(CHANNEL_TUNER_RDS);
+        EmotivaSubscriptionRequest emotivaSubscriptionRequest = new EmotivaSubscriptionRequest(subscriptionChannel);
+        String xmlString = xmlUtils.marshallJAXBElementObjects(emotivaSubscriptionRequest);
+        assertThat(xmlString, containsString("<emotivaSubscription protocol=\"2.0\">"));
+        assertThat(xmlString, containsString("<tuner_RDS ack=\"yes\" />"));
+        assertThat(xmlString, containsString("</emotivaSubscription>"));
+    }
+
+    @Test
+    void marshallWithTwoSubscriptionsNoAck() {
+        EmotivaCommandDTO command1 = new EmotivaCommandDTO(EmotivaControlCommands.volume, "10", "yes");
+        EmotivaCommandDTO command2 = new EmotivaCommandDTO(EmotivaControlCommands.power_off);
+
+        EmotivaSubscriptionRequest dto = new EmotivaSubscriptionRequest(List.of(command1, command2),
+                PROTOCOL_V2.value());
+
+        String xmlString = xmlUtils.marshallJAXBElementObjects(dto);
+        assertThat(xmlString, containsString("<emotivaSubscription protocol=\"2.0\">"));
+        assertThat(xmlString, containsString("<volume value=\"10\" ack=\"yes\" />"));
+        assertThat(xmlString, containsString("<power_off />"));
+        assertThat(xmlString, containsString("</emotivaSubscription>"));
+        assertThat(xmlString, not(containsString("<volume>")));
+        assertThat(xmlString, not(containsString("<command>")));
+    }
+
+    @Test
+    void unmarshall() throws JAXBException {
+        var dto = (EmotivaSubscriptionResponse) xmlUtils.unmarshallToEmotivaDTO(emotivaSubscriptionRequest);
+        assertThat(dto, is(notNullValue()));
+        assertThat(dto.getTags().size(), is(3));
+        assertThat(dto.getProperties(), is(nullValue()));
+
+        List<EmotivaNotifyDTO> commands = xmlUtils.unmarshallToNotification(dto.getTags());
+
+        assertThat(commands.get(0).getName(), is(EmotivaSubscriptionTags.selected_mode.name()));
+        assertThat(commands.get(0).getStatus(), is(nullValue()));
+        assertThat(commands.get(0).getValue(), is(nullValue()));
+        assertThat(commands.get(0).getVisible(), is(nullValue()));
+
+        assertThat(commands.get(1).getName(), is(EmotivaSubscriptionTags.power.name()));
+        assertThat(commands.get(1).getStatus(), is(nullValue()));
+        assertThat(commands.get(1).getValue(), is(nullValue()));
+        assertThat(commands.get(1).getVisible(), is(nullValue()));
+
+        assertThat(commands.get(2).getName(), is("unknown"));
+        assertThat(commands.get(2).getStatus(), is(nullValue()));
+        assertThat(commands.get(2).getValue(), is(nullValue()));
+        assertThat(commands.get(2).getVisible(), is(nullValue()));
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaSubscriptionResponseTest.java b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaSubscriptionResponseTest.java
new file mode 100644 (file)
index 0000000..e0dda27
--- /dev/null
@@ -0,0 +1,100 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.power_on;
+
+import java.util.Collections;
+import java.util.List;
+
+import javax.xml.bind.JAXBException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.emotiva.internal.AbstractDTOTestBase;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaPropertyStatus;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags;
+
+/**
+ * Unit tests for EmotivaSubscription responses.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+class EmotivaSubscriptionResponseTest extends AbstractDTOTestBase {
+
+    public EmotivaSubscriptionResponseTest() throws JAXBException {
+    }
+
+    @Test
+    void marshallNoProperty() {
+        var dto = new EmotivaSubscriptionResponse(Collections.emptyList());
+        String xmlString = xmlUtils.marshallEmotivaDTO(dto);
+        assertThat(xmlString, containsString("<emotivaSubscription/>"));
+        assertThat(xmlString, not(containsString("</emotivaSubscription>")));
+        assertThat(xmlString, not(containsString("<property")));
+        assertThat(xmlString, not(containsString("<property>")));
+        assertThat(xmlString, not(containsString("</property>")));
+    }
+
+    @Test
+    void marshallWithOneProperty() {
+        EmotivaPropertyDTO emotivaPropertyDTO = new EmotivaPropertyDTO(power_on.name(), "On", "true");
+        var dto = new EmotivaSubscriptionResponse(Collections.singletonList(emotivaPropertyDTO));
+        String xmlString = xmlUtils.marshallEmotivaDTO(dto);
+        assertThat(xmlString, containsString("<emotivaSubscription>"));
+        assertThat(xmlString, containsString("<property name=\"power_on\" value=\"On\" visible=\"true\"/>"));
+        assertThat(xmlString, not(containsString("<property>")));
+        assertThat(xmlString, not(containsString("</property>")));
+        assertThat(xmlString, containsString("</emotivaSubscription>"));
+    }
+
+    @Test
+    void unmarshall() throws JAXBException {
+        var dto = (EmotivaSubscriptionResponse) xmlUtils.unmarshallToEmotivaDTO(emotivaSubscriptionResponse);
+        assertThat(dto.tags, is(notNullValue()));
+        assertThat(dto.tags.size(), is(5));
+        List<EmotivaNotifyDTO> commands = xmlUtils.unmarshallToNotification(dto.getTags());
+        assertThat(commands, is(notNullValue()));
+        assertThat(commands.size(), is(dto.tags.size()));
+        assertThat(commands.get(0), instanceOf(EmotivaNotifyDTO.class));
+        assertThat(commands.get(0).getName(), is(EmotivaSubscriptionTags.power.name()));
+        assertThat(commands.get(0).getStatus(), is(EmotivaPropertyStatus.VALID.getValue()));
+        assertThat(commands.get(0).getVisible(), is(nullValue()));
+        assertThat(commands.get(0).getValue(), is(nullValue()));
+
+        assertThat(commands.get(1).getName(), is(EmotivaSubscriptionTags.source.name()));
+        assertThat(commands.get(1).getValue(), is("SHIELD    "));
+        assertThat(commands.get(1).getVisible(), is("true"));
+        assertThat(commands.get(1).getStatus(), is(EmotivaPropertyStatus.VALID.getValue()));
+
+        assertThat(commands.get(2).getName(), is(EmotivaSubscriptionTags.menu.name()));
+        assertThat(commands.get(2).getValue(), is("Off"));
+        assertThat(commands.get(2).getVisible(), is("true"));
+        assertThat(commands.get(2).getStatus(), is(EmotivaPropertyStatus.VALID.getValue()));
+
+        assertThat(commands.get(3).getName(), is(EmotivaSubscriptionTags.treble.name()));
+        assertThat(commands.get(3).getValue(), is("+ 1.5"));
+        assertThat(commands.get(3).getVisible(), is("true"));
+        assertThat(commands.get(3).getStatus(), is(EmotivaPropertyStatus.VALID.getValue()));
+        assertThat(commands.get(3).getAck(), is("yes"));
+
+        assertThat(commands.get(4).getName(), is(EmotivaSubscriptionTags.UNKNOWN_TAG));
+        assertThat(commands.get(4).getValue(), is(nullValue()));
+        assertThat(commands.get(4).getVisible(), is(nullValue()));
+        assertThat(commands.get(4).getStatus(), is(nullValue()));
+        assertThat(commands.get(4).getAck(), is("no"));
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaTransponderDTOTest.java b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaTransponderDTOTest.java
new file mode 100644 (file)
index 0000000..f97232b
--- /dev/null
@@ -0,0 +1,66 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import javax.xml.bind.JAXBException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.emotiva.internal.AbstractDTOTestBase;
+
+/**
+ * Unit tests for EmotivaTransponder message type.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+class EmotivaTransponderDTOTest extends AbstractDTOTestBase {
+
+    public EmotivaTransponderDTOTest() throws JAXBException {
+    }
+
+    @Test
+    void unmarshallV2() throws JAXBException {
+        EmotivaTransponderDTO dto = (EmotivaTransponderDTO) xmlUtils
+                .unmarshallToEmotivaDTO(emotivaTransponderResponseV2);
+        assertThat(dto, is(notNullValue()));
+        assertThat(dto.getModel(), is("XMC-1"));
+        assertThat(dto.getRevision(), is("2.0"));
+        assertThat(dto.getName(), is("Living Room"));
+        assertThat(dto.getControl().getVersion(), is("2.0"));
+        assertThat(dto.getControl().getControlPort(), is(7002));
+        assertThat(dto.getControl().getNotifyPort(), is(7003));
+        assertThat(dto.getControl().getInfoPort(), is(7004));
+        assertThat(dto.getControl().getSetupPortTCP(), is(7100));
+        assertThat(dto.getControl().getKeepAlive(), is(10000));
+    }
+
+    @Test
+    void unmarshallV3() throws JAXBException {
+        EmotivaTransponderDTO dto = (EmotivaTransponderDTO) xmlUtils
+                .unmarshallToEmotivaDTO(emotivaTransponderResponseV3);
+        assertThat(dto, is(notNullValue()));
+        assertThat(dto.getModel(), is("XMC-2"));
+        assertThat(dto.getRevision(), is("3.0"));
+        assertThat(dto.getName(), is("Living Room"));
+        assertThat(dto.getControl().getVersion(), is("3.0"));
+        assertThat(dto.getControl().getControlPort(), is(7002));
+        assertThat(dto.getControl().getNotifyPort(), is(7003));
+        assertThat(dto.getControl().getInfoPort(), is(7004));
+        assertThat(dto.getControl().getSetupPortTCP(), is(7100));
+        assertThat(dto.getControl().getKeepAlive(), is(10000));
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaUnsubscriptionTest.java b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaUnsubscriptionTest.java
new file mode 100644 (file)
index 0000000..95f4101
--- /dev/null
@@ -0,0 +1,65 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_TUNER_RDS;
+
+import java.util.List;
+
+import javax.xml.bind.JAXBException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.emotiva.internal.AbstractDTOTestBase;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags;
+
+/**
+ * Unit tests for EmotivaUnsubscribe requests.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+class EmotivaUnsubscriptionTest extends AbstractDTOTestBase {
+
+    public EmotivaUnsubscriptionTest() throws JAXBException {
+    }
+
+    @Test
+    void marshalFromChannelUID() {
+        EmotivaSubscriptionTags subscriptionChannel = EmotivaSubscriptionTags.fromChannelUID(CHANNEL_TUNER_RDS);
+        EmotivaUnsubscribeDTO emotivaSubscriptionRequest = new EmotivaUnsubscribeDTO(subscriptionChannel);
+        String xmlString = xmlUtils.marshallJAXBElementObjects(emotivaSubscriptionRequest);
+        assertThat(xmlString, containsString("<emotivaUnsubscribe>"));
+        assertThat(xmlString, containsString("<tuner_RDS />"));
+        assertThat(xmlString, containsString("</emotivaUnsubscribe>"));
+    }
+
+    @Test
+    void marshallWithTwoUnsubscriptions() {
+        EmotivaCommandDTO command1 = new EmotivaCommandDTO(EmotivaControlCommands.volume);
+        EmotivaCommandDTO command2 = new EmotivaCommandDTO(EmotivaControlCommands.power_off);
+
+        EmotivaUnsubscribeDTO dto = new EmotivaUnsubscribeDTO(List.of(command1, command2));
+
+        String xmlString = xmlUtils.marshallJAXBElementObjects(dto);
+        assertThat(xmlString, containsString("<emotivaUnsubscribe>"));
+        assertThat(xmlString, containsString("<volume />"));
+        assertThat(xmlString, containsString("<power_off />"));
+        assertThat(xmlString, containsString("</emotivaUnsubscribe>"));
+        assertThat(xmlString, not(containsString("<volume>")));
+        assertThat(xmlString, not(containsString("<command>")));
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaUpdateRequestTest.java b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaUpdateRequestTest.java
new file mode 100644 (file)
index 0000000..9396fe7
--- /dev/null
@@ -0,0 +1,58 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import java.util.Collections;
+
+import javax.xml.bind.JAXBException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.emotiva.internal.AbstractDTOTestBase;
+import org.openhab.binding.emotiva.internal.EmotivaBindingConstants;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags;
+
+/**
+ * Unit tests for EmotivaUpdate requests.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+class EmotivaUpdateRequestTest extends AbstractDTOTestBase {
+
+    public EmotivaUpdateRequestTest() throws JAXBException {
+    }
+
+    @Test
+    void marshallWithNoProperty() {
+        EmotivaUpdateRequest dto = new EmotivaUpdateRequest(Collections.emptyList());
+        String xmlAsString = xmlUtils.marshallJAXBElementObjects(dto);
+        assertThat(xmlAsString, containsString("<emotivaUpdate/>"));
+        assertThat(xmlAsString, not(containsString("<property")));
+        assertThat(xmlAsString, not(containsString("</emotivaUpdate>")));
+    }
+
+    @Test
+    void marshalFromChannelUID() {
+        EmotivaSubscriptionTags subscriptionChannel = EmotivaSubscriptionTags
+                .fromChannelUID(EmotivaBindingConstants.CHANNEL_TUNER_RDS);
+        EmotivaUpdateRequest emotivaUpdateRequest = new EmotivaUpdateRequest(subscriptionChannel);
+        String xmlString = xmlUtils.marshallJAXBElementObjects(emotivaUpdateRequest);
+        assertThat(xmlString, containsString("<emotivaUpdate>"));
+        assertThat(xmlString, containsString("<tuner_RDS />"));
+        assertThat(xmlString, containsString("</emotivaUpdate>"));
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaUpdateResponseTest.java b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/dto/EmotivaUpdateResponseTest.java
new file mode 100644 (file)
index 0000000..6c9f878
--- /dev/null
@@ -0,0 +1,97 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.dto;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaPropertyStatus.NOT_VALID;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaPropertyStatus.VALID;
+
+import java.util.Collections;
+import java.util.List;
+
+import javax.xml.bind.JAXBException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.emotiva.internal.AbstractDTOTestBase;
+import org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags;
+
+/**
+ * Unit tests for EmotivaUpdate responses.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+class EmotivaUpdateResponseTest extends AbstractDTOTestBase {
+
+    public EmotivaUpdateResponseTest() throws JAXBException {
+    }
+
+    @Test
+    void marshallWithNoProperty() {
+        EmotivaUpdateResponse dto = new EmotivaUpdateResponse(Collections.emptyList());
+        String xmlAsString = xmlUtils.marshallEmotivaDTO(dto);
+        assertThat(xmlAsString, containsString("<emotivaUpdate/>"));
+        assertThat(xmlAsString, not(containsString("<property")));
+        assertThat(xmlAsString, not(containsString("</emotivaUpdate>")));
+    }
+
+    @Test
+    void unmarshallV2() throws JAXBException {
+        var dto = (EmotivaUpdateResponse) xmlUtils.unmarshallToEmotivaDTO(emotivaUpdateResponseV2);
+        assertThat(dto, is(notNullValue()));
+        assertThat(dto.getProperties(), is(nullValue()));
+        List<EmotivaNotifyDTO> notifications = xmlUtils.unmarshallToNotification(dto.getTags());
+        assertThat(notifications.size(), is(3));
+
+        assertThat(notifications.get(0).getName(), is(EmotivaSubscriptionTags.power.name()));
+        assertThat(notifications.get(0).getValue(), is("On"));
+        assertThat(notifications.get(0).getVisible(), is("true"));
+        assertThat(notifications.get(0).getStatus(), is(VALID.getValue()));
+
+        assertThat(notifications.get(1).getName(), is(EmotivaSubscriptionTags.source.name()));
+        assertThat(notifications.get(1).getValue(), is("HDMI 1"));
+        assertThat(notifications.get(1).getVisible(), is("true"));
+        assertThat(notifications.get(1).getStatus(), is(NOT_VALID.getValue()));
+
+        assertThat(notifications.get(2).getName(), is(EmotivaSubscriptionTags.unknown.name()));
+        assertThat(notifications.get(2).getStatus(), is(nullValue()));
+        assertThat(notifications.get(2).getValue(), is(nullValue()));
+        assertThat(notifications.get(2).getVisible(), is(nullValue()));
+    }
+
+    @Test
+    void unmarshallV3() throws JAXBException {
+        var dto = (EmotivaUpdateResponse) xmlUtils.unmarshallToEmotivaDTO(emotivaUpdateResponseV3);
+        assertThat(dto, is(notNullValue()));
+        assertThat(dto.getTags(), is(nullValue()));
+        assertThat(dto.getProperties().size(), is(3));
+
+        assertThat(dto.getProperties().get(0), instanceOf(EmotivaPropertyDTO.class));
+        assertThat(dto.getProperties().get(0).getName(), is(EmotivaSubscriptionTags.power.name()));
+        assertThat(dto.getProperties().get(0).getValue(), is("On"));
+        assertThat(dto.getProperties().get(0).getVisible(), is("true"));
+        assertThat(dto.getProperties().get(0).getStatus(), is(VALID.getValue()));
+
+        assertThat(dto.getProperties().get(1).getName(), is(EmotivaSubscriptionTags.source.name()));
+        assertThat(dto.getProperties().get(1).getValue(), is("HDMI 1"));
+        assertThat(dto.getProperties().get(1).getVisible(), is("true"));
+        assertThat(dto.getProperties().get(1).getStatus(), is(NOT_VALID.getValue()));
+
+        assertThat(dto.getProperties().get(2).getName(), is("noKnownTag"));
+        assertThat(dto.getProperties().get(2).getStatus(), is(notNullValue()));
+        assertThat(dto.getProperties().get(2).getValue(), is(notNullValue()));
+        assertThat(dto.getProperties().get(2).getVisible(), is(notNullValue()));
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/protocol/EmotivaControlRequestTest.java b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/protocol/EmotivaControlRequestTest.java
new file mode 100644 (file)
index 0000000..754f18a
--- /dev/null
@@ -0,0 +1,270 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.protocol;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.*;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.*;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaProtocolVersion.*;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags.tuner_band;
+import static org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags.tuner_channel;
+import static org.openhab.core.types.RefreshType.REFRESH;
+
+import java.util.Collections;
+import java.util.EnumMap;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.openhab.binding.emotiva.internal.EmotivaBindingConstants;
+import org.openhab.binding.emotiva.internal.EmotivaCommandHelper;
+import org.openhab.binding.emotiva.internal.dto.EmotivaControlDTO;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.types.UpDownType;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+
+/**
+ * Unit tests for EmotivaControl requests.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+class EmotivaControlRequestTest {
+
+    private static Stream<Arguments> channelToDTOs() {
+        return Stream.of(Arguments.of(CHANNEL_STANDBY, OnOffType.ON, standby, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_STANDBY, OnOffType.OFF, standby, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MAIN_ZONE_POWER, OnOffType.ON, power_on, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MAIN_ZONE_POWER, OnOffType.OFF, power_off, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_SOURCE, new StringType("HDMI1"), hdmi1, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_SOURCE, new StringType("SHIELD"), source_2, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_SOURCE, new StringType("hdmi1"), hdmi1, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_SOURCE, new StringType("coax1"), coax1, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_SOURCE, new StringType("NOT_REAL"), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MENU, new StringType("0"), menu, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MENU_CONTROL, new StringType("0"), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MENU_CONTROL, new StringType("MENU"), menu, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MENU_CONTROL, new StringType("ENTER"), enter, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MENU_CONTROL, new StringType("UP"), up, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MENU_CONTROL, new StringType("DOWN"), down, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MENU_CONTROL, new StringType("LEFT"), left, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MENU_CONTROL, new StringType("RIGHT"), right, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MENU_UP, new StringType("0"), up, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MENU_DOWN, new StringType("0"), down, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MENU_LEFT, new StringType("0"), left, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MENU_RIGHT, new StringType("0"), right, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MENU_ENTER, new StringType("0"), enter, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MUTE, OnOffType.ON, mute_on, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MUTE, OnOffType.OFF, mute_off, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_DIM, OnOffType.ON, dim, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_DIM, OnOffType.OFF, dim, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MODE, new StringType("mode_ref_stereo"), reference_stereo, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MODE, new StringType("surround_mode"), surround_mode, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MODE, new StringType("mode_surround"), surround_mode, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MODE, new StringType("surround"), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MODE, new StringType("1"), mode_up, PROTOCOL_V2, "1"),
+                Arguments.of(CHANNEL_MODE, new DecimalType(-1), mode_down, PROTOCOL_V2, "-1"),
+                Arguments.of(CHANNEL_MODE, OnOffType.ON, none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MODE, new DecimalType(1), mode_up, PROTOCOL_V2, "1"),
+                Arguments.of(CHANNEL_MODE, new DecimalType(-10), mode_down, PROTOCOL_V2, "-1"),
+                Arguments.of(CHANNEL_CENTER, new QuantityType<>(10, Units.DECIBEL), center_trim_set, PROTOCOL_V2,
+                        "20.0"),
+                Arguments.of(CHANNEL_CENTER, new QuantityType<>(10, Units.DECIBEL), center_trim_set, PROTOCOL_V3,
+                        "20.0"),
+                Arguments.of(CHANNEL_CENTER, new DecimalType(-30), center_trim_set, PROTOCOL_V2, "-24.0"),
+                Arguments.of(CHANNEL_CENTER, new DecimalType(-30), center_trim_set, PROTOCOL_V3, "-24.0"),
+                Arguments.of(CHANNEL_SUBWOOFER, new DecimalType(1), subwoofer_trim_set, PROTOCOL_V2, "2.0"),
+                Arguments.of(CHANNEL_SUBWOOFER, new DecimalType(1), subwoofer_trim_set, PROTOCOL_V3, "2.0"),
+                Arguments.of(CHANNEL_SUBWOOFER, new DecimalType(-25), subwoofer_trim_set, PROTOCOL_V2, "-24.0"),
+                Arguments.of(CHANNEL_SUBWOOFER, new DecimalType(-25), subwoofer_trim_set, PROTOCOL_V3, "-24.0"),
+                Arguments.of(CHANNEL_SURROUND, new DecimalType(30), surround_trim_set, PROTOCOL_V2, "24.0"),
+                Arguments.of(CHANNEL_SURROUND, new DecimalType(30), surround_trim_set, PROTOCOL_V3, "24.0"),
+                Arguments.of(CHANNEL_SURROUND, new DecimalType(-3.5), surround_trim_set, PROTOCOL_V2, "-7.0"),
+                Arguments.of(CHANNEL_SURROUND, new DecimalType(-3), surround_trim_set, PROTOCOL_V3, "-6.0"),
+                Arguments.of(CHANNEL_BACK, new DecimalType(-3), back_trim_set, PROTOCOL_V2, "-6.0"),
+                Arguments.of(CHANNEL_BACK, new DecimalType(-3), back_trim_set, PROTOCOL_V3, "-6.0"),
+                Arguments.of(CHANNEL_BACK, new DecimalType(30), back_trim_set, PROTOCOL_V2, "24.0"),
+                Arguments.of(CHANNEL_BACK, new DecimalType(30), back_trim_set, PROTOCOL_V3, "24.0"),
+                Arguments.of(CHANNEL_MODE_SURROUND, new StringType("0"), surround_mode, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_SPEAKER_PRESET, OnOffType.ON, speaker_preset, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_SPEAKER_PRESET, OnOffType.OFF, speaker_preset, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_SPEAKER_PRESET, new StringType("preset2"), preset2, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_SPEAKER_PRESET, new StringType("1"), speaker_preset, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_SPEAKER_PRESET, new StringType("speaker_preset"), speaker_preset, PROTOCOL_V2,
+                        "0"),
+                Arguments.of(CHANNEL_MAIN_VOLUME, new DecimalType(30), set_volume, PROTOCOL_V2, "15.0"),
+                Arguments.of(CHANNEL_MAIN_VOLUME, new PercentType("50"), set_volume, PROTOCOL_V2, "-41"),
+                Arguments.of(CHANNEL_MAIN_VOLUME_DB, new QuantityType<>(-96, Units.DECIBEL), set_volume, PROTOCOL_V2,
+                        "-96.0"),
+                Arguments.of(CHANNEL_MAIN_VOLUME_DB, new QuantityType<>(-100, Units.DECIBEL), set_volume, PROTOCOL_V2,
+                        "-96.0"),
+                Arguments.of(CHANNEL_LOUDNESS, OnOffType.ON, loudness_on, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_LOUDNESS, OnOffType.OFF, loudness_off, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_ZONE2_POWER, OnOffType.ON, zone2_power_on, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_ZONE2_POWER, OnOffType.OFF, zone2_power_off, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_ZONE2_VOLUME, new DecimalType(30), zone2_set_volume, PROTOCOL_V2, "15.0"),
+                Arguments.of(CHANNEL_ZONE2_VOLUME, new PercentType("50"), zone2_set_volume, PROTOCOL_V2, "-41"),
+                Arguments.of(CHANNEL_ZONE2_VOLUME_DB, new QuantityType<>(-96, Units.DECIBEL), zone2_set_volume,
+                        PROTOCOL_V2, "-96.0"),
+                Arguments.of(CHANNEL_ZONE2_VOLUME_DB, new QuantityType<>(-100, Units.DECIBEL), zone2_set_volume,
+                        PROTOCOL_V2, "-96.0"),
+                Arguments.of(CHANNEL_ZONE2_MUTE, OnOffType.ON, zone2_mute_on, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_ZONE2_MUTE, OnOffType.OFF, zone2_mute_off, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_ZONE2_SOURCE, new StringType("HDMI1"), hdmi1, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_ZONE2_SOURCE, new StringType("SHIELD"), source_2, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_ZONE2_SOURCE, new StringType("hdmi1"), hdmi1, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_ZONE2_SOURCE, new StringType("coax1"), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_ZONE2_SOURCE, new StringType("zone2_coax1"), zone2_coax1, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_ZONE2_SOURCE, new StringType("zone2_ARC"), zone2_ARC, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_ZONE2_SOURCE, new StringType("NOT_REAL"), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_ZONE2_SOURCE, new StringType("zone2_follow_main"), zone2_follow_main, PROTOCOL_V2,
+                        "0"),
+                Arguments.of(CHANNEL_FREQUENCY, UpDownType.UP, frequency, PROTOCOL_V2, "1"),
+                Arguments.of(CHANNEL_FREQUENCY, UpDownType.DOWN, frequency, PROTOCOL_V2, "-1"),
+                Arguments.of(CHANNEL_SEEK, UpDownType.UP, seek, PROTOCOL_V2, "1"),
+                Arguments.of(CHANNEL_SEEK, UpDownType.DOWN, seek, PROTOCOL_V2, "-1"),
+                Arguments.of(CHANNEL_CHANNEL, UpDownType.UP, channel, PROTOCOL_V2, "1"),
+                Arguments.of(CHANNEL_CHANNEL, UpDownType.DOWN, channel, PROTOCOL_V2, "-1"),
+                Arguments.of(CHANNEL_TUNER_BAND, new StringType("band_am"), band_am, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_TUNER_BAND, new StringType("band_fm"), band_fm, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_TUNER_CHANNEL, new StringType("FM 107.90MHz"), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_TUNER_CHANNEL, QuantityType.valueOf(103000000, Units.HERTZ), none, PROTOCOL_V2,
+                        "0"),
+                Arguments.of(CHANNEL_TUNER_CHANNEL, new StringType("channel_1"), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_TUNER_CHANNEL_SELECT, new StringType("channel_1"), channel_1, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_TUNER_CHANNEL_SELECT, new StringType("CHANNEL_2"), channel_2, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_TUNER_CHANNEL_SELECT, new StringType("FM 107.90MHz"), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_TUNER_CHANNEL_SELECT, QuantityType.valueOf(103000000, Units.HERTZ), none,
+                        PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_TUNER_SIGNAL, new StringType("Mono   0dBuV"), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_TUNER_PROGRAM, new StringType("Black Metal"), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_TUNER_RDS, new StringType("The Zombie Apocalypse is upon us!"), none, PROTOCOL_V2,
+                        "0"),
+                Arguments.of(CHANNEL_AUDIO_INPUT, new StringType("HDMI 1"), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_AUDIO_BITSTREAM, new StringType("HDMI 1"), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_AUDIO_BITS, new StringType("PCM 5.1"), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_VIDEO_INPUT, new StringType("HDMI 1"), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_VIDEO_FORMAT, new StringType("1080P/60"), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_VIDEO_SPACE, new StringType("RGB 8bits"), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_INPUT1, new StringType("HDMI1"), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_INPUT2, new StringType("HDMI2"), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_INPUT3, new StringType("HDMI3"), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_INPUT4, new StringType("HDMI4"), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_INPUT5, new StringType("HDMI5"), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_INPUT6, new StringType("HDMI6"), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_INPUT7, new StringType("HDMI7"), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_INPUT8, new StringType("HDMI8"), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MODE_REF_STEREO, new StringType("0"), reference_stereo, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MODE_REF_STEREO, new StringType("0"), reference_stereo, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MODE_REF_STEREO, REFRESH, none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MODE_REF_STEREO, REFRESH, none, PROTOCOL_V3, "0"),
+                Arguments.of(CHANNEL_MODE_STEREO, new StringType("0"), stereo, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MODE_MUSIC, new StringType("0"), music, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MODE_MOVIE, new StringType("0"), movie, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MODE_DIRECT, new StringType("0"), direct, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MODE_DOLBY, new StringType("0"), dolby, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MODE_DTS, new StringType("0"), dts, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MODE_ALL_STEREO, new StringType("0"), all_stereo, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_MODE_AUTO, new StringType("0"), auto, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_SELECTED_MODE, new StringType("Auto"), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_SELECTED_MOVIE_MUSIC, new StringType("Surround"), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_TREBLE, new DecimalType(0.5), treble_up, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_TREBLE, new DecimalType(-1), treble_up, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_TREBLE, new DecimalType(0.5), treble_up, PROTOCOL_V3, "0"),
+                Arguments.of(CHANNEL_TREBLE, new DecimalType(-4), treble_down, PROTOCOL_V3, "0"),
+                Arguments.of(CHANNEL_BASS, new QuantityType<>(0, Units.DECIBEL), none, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_BASS, new QuantityType<>(-1, Units.DECIBEL), bass_down, PROTOCOL_V2, "0"),
+                Arguments.of(CHANNEL_BASS, new QuantityType<>(0, Units.DECIBEL), none, PROTOCOL_V3, "0"),
+                Arguments.of(CHANNEL_BASS, new QuantityType<>(-1, Units.DECIBEL), bass_down, PROTOCOL_V3, "0"),
+                Arguments.of(CHANNEL_WIDTH, new DecimalType(30), width_trim_set, PROTOCOL_V2, "24.0"),
+                Arguments.of(CHANNEL_WIDTH, new DecimalType(30), width_trim_set, PROTOCOL_V3, "24.0"),
+                Arguments.of(CHANNEL_WIDTH, new QuantityType<>(-1, Units.DECIBEL), width_trim_set, PROTOCOL_V2, "-2.0"),
+                Arguments.of(CHANNEL_WIDTH, new QuantityType<>(-1, Units.DECIBEL), width_trim_set, PROTOCOL_V3, "-2.0"),
+                Arguments.of(CHANNEL_HEIGHT, new DecimalType(0.499999), height_trim_set, PROTOCOL_V2, "1.0"),
+                Arguments.of(CHANNEL_HEIGHT, new DecimalType(-1.00000000001), height_trim_set, PROTOCOL_V3, "-2.0"),
+                Arguments.of(CHANNEL_HEIGHT, new QuantityType<>(-1, Units.DECIBEL), height_trim_set, PROTOCOL_V2,
+                        "-2.0"),
+                Arguments.of(CHANNEL_HEIGHT, new QuantityType<>(-1, Units.DECIBEL), height_trim_set, PROTOCOL_V3,
+                        "-2.0"));
+    }
+
+    private static final EnumMap<EmotivaControlCommands, String> MAP_SOURCES_MAIN_ZONE = new EnumMap<>(
+            EmotivaControlCommands.class);
+    private static final EnumMap<EmotivaControlCommands, String> MAP_SOURCES_ZONE_2 = new EnumMap<>(
+            EmotivaControlCommands.class);
+    private static final EnumMap<EmotivaControlCommands, String> CHANNEL_MAP = new EnumMap<>(
+            EmotivaControlCommands.class);
+    private static final EnumMap<EmotivaControlCommands, String> RADIO_BAND_MAP = new EnumMap<>(
+            EmotivaControlCommands.class);
+    private static final Map<String, State> STATE_MAP = Collections.synchronizedMap(new HashMap<>());
+    private static final Map<String, Map<EmotivaControlCommands, String>> COMMAND_MAPS = new ConcurrentHashMap<>();
+
+    @BeforeAll
+    static void beforeAll() {
+        MAP_SOURCES_MAIN_ZONE.put(source_1, "HDMI 1");
+        MAP_SOURCES_MAIN_ZONE.put(source_2, "SHIELD");
+        MAP_SOURCES_MAIN_ZONE.put(hdmi1, "HDMI1");
+        MAP_SOURCES_MAIN_ZONE.put(coax1, "Coax 1");
+        COMMAND_MAPS.put(EmotivaBindingConstants.MAP_SOURCES_MAIN_ZONE, MAP_SOURCES_MAIN_ZONE);
+
+        MAP_SOURCES_ZONE_2.put(source_1, "HDMI 1");
+        MAP_SOURCES_ZONE_2.put(source_2, "SHIELD");
+        MAP_SOURCES_ZONE_2.put(hdmi1, "HDMI1");
+        MAP_SOURCES_ZONE_2.put(zone2_coax1, "Coax 1");
+        MAP_SOURCES_ZONE_2.put(zone2_ARC, "Audio Return Channel");
+        MAP_SOURCES_ZONE_2.put(zone2_follow_main, "Follow Main");
+        COMMAND_MAPS.put(EmotivaBindingConstants.MAP_SOURCES_ZONE_2, MAP_SOURCES_ZONE_2);
+
+        CHANNEL_MAP.put(channel_1, "Channel 1");
+        CHANNEL_MAP.put(channel_2, "Channel 2");
+        CHANNEL_MAP.put(channel_3, "My Radio Channel");
+        COMMAND_MAPS.put(tuner_channel.getEmotivaName(), CHANNEL_MAP);
+
+        RADIO_BAND_MAP.put(band_am, "AM");
+        RADIO_BAND_MAP.put(band_fm, "FM");
+        COMMAND_MAPS.put(tuner_band.getEmotivaName(), RADIO_BAND_MAP);
+
+        STATE_MAP.put(CHANNEL_TREBLE, new DecimalType(-3));
+        STATE_MAP.put(CHANNEL_TUNER_CHANNEL, new StringType("FM    87.50MHz"));
+        STATE_MAP.put(CHANNEL_FREQUENCY, QuantityType.valueOf(107.90, Units.HERTZ));
+    }
+
+    @ParameterizedTest
+    @MethodSource("channelToDTOs")
+    void createDTO(String channel, Command ohValue, EmotivaControlCommands controlCommand,
+            EmotivaProtocolVersion protocolVersion, String requestValue) {
+        EmotivaControlRequest controlRequest = EmotivaCommandHelper.channelToControlRequest(channel, COMMAND_MAPS,
+                protocolVersion);
+
+        EmotivaControlDTO dto = controlRequest.createDTO(ohValue, STATE_MAP.get(channel));
+        assertThat(dto.getCommands().size(), is(1));
+        assertThat(dto.getCommands().get(0).getName(), is(controlCommand.name()));
+        assertThat(dto.getCommands().get(0).getValue(), is(requestValue));
+        assertThat(dto.getCommands().get(0).getVisible(), is(nullValue()));
+        assertThat(dto.getCommands().get(0).getStatus(), is(nullValue()));
+        assertThat(dto.getCommands().get(0).getAck(), is(DEFAULT_CONTROL_ACK_VALUE));
+    }
+}
diff --git a/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/protocol/EmotivaXmlUtilsTest.java b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/protocol/EmotivaXmlUtilsTest.java
new file mode 100644 (file)
index 0000000..01e4933
--- /dev/null
@@ -0,0 +1,75 @@
+/**
+ * Copyright (c) 2010-2024 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.emotiva.internal.protocol;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import javax.xml.bind.JAXBException;
+import javax.xml.bind.UnmarshalException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.emotiva.internal.AbstractDTOTestBase;
+import org.openhab.binding.emotiva.internal.dto.EmotivaNotifyWrapper;
+
+/**
+ * Unit tests for Emotiva message marshalling and unmarshalling.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+class EmotivaXmlUtilsTest extends AbstractDTOTestBase {
+
+    public EmotivaXmlUtilsTest() throws JAXBException {
+    }
+
+    @Test
+    void testUnmarshallEmptyString() {
+        assertThrows(JAXBException.class, () -> xmlUtils.unmarshallToEmotivaDTO(""), "xml value is null or empty");
+    }
+
+    @Test
+    void testUnmarshallNotValidXML() {
+        assertThrows(UnmarshalException.class, () -> xmlUtils.unmarshallToEmotivaDTO("notXmlAtAll"));
+    }
+
+    @Test
+    void testUnmarshallInstanceObject() throws JAXBException {
+        Object object = xmlUtils.unmarshallToEmotivaDTO(emotivaNotifyV2KeepAlive);
+
+        assertThat(object, instanceOf(EmotivaNotifyWrapper.class));
+    }
+
+    @Test
+    void testUnmarshallXml() throws JAXBException {
+        Object object = xmlUtils.unmarshallToEmotivaDTO(emotivaNotifyV2KeepAlive);
+
+        assertThat(object, instanceOf(EmotivaNotifyWrapper.class));
+    }
+
+    @Test
+    void testMarshallObjectWithoutXmlElements() {
+        String commands = xmlUtils.marshallEmotivaDTO("");
+        assertThat(commands, is(""));
+    }
+
+    @Test
+    void testMarshallNoValueDTO() {
+        EmotivaNotifyWrapper dto = new EmotivaNotifyWrapper();
+        String xmlAsString = xmlUtils.marshallEmotivaDTO(dto);
+        assertThat(xmlAsString, not(containsString("<emotivaNotify>")));
+        assertThat(xmlAsString, containsString("<emotivaNotify/>"));
+    }
+}
index 33b950b9ec09cc57f1359796e950924102e0ccec..67d903323cecebe53c67ae91ac6fb18b7870b955 100644 (file)
     <module>org.openhab.binding.electroluxair</module>
     <module>org.openhab.binding.elerotransmitterstick</module>
     <module>org.openhab.binding.elroconnects</module>
+    <module>org.openhab.binding.emotiva</module>
     <module>org.openhab.binding.energenie</module>
     <module>org.openhab.binding.energidataservice</module>
     <module>org.openhab.binding.enigma2</module>