[salus] Initial contribution (#16065)

* Init salus binding

Signed-off-by: Martin Grześlowski <martin.grzeslowski@gmail.com>
Co-authored-by: Holger Friedrich <mail@holger-friedrich.de>
Co-authored-by: Leo Siepel <leosiepel@gmail.com>
This commit is contained in:
Martin 2024-05-26 21:43:13 +02:00 committed by GitHub
parent bae5b0c939
commit 38e4f220a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 4735 additions and 0 deletions

View File

@ -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

View File

@ -1541,6 +1541,11 @@
<artifactId>org.openhab.binding.saicismart</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.salus</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.samsungtv</artifactId>

View File

@ -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

View File

@ -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.

View File

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>4.2.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.salus</artifactId>
<name>openHAB Add-ons :: Bundles :: Salus Binding</name>
<dependencies>
<!-- START caffeine -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.8</version>
</dependency>
<dependency>
<groupId>com.google.errorprone</groupId>
<artifactId>error_prone_annotations</artifactId>
<version>2.21.1</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.checkerframework</groupId>
<artifactId>checker-qual</artifactId>
<version>3.37.0</version>
<scope>compile</scope>
</dependency>
<!-- END caffeine -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.salus-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<feature name="openhab-binding-salus" description="Salus Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.salus/${project.version}</bundle>
</feature>
</features>

View File

@ -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<ThingTypeUID> 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<String> 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");
}
}

View File

@ -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<>());
}
}

View File

@ -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<String, Object> 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<String, Object> buildThingProperties(Device device) {
return Map.of(DSN, device.dsn());
}
}

View File

@ -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<Device> 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<Device> 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<DeviceProperty<?>> findPropertiesForDevice(String dsn) throws SalusApiException;
}

View File

@ -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='<SECRET>'" + ", url='" + url + '\''
+ ", refreshInterval=" + refreshInterval + ", propertiesRefreshInterval=" + propertiesRefreshInterval
+ '}';
}
}

View File

@ -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<String, SortedSet<DeviceProperty<?>>> 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<Thing> 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<DeviceProperty<?>> 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<DeviceProperty<?>> 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<Device> findDevices() throws SalusApiException {
return requireNonNull(this.salusApi).findDevices();
}
@Override
public Optional<Device> findDevice(String dsn) throws SalusApiException {
return findDevices().stream().filter(device -> device.dsn().equals(dsn)).findFirst();
}
}

View File

@ -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<String, String> channelUidMap = new HashMap<>();
private final Map<String, String> 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<Channel> 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<DeviceProperty<?>> 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<DeviceProperty<?>> 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);
}
}

View File

@ -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<String> 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<DeviceProperty.LongDeviceProperty> 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<DeviceProperty<?>> findDeviceProperties() throws SalusApiException {
return this.cloudApi.findPropertiesForDevice(dsn);
}
}

View File

@ -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='<SECRET>'" + ", refreshToken='<SECRET>'" + ", expiresIn=" + expiresIn
+ ", role='" + role + '\'' + '}';
}
}

View File

@ -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<Device> {
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 + '\'' + '}';
}
}

View File

