mirror of
https://github.com/openhab/openhab-addons.git
synced 2025-01-10 07:02:02 +01:00
[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:
parent
bae5b0c939
commit
38e4f220a7
@ -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
|
||||
|
@ -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>
|
||||
|
30
bundles/org.openhab.binding.salus/NOTICE
Normal file
30
bundles/org.openhab.binding.salus/NOTICE
Normal 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
|
979
bundles/org.openhab.binding.salus/README.md
Normal file
979
bundles/org.openhab.binding.salus/README.md
Normal 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.
|
||||
|
57
bundles/org.openhab.binding.salus/pom.xml
Normal file
57
bundles/org.openhab.binding.salus/pom.xml
Normal 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>
|
@ -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>
|
@ -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");
|
||||
}
|
||||
}
|
@ -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<>());
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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
|
||||
+ '}';
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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 + '\'' + '}';
|
||||
}
|
||||
}
|
@ -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 + '\'' + '}';
|
||||
}
|
||||
}
|
@ -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 : "";
|
||||
}
|
||||
}
|
||||
}
|
@ -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) {
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
@ -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!");
|
||||
}
|
||||
}
|
@ -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"));
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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>
|
@ -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!
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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);
|
||||
}
|
||||
}
|
@ -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'}");
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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>
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user