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