@ -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<T> implements Comparable<DeviceProperty<?>> {
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<String, @Nullable Object> 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<String, @Nullable Object> 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<String, @Nullable Object> 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<Boolean> {
protected BooleanDeviceProperty(String name, @Nullable Boolean readOnly, @Nullable String direction,
@Nullable String dataUpdatedAt, @Nullable String productName, @Nullable String displayName,
@Nullable Boolean value, @Nullable Map<String, @Nullable Object> 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<Long> {
protected LongDeviceProperty(String name, @Nullable Boolean readOnly, @Nullable String direction,
@Nullable String dataUpdatedAt, @Nullable String productName, @Nullable String displayName,
@Nullable Long value, @Nullable Map<String, @Nullable Object> 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<String> {
protected StringDeviceProperty(String name, @Nullable Boolean readOnly, @Nullable String direction,
@Nullable String dataUpdatedAt, @Nullable String productName, @Nullable String displayName,
@Nullable String value, @Nullable Map<String, @Nullable Object> properties) {
super(name, readOnly, direction, dataUpdatedAt, productName, displayName, findValue(value), properties);
}
private static String findValue(@Nullable String value) {
return value != null ? value : "";
}
}
}

View File

@ -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<Map<String, Object>> MAP_TYPE_REFERENCE = new TypeToken<>() {
};
private static final TypeToken<List<Object>> LIST_TYPE_REFERENCE = new TypeToken<>() {
};
private final Gson gson = new Gson();
public String loginParam(String username, char[] password) {
return gson.toJson(Map.of("user", Map.of("email", username, "password", new String(password))));
}
public AuthToken authToken(String json) {
return requireNonNull(gson.fromJson(json, AuthToken.class));
}
public List<Device> parseDevices(String json) {
return tryParseBody(json, LIST_TYPE_REFERENCE, List.of()).stream().map(this::parseDevice)
.filter(Optional::isPresent).map(Optional::get).toList();
}
private Optional<Device> parseDevice(Object obj) {
if (!(obj instanceof Map<?, ?> firstLevelMap)) {
logger.debug("Cannot parse device, because object is not type of map!\n{}", obj);
return empty();
}
if (!firstLevelMap.containsKey("device")) {
if (logger.isWarnEnabled()) {
var str = firstLevelMap.entrySet().stream()
.map(entry -> format("%s=%s", entry.getKey(), entry.getValue()))
.collect(Collectors.joining("\n"));
logger.debug("Cannot parse device, because firstLevelMap does not have [device] key!\n{}", str);
}
return empty();
}
var objLevel2 = firstLevelMap.get("device");
if (!(objLevel2 instanceof Map<?, ?> map)) {
logger.debug("Cannot parse device, because object is not type of map!\n{}", obj);
return empty();
}
// parse `dns`
if (!map.containsKey("dsn")) {
if (logger.isWarnEnabled()) {
var str = map.entrySet().stream().map(entry -> format("%s=%s", entry.getKey(), entry.getValue()))
.collect(Collectors.joining("\n"));
logger.debug("Cannot parse device, because map does not have [dsn] key!\n{}", str);
}
return empty();
}
var dsn = requireNonNull((String) map.get("dsn"));
// parse `name`
if (!map.containsKey("product_name")) {
if (logger.isWarnEnabled()) {
var str = map.entrySet().stream().map(entry -> format("%s=%s", entry.getKey(), entry.getValue()))
.collect(Collectors.joining("\n"));
logger.debug("Cannot parse device, because map does not have [product_name] key!\n{}", str);
}
return empty();
}
var name = requireNonNull((String) map.get("product_name"));
// parse `properties`
var list = map.entrySet().stream().filter(entry -> entry.getKey() != null)
.filter(entry -> !"name".equals(entry.getKey())).filter(entry -> !"base_type".equals(entry.getKey()))
.filter(entry -> !"read_only".equals(entry.getKey()))
.filter(entry -> !"direction".equals(entry.getKey()))
.filter(entry -> !"data_updated_at".equals(entry.getKey()))
.filter(entry -> !"product_name".equals(entry.getKey()))
.filter(entry -> !"display_name".equals(entry.getKey()))
.filter(entry -> !"value".equals(entry.getKey()))
.map(entry -> new Pair<>(requireNonNull(entry.getKey()).toString(), (Object) entry.getValue()))
.toList();
Map<@NotNull String, @Nullable Object> properties = new LinkedHashMap<>();
for (var entry : list) {
properties.put(entry.key, entry.value);
}
properties = Collections.unmodifiableMap(properties);
return Optional.of(new Device(dsn.trim(), name.trim(), properties));
}
@SuppressWarnings("SameParameterValue")
private <T> T tryParseBody(@Nullable String body, TypeToken<T> typeToken, T defaultValue) {
if (body == null) {
return defaultValue;
}
try {
return gson.fromJson(body, typeToken);
} catch (JsonSyntaxException e) {
if (logger.isTraceEnabled()) {
logger.trace("Error when parsing body!\n{}", body, e);
} else {
logger.debug("Error when parsing body! Turn on TRACE for more details", e);
}
return defaultValue;
}
}
public List<DeviceProperty<?>> parseDeviceProperties(String json) {
var deviceProperties = new ArrayList<DeviceProperty<?>>();
var objects = tryParseBody(json, LIST_TYPE_REFERENCE, List.of());
for (var obj : objects) {
parseDeviceProperty(obj).ifPresent(deviceProperties::add);
}
return Collections.unmodifiableList(deviceProperties);
}
private Optional<DeviceProperty<?>> parseDeviceProperty(@Nullable Object obj) {
if (!(obj instanceof Map<?, ?> firstLevelMap)) {
logger.debug("Cannot parse device property, because object is not type of map!\n{}", obj);
return empty();
}
if (!firstLevelMap.containsKey("property")) {
if (logger.isWarnEnabled()) {
var str = firstLevelMap.entrySet().stream()
.map(entry -> format("%s=%s", entry.getKey(), entry.getValue()))
.collect(Collectors.joining("\n"));
logger.debug("Cannot parse device property, because firstLevelMap does not have [property] key!\n{}",
str);
}
return empty();
}
var objLevel2 = firstLevelMap.get("property");
if (!(objLevel2 instanceof Map<?, ?> map)) {
logger.debug("Cannot parse device property, because object is not type of map!\n{}", obj);
return empty();
}
// name
if (!map.containsKey("name")) {
if (logger.isWarnEnabled()) {
var str = map.entrySet().stream().map(entry -> format("%s=%s", entry.getKey(), entry.getValue()))
.collect(Collectors.joining("\n"));
logger.debug("Cannot parse device property, because map does not have [name] key!\n{}", str);
}
return empty();
}
var name = requireNonNull((String) map.get("name"));
// other meaningful properties
var baseType = findOrNull(map, "base_type");
var readOnly = findBoolOrNull(map, "read_only");
var direction = findOrNull(map, "direction");
var dataUpdatedAt = findOrNull(map, "data_updated_at");
var productName = findOrNull(map, "product_name");
var displayName = findOrNull(map, "display_name");
var value = findObjectOrNull(map, "value");
// parse `properties`
var list = map.entrySet().stream().filter(entry -> entry.getKey() != null)
.filter(entry -> !"name".equals(entry.getKey())).filter(entry -> !"base_type".equals(entry.getKey()))
.filter(entry -> !"read_only".equals(entry.getKey()))
.filter(entry -> !"direction".equals(entry.getKey()))
.filter(entry -> !"data_updated_at".equals(entry.getKey()))
.filter(entry -> !"product_name".equals(entry.getKey()))
.filter(entry -> !"display_name".equals(entry.getKey()))
.filter(entry -> !"value".equals(entry.getKey()))
.map(entry -> new Pair<>(requireNonNull(entry.getKey()).toString(), (Object) entry.getValue()))
.toList();
// this weird thing need to be done,
// because `Collectors.toMap` does not support value=null
// and in our case, sometimes the values are null
SortedMap<@NotNull String, @Nullable Object> properties = new TreeMap<>();
for (var entry : list) {
properties.put(entry.key, entry.value);
}
properties = unmodifiableSortedMap(properties);
return Optional.of(buildDeviceProperty(name, baseType, value, readOnly, direction, dataUpdatedAt, productName,
displayName, properties));
}
private DeviceProperty<?> buildDeviceProperty(String name, @Nullable String baseType, @Nullable Object value,
@Nullable Boolean readOnly, @Nullable String direction, @Nullable String dataUpdatedAt,
@Nullable String productName, @Nullable String displayName,
SortedMap<String, @Nullable Object> properties) {
if ("boolean".equalsIgnoreCase(baseType)) {
Boolean bool;
if (value == null) {
bool = null;
} else if (value instanceof Boolean typedValue) {
bool = typedValue;
} else if (value instanceof Number typedValue) {
bool = typedValue.longValue() != 0;
} else if (value instanceof String typedValue) {
bool = parseBoolean(typedValue);
} else {
logger.debug("Cannot parse boolean from [{}]", value);
bool = null;
}
return new DeviceProperty.BooleanDeviceProperty(name, readOnly, direction, dataUpdatedAt, productName,
displayName, bool, properties);
}
if ("integer".equalsIgnoreCase(baseType)) {
Long longValue;
if (value == null) {
longValue = null;
} else if (value instanceof Number typedValue) {
longValue = typedValue.longValue();
} else if (value instanceof String string) {
try {
longValue = parseLong(string);
} catch (NumberFormatException ex) {
logger.debug("Cannot parse long from [{}]", value, ex);
longValue = null;
}
} else {
logger.debug("Cannot parse long from [{}]", value);
longValue = null;
}
return new DeviceProperty.LongDeviceProperty(name, readOnly, direction, dataUpdatedAt, productName,
displayName, longValue, properties);
}
var string = value != null ? value.toString() : null;
return new DeviceProperty.StringDeviceProperty(name, readOnly, direction, dataUpdatedAt, productName,
displayName, string, properties);
}
@Nullable
private String findOrNull(Map<?, ?> map, String name) {
if (!map.containsKey(name)) {
return null;
}
return (String) map.get(name);
}
@SuppressWarnings("SameParameterValue")
@Nullable
private Boolean findBoolOrNull(Map<?, ?> map, String name) {
if (!map.containsKey(name)) {
return null;
}
var value = map.get(name);
if (value == null) {
return null;
}
if (value instanceof Boolean bool) {
return bool;
}
if (value instanceof String string) {
return parseBoolean(string);
}
return null;
}
@SuppressWarnings("SameParameterValue")
@Nullable
private Object findObjectOrNull(Map<?, ?> map, String name) {
if (!map.containsKey(name)) {
return null;
}
return map.get(name);
}
public String datapointParam(Object value) {
return gson.toJson(Map.of("datapoint", Map.of("value", value)));
}
public Optional<Object> datapointValue(@Nullable String json) {
if (json == null) {
return empty();
}
var map = tryParseBody(json, MAP_TYPE_REFERENCE, Map.of());
if (!map.containsKey("datapoint")) {
return empty();
}
var datapoint = (Map<?, ?>) map.get("datapoint");
if (datapoint == null || !datapoint.containsKey("value")) {
return empty();
}
return Optional.ofNullable(datapoint.get("value"));
}
private static record Pair<K, @Nullable V> (K key, @Nullable V value) {
}
}

View File

@ -0,0 +1,92 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.rest;
import static java.util.Objects.requireNonNull;
import static java.util.concurrent.TimeUnit.SECONDS;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpResponseException;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.client.util.StringContentProvider;
/**
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
public class HttpClient implements RestClient {
private static final int TIMEOUT = 10;
private static final int IDLE_TIMEOUT = TIMEOUT;
private final org.eclipse.jetty.client.HttpClient client;
public HttpClient(org.eclipse.jetty.client.HttpClient client) {
this.client = requireNonNull(client, "client");
if (this.client.isStopped()) {
throw new IllegalStateException("HttpClient is stopped");
}
}
@Override
public @Nullable String get(String url, @Nullable Header... headers) throws SalusApiException {
var request = requireNonNull(client.newRequest(url));
return execute(request, headers, url);
}
@Override
public @Nullable String post(String url, @Nullable Content content, @Nullable Header... headers)
throws SalusApiException {
var request = requireNonNull(client.POST(url));
if (content != null) {
request.content(new StringContentProvider(content.body()), content.type());
}
return execute(request, headers, url);
}
@SuppressWarnings("ConstantValue")
private @Nullable String execute(Request request, @Nullable Header[] headers, String url) throws SalusApiException {
try {
if (headers != null) {
for (var header : headers) {
if (header == null) {
continue;
}
for (var value : header.values()) {
request.header(header.name(), value);
}
}
}
request.timeout(TIMEOUT, SECONDS);
request.idleTimeout(IDLE_TIMEOUT, SECONDS);
var response = request.send();
var status = response.getStatus();
if (status < 200 || status >= 399) {
throw new HttpSalusApiException(status, response.getReason());
}
return response.getContentAsString();
} catch (RuntimeException | TimeoutException | ExecutionException | InterruptedException ex) {
Throwable cause = ex;
while (cause != null) {
if (cause instanceof HttpResponseException hte) {
var response = hte.getResponse();
throw new HttpSalusApiException(response.getStatus(), response.getReason(), hte);
}
cause = cause.getCause();
}
throw new SalusApiException("Error while executing request to " + url, ex);
}
}
}

View File

@ -0,0 +1,50 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.rest;
import java.io.Serial;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.client.HttpResponseException;
/**
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
@SuppressWarnings("SerializableHasSerializationMethods")
public class HttpSalusApiException extends SalusApiException {
@Serial
private static final long serialVersionUID = 1L;
private final int code;
private final String msg;
public HttpSalusApiException(int code, String msg, HttpResponseException e) {
super("HTTP Error %s: %s".formatted(code, msg), e);
this.code = code;
this.msg = msg;
}
public HttpSalusApiException(int code, String msg) {
super("HTTP Error %s: %s".formatted(code, msg));
this.code = code;
this.msg = msg;
}
public int getCode() {
return code;
}
public String getMsg() {
return msg;
}
}

View File

@ -0,0 +1,74 @@
/**
* 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.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
public interface RestClient {
/**
* GET request to server
*
* @param url to send request
* @param headers to send
* @return response from server
*/
@Nullable
String get(String url, @Nullable Header... headers) throws SalusApiException;
/**
* POST request to server
*
* @param url to send request
* @param headers to send
* @param content to send
* @return response from server
*/
@Nullable
String post(String url, Content content, @Nullable Header... headers) throws SalusApiException;
/**
* Represents content with a body and a type.
*/
record Content(String body, String type) {
/**
* Creates a Content instance with the given body and default type ("application/json").
*
* @param body The content body.
*/
public Content(String body) {
this(body, "application/json");
}
}
/**
* Represents an HTTP header with a name and a list of values.
*/
record Header(String name, List<String> values) {
/**
* Creates a Header instance with the given name and a single value.
*
* @param name The header name.
* @param value The header value.
*/
public Header(String name, String value) {
this(name, List.of(value));
}
}
}

View File

@ -0,0 +1,68 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.rest;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
public class RetryHttpClient implements RestClient {
private final Logger logger = LoggerFactory.getLogger(RetryHttpClient.class);
private final RestClient restClient;
private final int maxRetries;
public RetryHttpClient(RestClient restClient, int maxRetries) {
this.restClient = restClient;
if (maxRetries <= 0) {
throw new IllegalArgumentException("maxRetries cannot be lower or equal to 0, but was " + maxRetries);
}
this.maxRetries = maxRetries;
}
@Override
public @Nullable String get(String url, @Nullable Header... headers) throws SalusApiException {
for (int i = 0; i < maxRetries; i++) {
try {
return restClient.get(url, headers);
} catch (SalusApiException ex) {
if (i < maxRetries - 1) {
logger.debug("Error while calling GET {}. Retrying {}/{}...", i + 1, maxRetries, url, ex);
} else {
throw ex;
}
}
}
throw new IllegalStateException("Should not happen!");
}
@Override
public @Nullable String post(String url, Content content, @Nullable Header... headers) throws SalusApiException {
for (int i = 0; i < maxRetries; i++) {
try {
return restClient.post(url, content, headers);
} catch (SalusApiException ex) {
if (i < maxRetries - 1) {
logger.debug("Error while calling POST {}. Retrying {}/{}...", i + 1, maxRetries, url, ex);
} else {
throw ex;
}
}
}
throw new IllegalStateException("Should not happen!");
}
}

View File

@ -0,0 +1,197 @@
/**
* 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.time.Clock;
import java.time.LocalDateTime;
import java.util.SortedSet;
import java.util.TreeSet;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The SalusApi class is responsible for interacting with a REST API to perform various operations related to the Salus
* system. It handles authentication, token management, and provides methods to retrieve and manipulate device
* information and properties.
*
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
public class SalusApi {
private static final int MAX_RETRIES = 3;
private static final long TOKEN_EXPIRE_TIME_ADJUSTMENT_SECONDS = 3;
private final Logger logger;
private final String username;
private final char[] password;
private final String baseUrl;
private final RestClient restClient;
private final GsonMapper mapper;
@Nullable
private AuthToken authToken;
@Nullable
private LocalDateTime authTokenExpireTime;
private final Clock clock;
public SalusApi(String username, char[] password, String baseUrl, RestClient restClient, GsonMapper mapper,
Clock clock) {
this.username = username;
this.password = password;
this.baseUrl = removeTrailingSlash(baseUrl);
this.restClient = restClient;
this.mapper = mapper;
this.clock = clock;
// thanks to this, logger will always inform for which rest client it's doing the job
// it's helpful when more than one SalusApi exists
logger = LoggerFactory.getLogger(SalusApi.class.getName() + "[" + username.replace(".", "_") + "]");
}
public SalusApi(String username, char[] password, String baseUrl, RestClient restClient, GsonMapper mapper) {
this(username, password, baseUrl, restClient, mapper, Clock.systemDefaultZone());
}
private @Nullable String get(String url, RestClient.Header header, int retryAttempt) throws SalusApiException {
refreshAccessToken();
try {
return restClient.get(url, authHeader());
} catch (HttpSalusApiException ex) {
if (ex.getCode() == 401) {
if (retryAttempt <= MAX_RETRIES) {
forceRefreshAccessToken();
return get(url, header, retryAttempt + 1);
}
logger.debug("Could not refresh access token after {} retries", MAX_RETRIES);
}
throw ex;
}
}
private @Nullable String post(String url, RestClient.Content content, RestClient.Header header, int retryAttempt)
throws SalusApiException {
refreshAccessToken();
try {
return restClient.post(url, content, header);
} catch (HttpSalusApiException ex) {
if (ex.getCode() == 401) {
if (retryAttempt <= MAX_RETRIES) {
forceRefreshAccessToken();
return post(url, content, header, retryAttempt + 1);
}
logger.debug("Could not refresh access token after {} retries", MAX_RETRIES);
}
throw ex;
}
}
private static String removeTrailingSlash(String str) {
if (str.endsWith("/")) {
return str.substring(0, str.length() - 1);
}
return str;
}
private void login(String username, char[] password) throws SalusApiException {
login(username, password, 1);
}
private void login(String username, char[] password, int retryAttempt) throws SalusApiException {
logger.debug("Login with username '{}', retryAttempt={}", username, retryAttempt);
authToken = null;
authTokenExpireTime = null;
var finalUrl = url("/users/sign_in.json");
var inputBody = mapper.loginParam(username, password);
try {
var response = restClient.post(finalUrl, new RestClient.Content(inputBody, "application/json"),
new RestClient.Header("Accept", "application/json"));
if (response == null) {
throw new HttpSalusApiException(401, "No response token from server");
}
var token = authToken = mapper.authToken(response);
authTokenExpireTime = LocalDateTime.now(clock).plusSeconds(token.expiresIn())
// this is to account that there is a delay between server setting `expires_in`
// and client (OpenHAB) receiving it
.minusSeconds(TOKEN_EXPIRE_TIME_ADJUSTMENT_SECONDS);
logger.debug("Correctly logged in for user {}, role={}, expires at {} ({} secs)", username, token.role(),
authTokenExpireTime, token.expiresIn());
} catch (HttpSalusApiException ex) {
if (ex.getCode() == 401 || ex.getCode() == 403) {
if (retryAttempt < MAX_RETRIES) {
login(username, password, retryAttempt + 1);
}
throw ex;
}
throw ex;
}
}
private void forceRefreshAccessToken() throws SalusApiException {
logger.debug("Force refresh access token");
authToken = null;
authTokenExpireTime = null;
refreshAccessToken();
}
private void refreshAccessToken() throws SalusApiException {
if (this.authToken == null || isExpiredToken()) {
try {
login(username, password);
} catch (SalusApiException ex) {
logger.warn("Accesstoken could not be acquired, for user '{}', response={}", username, ex.getMessage());
this.authToken = null;
this.authTokenExpireTime = null;
throw ex;
}
}
}
private boolean isExpiredToken() {
var expireTime = authTokenExpireTime;
return expireTime == null || LocalDateTime.now(clock).isAfter(expireTime);
}
private String url(String url) {
return baseUrl + url;
}
public SortedSet<Device> findDevices() throws SalusApiException {
refreshAccessToken();
var response = get(url("/apiv1/devices.json"), authHeader(), 1);
return new TreeSet<>(mapper.parseDevices(requireNonNull(response)));
}
private RestClient.Header authHeader() {
return new RestClient.Header("Authorization", "auth_token " + requireNonNull(authToken).accessToken());
}
public SortedSet<DeviceProperty<?>> findDeviceProperties(String dsn) throws SalusApiException {
refreshAccessToken();
var response = get(url("/apiv1/dsns/" + dsn + "/properties.json"), authHeader(), 1);
if (response == null) {
throw new SalusApiException("No device properties for device %s".formatted(dsn));
}
return new TreeSet<>(mapper.parseDeviceProperties(response));
}
public Object setValueForProperty(String dsn, String propertyName, Object value) throws SalusApiException {
refreshAccessToken();
var finalUrl = url("/apiv1/dsns/" + dsn + "/properties/" + propertyName + "/datapoints.json");
var json = mapper.datapointParam(value);
var response = post(finalUrl, new RestClient.Content(json), authHeader(), 1);
var datapointValue = mapper.datapointValue(response);
return datapointValue.orElseThrow(() -> new HttpSalusApiException(404, "No datapoint in return"));
}
}

View File

@ -0,0 +1,32 @@
/**
* 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.io.Serial;
/**
* @author Martin Grześlowski - Initial contribution
*/
@SuppressWarnings("SerializableHasSerializationMethods")
public class SalusApiException extends Exception {
@Serial
private static final long serialVersionUID = 1L;
public SalusApiException(String msg, Exception e) {
super(msg, e);
}
public SalusApiException(String msg) {
super(msg);
}
}

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<addon:addon id="salus" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
<type>binding</type>
<name>Salus Binding</name>
<description>
This is the binding for Salus, a renowned manufacturer of smart home solutions. This binding allows
integration of Salus' wide range devices into your home control system, facilitating bidirectional communication
and
control.
</description>
<connection>cloud</connection>
</addon:addon>

View File

@ -0,0 +1,81 @@
# add-on
addon.salus.name = Salus Binding
addon.salus.description = This is the binding for Salus, a renowned manufacturer of smart home solutions. This binding allows integration of Salus' wide range devices into your home control system, facilitating bidirectional communication and control.
# thing types
thing-type.salus.salus-cloud-bridge.label = Salus Cloud
thing-type.salus.salus-cloud-bridge.description = This bridge serves as a critical connection point to the Salus cloud. It's absolutely necessary for the integration of other Salus devices into the ecosystem, as it provides a pathway for them to interact with the Salus cloud. Without this bridge, the devices would be unable to send, receive or exchange data with the cloud platform, hindering functionality and data utilization.
thing-type.salus.salus-device.label = Salus Binding Thing
thing-type.salus.salus-device.description = This is a device type that represents a generic 'thing' for the Salus Binding, working in conjunction with the Salus cloud bridge. Channels will be discovered and established at runtime. The 'dsn' (ID in Salus cloud system) is a mandatory configuration parameter.
thing-type.salus.salus-it600-device.label = IT600 Salus Thermostat
thing-type.salus.salus-it600-device.description = The IT600 Salus Thermostat Device is a component utilized within the Salus IT600 thermostat system. This device communicates with the Salus cloud bridge and offers features including reading the current temperature, setting the desired temperature, and defining the operation type. The operation of this device depends on a unique Data Source Name (DSN) which serves as an identifier in the Salus cloud system.
# thing types config
thing-type.config.salus.salus-cloud-bridge.maxHttpRetries.label = Max HTTP Retries
thing-type.config.salus.salus-cloud-bridge.maxHttpRetries.description = How many times HTTP requests can be retried
thing-type.config.salus.salus-cloud-bridge.password.label = Password
thing-type.config.salus.salus-cloud-bridge.password.description = The password for your Salus account. This is used in conjunction with the username or email for authentication purposes.
thing-type.config.salus.salus-cloud-bridge.propertiesRefreshInterval.label = Device Property Cache Expiration
thing-type.config.salus.salus-cloud-bridge.propertiesRefreshInterval.description = The period (in seconds) after which the cached device properties will be discarded and re-fetched fresh from the Salus cloud.
thing-type.config.salus.salus-cloud-bridge.refreshInterval.label = Refresh Interval
thing-type.config.salus.salus-cloud-bridge.refreshInterval.description = The interval in seconds at which the connection to the Salus cloud should be refreshed to ensure up-to-date data.
thing-type.config.salus.salus-cloud-bridge.url.label = Salus base URL
thing-type.config.salus.salus-cloud-bridge.url.description = The base URL for the Salus cloud. Typically, this should remain as the default, unless directed to change by Salus.
thing-type.config.salus.salus-cloud-bridge.username.label = Username/Email
thing-type.config.salus.salus-cloud-bridge.username.description = The username or email associated with your Salus account. This is required for authentication with the Salus cloud.
thing-type.config.salus.salus-device.dsn.label = DSN
thing-type.config.salus.salus-device.dsn.description = Data Source Name (DSN) — This is a unique identifier used to establish a connection with the Salus cloud system. It's crucial for the correct operation of the Salus device, enabling communication between the device and the cloud.
thing-type.config.salus.salus-it600-device.dsn.label = DSN
thing-type.config.salus.salus-it600-device.dsn.description = Data Source Name (DSN) — This is a unique identifier used to establish a connection with the Salus cloud system. It's crucial for the correct operation of the Salus device, enabling communication between the device and the cloud.
# channel types
channel-type.salus.generic-input-bool-channel.label = Generic Bool Input Channel
channel-type.salus.generic-input-bool-channel.description = This channel type represents a generic input. The channel is write-only and its state is represented as a boolean.
channel-type.salus.generic-input-channel.label = Generic Input Channel
channel-type.salus.generic-input-channel.description = This channel type represents a generic input. The channel is write-only and its state is represented as a string.
channel-type.salus.generic-input-number-channel.label = Generic Number Input Channel
channel-type.salus.generic-input-number-channel.description = This channel type represents a generic input. The channel is write-only and its state is represented as a numeric.
channel-type.salus.generic-output-bool-channel.label = Generic Bool Output Channel
channel-type.salus.generic-output-bool-channel.description = This channel type represents a generic output. The channel is read-only and its state is represented as a boolean.
channel-type.salus.generic-output-channel.label = Generic Output Channel
channel-type.salus.generic-output-channel.description = This channel type represents a generic output. The channel is read-only and its state is represented as a string.
channel-type.salus.generic-output-number-channel.label = Generic Number Output Channel
channel-type.salus.generic-output-number-channel.description = This channel type represents a generic output. The channel is read-only and its state is represented as a numeric.
channel-type.salus.it600-expected-temp-channel.label = Expected Temperature
channel-type.salus.it600-expected-temp-channel.description = Sets the desired temperature in room
channel-type.salus.it600-temp-channel.label = Temperature
channel-type.salus.it600-temp-channel.description = Current temperature in room
channel-type.salus.it600-work-type-channel.label = Work Type
channel-type.salus.it600-work-type-channel.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.
channel-type.salus.it600-work-type-channel.state.option.OFF = OFF
channel-type.salus.it600-work-type-channel.state.option.MANUAL = Manual
channel-type.salus.it600-work-type-channel.state.option.AUTO = Automatic
channel-type.salus.it600-work-type-channel.state.option.TEMPORARY_MANUAL = Temporary Manual
channel-type.salus.temperature-input-channel.label = Generic Input Temperature Channel
channel-type.salus.temperature-input-channel.description = This channel type represents a generic input. The channel is write-only and its state is represented as a temperature (numeric).
channel-type.salus.temperature-output-channel.label = Generic Output Temperature Channel
channel-type.salus.temperature-output-channel.description = This channel type represents a generic output. The channel is read-only and its state is represented as a temperature (numeric).
# code i8n
cloud-bridge-handler.initialize.username-pass-not-valid = Username or password is missing
cloud-bridge-handler.initialize.cannot-connect-to-cloud = Cannot connect to Salus Cloud! Probably username/password mismatch! {0}
cloud-bridge-handler.errors.http = There was an error when sending a request to the Salus Cloud. {0}
device-handler.initialize.errors.no-bridge = There is no bridge for this thing. Remove it and add it again.
device-handler.initialize.errors.bridge-wrong-type = There is a wrong type of bridge for a cloud device!
device-handler.initialize.errors.no-dsn = There is no {0} for this thing. Remove it and add it again.
device-handler.initialize.errors.dsn-not-found = Device with DSN {} not found!
device-handler.initialize.errors.dsn-not-connected = Device with DSN {0} is not connected!
device-handler.initialize.errors.no-channels = There are no channels for {0}
device-handler.initialize.errors.general-error = Error when connecting to Salus Cloud!
it600-handler.initialize.errors.no-bridge = There is no bridge for this thing. Remove it and add it again.
it600-handler.initialize.errors.bridge-wrong-type = There is a wrong type of bridge for a cloud device!
it600-handler.initialize.errors.no-dsn = There is no {0} for this thing. Remove it and add it again.
it600-handler.initialize.errors.dsn-not-found = Device with DSN {} not found!
it600-handler.initialize.errors.dsn-not-connected = Device with DSN {0} is not connected!
it600-handler.initialize.errors.missing-channels = Device with DSN {0} is missing required properties: {1}
it600-handler.initialize.errors.general-error = Error when connecting to Salus Cloud!

View File

@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="salus"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<thing-type id="salus-it600-device">
<supported-bridge-type-refs>
<bridge-type-ref id="salus-cloud-bridge"/>
</supported-bridge-type-refs>
<label>IT600 Salus Thermostat</label>
<description>
The IT600 Salus Thermostat Device is a component utilized within the Salus IT600 thermostat system. This
device communicates with the Salus cloud bridge and offers features including reading the current temperature,
setting the desired temperature, and defining the operation type. The operation of this device depends on a unique
Data Source Name (DSN) which serves as an identifier in the Salus cloud system.
</description>
<channels>
<channel id="temperature" typeId="it600-temp-channel"/>
<channel id="expected-temperature" typeId="it600-expected-temp-channel"/>
<channel id="work-type" typeId="it600-work-type-channel"/>
</channels>
<representation-property>dsn</representation-property>
<config-description>
<parameter name="dsn" type="text" required="true">
<label>DSN</label>
<description>
Data Source Name (DSN) — This is a unique identifier used to establish a connection with the Salus
cloud system. It's crucial for the correct operation of the Salus device,
enabling communication between the device
and the cloud.
</description>
</parameter>
</config-description>
</thing-type>
<channel-type id="it600-temp-channel">
<item-type>Number:Temperature</item-type>
<label>Temperature</label>
<description>Current temperature in room</description>
<category>Temperature</category>
<state min="5" max="35" step="0.5" readOnly="true" pattern="%.1f %unit%"/>
</channel-type>
<channel-type id="it600-expected-temp-channel">
<item-type>Number:Temperature</item-type>
<label>Expected Temperature</label>
<description>Sets the desired temperature in room</description>
<category>Temperature</category>
<state min="5" max="35" step="0.5" pattern="%.1f %unit%"/>
</channel-type>
<channel-type id="it600-work-type-channel">
<item-type>String</item-type>
<label>Work Type</label>
<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.
</description>
<state>
<options>
<option value="OFF">OFF</option>
<option value="MANUAL">Manual</option>
<option value="AUTO">Automatic</option>
<option value="TEMPORARY_MANUAL">Temporary Manual</option>
</options>
</state>
</channel-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="salus"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="http://eclipse.org/smarthome/schemas/thing-description/v1.0.0"
xsi:schemaLocation="http://eclipse.org/smarthome/schemas/thing-description/v1.0.0
org.eclipse.smarthome.thing-description.xsd">
<bridge-type id="salus-cloud-bridge">
<label>Salus Cloud</label>
<description>
This bridge serves as a critical connection point to the Salus cloud. It's absolutely necessary for the
integration of other Salus devices into the ecosystem, as it provides a pathway for them to interact with the Salus
cloud. Without this bridge, the devices would be unable to send, receive or exchange data with the cloud platform,
hindering functionality and data utilization.
</description>
<representation-property>username</representation-property>
<config-description>
<parameter name="username" type="text" required="true">
<label>Username/Email</label>
<description>The username or email associated with your Salus account. This is required for authentication with the
Salus cloud.</description>
</parameter>
<parameter name="password" type="text" required="true">
<label>Password</label>
<context>password</context>
<description>The password for your Salus account. This is used in conjunction with the username or email for
authentication purposes.</description>
</parameter>
<parameter name="url" type="text" required="true">
<label>Salus API URL</label>
<default>https://eu.salusconnect.io</default>
<advanced>true</advanced>
<context>url</context>
<description>The base URL for the Salus cloud. Typically, this should remain as the default, unless directed to
change by Salus.</description>
</parameter>
<parameter name="refreshInterval" type="integer" required="false" min="1" max="600" unit="s">
<label>Refresh Interval</label>
<description>The interval in seconds at which the connection to the Salus cloud should be refreshed to ensure
up-to-date data.</description>
<advanced>true</advanced>
<default>30</default>
</parameter>
<parameter name="propertiesRefreshInterval" type="integer" required="false" min="1" max="600" unit="s">
<label>Device Property Cache Expiration</label>
<description>The period (in seconds) after which the cached device properties will be discarded and re-fetched fresh
from the Salus cloud.</description>
<advanced>true</advanced>
<default>5</default>
</parameter>
<parameter name="maxHttpRetries" type="integer" required="false">
<label>Max HTTP Retries</label>
<description>How many times HTTP requests can be retried</description>
<advanced>true</advanced>
<default>3</default>
</parameter>
</config-description>
</bridge-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="salus"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<!-- Sample Thing Type -->
<thing-type id="salus-device">
<supported-bridge-type-refs>
<bridge-type-ref id="salus-cloud-bridge"/>
</supported-bridge-type-refs>
<label>Salus Device</label>
<description>
This is a device type that represents a generic 'thing' for the Salus Binding, working in conjunction
with the Salus cloud bridge. Channels will be discovered and established at runtime.
The 'dsn' (ID in Salus cloud
system) is a mandatory configuration parameter.
</description>
<!-- Note: Channels will be discovered at runtime -->
<representation-property>dsn</representation-property>
<config-description>
<parameter name="dsn" type="text" required="true">
<label>DSN</label>
<description>
Data Source Name (DSN) — This is a unique identifier used to establish a connection with the Salus
cloud system. It's crucial for the correct operation of the Salus device,
enabling communication between the device
and the cloud.
</description>
</parameter>
</config-description>
</thing-type>
<!-- Generic String -->
<channel-type id="generic-output-channel">
<item-type>String</item-type>
<label>Generic Output</label>
<description>
This channel type represents a generic output.
The channel is read-only and its state is represented as a
string.
</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="generic-input-channel">
<item-type>String</item-type>
<label>Generic Input</label>
<description>
This channel type represents a generic input.
The channel is write-only and its state is represented as a
string.
</description>
</channel-type>
<!-- Generic Bool -->
<channel-type id="generic-output-bool-channel">
<item-type>Switch</item-type>
<label>Generic Bool Output</label>
<description>
This channel type represents a generic output.
The channel is read-only and its state is represented as a
boolean.
</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="generic-input-bool-channel">
<item-type>Switch</item-type>
<label>Generic Bool Input</label>
<description>
This channel type represents a generic input.
The channel is write-only and its state is represented as a
boolean.
</description>
</channel-type>
<!-- Generic Number -->
<channel-type id="generic-output-number-channel">
<item-type>Number:Dimensionless</item-type>
<label>Generic Number Output</label>
<description>
This channel type represents a generic output.
The channel is read-only and its state is represented as a
numeric.
</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="generic-input-number-channel">
<item-type>Number:Dimensionsless</item-type>
<label>Generic Number Input</label>
<description>
This channel type represents a generic input.
The channel is write-only and its state is represented as a
numeric.
</description>
</channel-type>
<!-- Temp channels -->
<channel-type id="temperature-output-channel">
<item-type>Number:Temperature</item-type>
<label>Generic Output Temperature</label>
<description>
This channel type represents a generic output.
The channel is read-only and its state is represented as a
temperature (numeric).
</description>
<category>Temperature</category>
<state readOnly="true" pattern="%.1f %unit%"/>
</channel-type>
<channel-type id="temperature-input-channel">
<item-type>Number:Temperature</item-type>
<label>Generic Input Temperature</label>
<description>
This channel type represents a generic input.
The channel is write-only and its state is represented as a
temperature (numeric).
</description>
<category>Temperature</category>
<state pattern="%.1f %unit%"/>
</channel-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,100 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.discovery;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.argThat;
import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import java.util.HashMap;
import java.util.List;
import java.util.Random;
import java.util.TreeSet;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
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.DiscoveryListener;
import org.openhab.core.thing.ThingUID;
/**
* @author Martin Grześlowski - Initial contribution
*/
public class CloudDiscoveryTest {
@Test
@DisplayName("Method filters out disconnected devices and adds connected devices as things using addThing method")
void testFiltersOutDisconnectedDevicesAndAddsConnectedDevicesAsThings() throws SalusApiException {
// Given
var cloudApi = mock(CloudApi.class);
var bridgeHandler = mock(CloudBridgeHandler.class);
var bridgeUid = new ThingUID("salus", "salus-device", "boo");
var discoveryService = new CloudDiscovery(bridgeHandler, cloudApi, bridgeUid);
var discoveryListener = mock(DiscoveryListener.class);
discoveryService.addDiscoveryListener(discoveryListener);
var device1 = randomDevice(true);
var device2 = randomDevice(true);
var device3 = randomDevice(false);
var device4 = randomDevice(false);
var devices = new TreeSet<>(List.of(device1, device2, device3, device4));
given(cloudApi.findDevices()).willReturn(devices);
// When
discoveryService.startScan();
// Then
verify(cloudApi).findDevices();
verify(discoveryListener).thingDiscovered(eq(discoveryService),
argThat(discoveryResult -> discoveryResult.getLabel().equals(device1.name())));
verify(discoveryListener).thingDiscovered(eq(discoveryService),
argThat(discoveryResult -> discoveryResult.getLabel().equals(device2.name())));
verify(discoveryListener, never()).thingDiscovered(eq(discoveryService),
argThat(discoveryResult -> discoveryResult.getLabel().equals(device3.name())));
verify(discoveryListener, never()).thingDiscovered(eq(discoveryService),
argThat(discoveryResult -> discoveryResult.getLabel().equals(device4.name())));
}
@Test
@DisplayName("Cloud API throws an exception during device retrieval, method logs the error")
void testLogsErrorWhenCloudApiThrowsException() throws SalusApiException {
// Given
var cloudApi = mock(CloudApi.class);
var bridgeHandler = mock(CloudBridgeHandler.class);
var bridgeUid = mock(ThingUID.class);
var discoveryService = new CloudDiscovery(bridgeHandler, cloudApi, bridgeUid);
given(cloudApi.findDevices()).willThrow(new SalusApiException("API error"));
// When
discoveryService.startScan();
// Then
// no error is thrown, OK
}
private Device randomDevice(boolean connected) {
var random = new Random();
var map = new HashMap<String, Object>();
if (connected) {
map.put("connection_status", "online");
}
return new Device("dsn-" + random.nextInt(), "name-" + random.nextInt(), map);
}
}

View File

@ -0,0 +1,228 @@
/**
* 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 org.assertj.core.api.Assertions.assertThat;
import java.util.HashMap;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* @author Martin Grześlowski - Initial contribution
*/
@SuppressWarnings("EqualsWithItself")
@NonNullByDefault
class DeviceTest {
// Returns true if 'connection_status' property exists and is set to 'online'
@Test
@DisplayName("Returns true if 'connection_status' property exists and is set to 'online'")
public void testReturnsTrueIfConnectionStatusPropertyExistsAndIsSetToOnline() {
// Given
var properties = new HashMap<String, @Nullable Object>();
properties.put("connection_status", "online");
var device = new Device("dsn", "name", properties);
// When
var result = device.isConnected();
// Then
assertThat(result).isTrue();
}
// Returns false if 'connection_status' property exists and is not set to 'online'
@Test
@DisplayName("Returns false if 'connection_status' property exists and is not set to 'online'")
public void testReturnsFalseIfConnectionStatusPropertyExistsAndIsNotSetToOnline() {
// Given
var properties = new HashMap<String, @Nullable Object>();
properties.put("connection_status", "offline");
var device = new Device("dsn", "name", properties);
// When
var result = device.isConnected();
// Then
assertThat(result).isFalse();
}
// Returns false if 'connection_status' property does not exist
@Test
@DisplayName("Returns false if 'connection_status' property does not exist")
public void testReturnsFalseIfConnectionStatusPropertyDoesNotExist() {
// Given
var properties = new HashMap<String, @Nullable Object>();
var device = new Device("dsn", "name", properties);
// When
var result = device.isConnected();
// Then
assertThat(result).isFalse();
}
// Returns false if 'properties' parameter does not contain 'connection_status' key
@Test
@DisplayName("Returns false if 'properties' parameter does not contain 'connection_status' key")
public void testReturnsFalseIfPropertiesParameterDoesNotContainConnectionStatusKey() {
// Given
var properties = new HashMap<String, @Nullable Object>();
var device = new Device("dsn", "name", properties);
// When
var result = device.isConnected();
// Then
assertThat(result).isFalse();
}
// Returns false if 'connection_status' property is null
@Test
@DisplayName("Returns false if 'connection_status' property is null")
public void testReturnsFalseIfConnectionStatusPropertyIsNull() {
// Given
var properties = new HashMap<String, @Nullable Object>();
properties.put("connection_status", null);
var device = new Device("dsn", "name", properties);
// When
var result = device.isConnected();
// Then
assertThat(result).isFalse();
}
// Returns false if 'connection_status' property is not a string
@Test
@DisplayName("Returns false if 'connection_status' property is not a string")
public void testReturnsFalseIfConnectionStatusPropertyIsNotAString() {
// Given
var properties = new HashMap<String, @Nullable Object>();
properties.put("connection_status", 123);
var device = new Device("dsn", "name", properties);
// When
var result = device.isConnected();
// Then
assertThat(result).isFalse();
}
// Creating a new Device object with valid parameters should succeed.
@Test
@DisplayName("Creating a new Device object with valid parameters should succeed")
public void testCreatingNewDeviceWithValidParametersShouldSucceed() {
// Given
String dsn = "123456";
String name = "Device 1";
Map<String, @Nullable Object> properties = Map.of("connection_status", "online");
// When
Device device = new Device(dsn, name, properties);
// Then
assertThat(device).isNotNull();
assertThat(device.dsn()).isEqualTo(dsn);
assertThat(device.name()).isEqualTo(name);
assertThat(device.properties()).isEqualTo(properties);
}
// Two Device objects with the same DSN should be considered equal.
@Test
@DisplayName("Two Device objects with the same DSN should be considered equal")
public void testTwoDevicesWithSameDsnShouldBeEqual() {
// Given
String dsn = "123456";
String name1 = "Device 1";
String name2 = "Device 2";
Map<String, @Nullable Object> properties = Map.of("connection_status", "online");
Device device1 = new Device(dsn, name1, properties);
Device device2 = new Device(dsn, name2, properties);
// When
boolean isEqual = device1.equals(device2);
// Then
assertThat(isEqual).isTrue();
}
// The compareTo method should correctly compare two Device objects based on their DSNs.
@Test
@DisplayName("The compareTo method should correctly compare two Device objects based on their DSNs")
public void testCompareToMethodShouldCorrectlyCompareDevicesBasedOnDsn() {
// Given
String dsn1 = "123456";
String dsn2 = "654321";
String name = "Device";
Map<String, @Nullable Object> properties = Map.of("connection_status", "online");
Device device1 = new Device(dsn1, name, properties);
Device device2 = new Device(dsn2, name, properties);
// When
int result1 = device1.compareTo(device2);
int result2 = device2.compareTo(device1);
int result3 = device1.compareTo(device1);
// Then
assertThat(result1).isNegative();
assertThat(result2).isPositive();
assertThat(result3).isZero();
}
// The isConnected method should return true if the connection_status property is "online".
@Test
@DisplayName("The isConnected method should return true if the connection_status property is \"online\"")
public void testIsConnectedMethodShouldReturnTrueIfConnectionStatusIsOnline() {
// Given
String dsn = "123456";
String name = "Device";
Map<String, @Nullable Object> properties1 = Map.of("connection_status", "online");
Map<String, @Nullable Object> properties2 = Map.of("connection_status", "offline");
Device device1 = new Device(dsn, name, properties1);
Device device2 = new Device(dsn, name, properties2);
// When
boolean isConnected1 = device1.isConnected();
boolean isConnected2 = device2.isConnected();
// Then
assertThat(isConnected1).isTrue();
assertThat(isConnected2).isFalse();
}
// The toString method should return a string representation of the Device object with its DSN and name.
@Test
@DisplayName("The toString method should return a string representation of the Device object with its DSN and name")
public void testToStringMethodShouldReturnStringRepresentationWithDsnAndName() {
// Given
String dsn = "123456";
String name = "Device";
Map<String, @Nullable Object> properties = Map.of("connection_status", "online");
Device device = new Device(dsn, name, properties);
// When
String result = device.toString();
// Then
assertThat(result).isEqualTo("Device{dsn='123456', name='Device'}");
}
}

View File

@ -0,0 +1,119 @@
/**
* 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 org.assertj.core.api.Assertions.assertThat;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
/**
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
public class GsonMapperTest {
// Can serialize login parameters to JSON
@Test
public void testSerializeLoginParametersToJson() {
// Given
GsonMapper gsonMapper = GsonMapper.INSTANCE;
String username = "test@example.com";
char[] password = "password".toCharArray();
String expectedJson1 = "{\"user\":{\"email\":\"test@example.com\",\"password\":\"password\"}}";
String expectedJson2 = "{\"user\":{\"password\":\"password\",\"email\":\"test@example.com\"}}";
// When
String json = gsonMapper.loginParam(username, password);
// Then
assertThat(json).isIn(expectedJson1, expectedJson2);
}
// Can deserialize authentication token from JSON
@Test
public void testDeserializeAuthenticationTokenFromJson() {
// Given
GsonMapper gsonMapper = GsonMapper.INSTANCE;
String json = "{\"access_token\":\"token\",\"refresh_token\":\"refresh\",\"expires_in\":3600,\"role\":\"admin\"}";
AuthToken expectedAuthToken = new AuthToken("token", "refresh", 3600L, "admin");
// When
AuthToken authToken = gsonMapper.authToken(json);
// Then
assertThat(authToken).isEqualTo(expectedAuthToken);
}
// Can parse list of devices from JSON
@Test
public void testParseListOfDevicesFromJson() {
// Given
GsonMapper gsonMapper = GsonMapper.INSTANCE;
String json = "[{\"device\":{\"dsn\":\"123\",\"product_name\":\"Product 1\"}},{\"device\":{\"dsn\":\"456\",\"product_name\":\"Product 2\"}}]";
List<Device> expectedDevices = List.of(new Device("123", "Product 1", Collections.emptyMap()),
new Device("456", "Product 2", Collections.emptyMap()));
// When
List<Device> devices = gsonMapper.parseDevices(json);
// Then
assertThat(devices).isEqualTo(expectedDevices);
}
// Returns empty list when parsing invalid JSON for devices
@Test
public void testReturnsEmptyListWhenParsingInvalidJsonForDevices() {
// Given
GsonMapper gsonMapper = GsonMapper.INSTANCE;
String json = "invalid json";
// When
List<Device> devices = gsonMapper.parseDevices(json);
// Then
assertThat(devices).isEmpty();
}
// Returns empty list when parsing invalid JSON for device properties
@Test
public void testReturnsEmptyListWhenParsingInvalidJsonForDeviceProperties() {
// Given
GsonMapper gsonMapper = GsonMapper.INSTANCE;
String json = "invalid json";
// When
List<DeviceProperty<?>> deviceProperties = gsonMapper.parseDeviceProperties(json);
// Then
assertThat(deviceProperties).isEmpty();
}
// Returns empty optional when parsing invalid JSON for datapoint value
@Test
public void testReturnsEmptyOptionalWhenParsingInvalidJsonForDatapointValue() {
// Given
GsonMapper gsonMapper = GsonMapper.INSTANCE;
String json = "invalid json";
// When
Optional<Object> datapointValue = gsonMapper.datapointValue(json);
// Then
assertThat(datapointValue).isEmpty();
}
}

View File

@ -0,0 +1,133 @@
/**
* 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 static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openhab.binding.salus.internal.rest.RestClient.Content;
import org.openhab.binding.salus.internal.rest.RestClient.Header;
/**
* @author Martin Grześlowski - Initial contribution
*/
@SuppressWarnings("DataFlowIssue")
@NonNullByDefault
@ExtendWith(MockitoExtension.class)
class RetryHttpClientTest {
@Mock
@Nullable
RestClient restClient;
String url = "https://example.com";
Header header = new Header("Authorization", "Bearer token");
Header[] headers = new Header[] { header };
String response = "Success";
Content content = new Content("Request body");
@Test
@DisplayName("get method retries calling restClient.get up to maxRetries times until it succeeds")
void testGetMethodRetriesUntilSucceeds() throws Exception {
// Given
var maxRetries = 4;
var retryHttpClient = new RetryHttpClient(requireNonNull(restClient), maxRetries);
given(restClient.get(url, headers))//
.willThrow(new SalusApiException("1")) //
.willThrow(new HttpSalusApiException(404, "2")) //
.willThrow(new SalusApiException("3")) //
.willReturn(response);
// When
var result = retryHttpClient.get(url, headers);
// Then
assertThat(result).isEqualTo(response);
verify(restClient, times(4)).get(url, headers);
}
@Test
@DisplayName("post method retries calling restClient.post up to maxRetries times until it succeeds")
void testPostMethodRetriesUntilSucceeds() throws SalusApiException {
// Given
int maxRetries = 4;
var retryHttpClient = new RetryHttpClient(requireNonNull(restClient), maxRetries);
given(restClient.post(url, content, headers))//
.willThrow(new SalusApiException("1")) //
.willThrow(new HttpSalusApiException(404, "2")) //
.willThrow(new SalusApiException("3")) //
.willReturn(response);
// When
var result = retryHttpClient.post(url, content, headers);
// Then
assertThat(result).isEqualTo(response);
verify(restClient, times(4)).post(url, content, headers);
}
@Test
@DisplayName("get method logs debug messages when it fails and retries")
public void testGetMethodLogsDebugMessagesWhenFailsAndRetries() throws SalusApiException {
// Given
var maxRetries = 3;
var retryHttpClient = new RetryHttpClient(requireNonNull(restClient), maxRetries);
given(restClient.get(url, headers)).willThrow(new RuntimeException("Error"));
// When
assertThatThrownBy(() -> retryHttpClient.get(url, headers))//
.isInstanceOf(RuntimeException.class)//
.hasMessage("Error");
}
@Test
@DisplayName("post method logs debug messages when it fails and retries")
public void testPostMethodLogsDebugMessagesWhenFailsAndRetries() throws SalusApiException {
// Given
int maxRetries = 3;
var retryHttpClient = new RetryHttpClient(requireNonNull(restClient), maxRetries);
given(restClient.post(url, content, headers)).willThrow(new RuntimeException("Error"));
// When
assertThatThrownBy(() -> retryHttpClient.post(url, content, headers))//
.isInstanceOf(RuntimeException.class)//
.hasMessage("Error");
}
@Test
@DisplayName("RetryHttpClient throws an IllegalArgumentException if maxRetries is less than or equal to 0")
public void testThrowsIllegalArgumentExceptionIfMaxRetriesLessThanOrEqualTo0() {
// Given
RestClient restClient = mock(RestClient.class);
int maxRetries = 0;
// When/Then
assertThatThrownBy(() -> new RetryHttpClient(restClient, maxRetries))//
.isInstanceOf(IllegalArgumentException.class)//
.hasMessage("maxRetries cannot be lower or equal to 0, but was " + maxRetries);
}
}

View File

@ -0,0 +1,292 @@
/**
* 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 org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.endsWith;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.time.Clock;
import java.util.ArrayList;
import java.util.Optional;
import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* @author Martin Grześlowski - Initial contribution
*/
@SuppressWarnings("DataFlowIssue")
@NonNullByDefault
public class SalusApiTest {
// Find devices returns sorted set of devices
@Test
@DisplayName("Find devices returns sorted set of devices")
public void testFindDevicesReturnsSortedSetOfDevices() throws Exception {
// Given
var username = "correct_username";
var password = "correct_password".toCharArray();
var baseUrl = "https://example.com";
var restClient = mock(RestClient.class);
var mapper = mock(GsonMapper.class);
var clock = Clock.systemDefaultZone();
var authToken = new AuthToken("access_token", "refresh_token", 3600L, "role");
var response = "devices_json";
when(restClient.get(anyString(), any())).thenReturn(response);
var devices = new ArrayList<Device>();
when(mapper.parseDevices(anyString())).thenReturn(devices);
var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock);
setAuthToken(salusApi, restClient, mapper, authToken);
// When
var result = salusApi.findDevices();
// Then
assertThat(result).containsExactlyInAnyOrderElementsOf(devices);
}
// Find device properties returns sorted set of device properties
@Test
@DisplayName("Find device properties returns sorted set of device properties")
public void testFindDevicePropertiesReturnsSortedSetOfDeviceProperties() throws Exception {
// Given
var username = "correct_username";
var password = "correct_password".toCharArray();
var baseUrl = "https://example.com";
var restClient = mock(RestClient.class);
var mapper = mock(GsonMapper.class);
var clock = Clock.systemDefaultZone();
var authToken = new AuthToken("access_token", "refresh_token", 3600L, "role");
var response = "device_properties_json";
when(restClient.get(anyString(), any())).thenReturn(response);
var deviceProperties = new ArrayList<DeviceProperty<?>>();
when(mapper.parseDeviceProperties(anyString())).thenReturn(deviceProperties);
var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock);
setAuthToken(salusApi, restClient, mapper, authToken);
// When
var result = salusApi.findDeviceProperties("dsn");
// Then
assertThat(result).containsExactlyInAnyOrderElementsOf(deviceProperties);
}
// Set value for property returns OK response with datapoint value
@Test
@DisplayName("Set value for property returns OK response with datapoint value")
public void testSetValueForPropertyReturnsOkResponseWithDatapointValue() throws Exception {
// Given
var username = "correct_username";
var password = "correct_password".toCharArray();
var baseUrl = "https://example.com";
var restClient = mock(RestClient.class);
var mapper = mock(GsonMapper.class);
var clock = Clock.systemDefaultZone();
var authToken = new AuthToken("access_token", "refresh_token", 3600L, "role");
var response = "datapoint_value_json";
when(restClient.post(anyString(), any(), any())).thenReturn(response);
var datapointValue = new Object();
when(mapper.datapointValue(anyString())).thenReturn(Optional.of(datapointValue));
var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock);
setAuthToken(salusApi, restClient, mapper, authToken);
// When
var result = salusApi.setValueForProperty("dsn", "property_name", "value");
// Then
assertThat(result).isEqualTo(datapointValue);
}
// Login with incorrect credentials throws HttpUnauthorizedException
@Test
@DisplayName("Login with incorrect credentials throws HttpUnauthorizedException")
public void testLoginWithIncorrectCredentialsThrowsHttpUnauthorizedException() throws Exception {
// Given
var username = "incorrect_username";
var password = "incorrect_password".toCharArray();
var baseUrl = "https://example.com";
var restClient = mock(RestClient.class);
var mapper = mock(GsonMapper.class);
var clock = Clock.systemDefaultZone();
when(restClient.post(anyString(), any(), any()))
.thenThrow(new HttpSalusApiException(401, "unauthorized_error_json"));
var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock);
// When
ThrowingCallable findDevicesResponse = salusApi::findDevices;
// Then
assertThatThrownBy(findDevicesResponse).isInstanceOf(HttpSalusApiException.class)
.hasMessage("HTTP Error 401: unauthorized_error_json");
}
// Find devices with invalid auth token throws HttpUnauthorizedException
@Test
@DisplayName("Find devices with invalid auth token throws HttpUnauthorizedException")
public void testFindDevicesWithInvalidAuthTokenThrowsHttpUnauthorizedException() throws Exception {
// Given
var username = "correct_username";
var password = "correct_password".toCharArray();
var baseUrl = "https://example.com";
var restClient = mock(RestClient.class);
var mapper = mock(GsonMapper.class);
var clock = Clock.systemDefaultZone();
var authToken = new AuthToken("access_token", "refresh_token", 3600L, "role");
when(restClient.get(anyString(), any())).thenThrow(new HttpSalusApiException(401, "unauthorized_error_json"));
var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock);
setAuthToken(salusApi, restClient, mapper, authToken);
// When
ThrowingCallable objectApiResponse = salusApi::findDevices;
// Then
assertThatThrownBy(objectApiResponse).isInstanceOf(HttpSalusApiException.class)
.hasMessage("HTTP Error 401: unauthorized_error_json");
}
// Find device properties with invalid auth token throws HttpUnauthorizedException
@Test
@DisplayName("Find device properties with invalid auth token throws HttpUnauthorizedException")
public void testFindDevicePropertiesWithInvalidAuthTokenThrowsHttpUnauthorizedException() throws Exception {
// Given
var username = "correct_username";
var password = "correct_password".toCharArray();
var baseUrl = "https://example.com";
var restClient = mock(RestClient.class);
var mapper = mock(GsonMapper.class);
var clock = Clock.systemDefaultZone();
var authToken = new AuthToken("access_token", "refresh_token", 3600L, "role");
when(restClient.get(anyString(), any())).thenThrow(new HttpSalusApiException(401, "unauthorized_error_json"));
var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock);
setAuthToken(salusApi, restClient, mapper, authToken);
// When
ThrowingCallable objectApiResponse = () -> salusApi.findDeviceProperties("dsn");
// Given
assertThatThrownBy(objectApiResponse).isInstanceOf(HttpSalusApiException.class)
.hasMessage("HTTP Error 401: unauthorized_error_json");
}
// Set value for property with invalid auth token throws HttpUnauthorizedException
@Test
@DisplayName("Set value for property with invalid auth token throws HttpUnauthorizedException")
public void testSetValueForPropertyWithInvalidAuthTokenThrowsHttpUnauthorizedException() throws Exception {
// Given
var username = "correct_username";
var password = "correct_password".toCharArray();
var baseUrl = "https://example.com";
var restClient = mock(RestClient.class);
var mapper = mock(GsonMapper.class);
var clock = Clock.systemDefaultZone();
var authToken = new AuthToken("access_token", "refresh_token", 3600L, "role");
when(restClient.post(anyString(), any(), any()))
.thenThrow(new HttpSalusApiException(401, "unauthorized_error_json"));
var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock);
// When
ThrowingCallable objectApiResponse = () -> salusApi.setValueForProperty("dsn", "property_name", "value");
// given
assertThatThrownBy(objectApiResponse).isInstanceOf(HttpSalusApiException.class)
.hasMessage("HTTP Error 401: unauthorized_error_json");
}
// Find device properties with invalid DSN returns ApiResponse with error
@Test
@DisplayName("Find device properties with invalid DSN returns ApiResponse with error")
public void testFindDevicePropertiesWithInvalidDsnReturnsApiResponseWithError() throws Exception {
// Given
var username = "correct_username";
var password = "correct_password".toCharArray();
var baseUrl = "https://example.com";
var restClient = mock(RestClient.class);
var mapper = mock(GsonMapper.class);
var clock = Clock.systemDefaultZone();
var authToken = new AuthToken("access_token", "refresh_token", 3600L, "role");
when(restClient.get(anyString(), any())).thenThrow(new HttpSalusApiException(404, "not found"));
var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock);
setAuthToken(salusApi, restClient, mapper, authToken);
// When
ThrowingCallable result = () -> salusApi.findDeviceProperties("invalid_dsn");
// Then
assertThatThrownBy(result).isInstanceOf(HttpSalusApiException.class).hasMessage("HTTP Error 404: not found");
}
// Login with incorrect credentials 3 times throws HttpForbiddenException
@Test
@DisplayName("Login with incorrect credentials 3 times throws HttpForbiddenException")
public void testLoginWithIncorrectCredentials3TimesThrowsHttpForbiddenException() throws Exception {
// Given
var username = "incorrect_username";
var password = "incorrect_password".toCharArray();
var baseUrl = "https://example.com";
var restClient = mock(RestClient.class);
var mapper = mock(GsonMapper.class);
var clock = Clock.systemDefaultZone();
when(restClient.post(anyString(), any(), any()))
.thenThrow(new HttpSalusApiException(403, "forbidden_error_json"));
var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock);
// When
ThrowingCallable findDevicesResponse = salusApi::findDevices;
// Then
assertThatThrownBy(findDevicesResponse).isInstanceOf(HttpSalusApiException.class)
.hasMessage("HTTP Error 403: forbidden_error_json");
}
private void setAuthToken(SalusApi salusApi, RestClient restClient, GsonMapper mapper, AuthToken authToken)
throws SalusApiException {
var username = "correct_username";
var password = "correct_password".toCharArray();
var inputBody = "login_param_json";
when(mapper.loginParam(username, password)).thenReturn(inputBody);
var authTokenJson = "auth_token";
when(mapper.authToken(authTokenJson)).thenReturn(authToken);
when(restClient.post(endsWith("/users/sign_in.json"), eq(new RestClient.Content(inputBody, "application/json")),
any())).thenReturn(authTokenJson);
}
}

View File

@ -0,0 +1,13 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<logger name="org.openhab.binding.salus" level="DEBUG" />
<root level="INFO">
<appender-ref ref="STDOUT" />
</root>
</configuration>

View File

@ -343,6 +343,7 @@
<module>org.openhab.binding.russound</module>
<module>org.openhab.binding.sagercaster</module>
<module>org.openhab.binding.saicismart</module>
<module>org.openhab.binding.salus</module>
<module>org.openhab.binding.samsungtv</module>
<module>org.openhab.binding.satel</module>
<module>org.openhab.binding.semsportal</module>