diff --git a/CODEOWNERS b/CODEOWNERS
index 7c798cf0bf1..fefa151ea81 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -310,6 +310,7 @@
/bundles/org.openhab.binding.russound/ @openhab/add-ons-maintainers
/bundles/org.openhab.binding.sagercaster/ @clinique
/bundles/org.openhab.binding.saicismart/ @tisoft @dougculnane
+/bundles/org.openhab.binding.salus/ @magx2
/bundles/org.openhab.binding.samsungtv/ @paulianttila
/bundles/org.openhab.binding.satel/ @druciak
/bundles/org.openhab.binding.semsportal/ @itb3
diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml
index 6a669f40976..839603965c4 100644
--- a/bom/openhab-addons/pom.xml
+++ b/bom/openhab-addons/pom.xml
@@ -1541,6 +1541,11 @@
org.openhab.binding.saicismart
${project.version}
+
+ org.openhab.addons.bundles
+ org.openhab.binding.salus
+ ${project.version}
+
org.openhab.addons.bundles
org.openhab.binding.samsungtv
diff --git a/bundles/org.openhab.binding.salus/NOTICE b/bundles/org.openhab.binding.salus/NOTICE
new file mode 100644
index 00000000000..7814d311f20
--- /dev/null
+++ b/bundles/org.openhab.binding.salus/NOTICE
@@ -0,0 +1,30 @@
+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
+
+== Third-party Content
+
+caffeine
+* License: Apache License 2.0
+* Project: https://github.com/ben-manes/caffeine
+* Source: https://github.com/ben-manes/caffeine
+
+error_prone_annotations
+* License: Apache License 2.0
+* Project: https://github.com/google/error-prone
+* Source: https://github.com/google/error-prone
+
+checker-qual
+* License: GNU General Public License (GPL), version 2, with the classpath exception (https://checkerframework.org/manual/#license)
+* Project: https://checkerframework.org/
+* Source: https://github.com/typetools/checker-framework
diff --git a/bundles/org.openhab.binding.salus/README.md b/bundles/org.openhab.binding.salus/README.md
new file mode 100644
index 00000000000..6f5296b1004
--- /dev/null
+++ b/bundles/org.openhab.binding.salus/README.md
@@ -0,0 +1,979 @@
+# Salus Binding
+
+The Salus Binding facilitates seamless integration between OpenHAB and [Salus Cloud](https://eu.salusconnect.io/).
+
+For years, SALUS Controls has been at the forefront of designing building automation solutions for the heating industry.
+Our commitment to innovation has resulted in modern, efficient solutions to control various heating systems. With
+extensive experience, we accurately identify user needs and introduce products that precisely meet those needs.
+
+## Supported Things
+
+- **`salus-cloud-bridge`**: This bridge connects to Salus Cloud. Multiple bridges are supported for those with multiple
+ accounts.
+- **`salus-device`**: A generic Salus device that exposes all properties (as channels) from the Cloud without any
+ modifications.
+- **`salus-it600-device`**: A temperature controller with extended capabilities.
+
+## Discovery
+
+After adding a bridge, all connected devices can be automatically discovered from Salus Cloud. The type of device is
+assumed automatically based on the `oem_model`.
+
+## Thing Configuration
+
+### `salus-cloud-bridge` Thing Configuration
+
+| Name | Type | Description | Default | Required | Advanced |
+|---------------------------|-------------------|---------------------------------------------|----------------------------|----------|----------|
+| username | text | Username/email to log in to Salus Cloud | N/A | yes | no |
+| password | text | Password to log in to Salus Cloud | N/A | yes | no |
+| url | text | URL to Salus Cloud | https://eu.salusconnect.io | no | yes |
+| refreshInterval | integer (seconds) | Refresh time in seconds | 30 | no | yes |
+| propertiesRefreshInterval | integer (seconds) | How long device properties should be cached | 5 | no | yes |
+
+### `salus-device` and `salus-it600-device` Thing Configuration
+
+| Name | Type | Description | Default | Required | Advanced |
+|------|------|--------------------------|---------|----------|----------|
+| dsn | text | ID in Salus cloud system | N/A | yes | no |
+
+## Channels
+
+### `salus-device` Channels
+
+| Channel | Type | Read/Write | Description |
+|-------------------------------|--------|------------|------------------------|
+| generic-output-channel | String | RO | Generic channel |
+| generic-input-channel | String | RW | Generic channel |
+| generic-output-bool-channel | Switch | RO | Generic bool channel |
+| generic-input-bool-channel | Switch | RW | Generic bool channel |
+| generic-output-number-channel | Number | RO | Generic number channel |
+| generic-input-number-channel | Number | RW | Generic number channel |
+| temperature-output-channel | Number | RO | Temperature channel |
+| temperature-input-channel | Number | RW | Temperature channel |
+
+#### `x100` Channels
+
+If a property from Salus Cloud ends with `x100`, in the binding, the value is divided by `100`, and the `x100` suffix is
+removed.
+
+### `salus-it600-device` Channels
+
+| Channel | Type | Read/Write | Description |
+|-----------------------------|--------------------|------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| it600-temp-channel | Number:Temperature | RO | Current temperature in the room |
+| it600-expected-temp-channel | Number:Temperature | RW | Sets the desired temperature in the room |
+| it600-work-type-channel | String | RW | Sets the work type for the device. OFF - device is turned off MANUAL - schedules are turned off, following a manual temperature set, AUTOMATIC - schedules are turned on, following schedule, TEMPORARY_MANUAL - schedules are turned on, following manual temperature until the next schedule. |
+
+## Full Example
+
+### salus-cloud-bridge
+
+```yaml
+UID: salus:salus-cloud-bridge:01f3a5bff0
+label: Salus Cloud
+thingTypeUID: salus:salus-cloud-bridge
+configuration:
+ password: qwerty123
+ propertiesRefreshInterval: 5
+ refreshInterval: 30
+ url: https://eu.salusconnect.io
+ username: joe.doe@abc.xyz
+```
+
+### salus-device
+
+```yaml
+UID: salus:salus-device:01f3a5bff0:1619a6f927
+label: Salus Binding Thing
+thingTypeUID: salus:salus-device
+configuration:
+ dsn: VR00ZN00000000
+bridgeUID: salus:salus-cloud-bridge:01f3a5bff0
+channels:
+ - id: ep_9_sAWSReg_Registration
+ channelTypeUID: salus:generic-output-number-channel
+ label: Registration
+ description: null
+ configuration: { }
+ - id: ep_9_sBasicS_ApplicationVersion_d
+ channelTypeUID: salus:generic-output-number-channel
+ label: ApplicationVersion_d
+ description: null
+ configuration: { }
+ - id: ep_9_sBasicS_HardwareVersion
+ channelTypeUID: salus:generic-output-channel
+ label: HardwareVersion
+ description: null
+ configuration: { }
+ - id: ep_9_sBasicS_ManufactureName
+ channelTypeUID: salus:generic-output-channel
+ label: ManufactureName
+ description: null
+ configuration: { }
+ - id: ep_9_sBasicS_ModelIdentifier
+ channelTypeUID: salus:generic-output-channel
+ label: ModelIdentifier
+ description: null
+ configuration: { }
+ - id: ep_9_sBasicS_PowerSource
+ channelTypeUID: salus:generic-output-number-channel
+ label: PowerSource
+ description: null
+ configuration: { }
+ - id: ep_9_sBasicS_SetFactoryDefaultReset
+ channelTypeUID: salus:generic-input-bool-channel
+ label: SetFactoryDefaultReset
+ description: null
+ configuration: { }
+ - id: ep_9_sBasicS_StackVersion_d
+ channelTypeUID: salus:generic-output-number-channel
+ label: StackVersion_d
+ description: null
+ configuration: { }
+ - id: ep_9_sGenSche_GenScheTimeStamp
+ channelTypeUID: salus:generic-output-channel
+ label: GenScheTimeStamp
+ description: null
+ configuration: { }
+ - id: ep_9_sGenSche_GenScheURL
+ channelTypeUID: salus:generic-output-channel
+ label: GenScheURL
+ description: null
+ configuration: { }
+ - id: ep_9_sGenSche_SetGenScheURL
+ channelTypeUID: salus:generic-input-channel
+ label: SetGenScheURL
+ description: null
+ configuration: { }
+ - id: ep_9_sGenSche_SetUpdateGenScheURL
+ channelTypeUID: salus:generic-input-channel
+ label: SetUpdateGenScheURL
+ description: null
+ configuration: { }
+ - id: ep_9_sGenSche_UpdateGenScheStatus
+ channelTypeUID: salus:generic-output-number-channel
+ label: UpdateGenScheStatus
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600D_DeviceIndex
+ channelTypeUID: salus:generic-output-number-channel
+ label: DeviceIndex
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600D_SetReboot_d
+ channelTypeUID: salus:generic-input-bool-channel
+ label: SetReboot_d
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600D_SetUpload_d
+ channelTypeUID: salus:generic-input-bool-channel
+ label: SetUpload_d
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600D_SyncResponseVersion_d
+ channelTypeUID: salus:generic-output-channel
+ label: SyncResponseVersion_d
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600D_UploadData_d
+ channelTypeUID: salus:generic-output-channel
+ label: UploadData_d
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600I_CommandResponse_d
+ channelTypeUID: salus:generic-output-channel
+ label: CommandResponse_d
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600I_LastMessageLQI_d
+ channelTypeUID: salus:generic-output-number-channel
+ label: LastMessageLQI_d
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600I_LastMessageRSSI_d
+ channelTypeUID: salus:generic-output-number-channel
+ label: LastMessageRSSI_d
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600I_Mode
+ channelTypeUID: salus:generic-output-number-channel
+ label: Mode
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600I_PairedThermostatShortID
+ channelTypeUID: salus:generic-output-number-channel
+ label: PairedThermostatShortID
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600I_RXError33
+ channelTypeUID: salus:generic-output-number-channel
+ label: RXError33
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600I_RelayStatus
+ channelTypeUID: salus:generic-output-bool-channel
+ label: RelayStatus
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600I_SetCommand_d
+ channelTypeUID: salus:generic-input-channel
+ label: SetCommand_d
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600I_SetReadLastMessageRSSI_d
+ channelTypeUID: salus:generic-input-number-channel
+ label: SetReadLastMessageRSSI_d
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600I_TRVError01
+ channelTypeUID: salus:generic-output-bool-channel
+ label: TRVError01
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600I_TRVError22
+ channelTypeUID: salus:generic-output-bool-channel
+ label: TRVError22
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600I_TRVError23
+ channelTypeUID: salus:generic-output-bool-channel
+ label: TRVError23
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600I_TRVError30
+ channelTypeUID: salus:generic-output-bool-channel
+ label: TRVError30
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600I_TRVError31
+ channelTypeUID: salus:generic-output-bool-channel
+ label: TRVError31
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_AllowAdjustSetpoint
+ channelTypeUID: salus:generic-output-number-channel
+ label: AllowAdjustSetpoint
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_AllowUnlockFromDevice
+ channelTypeUID: salus:generic-output-number-channel
+ label: AllowUnlockFromDevice
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_AutoCoolingSetpoint
+ channelTypeUID: salus:temperature-output-channel
+ label: AutoCoolingSetpoint
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_AutoCoolingSetpoint_a
+ channelTypeUID: salus:temperature-output-channel
+ label: AutoCoolingSetpoint_a
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_AutoHeatingSetpoint
+ channelTypeUID: salus:temperature-output-channel
+ label: AutoHeatingSetpoint
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_AutoHeatingSetpoint_a
+ channelTypeUID: salus:temperature-output-channel
+ label: AutoHeatingSetpoint_a
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_BatteryLevel
+ channelTypeUID: salus:generic-output-number-channel
+ label: BatteryLevel
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_CloudOverride
+ channelTypeUID: salus:generic-output-number-channel
+ label: CloudOverride
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_CloudySetpoint
+ channelTypeUID: salus:generic-output-number-channel
+ label: CloudySetpoint
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_CoolingControl
+ channelTypeUID: salus:generic-output-number-channel
+ label: CoolingControl
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_CoolingSetpoint
+ channelTypeUID: salus:temperature-output-channel
+ label: CoolingSetpoint
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_CoolingSetpoint_a
+ channelTypeUID: salus:temperature-output-channel
+ label: CoolingSetpoint_a
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_DaylightSaving_d
+ channelTypeUID: salus:generic-output-number-channel
+ label: DaylightSaving_d
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_DelayStart
+ channelTypeUID: salus:generic-output-number-channel
+ label: DelayStart
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_Error01
+ channelTypeUID: salus:generic-output-bool-channel
+ label: Error01
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_Error02
+ channelTypeUID: salus:generic-output-bool-channel
+ label: Error02
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_Error03
+ channelTypeUID: salus:generic-output-bool-channel
+ label: Error03
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_Error04
+ channelTypeUID: salus:generic-output-bool-channel
+ label: Error04
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_Error05
+ channelTypeUID: salus:generic-output-bool-channel
+ label: Error05
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_Error06
+ channelTypeUID: salus:generic-output-bool-channel
+ label: Error06
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_Error07
+ channelTypeUID: salus:generic-output-bool-channel
+ label: Error07
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_Error07TRVIndex
+ channelTypeUID: salus:generic-output-number-channel
+ label: Error07TRVIndex
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_Error08
+ channelTypeUID: salus:generic-output-bool-channel
+ label: Error08
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_Error09
+ channelTypeUID: salus:generic-output-bool-channel
+ label: Error09
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_Error21
+ channelTypeUID: salus:generic-output-bool-channel
+ label: Error21
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_Error22
+ channelTypeUID: salus:generic-output-bool-channel
+ label: Error22
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_Error23
+ channelTypeUID: salus:generic-output-bool-channel
+ label: Error23
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_Error24
+ channelTypeUID: salus:generic-output-bool-channel
+ label: Error24
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_Error25
+ channelTypeUID: salus:generic-output-bool-channel
+ label: Error25
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_Error30
+ channelTypeUID: salus:generic-output-bool-channel
+ label: Error30
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_Error31
+ channelTypeUID: salus:generic-output-bool-channel
+ label: Error31
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_Error32
+ channelTypeUID: salus:generic-output-bool-channel
+ label: Error32
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_FloorCoolingMax
+ channelTypeUID: salus:temperature-output-channel
+ label: FloorCoolingMax
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_FloorCoolingMin
+ channelTypeUID: salus:temperature-output-channel
+ label: FloorCoolingMin
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_FloorHeatingMax
+ channelTypeUID: salus:temperature-output-channel
+ label: FloorHeatingMax
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_FloorHeatingMin
+ channelTypeUID: salus:temperature-output-channel
+ label: FloorHeatingMin
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_FrostSetpoint
+ channelTypeUID: salus:temperature-output-channel
+ label: FrostSetpoint
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_GroupNumber
+ channelTypeUID: salus:generic-output-number-channel
+ label: GroupNumber
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_HeatingControl
+ channelTypeUID: salus:generic-output-number-channel
+ label: HeatingControl
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_HeatingSetpoint
+ channelTypeUID: salus:temperature-output-channel
+ label: HeatingSetpoint
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_HeatingSetpoint_a
+ channelTypeUID: salus:temperature-output-channel
+ label: HeatingSetpoint_a
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_HoldType
+ channelTypeUID: salus:generic-output-number-channel
+ label: HoldType
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_HoldType_a
+ channelTypeUID: salus:generic-output-number-channel
+ label: HoldType_a
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_LocalTemperature
+ channelTypeUID: salus:temperature-output-channel
+ label: LocalTemperature
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_LockKey
+ channelTypeUID: salus:generic-output-number-channel
+ label: LockKey
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_LockKey_a
+ channelTypeUID: salus:generic-output-number-channel
+ label: LockKey_a
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_MaxCoolSetpoint
+ channelTypeUID: salus:temperature-output-channel
+ label: MaxCoolSetpoint
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_MaxHeatSetpoint
+ channelTypeUID: salus:temperature-output-channel
+ label: MaxHeatSetpoint
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_MaxHeatSetpoint_a
+ channelTypeUID: salus:temperature-output-channel
+ label: MaxHeatSetpoint_a
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_MinCoolSetpoint
+ channelTypeUID: salus:temperature-output-channel
+ label: MinCoolSetpoint
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_MinCoolSetpoint_a
+ channelTypeUID: salus:temperature-output-channel
+ label: MinCoolSetpoint_a
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_MinHeatSetpoint
+ channelTypeUID: salus:temperature-output-channel
+ label: MinHeatSetpoint
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_MinTurnOffTime
+ channelTypeUID: salus:generic-output-number-channel
+ label: MinTurnOffTime
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_MoonSetpoint
+ channelTypeUID: salus:generic-output-number-channel
+ label: MoonSetpoint
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_OUTSensorProbe
+ channelTypeUID: salus:generic-output-number-channel
+ label: OUTSensorProbe
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_OUTSensorType
+ channelTypeUID: salus:generic-output-number-channel
+ label: OUTSensorType
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_PairedTRVShortID
+ channelTypeUID: salus:generic-output-channel
+ label: PairedTRVShortID
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_PairedWCNumber
+ channelTypeUID: salus:generic-output-number-channel
+ label: PairedWCNumber
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_PipeTemperature
+ channelTypeUID: salus:temperature-output-channel
+ label: PipeTemperature
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_ProgramOperationMode
+ channelTypeUID: salus:generic-output-number-channel
+ label: ProgramOperationMode
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_RunningMode
+ channelTypeUID: salus:generic-output-number-channel
+ label: RunningMode
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_RunningState
+ channelTypeUID: salus:generic-output-number-channel
+ label: RunningState
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_Schedule
+ channelTypeUID: salus:generic-output-channel
+ label: Schedule
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_ScheduleOffset_x10
+ channelTypeUID: salus:generic-output-number-channel
+ label: ScheduleOffset_x10
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_ScheduleType
+ channelTypeUID: salus:generic-output-number-channel
+ label: ScheduleType
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetAllowAdjustSetpoint
+ channelTypeUID: salus:generic-input-number-channel
+ label: SetAllowAdjustSetpoint
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetAllowUnlockFromDevice
+ channelTypeUID: salus:generic-input-number-channel
+ label: SetAllowUnlockFromDevice
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetAutoCoolingSetpoint
+ channelTypeUID: salus:temperature-input-channel
+ label: SetAutoCoolingSetpoint
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetAutoHeatingSetpoint
+ channelTypeUID: salus:temperature-input-channel
+ label: SetAutoHeatingSetpoint
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetCloudOverride
+ channelTypeUID: salus:generic-input-number-channel
+ label: SetCloudOverride
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetCoolingControl
+ channelTypeUID: salus:generic-input-number-channel
+ label: SetCoolingControl
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetCoolingSetpoint
+ channelTypeUID: salus:temperature-input-channel
+ label: SetCoolingSetpoint
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetDelayStart
+ channelTypeUID: salus:generic-input-number-channel
+ label: SetDelayStart
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetFloorCoolingMin
+ channelTypeUID: salus:temperature-input-channel
+ label: SetFloorCoolingMin
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetFloorHeatingMax
+ channelTypeUID: salus:temperature-input-channel
+ label: SetFloorHeatingMax
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetFloorHeatingMin
+ channelTypeUID: salus:temperature-input-channel
+ label: SetFloorHeatingMin
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetFrostSetpoint
+ channelTypeUID: salus:temperature-input-channel
+ label: SetFrostSetpoint
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetHeatingControl
+ channelTypeUID: salus:generic-input-number-channel
+ label: SetHeatingControl
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetHeatingSetpoint
+ channelTypeUID: salus:temperature-input-channel
+ label: SetHeatingSetpoint
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetHoldType
+ channelTypeUID: salus:generic-input-number-channel
+ label: SetHoldType
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetLockKey
+ channelTypeUID: salus:generic-input-number-channel
+ label: SetLockKey
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetMaxHeatSetpoint
+ channelTypeUID: salus:temperature-input-channel
+ label: SetMaxHeatSetpoint
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetMinCoolSetpoint
+ channelTypeUID: salus:temperature-input-channel
+ label: SetMinCoolSetpoint
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetMinTurnOffTime
+ channelTypeUID: salus:generic-input-number-channel
+ label: SetMinTurnOffTime
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetOUTSensorProbe
+ channelTypeUID: salus:generic-input-number-channel
+ label: SetOUTSensorProbe
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetOUTSensorType
+ channelTypeUID: salus:generic-input-number-channel
+ label: SetOUTSensorType
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetPairedTRVShortID
+ channelTypeUID: salus:generic-input-channel
+ label: SetPairedTRVShortID
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetScheduleOffset_x10
+ channelTypeUID: salus:generic-input-number-channel
+ label: SetScheduleOffset_x10
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetShutOffDisplay
+ channelTypeUID: salus:generic-input-number-channel
+ label: SetShutOffDisplay
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetSystemMode
+ channelTypeUID: salus:generic-input-number-channel
+ label: SetSystemMode
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetTemperatureDisplayMode
+ channelTypeUID: salus:generic-input-number-channel
+ label: SetTemperatureDisplayMode
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetTemperatureOffset
+ channelTypeUID: salus:generic-input-number-channel
+ label: SetTemperatureOffset
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetTimeFormat24Hour
+ channelTypeUID: salus:generic-input-number-channel
+ label: SetTimeFormat24Hour
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SetValveProtection
+ channelTypeUID: salus:generic-input-number-channel
+ label: SetValveProtection
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_ShutOffDisplay
+ channelTypeUID: salus:generic-output-number-channel
+ label: ShutOffDisplay
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_Status_d
+ channelTypeUID: salus:generic-output-channel
+ label: Status_d
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SunnySetpoint
+ channelTypeUID: salus:generic-output-number-channel
+ label: SunnySetpoint
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SyncResponseDST_d
+ channelTypeUID: salus:generic-output-number-channel
+ label: SyncResponseDST_d
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SyncResponseTimeOffset_d
+ channelTypeUID: salus:generic-output-number-channel
+ label: SyncResponseTimeOffset_d
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SyncResponseTimeZone_d
+ channelTypeUID: salus:generic-output-number-channel
+ label: SyncResponseTimeZone_d
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SystemMode
+ channelTypeUID: salus:generic-output-number-channel
+ label: SystemMode
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_SystemMode_a
+ channelTypeUID: salus:generic-output-number-channel
+ label: SystemMode_a
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_TemperatureDisplayMode
+ channelTypeUID: salus:generic-output-number-channel
+ label: TemperatureDisplayMode
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_TemperatureOffset
+ channelTypeUID: salus:generic-output-number-channel
+ label: TemperatureOffset
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_TimeFormat24Hour
+ channelTypeUID: salus:generic-output-number-channel
+ label: TimeFormat24Hour
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_TimeZone_d
+ channelTypeUID: salus:generic-output-number-channel
+ label: TimeZone_d
+ description: null
+ configuration: { }
+ - id: ep_9_sIT600TH_ValveProtection
+ channelTypeUID: salus:generic-output-number-channel
+ label: ValveProtection
+ description: null
+ configuration: { }
+ - id: ep_9_sIdentiS_IdentifyTime_d
+ channelTypeUID: salus:generic-output-number-channel
+ label: IdentifyTime_d
+ description: null
+ configuration: { }
+ - id: ep_9_sIdentiS_SetIndicator
+ channelTypeUID: salus:generic-input-number-channel
+ label: SetIndicator
+ description: null
+ configuration: { }
+ - id: ep_9_sIdentiS_SetReadIdentifyTime_d
+ channelTypeUID: salus:generic-input-bool-channel
+ label: SetReadIdentifyTime_d
+ description: null
+ configuration: { }
+ - id: ep_9_sOTA_OTADisableTime
+ channelTypeUID: salus:generic-output-channel
+ label: OTADisableTime
+ description: null
+ configuration: { }
+ - id: ep_9_sOTA_OTAFirmwareURL_d
+ channelTypeUID: salus:generic-output-channel
+ label: OTAFirmwareURL_d
+ description: null
+ configuration: { }
+ - id: ep_9_sOTA_OTAStatus_d
+ channelTypeUID: salus:generic-output-number-channel
+ label: OTAStatus_d
+ description: null
+ configuration: { }
+ - id: ep_9_sOTA_SetOTADisableTime
+ channelTypeUID: salus:generic-input-channel
+ label: SetOTADisableTime
+ description: null
+ configuration: { }
+ - id: ep_9_sOTA_SetOTAFirmwareURL_d
+ channelTypeUID: salus:generic-input-channel
+ label: SetOTAFirmwareURL_d
+ description: null
+ configuration: { }
+ - id: ep_9_sZDO_DeviceName
+ channelTypeUID: salus:generic-output-channel
+ label: DeviceName
+ description: null
+ configuration: { }
+ - id: ep_9_sZDO_EUID
+ channelTypeUID: salus:generic-output-channel
+ label: EUID
+ description: null
+ configuration: { }
+ - id: ep_9_sZDO_FirmwareVersion
+ channelTypeUID: salus:generic-output-channel
+ label: FirmwareVersion
+ description: null
+ configuration: { }
+ - id: ep_9_sZDO_GatewayNodeDSN
+ channelTypeUID: salus:generic-output-channel
+ label: GatewayNodeDSN
+ description: null
+ configuration: { }
+ - id: ep_9_sZDO_LeaveNetwork
+ channelTypeUID: salus:generic-output-bool-channel
+ label: LeaveNetwork
+ description: null
+ configuration: { }
+ - id: ep_9_sZDO_LeaveRequest_d
+ channelTypeUID: salus:generic-output-bool-channel
+ label: LeaveRequest_d
+ description: null
+ configuration: { }
+ - id: ep_9_sZDO_SetDeviceName
+ channelTypeUID: salus:generic-input-channel
+ label: SetDeviceName
+ description: null
+ configuration: { }
+ - id: ep_9_sZDO_SetLeaveNetwork
+ channelTypeUID: salus:generic-input-bool-channel
+ label: SetLeaveNetwork
+ description: null
+ configuration: { }
+ - id: ep_9_sZDO_SetOnlineRefresh
+ channelTypeUID: salus:generic-input-bool-channel
+ label: SetOnlineRefresh
+ description: null
+ configuration: { }
+ - id: ep_9_sZDO_SetRefresh_d
+ channelTypeUID: salus:generic-input-bool-channel
+ label: SetRefresh_d
+ description: null
+ configuration: { }
+ - id: ep_9_sZDO_SetTriggerJoin
+ channelTypeUID: salus:generic-input-bool-channel
+ label: SetTriggerJoin
+ description: null
+ configuration: { }
+ - id: ep_9_sZDO_ShortID_d
+ channelTypeUID: salus:generic-output-number-channel
+ label: ShortID_d
+ description: null
+ configuration: { }
+ - id: ep_9_sZDOInfo_AppData_c
+ channelTypeUID: salus:generic-output-channel
+ label: AppData_c
+ description: null
+ configuration: { }
+ - id: ep_9_sZDOInfo_ConfigureReportResponse
+ channelTypeUID: salus:generic-output-channel
+ label: ConfigureReportResponse
+ description: null
+ configuration: { }
+ - id: ep_9_sZDOInfo_JoinConfigEnd
+ channelTypeUID: salus:generic-output-number-channel
+ label: JoinConfigEnd
+ description: null
+ configuration: { }
+ - id: ep_9_sZDOInfo_OnlineStatus_i
+ channelTypeUID: salus:generic-output-bool-channel
+ label: OnlineStatus_i
+ description: null
+ configuration: { }
+ - id: ep_9_sZDOInfo_ServerData_c
+ channelTypeUID: salus:generic-output-channel
+ label: ServerData_c
+ description: null
+ configuration: { }
+ - id: ep_9_sZDOInfo_SetAppData_c
+ channelTypeUID: salus:generic-input-channel
+ label: SetAppData_c
+ description: null
+ configuration: { }
+ - id: ep_9_sZDOInfo_SetConfigureReport
+ channelTypeUID: salus:generic-input-channel
+ label: SetConfigureReport
+ description: null
+ configuration: { }
+ - id: ep_9_sZDOInfo_zigbeeOTAcontrol_i
+ channelTypeUID: salus:generic-input-number-channel
+ label: zigbeeOTAcontrol_i
+ description: null
+ configuration: { }
+ - id: ep_9_sZDOInfo_zigbeeOTAfile_i
+ channelTypeUID: salus:generic-input-channel
+ label: zigbeeOTAfile_i
+ description: null
+ configuration: { }
+ - id: ep_9_sZDOInfo_zigbeeOTArespond_i
+ channelTypeUID: salus:generic-input-number-channel
+ label: zigbeeOTArespond_i
+ description: null
+ configuration: { }
+```
+
+### salus-it600-device
+
+```yaml
+UID: salus:salus-it600-device:01f3a5bff0:VR00ZN000247491
+label: Office
+thingTypeUID: salus:salus-it600-device
+configuration:
+ dsn: VR00ZN00000000
+ propertyCache: 5
+bridgeUID: salus:salus-cloud-bridge:01f3a5bff0
+channels:
+ - id: temperature
+ channelTypeUID: salus:it600-temp-channel
+ label: Temperature
+ description: Current temperature in room
+ configuration: { }
+ - id: expected-temperature
+ channelTypeUID: salus:it600-expected-temp-channel
+ label: Expected Temperature
+ description: Sets the desired temperature in room
+ configuration: { }
+ - id: work-type
+ channelTypeUID: salus:it600-work-type-channel
+ label: Work Type
+ description: Sets the work type for the device. OFF - device is turned off
+ MANUAL - schedules are turned off, following a manual temperature set,
+ AUTOMATIC - schedules are turned on, following schedule, TEMPORARY_MANUAL
+ - schedules are turned on, following manual temperature until next
+ schedule.
+ configuration: { }
+```
+
+## Developer's Note
+
+The Salus API poses challenges, and all coding efforts are a result of reverse engineering. Attempts were made to
+contact the Salus Team, but the closed-source nature of the API limited assistance. Consequently, there may be errors in
+implementation or channel visibility issues. If you encounter any issues, please report them, and efforts will be made
+to address and resolve them.
+
diff --git a/bundles/org.openhab.binding.salus/pom.xml b/bundles/org.openhab.binding.salus/pom.xml
new file mode 100644
index 00000000000..e03c5fcde20
--- /dev/null
+++ b/bundles/org.openhab.binding.salus/pom.xml
@@ -0,0 +1,57 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.addons.bundles
+ org.openhab.addons.reactor.bundles
+ 4.2.0-SNAPSHOT
+
+
+ org.openhab.binding.salus
+
+ openHAB Add-ons :: Bundles :: Salus Binding
+
+
+
+
+ com.github.ben-manes.caffeine
+ caffeine
+ 3.1.8
+
+
+ com.google.errorprone
+ error_prone_annotations
+ 2.21.1
+ compile
+
+
+ org.checkerframework
+ checker-qual
+ 3.37.0
+ compile
+
+
+
+
+ ch.qos.logback
+ logback-classic
+ 1.2.3
+ test
+
+
+ org.assertj
+ assertj-core
+ 3.25.3
+ test
+
+
+ org.mockito
+ mockito-core
+ 5.11.0
+ test
+
+
+
diff --git a/bundles/org.openhab.binding.salus/src/main/feature/feature.xml b/bundles/org.openhab.binding.salus/src/main/feature/feature.xml
new file mode 100644
index 00000000000..316152755d3
--- /dev/null
+++ b/bundles/org.openhab.binding.salus/src/main/feature/feature.xml
@@ -0,0 +1,9 @@
+
+
+ mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features
+
+
+ openhab-runtime-base
+ mvn:org.openhab.addons.bundles/org.openhab.binding.salus/${project.version}
+
+
diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/SalusBindingConstants.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/SalusBindingConstants.java
new file mode 100644
index 00000000000..1398e070c2b
--- /dev/null
+++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/SalusBindingConstants.java
@@ -0,0 +1,94 @@
+/**
+ * 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.salus.internal;
+
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.thing.ThingTypeUID;
+
+/**
+ * @author Martin Grześlowski - Initial contribution
+ */
+/**
+ * The {@link SalusBindingConstants} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author Martin Grzeslowski - Initial contribution
+ */
+@NonNullByDefault
+public class SalusBindingConstants {
+
+ public static final String BINDING_ID = "salus";
+
+ // List of all Thing Type UIDs
+
+ public static final ThingTypeUID SALUS_DEVICE_TYPE = new ThingTypeUID(BINDING_ID, "salus-device");
+ public static final ThingTypeUID SALUS_IT600_DEVICE_TYPE = new ThingTypeUID(BINDING_ID, "salus-it600-device");
+ public static final ThingTypeUID SALUS_SERVER_TYPE = new ThingTypeUID(BINDING_ID, "salus-cloud-bridge");
+
+ public static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(SALUS_DEVICE_TYPE,
+ SALUS_IT600_DEVICE_TYPE, SALUS_SERVER_TYPE);
+
+ public static class SalusCloud {
+ public static final String DEFAULT_URL = "https://eu.salusconnect.io";
+ }
+
+ public static class SalusDevice {
+ public static final String DSN = "dsn";
+ public static final String OEM_MODEL = "oem_model";
+ public static final String IT_600 = "it600";
+ }
+
+ public static class It600Device {
+ public static class HoldType {
+ public static final int AUTO = 0;
+ public static final int MANUAL = 2;
+ public static final int TEMPORARY_MANUAL = 1;
+ public static final int OFF = 7;
+ }
+ }
+
+ public static class Channels {
+ public static class It600 {
+ public static final String TEMPERATURE = "temperature";
+ public static final String EXPECTED_TEMPERATURE = "expected-temperature";
+ public static final String WORK_TYPE = "work-type";
+ }
+
+ public static final String GENERIC_OUTPUT_CHANNEL = "generic-output-channel";
+ public static final String GENERIC_INPUT_CHANNEL = "generic-input-channel";
+ public static final String GENERIC_OUTPUT_BOOL_CHANNEL = "generic-output-bool-channel";
+ public static final String GENERIC_INPUT_BOOL_CHANNEL = "generic-input-bool-channel";
+ public static final String GENERIC_OUTPUT_NUMBER_CHANNEL = "generic-output-number-channel";
+ public static final String GENERIC_INPUT_NUMBER_CHANNEL = "generic-input-number-channel";
+ public static final String TEMPERATURE_OUTPUT_NUMBER_CHANNEL = "temperature-output-channel";
+ public static final String TEMPERATURE_INPUT_NUMBER_CHANNEL = "temperature-input-channel";
+ public static final Set TEMPERATURE_CHANNELS = Set.of("ep_9:sIT600TH:AutoCoolingSetpoint_x100",
+ "ep_9:sIT600TH:AutoCoolingSetpoint_x100_a", "ep_9:sIT600TH:AutoHeatingSetpoint_x100",
+ "ep_9:sIT600TH:AutoHeatingSetpoint_x100_a", "ep_9:sIT600TH:CoolingSetpoint_x100",
+ "ep_9:sIT600TH:CoolingSetpoint_x100_a", "ep_9:sIT600TH:FloorCoolingMax_x100",
+ "ep_9:sIT600TH:FloorCoolingMin_x100", "ep_9:sIT600TH:FloorHeatingMax_x100",
+ "ep_9:sIT600TH:FloorHeatingMin_x100", "ep_9:sIT600TH:FrostSetpoint_x100",
+ "ep_9:sIT600TH:HeatingSetpoint_x100", "ep_9:sIT600TH:HeatingSetpoint_x100_a",
+ "ep_9:sIT600TH:LocalTemperature_x100", "ep_9:sIT600TH:MaxCoolSetpoint_x100",
+ "ep_9:sIT600TH:MaxHeatSetpoint_x100", "ep_9:sIT600TH:MaxHeatSetpoint_x100_a",
+ "ep_9:sIT600TH:MinCoolSetpoint_x100", "ep_9:sIT600TH:MinCoolSetpoint_x100_a",
+ "ep_9:sIT600TH:MinHeatSetpoint_x100", "ep_9:sIT600TH:PipeTemperature_x100",
+ "ep_9:sIT600TH:SetAutoCoolingSetpoint_x100", "ep_9:sIT600TH:SetAutoHeatingSetpoint_x100",
+ "ep_9:sIT600TH:SetCoolingSetpoint_x100", "ep_9:sIT600TH:SetFloorCoolingMin_x100",
+ "ep_9:sIT600TH:SetFloorHeatingMax_x100", "ep_9:sIT600TH:SetFloorHeatingMin_x100",
+ "ep_9:sIT600TH:SetFrostSetpoint_x100", "ep_9:sIT600TH:SetHeatingSetpoint_x100",
+ "ep_9:sIT600TH:SetMaxHeatSetpoint_x100", "ep_9:sIT600TH:SetMinCoolSetpoint_x100");
+ }
+}
diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/SalusHandlerFactory.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/SalusHandlerFactory.java
new file mode 100644
index 00000000000..93bc189ce37
--- /dev/null
+++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/SalusHandlerFactory.java
@@ -0,0 +1,102 @@
+/**
+ * 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.salus.internal;
+
+import static org.openhab.binding.salus.internal.SalusBindingConstants.SALUS_DEVICE_TYPE;
+import static org.openhab.binding.salus.internal.SalusBindingConstants.SALUS_IT600_DEVICE_TYPE;
+import static org.openhab.binding.salus.internal.SalusBindingConstants.SALUS_SERVER_TYPE;
+import static org.openhab.binding.salus.internal.SalusBindingConstants.SUPPORTED_THING_TYPES_UIDS;
+
+import java.util.Hashtable;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.salus.internal.discovery.CloudDiscovery;
+import org.openhab.binding.salus.internal.handler.CloudBridgeHandler;
+import org.openhab.binding.salus.internal.handler.DeviceHandler;
+import org.openhab.binding.salus.internal.handler.It600Handler;
+import org.openhab.core.config.discovery.DiscoveryService;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.openhab.core.thing.Bridge;
+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 SalusHandlerFactory} is responsible for creating things and thing
+ * handlers.
+ *
+ * @author Martin Grzeslowski - Initial contribution
+ */
+@NonNullByDefault
+@Component(configurationPid = "binding.salus", service = ThingHandlerFactory.class)
+public class SalusHandlerFactory extends BaseThingHandlerFactory {
+
+ private final Logger logger = LoggerFactory.getLogger(SalusHandlerFactory.class);
+ protected final @NonNullByDefault({}) HttpClientFactory httpClientFactory;
+
+ @Activate
+ public SalusHandlerFactory(@Reference HttpClientFactory httpClientFactory) {
+ this.httpClientFactory = httpClientFactory;
+ }
+
+ @Override
+ public boolean supportsThingType(ThingTypeUID thingTypeUID) {
+ return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
+ }
+
+ @Override
+ protected @Nullable ThingHandler createHandler(Thing thing) {
+ var thingTypeUID = thing.getThingTypeUID();
+
+ if (SALUS_DEVICE_TYPE.equals(thingTypeUID)) {
+ return newSalusDevice(thing);
+ }
+ if (SALUS_IT600_DEVICE_TYPE.equals(thingTypeUID)) {
+ return newIt600(thing);
+ }
+ if (SALUS_SERVER_TYPE.equals(thingTypeUID)) {
+ return newSalusCloudBridge(thing);
+ }
+
+ return null;
+ }
+
+ private ThingHandler newSalusDevice(Thing thing) {
+ logger.debug("New Salus Device {}", thing.getUID().getId());
+ return new DeviceHandler(thing);
+ }
+
+ private ThingHandler newIt600(Thing thing) {
+ logger.debug("Registering IT600");
+ return new It600Handler(thing);
+ }
+
+ private ThingHandler newSalusCloudBridge(Thing thing) {
+ var handler = new CloudBridgeHandler((Bridge) thing, httpClientFactory);
+ var cloudDiscovery = new CloudDiscovery(handler, handler, handler.getThing().getUID());
+ registerThingDiscovery(cloudDiscovery);
+ return handler;
+ }
+
+ private synchronized void registerThingDiscovery(DiscoveryService discoveryService) {
+ bundleContext.registerService(DiscoveryService.class.getName(), discoveryService, new Hashtable<>());
+ }
+}
diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/discovery/CloudDiscovery.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/discovery/CloudDiscovery.java
new file mode 100644
index 00000000000..824c650bbc8
--- /dev/null
+++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/discovery/CloudDiscovery.java
@@ -0,0 +1,99 @@
+/**
+ * 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.salus.internal.discovery;
+
+import static org.openhab.binding.salus.internal.SalusBindingConstants.SALUS_DEVICE_TYPE;
+import static org.openhab.binding.salus.internal.SalusBindingConstants.SALUS_IT600_DEVICE_TYPE;
+import static org.openhab.binding.salus.internal.SalusBindingConstants.SUPPORTED_THING_TYPES_UIDS;
+import static org.openhab.binding.salus.internal.SalusBindingConstants.SalusDevice.DSN;
+import static org.openhab.binding.salus.internal.SalusBindingConstants.SalusDevice.IT_600;
+import static org.openhab.binding.salus.internal.SalusBindingConstants.SalusDevice.OEM_MODEL;
+
+import java.util.Locale;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.salus.internal.handler.CloudApi;
+import org.openhab.binding.salus.internal.handler.CloudBridgeHandler;
+import org.openhab.binding.salus.internal.rest.Device;
+import org.openhab.binding.salus.internal.rest.SalusApiException;
+import org.openhab.core.config.discovery.AbstractDiscoveryService;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * @author Martin Grześlowski - Initial contribution
+ */
+@NonNullByDefault
+public class CloudDiscovery extends AbstractDiscoveryService {
+ private final Logger logger = LoggerFactory.getLogger(CloudDiscovery.class);
+ private final CloudApi cloudApi;
+ private final ThingUID bridgeUid;
+
+ public CloudDiscovery(CloudBridgeHandler bridgeHandler, CloudApi cloudApi, ThingUID bridgeUid)
+ throws IllegalArgumentException {
+ super(SUPPORTED_THING_TYPES_UIDS, 10, true);
+ this.cloudApi = cloudApi;
+ this.bridgeUid = bridgeUid;
+ }
+
+ @Override
+ protected void startScan() {
+ try {
+ var devices = cloudApi.findDevices();
+ logger.debug("Found {} devices while scanning", devices.size());
+ devices.stream().filter(Device::isConnected).forEach(this::addThing);
+ } catch (SalusApiException e) {
+ logger.warn("Error while scanning", e);
+ stopScan();
+ }
+ }
+
+ private void addThing(Device device) {
+ logger.debug("Adding device \"{}\" ({}) to found things", device.name(), device.dsn());
+ var thingUID = new ThingUID(findDeviceType(device), bridgeUid, device.dsn());
+ var discoveryResult = createDiscoveryResult(thingUID, buildThingLabel(device), buildThingProperties(device));
+ thingDiscovered(discoveryResult);
+ }
+
+ private static ThingTypeUID findDeviceType(Device device) {
+ var props = device.properties();
+ if (props.containsKey(OEM_MODEL)) {
+ var model = props.get(OEM_MODEL);
+ if (model != null) {
+ if (model.toString().toLowerCase(Locale.ENGLISH).contains(IT_600)) {
+ return SALUS_IT600_DEVICE_TYPE;
+ }
+ }
+ }
+ return SALUS_DEVICE_TYPE;
+ }
+
+ private DiscoveryResult createDiscoveryResult(ThingUID thingUID, String label, Map properties) {
+ return DiscoveryResultBuilder.create(thingUID).withBridge(bridgeUid).withProperties(properties).withLabel(label)
+ .withRepresentationProperty(DSN).build();
+ }
+
+ private String buildThingLabel(Device device) {
+ var name = device.name();
+ return (!"".equals(name)) ? name : device.dsn();
+ }
+
+ private Map buildThingProperties(Device device) {
+ return Map.of(DSN, device.dsn());
+ }
+}
diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/CloudApi.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/CloudApi.java
new file mode 100644
index 00000000000..8ef4d784500
--- /dev/null
+++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/CloudApi.java
@@ -0,0 +1,60 @@
+/**
+ * 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.salus.internal.handler;
+
+import java.util.Optional;
+import java.util.SortedSet;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.salus.internal.rest.Device;
+import org.openhab.binding.salus.internal.rest.DeviceProperty;
+import org.openhab.binding.salus.internal.rest.SalusApiException;
+
+/**
+ * @author Martin Grześlowski - Initial contribution
+ */
+@NonNullByDefault
+public interface CloudApi {
+ /**
+ * Finds all devices from cloud
+ *
+ * @return all devices from cloud
+ */
+ SortedSet findDevices() throws SalusApiException;
+
+ /**
+ * Find a device by DSN
+ *
+ * @param dsn of the device to find
+ * @return a device with given DSN (or empty if no found)
+ */
+ Optional findDevice(String dsn) throws SalusApiException;
+
+ /**
+ * Sets value for a property
+ *
+ * @param dsn of the device
+ * @param propertyName property name
+ * @param value value to set
+ * @return if value was properly set
+ */
+ boolean setValueForProperty(String dsn, String propertyName, Object value) throws SalusApiException;
+
+ /**
+ * Finds all properties for a device
+ *
+ * @param dsn of the device
+ * @return all properties of the device
+ */
+ SortedSet> findPropertiesForDevice(String dsn) throws SalusApiException;
+}
diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/CloudBridgeConfig.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/CloudBridgeConfig.java
new file mode 100644
index 00000000000..2c6f74d5b17
--- /dev/null
+++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/CloudBridgeConfig.java
@@ -0,0 +1,104 @@
+/**
+ * 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.salus.internal.handler;
+
+import static org.openhab.binding.salus.internal.SalusBindingConstants.SalusCloud.DEFAULT_URL;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Martin Grześlowski - Initial contribution
+ */
+@NonNullByDefault
+public class CloudBridgeConfig {
+ private String username = "";
+ private String password = "";
+ private String url = "";
+ private long refreshInterval = 30;
+ private long propertiesRefreshInterval = 5;
+ private int maxHttpRetries = 3;
+
+ public CloudBridgeConfig() {
+ }
+
+ public CloudBridgeConfig(String username, String password, String url, long refreshInterval,
+ long propertiesRefreshInterval) {
+ this.username = username;
+ this.password = password;
+ this.url = url;
+ this.refreshInterval = refreshInterval;
+ this.propertiesRefreshInterval = propertiesRefreshInterval;
+ }
+
+ public String getUsername() {
+ return username;
+ }
+
+ public void setUsername(String username) {
+ this.username = username;
+ }
+
+ public String getPassword() {
+ return password;
+ }
+
+ public void setPassword(String password) {
+ this.password = password;
+ }
+
+ public String getUrl() {
+ if (url.isBlank()) {
+ return DEFAULT_URL;
+ }
+ return url;
+ }
+
+ public void setUrl(String url) {
+ this.url = url;
+ }
+
+ public long getRefreshInterval() {
+ return refreshInterval;
+ }
+
+ public void setRefreshInterval(long refreshInterval) {
+ this.refreshInterval = refreshInterval;
+ }
+
+ public long getPropertiesRefreshInterval() {
+ return propertiesRefreshInterval;
+ }
+
+ public void setPropertiesRefreshInterval(long propertiesRefreshInterval) {
+ this.propertiesRefreshInterval = propertiesRefreshInterval;
+ }
+
+ public int getMaxHttpRetries() {
+ return maxHttpRetries;
+ }
+
+ public void setMaxHttpRetries(int maxHttpRetries) {
+ this.maxHttpRetries = maxHttpRetries;
+ }
+
+ public boolean isValid() {
+ return !username.isBlank() && !password.isBlank();
+ }
+
+ @Override
+ public String toString() {
+ return "CloudBridgeConfig{" + "username='" + username + '\'' + ", password=''" + ", url='" + url + '\''
+ + ", refreshInterval=" + refreshInterval + ", propertiesRefreshInterval=" + propertiesRefreshInterval
+ + '}';
+ }
+}
diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/CloudBridgeHandler.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/CloudBridgeHandler.java
new file mode 100644
index 00000000000..98ee6dbaf53
--- /dev/null
+++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/CloudBridgeHandler.java
@@ -0,0 +1,223 @@
+/**
+ * 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.salus.internal.handler;
+
+import static java.util.Objects.requireNonNull;
+import static java.util.concurrent.TimeUnit.*;
+import static org.openhab.core.thing.ThingStatus.OFFLINE;
+import static org.openhab.core.thing.ThingStatus.ONLINE;
+import static org.openhab.core.thing.ThingStatusDetail.*;
+import static org.openhab.core.types.RefreshType.REFRESH;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Optional;
+import java.util.SortedSet;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.salus.internal.SalusBindingConstants;
+import org.openhab.binding.salus.internal.rest.Device;
+import org.openhab.binding.salus.internal.rest.DeviceProperty;
+import org.openhab.binding.salus.internal.rest.GsonMapper;
+import org.openhab.binding.salus.internal.rest.HttpClient;
+import org.openhab.binding.salus.internal.rest.RestClient;
+import org.openhab.binding.salus.internal.rest.RetryHttpClient;
+import org.openhab.binding.salus.internal.rest.SalusApi;
+import org.openhab.binding.salus.internal.rest.SalusApiException;
+import org.openhab.core.common.ThreadPoolManager;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.binding.BaseBridgeHandler;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.types.Command;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.LoadingCache;
+
+/**
+ * @author Martin Grześlowski - Initial contribution
+ */
+@NonNullByDefault
+public final class CloudBridgeHandler extends BaseBridgeHandler implements CloudApi {
+ private Logger logger = LoggerFactory.getLogger(CloudBridgeHandler.class.getName());
+ private final HttpClientFactory httpClientFactory;
+ @NonNullByDefault({})
+ private LoadingCache>> devicePropertiesCache;
+ @Nullable
+ private SalusApi salusApi;
+ @Nullable
+ private ScheduledFuture> scheduledFuture;
+
+ public CloudBridgeHandler(Bridge bridge, HttpClientFactory httpClientFactory) {
+ super(bridge);
+ this.httpClientFactory = httpClientFactory;
+ }
+
+ @Override
+ public void initialize() {
+ CloudBridgeConfig config = this.getConfigAs(CloudBridgeConfig.class);
+ if (!config.isValid()) {
+ updateStatus(OFFLINE, CONFIGURATION_ERROR, "@text/cloud-bridge-handler.initialize.username-pass-not-valid");
+ return;
+ }
+ RestClient httpClient = new HttpClient(httpClientFactory.getCommonHttpClient());
+ if (config.getMaxHttpRetries() > 0) {
+ httpClient = new RetryHttpClient(httpClient, config.getMaxHttpRetries());
+ }
+ @Nullable
+ SalusApi localSalusApi = salusApi = new SalusApi(config.getUsername(), config.getPassword().toCharArray(),
+ config.getUrl(), httpClient, GsonMapper.INSTANCE);
+ logger = LoggerFactory
+ .getLogger(CloudBridgeHandler.class.getName() + "[" + config.getUsername().replace(".", "_") + "]");
+
+ ScheduledExecutorService scheduledPool = ThreadPoolManager.getScheduledPool(SalusBindingConstants.BINDING_ID);
+ scheduledPool.schedule(() -> tryConnectToCloud(localSalusApi), 1, MICROSECONDS);
+
+ this.devicePropertiesCache = Caffeine.newBuilder().maximumSize(10_000)
+ .expireAfterWrite(Duration.ofSeconds(config.getPropertiesRefreshInterval()))
+ .refreshAfterWrite(Duration.ofSeconds(config.getPropertiesRefreshInterval()))
+ .build(this::findPropertiesForDevice);
+ this.scheduledFuture = scheduledPool.scheduleWithFixedDelay(this::refreshCloudDevices,
+ config.getRefreshInterval() * 2, config.getRefreshInterval(), SECONDS);
+
+ // Do NOT set state to online to prevent it to flip from online to offline
+ // check *tryConnectToCloud(SalusApi)*
+ }
+
+ private void tryConnectToCloud(SalusApi localSalusApi) {
+ try {
+ localSalusApi.findDevices();
+ // there is a connection with the cloud
+ updateStatus(ONLINE);
+ } catch (SalusApiException ex) {
+ updateStatus(OFFLINE, COMMUNICATION_ERROR,
+ "@text/cloud-bridge-handler.initialize.cannot-connect-to-cloud [\"" + ex.getMessage() + "\"]");
+ }
+ }
+
+ private void refreshCloudDevices() {
+ logger.debug("Refreshing devices from CloudBridgeHandler");
+ if (!(thing instanceof Bridge bridge)) {
+ logger.debug("No bridge, refresh cancelled");
+ return;
+ }
+ List things = bridge.getThings();
+ for (Thing thing : things) {
+ if (!thing.isEnabled()) {
+ logger.debug("Thing {} is disabled, refresh cancelled", thing.getUID());
+ continue;
+ }
+
+ @Nullable
+ ThingHandler handler = thing.getHandler();
+ if (handler == null) {
+ logger.debug("No handler for thing {} refresh cancelled", thing.getUID());
+ continue;
+ }
+ thing.getChannels().forEach(channel -> handler.handleCommand(channel.getUID(), REFRESH));
+ }
+
+ var local = salusApi;
+ if (local != null) {
+ tryConnectToCloud(local);
+ }
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ // no commands in this bridge
+ logger.debug("Bridge does not support any commands to any channels. channelUID={}, command={}", channelUID,
+ command);
+ }
+
+ @Override
+ public void dispose() {
+ ScheduledFuture> localScheduledFuture = scheduledFuture;
+ if (localScheduledFuture != null) {
+ localScheduledFuture.cancel(true);
+ scheduledFuture = null;
+ }
+ super.dispose();
+ }
+
+ @Override
+ public SortedSet> findPropertiesForDevice(String dsn) throws SalusApiException {
+ logger.debug("Finding properties for device {} using salusClient", dsn);
+ return requireNonNull(salusApi).findDeviceProperties(dsn);
+ }
+
+ @Override
+ public boolean setValueForProperty(String dsn, String propertyName, Object value) throws SalusApiException {
+ try {
+ @Nullable
+ SalusApi api = requireNonNull(salusApi);
+ logger.debug("Setting property {} on device {} to value {} using salusClient", propertyName, dsn, value);
+ Object setValue = api.setValueForProperty(dsn, propertyName, value);
+ if ((!(setValue instanceof Boolean) && !(setValue instanceof String) && !(setValue instanceof Number))) {
+ logger.warn(
+ "Cannot set value {} ({}) for property {} on device {} because it is not a Boolean, String, Long or Integer",
+ setValue, setValue.getClass().getSimpleName(), propertyName, dsn);
+ return false;
+ }
+ var properties = devicePropertiesCache.get(dsn);
+ Optional> property = requireNonNull(properties).stream()
+ .filter(prop -> prop.getName().equals(propertyName)).findFirst();
+ if (property.isEmpty()) {
+ String simpleName = setValue.getClass().getSimpleName();
+ logger.warn(
+ "Cannot set value {} ({}) for property {} on device {} because it is not found in the cache. Invalidating cache",
+ setValue, simpleName, propertyName, dsn);
+ devicePropertiesCache.invalidate(dsn);
+ return false;
+ }
+ DeviceProperty> prop = property.get();
+ if (setValue instanceof Boolean b && prop instanceof DeviceProperty.BooleanDeviceProperty boolProp) {
+ boolProp.setValue(b);
+ return true;
+ }
+ if (setValue instanceof String s && prop instanceof DeviceProperty.StringDeviceProperty stringProp) {
+ stringProp.setValue(s);
+ return true;
+ }
+ if (setValue instanceof Number l && prop instanceof DeviceProperty.LongDeviceProperty longProp) {
+ longProp.setValue(l.longValue());
+ return true;
+ }
+
+ logger.warn(
+ "Cannot set value {} ({}) for property {} ({}) on device {} because value class does not match property class",
+ setValue, setValue.getClass().getSimpleName(), propertyName, prop.getClass().getSimpleName(), dsn);
+ return false;
+ } catch (SalusApiException ex) {
+ devicePropertiesCache.invalidateAll();
+ throw ex;
+ }
+ }
+
+ @Override
+ public SortedSet findDevices() throws SalusApiException {
+ return requireNonNull(this.salusApi).findDevices();
+ }
+
+ @Override
+ public Optional findDevice(String dsn) throws SalusApiException {
+ return findDevices().stream().filter(device -> device.dsn().equals(dsn)).findFirst();
+ }
+}
diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/DeviceHandler.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/DeviceHandler.java
new file mode 100644
index 00000000000..d54ebcfb873
--- /dev/null
+++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/DeviceHandler.java
@@ -0,0 +1,335 @@
+/**
+ * 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.salus.internal.handler;
+
+import static java.math.RoundingMode.HALF_EVEN;
+import static java.util.Objects.requireNonNull;
+import static org.openhab.binding.salus.internal.SalusBindingConstants.BINDING_ID;
+import static org.openhab.binding.salus.internal.SalusBindingConstants.SalusDevice.DSN;
+import static org.openhab.core.thing.ThingStatus.OFFLINE;
+import static org.openhab.core.thing.ThingStatus.ONLINE;
+import static org.openhab.core.thing.ThingStatusDetail.*;
+import static org.openhab.core.types.RefreshType.REFRESH;
+
+import java.math.BigDecimal;
+import java.math.MathContext;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.SortedSet;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.salus.internal.SalusBindingConstants;
+import org.openhab.binding.salus.internal.rest.DeviceProperty;
+import org.openhab.binding.salus.internal.rest.SalusApiException;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.OpenClosedType;
+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.thing.Channel;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.thing.binding.builder.ChannelBuilder;
+import org.openhab.core.thing.type.ChannelTypeUID;
+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;
+
+/**
+ * @author Martin Grześlowski - Initial contribution
+ */
+@NonNullByDefault
+public class DeviceHandler extends BaseThingHandler {
+ private static final BigDecimal ONE_HUNDRED = new BigDecimal(100);
+ private final Logger logger;
+ @NonNullByDefault({})
+ private String dsn;
+ @NonNullByDefault({})
+ private CloudApi cloudApi;
+ private final Map channelUidMap = new HashMap<>();
+ private final Map channelX100UidMap = new HashMap<>();
+
+ public DeviceHandler(Thing thing) {
+ super(thing);
+ logger = LoggerFactory.getLogger(DeviceHandler.class.getName() + "[" + thing.getUID().getId() + "]");
+ }
+
+ @Override
+ public void initialize() {
+ var bridge = getBridge();
+ if (bridge == null) {
+ updateStatus(OFFLINE, BRIDGE_UNINITIALIZED, "@text/device-handler.initialize.errors.no-bridge");
+ return;
+ }
+ var bridgeHandler = bridge.getHandler();
+ if (!(bridgeHandler instanceof CloudBridgeHandler cloudHandler)) {
+ updateStatus(OFFLINE, BRIDGE_UNINITIALIZED, "@text/device-handler.initialize.errors.bridge-wrong-type");
+ return;
+ }
+ this.cloudApi = cloudHandler;
+
+ dsn = (String) getConfig().get(DSN);
+
+ if ("".equals(dsn)) {
+ updateStatus(OFFLINE, CONFIGURATION_ERROR,
+ "@text/device-handler.initialize.errors.no-dsn [\"" + DSN + "\"]");
+ return;
+ }
+
+ try {
+ var device = this.cloudApi.findDevice(dsn);
+ if (device.isEmpty()) {
+ updateStatus(OFFLINE, COMMUNICATION_ERROR,
+ "@text/device-handler.initialize.errors.dsn-not-found [\"" + dsn + "\"]");
+ return;
+ }
+ if (!device.get().isConnected()) {
+ updateStatus(OFFLINE, COMMUNICATION_ERROR,
+ "@text/device-handler.initialize.errors.dsn-not-connected [\"" + dsn + "\"]");
+ return;
+ }
+ var channels = findDeviceProperties().stream().map(this::buildChannel).toList();
+ if (channels.isEmpty()) {
+ updateStatus(OFFLINE, CONFIGURATION_ERROR,
+ "@text/device-handler.initialize.errors.no-channels [\"" + dsn + "\"]");
+ return;
+ }
+ updateChannels(channels);
+ } catch (Exception e) {
+ updateStatus(OFFLINE, COMMUNICATION_ERROR, "@text/device-handler.initialize.errors.general-error");
+ return;
+ }
+
+ // done
+ updateStatus(ONLINE);
+ }
+
+ private Channel buildChannel(DeviceProperty> property) {
+ String channelId;
+ String acceptedItemType;
+ if (property instanceof DeviceProperty.BooleanDeviceProperty) {
+ channelId = inOrOut(property.getDirection(), SalusBindingConstants.Channels.GENERIC_INPUT_BOOL_CHANNEL,
+ SalusBindingConstants.Channels.GENERIC_OUTPUT_BOOL_CHANNEL);
+ acceptedItemType = "Switch";
+ } else if (property instanceof DeviceProperty.LongDeviceProperty longDeviceProperty) {
+ if (SalusBindingConstants.Channels.TEMPERATURE_CHANNELS.contains(longDeviceProperty.getName())) {
+ // a temp channel
+ channelId = inOrOut(property.getDirection(),
+ SalusBindingConstants.Channels.TEMPERATURE_INPUT_NUMBER_CHANNEL,
+ SalusBindingConstants.Channels.TEMPERATURE_OUTPUT_NUMBER_CHANNEL);
+ } else {
+ channelId = inOrOut(property.getDirection(),
+ SalusBindingConstants.Channels.GENERIC_INPUT_NUMBER_CHANNEL,
+ SalusBindingConstants.Channels.GENERIC_OUTPUT_NUMBER_CHANNEL);
+ }
+ acceptedItemType = "Number";
+ } else if (property instanceof DeviceProperty.StringDeviceProperty) {
+ channelId = inOrOut(property.getDirection(), SalusBindingConstants.Channels.GENERIC_INPUT_CHANNEL,
+ SalusBindingConstants.Channels.GENERIC_OUTPUT_CHANNEL);
+ acceptedItemType = "String";
+ } else {
+ throw new UnsupportedOperationException(
+ "Property class " + property.getClass().getSimpleName() + " is not supported!");
+ }
+
+ var channelUid = new ChannelUID(thing.getUID(), buildChannelUid(property.getName()));
+ var channelTypeUID = new ChannelTypeUID(BINDING_ID, channelId);
+ return ChannelBuilder.create(channelUid, acceptedItemType).withType(channelTypeUID)
+ .withLabel(buildChannelDisplayName(property.getDisplayName())).build();
+ }
+
+ private String buildChannelUid(final String name) {
+ String uid = name;
+ var map = channelUidMap;
+ if (name.contains("x100")) {
+ map = channelX100UidMap;
+ uid = removeX100(uid);
+ }
+ uid = uid.replaceAll("[^[\\w-]*]", "_");
+ final var firstUid = uid;
+ var idx = 1;
+ while (map.containsKey(uid)) {
+ uid = firstUid + "_" + idx++;
+ }
+ map.put(uid, name);
+ return uid;
+ }
+
+ private String buildChannelDisplayName(final String displayName) {
+ if (displayName.contains("x100")) {
+ return removeX100(displayName);
+ }
+ return displayName;
+ }
+
+ private static String removeX100(String name) {
+ var withoutSuffix = name.replace("_x100", "").replace("x100", "");
+ if (withoutSuffix.endsWith("_")) {
+ withoutSuffix = withoutSuffix.substring(0, withoutSuffix.length() - 2);
+ }
+ return withoutSuffix;
+ }
+
+ private String inOrOut(@Nullable String direction, String in, String out) {
+ if ("output".equalsIgnoreCase(direction)) {
+ return out;
+ }
+ if ("input".equalsIgnoreCase(direction)) {
+ return in;
+ }
+
+ logger.warn("Direction [{}] is unknown!", direction);
+ return out;
+ }
+
+ private void updateChannels(final List channels) {
+ var thingBuilder = editThing();
+ thingBuilder.withChannels(channels);
+ updateThing(thingBuilder.build());
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ try {
+ if (command instanceof RefreshType) {
+ handleRefreshCommand(channelUID);
+ } else if (command instanceof OnOffType typedCommand) {
+ handleBoolCommand(channelUID, typedCommand == OnOffType.ON);
+ } else if (command instanceof UpDownType typedCommand) {
+ handleBoolCommand(channelUID, typedCommand == UpDownType.UP);
+ } else if (command instanceof OpenClosedType typedCommand) {
+ handleBoolCommand(channelUID, typedCommand == OpenClosedType.OPEN);
+ } else if (command instanceof PercentType typedCommand) {
+ handleDecimalCommand(channelUID, typedCommand.as(DecimalType.class));
+ } else if (command instanceof DecimalType typedCommand) {
+ handleDecimalCommand(channelUID, typedCommand);
+ } else if (command instanceof StringType typedCommand) {
+ handleStringCommand(channelUID, typedCommand);
+ } else {
+ logger.warn("Does not know how to handle command `{}` ({}) on channel `{}`!", command,
+ command.getClass().getSimpleName(), channelUID);
+ }
+ } catch (SalusApiException e) {
+ logger.debug("Error while handling command `{}` on channel `{}`", command, channelUID, e);
+ updateStatus(OFFLINE, COMMUNICATION_ERROR, e.getLocalizedMessage());
+ }
+ }
+
+ private void handleRefreshCommand(ChannelUID channelUID) throws SalusApiException {
+ var id = channelUID.getId();
+ String salusId;
+ boolean isX100;
+ if (channelUidMap.containsKey(id)) {
+ salusId = channelUidMap.get(id);
+ isX100 = false;
+ } else if (channelX100UidMap.containsKey(id)) {
+ salusId = channelX100UidMap.get(id);
+ isX100 = true;
+ } else {
+ logger.warn("Channel {} not found in channelUidMap and channelX100UidMap!", id);
+ return;
+ }
+
+ Optional> propertyOptional = findDeviceProperties().stream()
+ .filter(property -> property.getName().equals(salusId)).findFirst();
+ if (propertyOptional.isEmpty()) {
+ logger.warn("Property {} not found in response!", salusId);
+ return;
+ }
+ var property = propertyOptional.get();
+ State state;
+ if (property instanceof DeviceProperty.BooleanDeviceProperty booleanProperty) {
+ var value = booleanProperty.getValue();
+ if (value != null && value) {
+ state = OnOffType.ON;
+ } else {
+ state = OnOffType.OFF;
+ }
+ } else if (property instanceof DeviceProperty.LongDeviceProperty longDeviceProperty) {
+ var value = longDeviceProperty.getValue();
+ if (value == null) {
+ value = 0L;
+ }
+ if (isX100) {
+ state = new DecimalType(new BigDecimal(value).divide(ONE_HUNDRED, new MathContext(5, HALF_EVEN)));
+ } else {
+ state = new DecimalType(value);
+ }
+ } else if (property instanceof DeviceProperty.StringDeviceProperty stringDeviceProperty) {
+ state = new StringType(stringDeviceProperty.getValue());
+ } else {
+ logger.warn("Property class {} is not supported!", property.getClass().getSimpleName());
+ return;
+ }
+ updateState(channelUID, state);
+ }
+
+ private SortedSet> findDeviceProperties() throws SalusApiException {
+ return this.cloudApi.findPropertiesForDevice(dsn);
+ }
+
+ private void handleBoolCommand(ChannelUID channelUID, boolean command) throws SalusApiException {
+ var id = channelUID.getId();
+ String salusId;
+ if (channelUidMap.containsKey(id)) {
+ salusId = requireNonNull(channelUidMap.get(id));
+ } else {
+ logger.warn("Channel {} not found in channelUidMap!", id);
+ return;
+ }
+ cloudApi.setValueForProperty(dsn, salusId, command);
+ handleCommand(channelUID, REFRESH);
+ }
+
+ private void handleDecimalCommand(ChannelUID channelUID, @Nullable DecimalType command) throws SalusApiException {
+ if (command == null) {
+ return;
+ }
+ var id = channelUID.getId();
+ String salusId;
+ long value;
+ if (channelUidMap.containsKey(id)) {
+ salusId = requireNonNull(channelUidMap.get(id));
+ value = command.toBigDecimal().longValue();
+ } else if (channelX100UidMap.containsKey(id)) {
+ salusId = requireNonNull(channelX100UidMap.get(id));
+ value = command.toBigDecimal().multiply(ONE_HUNDRED).longValue();
+ } else {
+ logger.warn("Channel {} not found in channelUidMap and channelX100UidMap!", id);
+ return;
+ }
+ cloudApi.setValueForProperty(dsn, salusId, value);
+ handleCommand(channelUID, REFRESH);
+ }
+
+ private void handleStringCommand(ChannelUID channelUID, StringType command) throws SalusApiException {
+ var id = channelUID.getId();
+ String salusId;
+ if (channelUidMap.containsKey(id)) {
+ salusId = requireNonNull(channelUidMap.get(id));
+ } else {
+ logger.warn("Channel {} not found in channelUidMap!", id);
+ return;
+ }
+ var value = command.toFullString();
+ cloudApi.setValueForProperty(dsn, salusId, value);
+ handleCommand(channelUID, REFRESH);
+ }
+}
diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/It600Handler.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/It600Handler.java
new file mode 100644
index 00000000000..fbc5231ca1c
--- /dev/null
+++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/It600Handler.java
@@ -0,0 +1,277 @@
+/**
+ * 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.salus.internal.handler;
+
+import static java.math.RoundingMode.HALF_EVEN;
+import static java.util.Objects.requireNonNull;
+import static org.openhab.binding.salus.internal.SalusBindingConstants.Channels.It600.EXPECTED_TEMPERATURE;
+import static org.openhab.binding.salus.internal.SalusBindingConstants.Channels.It600.TEMPERATURE;
+import static org.openhab.binding.salus.internal.SalusBindingConstants.Channels.It600.WORK_TYPE;
+import static org.openhab.binding.salus.internal.SalusBindingConstants.It600Device.HoldType.AUTO;
+import static org.openhab.binding.salus.internal.SalusBindingConstants.It600Device.HoldType.MANUAL;
+import static org.openhab.binding.salus.internal.SalusBindingConstants.It600Device.HoldType.OFF;
+import static org.openhab.binding.salus.internal.SalusBindingConstants.It600Device.HoldType.TEMPORARY_MANUAL;
+import static org.openhab.binding.salus.internal.SalusBindingConstants.SalusDevice.DSN;
+import static org.openhab.core.library.unit.SIUnits.CELSIUS;
+import static org.openhab.core.thing.ThingStatus.OFFLINE;
+import static org.openhab.core.thing.ThingStatus.ONLINE;
+import static org.openhab.core.thing.ThingStatusDetail.BRIDGE_UNINITIALIZED;
+import static org.openhab.core.thing.ThingStatusDetail.COMMUNICATION_ERROR;
+import static org.openhab.core.thing.ThingStatusDetail.CONFIGURATION_ERROR;
+
+import java.math.BigDecimal;
+import java.math.MathContext;
+import java.util.ArrayList;
+import java.util.Optional;
+import java.util.Set;
+import java.util.SortedSet;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.salus.internal.rest.DeviceProperty;
+import org.openhab.binding.salus.internal.rest.SalusApiException;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * @author Martin Grześlowski - Initial contribution
+ */
+@NonNullByDefault
+public class It600Handler extends BaseThingHandler {
+ private static final BigDecimal ONE_HUNDRED = new BigDecimal(100);
+ private static final Set REQUIRED_CHANNELS = Set.of("ep_9:sIT600TH:LocalTemperature_x100",
+ "ep_9:sIT600TH:HeatingSetpoint_x100", "ep_9:sIT600TH:SetHeatingSetpoint_x100", "ep_9:sIT600TH:HoldType",
+ "ep_9:sIT600TH:SetHoldType");
+ private final Logger logger;
+ @NonNullByDefault({})
+ private String dsn;
+ @NonNullByDefault({})
+ private CloudApi cloudApi;
+
+ public It600Handler(Thing thing) {
+ super(thing);
+ logger = LoggerFactory.getLogger(It600Handler.class.getName() + "[" + thing.getUID().getId() + "]");
+ }
+
+ @Override
+ public void initialize() {
+ {
+ var bridge = getBridge();
+ if (bridge == null) {
+ updateStatus(OFFLINE, BRIDGE_UNINITIALIZED, "@text/it600-handler.initialize.errors.no-bridge");
+ return;
+ }
+ if (!(bridge.getHandler() instanceof CloudBridgeHandler cloudHandler)) {
+ updateStatus(OFFLINE, BRIDGE_UNINITIALIZED, "@text/it600-handler.initialize.errors.bridge-wrong-type");
+ return;
+ }
+ this.cloudApi = cloudHandler;
+ }
+
+ dsn = (String) getConfig().get(DSN);
+
+ if ("".equals(dsn)) {
+ updateStatus(OFFLINE, CONFIGURATION_ERROR,
+ "@text/it600-handler.initialize.errors.no-dsn [\"" + DSN + "\"]");
+ return;
+ }
+
+ try {
+ var device = this.cloudApi.findDevice(dsn);
+ // no device in cloud
+ if (device.isEmpty()) {
+ updateStatus(OFFLINE, COMMUNICATION_ERROR,
+ "@text/it600-handler.initialize.errors.dsn-not-found [\"" + dsn + "\"]");
+ return;
+ }
+ // device is not connected
+ if (!device.get().isConnected()) {
+ updateStatus(OFFLINE, COMMUNICATION_ERROR,
+ "@text/it600-handler.initialize.errors.dsn-not-connected [\"" + dsn + "\"]");
+ return;
+ }
+ // device is missing properties
+ try {
+ var deviceProperties = findDeviceProperties().stream().map(DeviceProperty::getName).toList();
+ var result = new ArrayList<>(REQUIRED_CHANNELS);
+ result.removeAll(deviceProperties);
+ if (!result.isEmpty()) {
+ updateStatus(OFFLINE, CONFIGURATION_ERROR,
+ "@text/it600-handler.initialize.errors.missing-channels [\"" + dsn + "\", \""
+ + String.join(", ", result) + "\"]");
+ return;
+ }
+ } catch (SalusApiException ex) {
+ updateStatus(OFFLINE, COMMUNICATION_ERROR, ex.getLocalizedMessage());
+ return;
+ }
+ } catch (Exception e) {
+ updateStatus(OFFLINE, COMMUNICATION_ERROR, "@text/it600-handler.initialize.errors.general-error");
+ return;
+ }
+
+ // done
+ updateStatus(ONLINE);
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ try {
+ var id = channelUID.getId();
+ switch (id) {
+ case TEMPERATURE:
+ handleCommandForTemperature(channelUID, command);
+ break;
+ case EXPECTED_TEMPERATURE:
+ handleCommandForExpectedTemperature(channelUID, command);
+ break;
+ case WORK_TYPE:
+ handleCommandForWorkType(channelUID, command);
+ break;
+ default:
+ logger.warn("Unknown channel `{}` for command `{}`", id, command);
+ }
+ } catch (SalusApiException e) {
+ logger.debug("Error while handling command `{}` on channel `{}`", command, channelUID, e);
+ updateStatus(OFFLINE, COMMUNICATION_ERROR, e.getLocalizedMessage());
+ }
+ }
+
+ private void handleCommandForTemperature(ChannelUID channelUID, Command command) throws SalusApiException {
+ if (!(command instanceof RefreshType)) {
+ // only refresh commands are supported for temp channel
+ return;
+ }
+
+ findLongProperty("ep_9:sIT600TH:LocalTemperature_x100", "LocalTemperature_x100")
+ .map(DeviceProperty.LongDeviceProperty::getValue).map(BigDecimal::new)
+ .map(value -> value.divide(ONE_HUNDRED, new MathContext(5, HALF_EVEN))).map(DecimalType::new)
+ .ifPresent(state -> {
+ updateState(channelUID, state);
+ updateStatus(ONLINE);
+ });
+ }
+
+ private void handleCommandForExpectedTemperature(ChannelUID channelUID, Command command) throws SalusApiException {
+ if (command instanceof RefreshType) {
+ findLongProperty("ep_9:sIT600TH:HeatingSetpoint_x100", "HeatingSetpoint_x100")
+ .map(DeviceProperty.LongDeviceProperty::getValue).map(BigDecimal::new)
+ .map(value -> value.divide(ONE_HUNDRED, new MathContext(5, HALF_EVEN))).map(DecimalType::new)
+ .ifPresent(state -> {
+ updateState(channelUID, state);
+ updateStatus(ONLINE);
+ });
+ return;
+ }
+
+ BigDecimal rawValue = null;
+ if (command instanceof QuantityType> commandAsQuantityType) {
+ rawValue = requireNonNull(commandAsQuantityType.toUnit(CELSIUS)).toBigDecimal();
+ } else if (command instanceof DecimalType commandAsDecimalType) {
+ rawValue = commandAsDecimalType.toBigDecimal();
+ }
+
+ if (rawValue != null) {
+ var value = rawValue.multiply(ONE_HUNDRED).longValue();
+ var property = findLongProperty("ep_9:sIT600TH:SetHeatingSetpoint_x100", "SetHeatingSetpoint_x100");
+ if (property.isEmpty()) {
+ return;
+ }
+ var wasSet = cloudApi.setValueForProperty(dsn, property.get().getName(), value);
+ if (wasSet) {
+ findLongProperty("ep_9:sIT600TH:HeatingSetpoint_x100", "HeatingSetpoint_x100")
+ .ifPresent(prop -> prop.setValue(value));
+ findLongProperty("ep_9:sIT600TH:HoldType", "HoldType").ifPresent(prop -> prop.setValue((long) MANUAL));
+ updateStatus(ONLINE);
+ }
+ return;
+ }
+
+ logger.debug("Does not know how to handle command `{}` ({}) on channel `{}`!", command,
+ command.getClass().getSimpleName(), channelUID);
+ }
+
+ private void handleCommandForWorkType(ChannelUID channelUID, Command command) throws SalusApiException {
+ if (command instanceof RefreshType) {
+ findLongProperty("ep_9:sIT600TH:HoldType", "HoldType").map(DeviceProperty.LongDeviceProperty::getValue)
+ .map(value -> switch (value.intValue()) {
+ case AUTO -> "AUTO";
+ case MANUAL -> "MANUAL";
+ case TEMPORARY_MANUAL -> "TEMPORARY_MANUAL";
+ case OFF -> "OFF";
+ default -> {
+ logger.warn("Unknown value {} for property HoldType!", value);
+ yield "AUTO";
+ }
+ }).map(StringType::new).ifPresent(state -> {
+ updateState(channelUID, state);
+ updateStatus(ONLINE);
+ });
+ return;
+ }
+
+ if (command instanceof StringType typedCommand) {
+ long value;
+ if ("AUTO".equals(typedCommand.toString())) {
+ value = AUTO;
+ } else if ("MANUAL".equals(typedCommand.toString())) {
+ value = MANUAL;
+ } else if ("TEMPORARY_MANUAL".equals(typedCommand.toString())) {
+ value = TEMPORARY_MANUAL;
+ } else if ("OFF".equals(typedCommand.toString())) {
+ value = OFF;
+ } else {
+ logger.warn("Unknown value `{}` for property HoldType!", typedCommand);
+ return;
+ }
+ var property = findLongProperty("ep_9:sIT600TH:SetHoldType", "SetHoldType");
+ if (property.isEmpty()) {
+ return;
+ }
+ cloudApi.setValueForProperty(dsn, property.get().getName(), value);
+ updateStatus(ONLINE);
+ return;
+ }
+
+ logger.debug("Does not know how to handle command `{}` ({}) on channel `{}`!", command,
+ command.getClass().getSimpleName(), channelUID);
+ }
+
+ private Optional findLongProperty(String name, String shortName)
+ throws SalusApiException {
+ var deviceProperties = findDeviceProperties();
+ var property = deviceProperties.stream().filter(p -> p.getName().equals(name))
+ .filter(DeviceProperty.LongDeviceProperty.class::isInstance)
+ .map(DeviceProperty.LongDeviceProperty.class::cast).findAny();
+ if (property.isEmpty()) {
+ property = deviceProperties.stream().filter(p -> p.getName().contains(shortName))
+ .filter(DeviceProperty.LongDeviceProperty.class::isInstance)
+ .map(DeviceProperty.LongDeviceProperty.class::cast).findAny();
+ }
+ if (property.isEmpty()) {
+ logger.debug("{}/{} property not found!", name, shortName);
+ }
+ return property;
+ }
+
+ private SortedSet> findDeviceProperties() throws SalusApiException {
+ return this.cloudApi.findPropertiesForDevice(dsn);
+ }
+}
diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/AuthToken.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/AuthToken.java
new file mode 100644
index 00000000000..3246ae1dd6d
--- /dev/null
+++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/AuthToken.java
@@ -0,0 +1,35 @@
+/**
+ * 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.salus.internal.rest;
+
+import java.util.Objects;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Martin Grześlowski - Initial contribution
+ */
+public record AuthToken(@SerializedName("access_token") String accessToken,
+ @SerializedName("refresh_token") String refreshToken, @SerializedName("expires_in") Long expiresIn,
+ @SerializedName("role") String role) {
+ public AuthToken {
+ Objects.requireNonNull(accessToken, "accessToken cannot be null!");
+ Objects.requireNonNull(refreshToken, "refreshToken cannot be null!");
+ }
+
+ @Override
+ public String toString() {
+ return "AuthToken{" + "accessToken=''" + ", refreshToken=''" + ", expiresIn=" + expiresIn
+ + ", role='" + role + '\'' + '}';
+ }
+}
diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/Device.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/Device.java
new file mode 100644
index 00000000000..955eb634e42
--- /dev/null
+++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/Device.java
@@ -0,0 +1,70 @@
+/**
+ * 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.salus.internal.rest;
+
+import static java.util.Objects.requireNonNull;
+
+import java.util.Map;
+
+import javax.validation.constraints.NotNull;
+
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * @author Martin Grześlowski - Initial contribution
+ */
+public record Device(@NotNull String dsn, @NotNull String name,
+ @NotNull Map<@NotNull String, @Nullable Object> properties) implements Comparable {
+ public Device {
+ requireNonNull(dsn, "DSN is required!");
+ requireNonNull(name, "name is required!");
+ requireNonNull(properties, "properties is required!");
+ }
+
+ @Override
+ public int compareTo(Device o) {
+ return dsn.compareTo(o.dsn);
+ }
+
+ public boolean isConnected() {
+ if (properties.containsKey("connection_status")) {
+ var connectionStatus = properties.get("connection_status");
+ return connectionStatus != null && "online".equalsIgnoreCase(connectionStatus.toString());
+ }
+ return false;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+
+ Device device = (Device) o;
+
+ return dsn.equals(device.dsn);
+ }
+
+ @Override
+ public int hashCode() {
+ return dsn.hashCode();
+ }
+
+ @Override
+ public String toString() {
+ return "Device{" + "dsn='" + dsn + '\'' + ", name='" + name + '\'' + '}';
+ }
+}
diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/DeviceProperty.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/DeviceProperty.java
new file mode 100644
index 00000000000..d5548bcd91c
--- /dev/null
+++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/DeviceProperty.java
@@ -0,0 +1,175 @@
+/**
+ * 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.salus.internal.rest;
+
+import static java.util.Objects.requireNonNull;
+
+import java.util.Map;
+
+import javax.validation.constraints.NotNull;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * @author Martin Grześlowski - Initial contribution
+ */
+@NonNullByDefault
+public abstract sealed class DeviceProperty implements Comparable> {
+
+ private final @NotNull String name;
+ private final Boolean readOnly;
+ @Nullable
+ private final String direction;
+ @Nullable
+ private final String dataUpdatedAt;
+ @Nullable
+ private final String productName;
+ @Nullable
+ private final String displayName;
+ private T value;
+ private final Map properties;
+
+ protected DeviceProperty(String name, @Nullable Boolean readOnly, @Nullable String direction,
+ @Nullable String dataUpdatedAt, @Nullable String productName, @Nullable String displayName,
+ @Nullable T value, @Nullable Map properties) {
+ this.name = requireNonNull(name, "name cannot be null!");
+ this.readOnly = readOnly != null ? readOnly : true;
+ this.direction = direction;
+ this.dataUpdatedAt = dataUpdatedAt;
+ this.productName = productName;
+ this.displayName = displayName;
+ this.value = requireNonNull(value, "value");
+ this.properties = properties != null ? properties : Map.of();
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public Boolean getReadOnly() {
+ return readOnly;
+ }
+
+ @Nullable
+ public String getDirection() {
+ return direction;
+ }
+
+ @Nullable
+ public String getDataUpdatedAt() {
+ return dataUpdatedAt;
+ }
+
+ @Nullable
+ public String getProductName() {
+ return productName;
+ }
+
+ @NotNull
+ public String getDisplayName() {
+ var dn = displayName;
+ return dn != null ? dn : name;
+ }
+
+ @Nullable
+ public T getValue() {
+ return value;
+ }
+
+ public void setValue(@Nullable T value) {
+ this.value = value;
+ }
+
+ public Map getProperties() {
+ return properties;
+ }
+
+ @Override
+ public boolean equals(@Nullable Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+
+ DeviceProperty> that = (DeviceProperty>) o;
+
+ return name.equals(that.name);
+ }
+
+ @Override
+ public int hashCode() {
+ return name.hashCode();
+ }
+
+ @Override
+ public int compareTo(DeviceProperty> o) {
+ return name.compareTo(o.name);
+ }
+
+ @Override
+ public String toString() {
+ return "DeviceProperty{" + "name='" + name + '\'' + ", readOnly=" + readOnly + ", direction='" + direction
+ + '\'' + ", value=" + value + '}';
+ }
+
+ /**
+ * @author Martin Grześlowski - Initial contribution
+ */
+ public static final class BooleanDeviceProperty extends DeviceProperty {
+
+ protected BooleanDeviceProperty(String name, @Nullable Boolean readOnly, @Nullable String direction,
+ @Nullable String dataUpdatedAt, @Nullable String productName, @Nullable String displayName,
+ @Nullable Boolean value, @Nullable Map properties) {
+ super(name, readOnly, direction, dataUpdatedAt, productName, displayName, findValue(value), properties);
+ }
+
+ private static Boolean findValue(@Nullable Boolean value) {
+ return value != null ? value : false;
+ }
+ }
+
+ /**
+ * @author Martin Grześlowski - Initial contribution
+ */
+ public static final class LongDeviceProperty extends DeviceProperty {
+
+ protected LongDeviceProperty(String name, @Nullable Boolean readOnly, @Nullable String direction,
+ @Nullable String dataUpdatedAt, @Nullable String productName, @Nullable String displayName,
+ @Nullable Long value, @Nullable Map properties) {
+ super(name, readOnly, direction, dataUpdatedAt, productName, displayName, findValue(value), properties);
+ }
+
+ private static Long findValue(@Nullable Long value) {
+ return value != null ? value : 0;
+ }
+ }
+
+ /**
+ * @author Martin Grześlowski - Initial contribution
+ */
+ public static final class StringDeviceProperty extends DeviceProperty {
+
+ protected StringDeviceProperty(String name, @Nullable Boolean readOnly, @Nullable String direction,
+ @Nullable String dataUpdatedAt, @Nullable String productName, @Nullable String displayName,
+ @Nullable String value, @Nullable Map properties) {
+ super(name, readOnly, direction, dataUpdatedAt, productName, displayName, findValue(value), properties);
+ }
+
+ private static String findValue(@Nullable String value) {
+ return value != null ? value : "";
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/GsonMapper.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/GsonMapper.java
new file mode 100644
index 00000000000..db9083826cf
--- /dev/null
+++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/GsonMapper.java
@@ -0,0 +1,334 @@
+/**
+ * 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.salus.internal.rest;
+
+import static java.lang.Boolean.parseBoolean;
+import static java.lang.Long.parseLong;
+import static java.lang.String.format;
+import static java.util.Collections.unmodifiableSortedMap;
+import static java.util.Objects.requireNonNull;
+import static java.util.Optional.empty;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import java.util.stream.Collectors;
+
+import javax.validation.constraints.NotNull;
+
+import org.checkerframework.checker.units.qual.K;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonSyntaxException;
+import com.google.gson.reflect.TypeToken;
+
+/**
+ * The GsonMapper class is responsible for mapping JSON data to Java objects using the Gson library. It provides methods
+ * for converting JSON strings to various types of objects, such as authentication tokens, devices, device properties,
+ * and error messages.
+ *
+ * @author Martin Grześlowski - Initial contribution
+ */
+@NonNullByDefault
+public class GsonMapper {
+ public static final GsonMapper INSTANCE = new GsonMapper();
+ private final Logger logger = LoggerFactory.getLogger(GsonMapper.class);
+ private static final TypeToken