[argoclima] Initial contribution (#15481)

* Initial contribution of the ArgoClima binding

Signed-off-by: Mateusz Bronk <bronk.m+gh@gmail.com>
Signed-off-by: Ciprian Pascu <contact@ciprianpascu.ro>
This commit is contained in:
Mateusz Bronk 2024-07-01 19:41:04 +02:00 committed by Ciprian Pascu
parent 5a24cc6f52
commit 24b3d6b7c1
70 changed files with 10041 additions and 0 deletions

View File

@ -29,6 +29,7 @@
/bundles/org.openhab.binding.androidtv/ @morph166955
/bundles/org.openhab.binding.anel/ @paphko
/bundles/org.openhab.binding.anthem/ @mhilbush
/bundles/org.openhab.binding.argoclima/ @mbronk
/bundles/org.openhab.binding.asuswrt/ @wildcs
/bundles/org.openhab.binding.astro/ @gerrieg
/bundles/org.openhab.binding.atlona/ @mlobstein

View File

@ -136,6 +136,11 @@
<artifactId>org.openhab.binding.anthem</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.argoclima</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.astro</artifactId>

View File

@ -0,0 +1,13 @@
This content is produced and maintained by the openHAB project.
* Project home: https://www.openhab.org
== Declared Project Licenses
This program and the accompanying materials are made available under the terms
of the Eclipse Public License 2.0 which is available at
https://www.eclipse.org/legal/epl-2.0/.
== Source Code
https://github.com/openhab/openhab-addons

View File

@ -0,0 +1,429 @@
# ArgoClima Binding
The binding provides support for [ArgoClima](https://argoclima.com/en/) Wi-Fi-enabled air conditioning devices which use ***Argo Web APP*** for control.
Refer to [Argo Web APP details](#argo-web-app-details) section for an example.
> ***IMPORTANT:*** The same vendor also manufactures HVAC devices supported by a [phone application](http://smart.argoclima.com/EN/).
>
> These devices are using a different protocol and are ***not*** supported by this binding.
> There are good chances these will be supported by the [Gree](https://www.openhab.org/addons/bindings/gree/) binding, though!
The binding supports all HVAC remote functions (including built-in schedule and settings) except for ***iFeel*** (room) temperature which is not supported by the Argo remote protocol and has to be sent via infrared.
The binding can operate in local, remote and hybrid modes.
Refer to [Connection Modes](#connection-modes) for more details.
See also [Argo protocol details](#argo-protocol-details) to find out more about how the device operates.
## Supported Things
- `remote`: Represents a HVAC device which is controlled remotely - through vendor's web application
- `local`: Represents a locally available device, which openHAB interacts with directly *(or indirectly, through a stub server)*. Refer to [Connection Modes](#connection-modes) for more details.
The binding has been primarily developed and tested using [Ulisse 13 DCI ECO Wi-Fi](https://argoclima.com/en/prodotti/argo-ulisse-eco/) device.
## Discovery
The binding does not support device auto-discovery (as the devices don't announce themselves locally).
- Note it is *technically* possible for the advanced mode with API stub to discover devices, but as it requires manual firewall reconfiguration, it won't be an "auto" anyway so was not implemented.
## Thing Configuration
### `remote` Thing Configuration
| Name | Type | Description | Default | Required | Advanced |
|------------------|---------|---------------------------------------------------------------|---------------|----------|----------|
| **username** | text | Remote site login (can be retrieved from device during setup) | N/A | yes | no |
| **password** | text | Password to access the device | N/A | yes | no |
| refreshInterval | integer | Interval the remote API is polled with (in sec.) | 30 | no | yes |
| oemServerAddress | text | The Argo server's IP or hostname, used for communications. | 31.14.128.210 | no | yes |
| oemServerPort | integer | The Argo server's port. | 80 | no | yes |
### `local` Thing Configuration
| Name | Type | Description | Default | Required | Advanced |
|-------------------------------------------|----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------|----------|----------|
| **hostname** | text | Hostname or IP address of the HVAC device. If ```useLocalConnection``` setting is enabled, **this** address will be used for direct communication with the device. | N/A | yes | no |
| connectionMode | text | Type of connection to use. One of: ```LOCAL_CONNECTION```, ```REMOTE_API_STUB```, ```REMOTE_API_PROXY```. Refer to [Connection Modes](#connection-modes) | LOCAL_CONNECTION | yes | no |
| hvacListenPort | integer | Port at which the HVAC listens on (used if ```useLocalConnection== true```) | 1001 | no | yes |
| localDeviceIP | text | Local IP address of the device for matching intercepted requests (may be different from **hostname**, if behind NAT). Used in ```REMOTE_API_*``` modes. | N/A | no | yes |
| deviceCpuId | text | CPU ID of the device. Optional, value is used for detecting device update in proxy mode. Used in ```REMOTE_API_*``` modes. | N/A | no | yes |
| useLocalConnection | boolean | Whether the binding is permitted to talk to the device directly. | yes | no | yes |
| refreshInterval | integer | Interval the device is polled in (in sec.). Used if ```useLocalConnection== true``` | 30 | no | yes |
| stubServerPort | integer | Stub server listen port Used in ```REMOTE_API_*``` modes. | 8239 | no | yes |
| stubServerListenAddresses | text(multiple) | List of interfaces the stub server will listen on Used in ```REMOTE_API_*``` modes. | 0.0.0.0 | no | yes |
| oemServerAddress | text | The Argo server's IP or hostname, used for pass-through. Used in ```REMOTE_API_PROXY``` mode | 31.14.128.210 | no | yes |
| oemServerPort | integer | The Argo server's port. Used in ```REMOTE_API_PROXY``` mode | 80 | no | yes |
| includeDeviceSidePasswordsInProperties | text | Whether to show the intercepted passwords (in ```REMOTE_API_*``` modes) as Thing Properties. One of: ```NEVER```, ```MASKED```, ```CLEARTEXT``` | NEVER | no | yes |
| matchAnyIncomingDeviceIp | boolean | If enabled, will accept any Argo message as matching this Thing (instead of requiring an exact-match by ```hostname``` or ```localDeviceIP```). Used in ```REMOTE_API_*``` modes. | no | no | yes |
### General Device Configuration (dynamic)
These parameters are modeled as thing configuration, but are actually configuring behavior of the HVAC device itself (when in certain modes).
The same values apply to **both** `remote` and `local`.
| Name | Type | Description | Default | Required | Advanced |
|------------------------|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------|----------|----------|
| schedule1DayOfWeek | text(multiple) | Days (set comprising of values ```MON```, ```TUE```, ```WED```, ```THU```, ```FRI```, ```SAT```, ``SUN``), when Schedule Timer 1 actions should be performed. This is used only if ```active-timer``` [channel](#channels) is in ```SCHEDULE_TIMER_1``` mode. | [MON, TUE, WED, THU, FRI, SAT, SUN] | no | yes |
| schedule1OnTime | text | The time of day (HH:MM) the device should turn **ON** *(in the last used mode)* on the ```schedule1DayOfWeek```-specified days. In effect only if ```active-timer``` [channel](#channels) is in ```SCHEDULE_TIMER_1``` mode | 8:00 | no | yes |
| schedule1OffTime | text | The time of day (HH:MM) the device should turn **OFF** on the ```schedule1DayOfWeek```-specified days. In effect only if ```active-timer``` [channel](#channels) is in ```SCHEDULE_TIMER_1``` mode | 18:00 | no | yes |
| schedule2DayOfWeek | text(multiple) | Days (set comprising of values ```MON```, ```TUE```, ```WED```, ```THU```, ```FRI```, ```SAT```, ``SUN``), when Schedule Timer 1 actions should be performed. This is used only if ```active-timer``` [channel](#channels) is in ```SCHEDULE_TIMER_1``` mode. | [MON, TUE, WED, THU, FRI] | no | yes |
| schedule2OnTime | text | The time of day (HH:MM) the device should turn **ON** *(in the last used mode)* on the ```schedule2DayOfWeek```-specified days. In effect only if ```active-timer``` [channel](#channels) is in ```SCHEDULE_TIMER_2``` mode | 15:00 | no | yes |
| schedule2OffTime | text | The time of day (HH:MM) the device should turn **OFF** on the ```schedule2DayOfWeek```-specified days. In effect only if ```active-timer``` [channel](#channels) is in ```SCHEDULE_TIMER_2``` mode | 20:00 | no | yes |
| schedule3DayOfWeek | text(multiple) | Days (set comprising of values ```MON```, ```TUE```, ```WED```, ```THU```, ```FRI```, ```SAT```, ``SUN``), when Schedule Timer 1 actions should be performed. This is used only if ```active-timer``` [channel](#channels) is in ```SCHEDULE_TIMER_1``` mode. | [SAT, SUN] | no | yes |
| schedule3OnTime | text | The time of day (HH:MM) the device should turn **ON** *(in the last used mode)* on the ```schedule3DayOfWeek```-specified days. In effect only if ```active-timer``` [channel](#channels) is in ```SCHEDULE_TIMER_3``` mode | 11:00 | no | yes |
| schedule3OffTime | text | The time of day (HH:MM) the device should turn **OFF** on the ```schedule3DayOfWeek```-specified days. In effect only if ```active-timer``` [channel](#channels) is in ```SCHEDULE_TIMER_3``` mode | 22:00 | no | yes |
| resetToFactoryDefaults | boolean(action) | When set, upon successful Thing initialization, the binding will issue a one-time factory reset request to the device (and flip this value back do OFF) | false | no | yes |
## Channels
Both thing types are functionally equivalent and support the same channels.
| Channel | Type | Read/Write | Description |
|--------------------------|----------------------|------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| - **A/C Controls** ||||
| power | Switch | RW | This is the control channel |
| mode | String | RW | Operation mode. One of: ```COOL```, ```DRY```, ```FAN```, ```AUTO``` |
| set-temperature | Number:Temperature | RW | The device's target temperature |
| current-temperature | Number:Temperature | R | Actual (ambient) temperature. Either from device's built-in sensor or iFeel. Read-only, see also: [Room Temperature Support](#room-temperature-support) |
| fan-speed | String | RW | Fan mode. One of: ```AUTO```, ```LEVEL_1```, ```LEVEL_2```, ```LEVEL_3```, ```LEVEL_4```, ```LEVEL_5```, ```LEVEL_6``` |
| - **Operation Modes** ||||
| eco-mode | Switch | RW | Economy (Energy Saving) Mode (cap device max power to the ```eco-power-limit```) |
| turbo-mode | Switch | RW | Turbo mode (max power). *While the device API (similarly to original remote) allows enabling ```turbo``` **while** ```night``` and/or ```economy``` modes are **active**, actual effect of such a combo is unknown :)* |
| night-mode | Switch | RW | Night mode *(lowers device noise by lowering the fan speed and automatically raising the set temperature by 1°C after 60 minutes of enabling this option)* |
| - **Timers (advanced)** ||||
| active-timer | String | RW | Active timer. One of ```NO_TIMER```, ```DELAY_TIMER```, ```SCHEDULE_TIMER_1```, ```SCHEDULE_TIMER_2```, ```SCHEDULE_TIMER_3```. See also [schedule configuration](#general-device-configuration-dynamic) |
| delay-timer | Number:Time | W | Delay timer value. In effect only if ```active-timer``` is in ```DELAY_TIMER``` mode. The delay timer toggles the current ```power``` (ex. OFF->ON) after the configured period elapses |
| - **Settings** ||||
| ifeel-enabled | Switch | RW | Use iFeel Temperature updates for ```current-temperature``` |
| device-lights | Switch | RW | Device Lights |
| temperature-display-unit | String | W | **(advanced)** Unit's display temperature display unit. One of ```SCALE_CELSIUS```, ```SCALE_FARHENHEIT``` |
| eco-power-limit | Number:Dimensionless | W | **(advanced)** Power limit in eco mode (in %, factory default is 75%), |
| - **Advanced (not supported by all devices)** ||||
| mode-ex | String | RW | Extended Operation mode. Same as ```mode```, but also supports ```WARM``` |
| swing-mode | String | RW | Airflow Direction (flap setting). One of ```AUTO```, ```LEVEL_1```, ```LEVEL_2```, ```LEVEL_3```, ```LEVEL_4```, ```LEVEL_5```, ```LEVEL_6```, ```LEVEL_7``` |
| filter-mode | Switch | RW | Filter Mode |
## Full Example
### argoclima.things
```java
//BASIC MODES examples
Thing argoclima:remote:argoHvacRemote "Argo HVAC (via Argo remote API)" @ "Living Room" [
username="<yourArgoLogin>",
password="<yourArgoPassword>"
]
Thing argoclima:local:argoHvacLocalDirect "Argo HVAC (connected locally)" @ "Living Room" [
hostname="192.168.0.3"
]
Thing argoclima:local:argoHvacLocalDirectEx "Argo HVAC (connected locally) - extended example (with explicit options)" [
hostname="192.168.0.3",
connectionMode="LOCAL_CONNECTION",
refreshInterval=30,
hvacListenPort=1001,
// Schedule options (these are valid for all thing types)
schedule1DayOfWeek="[FRI, SAT, SUN, MON]",
schedule1OnTime="7:35",
schedule1OffTime="18:00",
schedule2DayOfWeek="[MON, TUE, WED, THU, FRI]",
schedule2OnTime="15:00",
schedule2OffTime="22:00",
schedule3DayOfWeek="SUN","SAT", //Alternative syntax for the weekdays list
schedule3OnTime="11:00",
schedule3OffTime="22:00"
//,resetToFactoryDefaults=true //This triggers a one-shot command each time the thing
// definition is (re)loaded from file.
// Use only intermittently - it is not designed with prolonged
// usage via Things text file in mind (mostly a MainUI feature!)
]
//ADVANCED MODES examples
Thing argoclima:local:argoHvacLocalWithPassthroughIndirect "Argo HVAC (accessible only indirectly, via pass-through mode)" [
hostname="192.168.4.2", // Doesn't have to be reachable!
connectionMode="REMOTE_API_PROXY",
useLocalConnection=false
]
Thing argoclima:local:argoHvacLocalWithPassthroughPlusDirectEx "Argo HVAC (accessible both indirectly and directly, via pass-through mode, with explicit options)" [
hostname="192.168.0.3", // Direct address of the device (reachable from openHAB)
connectionMode="REMOTE_API_PROXY",
hvacListenPort=1001,
refreshInterval=30,
useLocalConnection=true,
// Stub server-specific
stubServerPort=8240,
stubServerListenAddresses="7d47:86bd:0bfe:0413:4688:4523:4284:5936","192.168.0.195",
includeDeviceSidePasswordsInProperties="MASKED",
matchAnyIncomingDeviceIp=false,
deviceCpuId="deadbeefdeadbeef", // For direct match to a concrete device (optional)
localDeviceIP="192.168.4.2", // Address in local subnet (used for indirect request matching)
// Pass-through-specific
oemServerAddress="uisetup.ddns.net",
oemServerPort=80
]
Thing argoclima:local:argoHvacLocalWithStub "Argo HVAC (accessible both indirectly and directly with a stub) - **RECOMMENDED MODE**" [
hostname="192.168.0.3", // Has to be reachable, since useLocalConnection is true (default)
connectionMode="REMOTE_API_STUB",
localDeviceIP="192.168.4.2" // Or use matchAnyIncomingDeviceIp=true
]
```
### argoclima.items
```java
Group GArgoClimaHVACRemote "Ulisse 13 DCI ECO - remote mode" ["HVAC"]
Switch ArgoClimaHVACRemote_Power "Power" <switch> (GArgoClimaHVACRemote) {
channel="argoclima:remote:argoHvacRemote:ac-controls#power"
}
String ArgoClimaHVACRemote_Mode "Mode" <climate> (GArgoClimaHVACRemote) ["Control"] {
channel="argoclima:remote:argoHvacRemote:ac-controls#mode"
}
Number:Temperature ArgoClimaHVACRemote_SetTemperature "Set Temperature" <temperature> (GArgoClimaHVACRemote) ["Temperature", "Setpoint"] {
channel="argoclima:remote:argoHvacRemote:ac-controls#set-temperature",
unit="°C",
stateDescription="" [ pattern="%.1f °C", readOnly=false, min=10.0, max=36.0, step=0.5],
widget="oh-stepper-card" [ min=10, max=36, step=0.5, autorepeat=true],
listWidget="oh-stepper-item" [min=10, max=36, step=0.5, autorepeat=true]
}
Number:Temperature ArgoClimaHVACRemote_CurrentTemperature "Current Temperature" <temperature> (GArgoClimaHVACRemote) ["Temperature", "Measurement"] {
channel="argoclima:remote:argoHvacRemote:ac-controls#current-temperature"
}
String ArgoClimaHVACRemote_FanSpeed "Fan Speed" <fan> (GArgoClimaHVACRemote) {
channel="argoclima:remote:argoHvacRemote:ac-controls#fan-speed"
}
Switch ArgoClimaHVACRemote_EcoMode "Eco Mode" <vacation> (GArgoClimaHVACRemote) {
channel="argoclima:remote:argoHvacRemote:modes#eco-mode"
}
Switch ArgoClimaHVACRemote_TurboMode "Turbo Mode" <party> (GArgoClimaHVACRemote) {
channel="argoclima:remote:argoHvacRemote:modes#turbo-mode"
}
Switch ArgoClimaHVACRemote_NightMode "Night Mode" <moon> (GArgoClimaHVACRemote) {
channel="argoclima:remote:argoHvacRemote:modes#night-mode"
}
String ArgoClimaHVACRemote_ActiveTimer "Active timer" <calendar> (GArgoClimaHVACRemote) {
channel="argoclima:remote:argoHvacRemote:timers#active-timer"
}
Number:Time ArgoClimaHVACRemote_DelayTimer "Delay timer value" <time> (GArgoClimaHVACRemote) ["Setpoint"] {
channel="argoclima:remote:argoHvacRemote:timers#delay-timer",
unit="min",
stateDescription="" [ pattern="%d min", readOnly=false, min=10, max=1190, step=10 ],
widget="oh-stepper-card" [ min=10, max=1190, step=10, autorepeat=true],
listWidget="oh-stepper-item" [min=10, max=1190, step=10, autorepeat=true]
}
Switch ArgoClimaHVACRemote_IFeelEnabled "Use iFeel Temperature" <network> (GArgoClimaHVACRemote) {
channel="argoclima:remote:argoHvacRemote:settings#ifeel-enabled"
}
Switch ArgoClimaHVACRemote_DeviceLights "Device Lights" <light> (GArgoClimaHVACRemote) {
channel="argoclima:remote:argoHvacRemote:settings#device-lights"
}
String ArgoClimaHVACRemote_TemperatureDisplayUnit "Temperature Display Unit []" <settings> (GArgoClimaHVACRemote) {
stateDescription="" [ options="SCALE_CELSIUS=°C,SCALE_FAHRENHEIT=°F" ],
commandDescription="" [ options="SCALE_CELSIUS=°C,SCALE_FAHRENHEIT=°F" ],
channel="argoclima:remote:argoHvacRemote:settings#temperature-display-unit"
}
Number:Dimensionless ArgoClimaHVACRemote_EcoPowerLimit "Power limit in eco mode" <price> (GArgoClimaHVACRemote) ["Setpoint"] {
channel="argoclima:remote:argoHvacRemote:settings#eco-power-limit",
unit="%",
stateDescription=" " [ pattern="%d %%", readOnly=false, min=30, max=99, step=1 ],
widget="oh-stepper-card" [ min=30, max=99, step=1, autorepeat=true],
listWidget="oh-stepper-item" [min=30, max=99, step=1, autorepeat=true]
}
String ArgoClimaHVACRemote_ModeEx "Extended Mode" <heating> (GArgoClimaHVACRemote) {
channel="argoclima:remote:argoHvacRemote:unsupported#mode-ex"
}
String ArgoClimaHVACRemote_SwingMode "Airflow Direction" <flow> (GArgoClimaHVACRemote) {
channel="argoclima:remote:argoHvacRemote:unsupported#swing-mode"
}
Switch ArgoClimaHVACRemote_FilterMode "Filter Mode" <switch> (GArgoClimaHVACRemote) {
channel="argoclima:remote:argoHvacRemote:unsupported#filter-mode"
}
```
### argoclima.sitemap
```java
// All things in all modes expose the same channels
Frame label="❄ HVAC Control" {
Switch item=ArgoClimaHVACRemote_Power
Switch item=ArgoClimaHVACRemote_Mode label="Mode []" mappings=[
COOL="Cool", DRY="Dry", FAN="Fan", AUTO="Auto"
]
Setpoint item=ArgoClimaHVACRemote_SetTemperature minValue=19 maxValue=36 step=0.5
Text item=ArgoClimaHVACRemote_CurrentTemperature
Selection item=ArgoClimaHVACRemote_FanSpeed mappings=[
LEVEL_1="1", LEVEL_2="2", LEVEL_3="3", LEVEL_4="4", LEVEL_5="5", LEVEL_6="6",AUTO="Auto"
]
Default item=GArgoClimaHVACRemote label="All settings"
}
Frame label="⛄ HVAC Modes"
{
Switch item=ArgoClimaHVACRemote_TurboMode
Switch item=ArgoClimaHVACRemote_NightMode
Switch item=ArgoClimaHVACRemote_EcoMode
Slider item=ArgoClimaHVACRemote_EcoPowerLimit minValue=30 maxValue=99 step=1
Switch item=ArgoClimaHVACRemote_IFeelEnabled
Switch item=ArgoClimaHVACRemote_DeviceLights
}
Frame label="⏲ HVAC timers" {
Selection item=ArgoClimaHVACRemote_ActiveTimer mappings=[
NO_TIMER="No Timer", DELAY_TIMER="Delay Timer",
SCHEDULE_TIMER_1="Schedule 1", SCHEDULE_TIMER_2="Schedule 2", SCHEDULE_TIMER_3="Schedule 3"
]
Setpoint item=ArgoClimaHVACRemote_DelayTimer minValue=10 maxValue=1190 step=10
Slider item=ArgoClimaHVACRemote_DelayTimer label="Delay time [%.1f h]" minValue=0.17 maxValue=19.83 step=0.1
}
```
## Connection Modes
### Basic Modes
These modes assume the HVAC device is connected directly to the Internet.
This is the default (vendor-recommended) configuration which does not require any special network-level changes.
There are [security considerations](#argo-protocol-details) when using these modes, though.
#### Basic: Local connection
The device is locally available through the LAN. openHAB sends commands directly (and polls for status).
![Basic local connection diagram](doc/Argoclima_connection_Basic_LOCAL_CONNECTION.png)
#### Basic: Remote connection
The device may not be locally available through the LAN (or the user wants silent/no-beep behavior).
openHAB sends commands to a remote vendor's service (and polls it for device status), while the device also talks to the same service and eventually learns the updates.
Commands are delayed in this mode.
![Basic remote connection diagram](doc/Argoclima_connection_Basic_REMOTE_CONNECTION.png)
### Advanced Modes
In these modes, the HVAC traffic which is originally targeted towards vendor's servers is **rerouted on the network layer**, and flows to openHAB instead.
This is possible as the device does use plain HTTP (no TLS, certificate pinning etc.)
The following ```nftables``` snippet provides an example rule redirecting traffic to ```31.14.128.210``` (Argo server) to a local openHAB instance listening at ```192.168.0.15:8239```.
Please note this is **not** a complete configuration, and an actual secure configuration requires a few more rules and network setup.
```text
table inet fw4 {
(...)
chain dstnat_iot {
ip daddr 31.14.128.210 tcp dport 1-8000 counter packets 2593 bytes 114092 dnat ip to 192.168.0.15:8239 comment "!fw4: Argo 2 lan"
}
(...)
}
```
For example, if your home router is based on [OpenWrt](https://openwrt.org/) ```LuCI``` interface, a graphical representation of the forwarding rule might look similar to the below.
![Argo traffic forwarding rule in OpenWrt LuCI](doc/OpenWRT_LUCI_port_forwarding_rule.png)
Please note this forwarding rule would need to be accompanied with other traffic rules (and separate WLAN/SSID) for the device (this configuration is not covered here - refer to your network equipment's manual for more).
#### Advanced: Local connection with pass-through proxy
In this mode openHAB is acting as an **almost** transparent proxy, and does a pass-through of device-side messages and remote-side responses (a man-in-the-middle).
This allows to have the device fully controllable via openHAB **as well as** vendor's application (at the expense of security!).
Possible other use of this mode is for firmware update or ad-hoc controlling some settings which are not easily accessible via openHAB.
> ***IMPORTANT***: Most of the time, openHAB serves as a fully transparent proxy, not interfering with the traffic, **except for** cases when cloud has no updates for the device while openHAB **has** a command pending send to the device.
> In such case, the binding injects it into the communication flow as-if it was cloud-issued!
![Advanced local connection diagram: REMOTE_API_PROXY mode](doc/Argoclima_connection_Advanced_REMOTE_API_PROXY.png)
#### Advanced: Local connection with STUB (simulated server) - RECOMMENDED
In this mode openHAB is simulating vendor's server (which is out of the picture).
The HVAC is functioning fully locally.
This is the recommended mode for maximum security.
![Advanced local connection diagram: REMOTE_API_STUB mode](doc/Argoclima_connection_Advanced_REMOTE_API_STUB.png)
## Argo protocol details
The HVAC device accepts multiple command in one request (similarly to how the remote control works).
Dual APIs (local and remote) are exposed:
- The **local** API uses direct HTTP communication (all requests are ```HTTP GET```) and polling for getting the device state.
Sending any command through this interface effects an *immediate* change, and audible confirmation (beep).
- The **remote** API involves the device periodically (for example, every minute) reaching out to manufacturer's server, and getting any withstanding commands.
Commands sent through this interface will be *delayed*, and not yield an audible confirmation (no beep).
**IMPORTANT**: The Argo HVAC device ***has to*** be connected to Wi-Fi and communicating with a vendor (or vendor-like) server for either of its APIs to work.
This is true even if the device is desired to be controlled via local APIs only!
> ***A NOTE ON SECURITY:*** The device protocol is plain HTTP (no TLS), and it transmits all the device secrets to the cloud service in cleartext (that includes device password as well as **your Wi-Fi password**!)
>
> Hence, security-savvy users may choose to not only connect it to a dedicated ```IOT```-specific Wi-Fi network, but also deny its Internet access (ex. to prevent a malicious firmware update converting it to a network backdoor).
> While the device needs to communicate with **a** protocol-compatible server to work, this binding provides a convenient simulated server exactly for this purpose!
> Its use requires a specialized forwarding rules set-up on the home router, hence is only recommended to advanced users, though. Refer to [Connection Modes](#connection-modes) for more details.
### Room temperature support
The Argo APIs support all functionalities of the device's remote, and two-way communication, except for **room temperature** (iFeel) provided from external sensor (the device's remote control).
Its value is read-only in the API protocol.
While using the temperature sensor built-in into the device is sufficient for most cases, for certain scenarios it may be desired to be able to set the room temperature from openHAB (for example: to have multi-sensor aggregate temperature display on the built-in HVAC display).
Since the iFeel temperature updates **have to** be sent via infrared, there are two notable options to consider:
- **Use the original remote**:
As long as the remote control is pointing towards the HVAC and A/C status on the remote is ```ON``` (with iFeel option enabled), it will periodically beam current temperature (which the HVAC will accept if its iFeel option is ```ON```, which can be configured through the binding).
- **Use openHAB-controlled (custom) IR Blaster**:
The Argo protocol is fully supported by the [IRremoteESP8266](https://github.com/crankyoldgit/IRremoteESP8266) library, and [Tasmota](https://github.com/arendst/Tasmota). Hence, any [Tasmota-IR](https://tasmota.github.io/docs/Tasmota-IR/)-compatible device would be able to send iFeel data to the HVAC (and the device itself can be controlled via openHAB's [MQTT Binding](https://www.openhab.org/addons/bindings/mqtt/)).
For example, (by default) the following command sent to ```cmnd/<tasmota_dev_id>/irhvac``` channel does send the iFeel temperature.
```jsonc
// Hint: You may use JINJA transform to fill the room temperature (in Celsius) from a channel.
// The status of IR command execution is available through [stat/<tasmota_dev_id>/RESULT] channel
{ "Vendor": "Argo", "Model": "WREM3", "Command": "iFeel Report", "SensorTemp": <the_room_temperature_value> }
```
## Argo Web APP details
The supported devices will typically have a web interface similar to the one shown on the image below.
![Argo Web APP](doc/ArgoWebAPP_UI.png)
For example, the original web application for **Ulisse 13 DCI ECO** air conditioner is available through [http://31.14.128.210/UI/WEBAPP/webapp.php?logo=Argo](http://31.14.128.210/UI/WEBAPP/webapp.php?logo=Argo) link, as referenced in the [manual](https://www.pumppujapaneli.fi/flaria/media/ulisse-kayttoohje.pdf).
## Credits
The author would like to thank the following individuals:
- Maintainers of [IRremoteESP8266](https://github.com/crankyoldgit/IRremoteESP8266) and [Tasmota](https://github.com/arendst/Tasmota) projects - for reviewing an accepting the infrared-related part of Argo protocol into their libraries!
- [@nyffchanium](https://github.com/nyffchanium) for creating an awesome [Argoclima integration for HomeAssistant](https://github.com/nyffchanium/argoclima-integration) which was used to confirm & speed up the analysis of device's protocol.
While most of the learnings come from analyzing the JavaScript code in Argo's own application and network captures, the HA integration has proven **invaluable** as a secondary/confirmed source and allowed to validate a few concepts early on!
- In case you're experiencing issues, make sure to read the HomeAssistant binding's [README](https://github.com/nyffchanium/argoclima-integration/blob/master/readme.md) as well, for useful troubleshooting info!
- [@lallinger](https://github.com/lallinger) for a [dummy server](https://github.com/nyffchanium/argoclima-integration/tree/master/dummy-server) which was the idea that served as the cornerstone of the stub server built into this binding (which later got extended to do something useful, instead of just keeping the device happy).
## Disclaimer
This project is not affiliated with, funded, or in any way associated with Argoclima S.p.A.
All third-party product, company names, logos and trademarks™ or registered® trademarks remain the property of their respective holders.

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>4.2.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.argoclima</artifactId>
<name>openHAB Add-ons :: Bundles :: ArgoClima Binding</name>
</project>

View File

@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""
Downloads current Argo Ulisse firmware binary files from manufacturer's servers
"""
__license__ = """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
"""
import hashlib
import secrets
import urllib.request
from enum import Enum
from itertools import cycle
# Randomized values. Do not seem to impact the downloaded file
USERNAME = secrets.token_hex(4)
PASSWORD_MD5 = hashlib.md5(secrets.token_hex(4).encode('ASCII')).hexdigest()
CPU_ID = secrets.token_hex(8)
class FwType(str, Enum):
UNIT = 'OU_FW'
WIFI = 'UI_FW'
def get_uri(fw_type: FwType, page: int):
return f'http://31.14.128.210/UI/UI.php?CM={fw_type}&PK={page}&USN={USERNAME}&PSW={PASSWORD_MD5}&CPU_ID={CPU_ID}'
def get_api_response(fw_type: FwType, page: int):
with urllib.request.urlopen(get_uri(fw_type, page)) as response:
data: str = response.read().decode().rstrip()
if not data.endswith('|||'):
raise RuntimeError(f"Invalid upstream response {data}")
return {e.split('=')[0]: str.join("=", e.split('=')[1:]) for e in data[:-3].split('|')}
def download_fw_from_remote_server(fw_type: FwType, split_into_multiple_files=False):
print(f'> {get_uri(fw_type, -1)}...')
ver_response = get_api_response(fw_type, -1)
try:
size = int(ver_response['SIZE'])
chunk_count = int(ver_response['NUM_PACK'])
checksum = int(ver_response['CKS']) # CRC-16?
base_offset = int(ver_response['OFFSET'])
print(f'FW Version: {ver_response}\n\tRelease: {ver_response["RELEASE"]}\n\tSize: {size}'
f'\n\t#chunks: {chunk_count}\n\tchecksum: {checksum}')
total_received_size = 0
data = ""
current_offset = base_offset
for i in range(0, chunk_count):
chunk_response = get_api_response(fw_type, i)
current_chunk_size_bytes = int(chunk_response['SIZE'])
print(f'{fw_type} chunk [{i+1}/{chunk_count}] - Response: {chunk_response}')
response_offset = int(chunk_response['OFFSET'])
if response_offset != current_offset:
if not split_into_multiple_files:
difference = response_offset - current_offset
print(f"Current offset is {current_offset}, but the response wants to write to {response_offset}."
f" Padding with 0xDEADBEEF")
fillers = cycle(['DE', 'AD', 'BE', 'EF'])
for x in range(0, difference):
data += next(fillers)
current_offset += difference
else:
save_to_file(base_offset, data, fw_type, total_received_size, ver_response["RELEASE"])
total_received_size = 0
data = ""
current_offset = response_offset
base_offset = response_offset
total_received_size += current_chunk_size_bytes
current_offset += current_chunk_size_bytes
data += chunk_response['DATA'][:current_chunk_size_bytes*2]
save_to_file(base_offset, data, fw_type, total_received_size, ver_response["RELEASE"])
finally:
finish_response = get_api_response(fw_type, 256)
print(finish_response)
def save_to_file(base_offset, data, fw_type, total_received_size, version):
print()
print('-' * 50)
print(f'Received {total_received_size} bytes. Total binary size: {len(data) / 2:.0f}[b]')
print(f'Data (base16):\n\t{data}\n')
fw_binary = bytes.fromhex(data)
filename = f'Argo_firmware_{fw_type}_v{version}__offset_0x{base_offset:X}.bin'
with open(filename, "wb") as output_file:
output_file.write(fw_binary)
print(f'Firmware written to {filename}')
if __name__ == '__main__':
print(f'Username={USERNAME}, Password={PASSWORD_MD5}, CPU_ID={CPU_ID}')
download_fw_from_remote_server(fw_type=FwType.UNIT, split_into_multiple_files=False)
download_fw_from_remote_server(fw_type=FwType.WIFI, split_into_multiple_files=False)

View File

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

View File

@ -0,0 +1,212 @@
/**
* 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.argoclima.internal;
import java.time.Duration;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingTypeUID;
/**
* The {@link ArgoClimaBindingConstants} class defines common constants, which are
* used across the whole binding.
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class ArgoClimaBindingConstants {
public static final String BINDING_ID = "argoclima";
/////////////
// List of all Thing Type UIDs
/////////////
public static final ThingTypeUID THING_TYPE_ARGOCLIMA_LOCAL = new ThingTypeUID(BINDING_ID, "local");
public static final ThingTypeUID THING_TYPE_ARGOCLIMA_REMOTE = new ThingTypeUID(BINDING_ID, "remote");
/////////////
// Thing configuration parameters
/////////////
public static final String PARAMETER_HOSTNAME = "hostname";
public static final String PARAMETER_LOCAL_DEVICE_IP = "localDeviceIP";
public static final String PARAMETER_HVAC_LISTEN_PORT = "hvacListenPort";
public static final String PARAMETER_DEVICE_CPU_ID = "deviceCpuId";
public static final String PARAMETER_CONNECTION_MODE = "connectionMode"; // LOCAL_CONNECTION | REMOTE_API_STUB |
// REMOTE_API_PROXY
public static final String PARAMETER_USE_LOCAL_CONNECTION = "useLocalConnection";
public static final String PARAMETER_REFRESH_INTERNAL = "refreshInterval";
public static final String PARAMETER_STUB_SERVER_PORT = "stubServerPort";
public static final String PARAMETER_STUB_SERVER_LISTEN_ADDRESSES = "stubServerListenAddresses";
public static final String PARAMETER_OEM_SERVER_PORT = "oemServerPort";
public static final String PARAMETER_OEM_SERVER_ADDRESS = "oemServerAddress";
public static final String PARAMETER_INCLUDE_DEVICE_SIDE_PASSWORDS_IN_PROPERTIES = "includeDeviceSidePasswordsInProperties";
public static final String PARAMETER_MATCH_ANY_INCOMING_DEVICE_IP = "matchAnyIncomingDeviceIp";
public static final String PARAMETER_USERNAME = "username";
public static final String PARAMETER_PASSWORD = "password";
public static final String PARAMETER_SCHEDULE_GROUP_NAME = "schedule%d"; // 1..3
public static final String PARAMETER_SCHEDULE_X_DAYS = PARAMETER_SCHEDULE_GROUP_NAME + "DayOfWeek";
public static final String PARAMETER_SCHEDULE_X_ON_TIME = PARAMETER_SCHEDULE_GROUP_NAME + "OnTime";
public static final String PARAMETER_SCHEDULE_X_OFF_TIME = PARAMETER_SCHEDULE_GROUP_NAME + "OffTime";
public static final String PARAMETER_ACTIONS_GROUP_NAME = "actions";
public static final String PARAMETER_RESET_TO_FACTORY_DEFAULTS = "resetToFactoryDefaults";
/////////////
// Thing configuration properties
/////////////
public static final String PROPERTY_CPU_ID = "cpuId";
public static final String PROPERTY_LOCAL_IP_ADDRESS = "localIpAddress";
public static final String PROPERTY_UNIT_FW = "unitFirmwareVersion";
public static final String PROPERTY_WIFI_FW = "wifiFirmwareVersion";
public static final String PROPERTY_LAST_SEEN = "lastSeen";
public static final String PROPERTY_WEB_UI = "argoWebUI";
public static final String PROPERTY_WEB_UI_USERNAME = "argoWebUIUsername";
public static final String PROPERTY_WEB_UI_PASSWORD = "argoWebUIPassword";
public static final String PROPERTY_WIFI_SSID = "wifiSSID";
public static final String PROPERTY_WIFI_PASSWORD = "wifiPassword";
public static final String PROPERTY_LOCAL_TIME = "localTime";
/////////////
// List of all Channel IDs
/////////////
public static final String CHANNEL_POWER = "ac-controls#power";
public static final String CHANNEL_MODE = "ac-controls#mode";
public static final String CHANNEL_SET_TEMPERATURE = "ac-controls#set-temperature";
public static final String CHANNEL_CURRENT_TEMPERATURE = "ac-controls#current-temperature";
public static final String CHANNEL_FAN_SPEED = "ac-controls#fan-speed";
public static final String CHANNEL_ECO_MODE = "modes#eco-mode";
public static final String CHANNEL_TURBO_MODE = "modes#turbo-mode";
public static final String CHANNEL_NIGHT_MODE = "modes#night-mode";
public static final String CHANNEL_ACTIVE_TIMER = "timers#active-timer";
public static final String CHANNEL_DELAY_TIMER = "timers#delay-timer";
// Note: schedule timers day of week/time setting not currently supported as channels (YAGNI), and moved to config
public static final String CHANNEL_MODE_EX = "unsupported#mode-ex";
public static final String CHANNEL_SWING_MODE = "unsupported#swing-mode";
public static final String CHANNEL_FILTER_MODE = "unsupported#filter-mode";
public static final String CHANNEL_I_FEEL_ENABLED = "settings#ifeel-enabled";
public static final String CHANNEL_DEVICE_LIGHTS = "settings#device-lights";
public static final String CHANNEL_TEMPERATURE_DISPLAY_UNIT = "settings#temperature-display-unit";
public static final String CHANNEL_ECO_POWER_LIMIT = "settings#eco-power-limit";
/////////////
// Binding's hard-coded configuration (not parameterized)
/////////////
/** Maximum number of failed status polls after which the device will be considered offline */
public static final int MAX_API_RETRIES = 3;
/**
* Time to wait between command issue and communicating with the device. Allows to include multiple commands in one
* device communication session (preferred).
* Time window chosen so that it is not (too) perceptible by an user, while still enough for rules/groups to be able
* to fit
*/
public static final Duration SEND_COMMAND_DEBOUNCE_TIME = Duration.ofMillis(100);
/**
* The minimum resolution during which the command sending background thread does any meaningful action. This is
* merely to avoid busy wait and doesn't mean the thread is doing anything of use on every cycle. There are separate
* configurable "update" and "(re)send" frequencies governing that. This parameter only controls the lowest possible
* resolution of those (a "tick")
*/
public static final Duration SEND_COMMAND_DUTY_CYCLE = Duration.ofSeconds(1);
/**
* The frequency to poll the device with, waiting for the command confirmation
*/
public static final Duration POLL_FREQUENCY_AFTER_COMMAND_SENT_LOCAL = Duration.ofSeconds(3);
/**
* The frequency to poll the Argo servers with, waiting for the command confirmation
*/
public static final Duration POLL_FREQUENCY_AFTER_COMMAND_SENT_REMOTE = Duration.ofSeconds(5);
/**
* The frequency to re-send the pending command to the device at (if it hadn't been confirmed yet).
* Aka. the optimistic time when the device "should acknowledge. Should be greater than
* {@link #POLL_FREQUENCY_AFTER_COMMAND_SENT_LOCAL}
*
* @see #SEND_COMMAND_MAX_WAIT_TIME_LOCAL_DIRECT
* @see #SEND_COMMAND_MAX_WAIT_TIME_LOCAL_INDIRECT
*/
public static final Duration SEND_COMMAND_RETRY_FREQUENCY_LOCAL = Duration.ofSeconds(10);
/**
* The frequency to re-send the pending command to the remote Argo server at (if it hadn't been confirmed yet).
* Aka. the optimistic time when the server "should acknowledge. Should be greater than
* {@link #POLL_FREQUENCY_AFTER_COMMAND_SENT_REMOTE}
*
* @see #SEND_COMMAND_MAX_WAIT_TIME_REMOTE
*/
public static final Duration SEND_COMMAND_RETRY_FREQUENCY_REMOTE = Duration.ofSeconds(20);
/**
* Max time to wait for a pending command to be confirmed by the device in a local-direct mode (when we are issuing
* communications to a device in local LAN).
* <p>
* During this time, the commands may get {@link #SEND_COMMAND_RETRY_FREQUENCY_LOCAL retried} and the device status
* may be
* {@link #POLL_FREQUENCY_AFTER_COMMAND_SENT_LOCAL re-fetched}
*/
public static final Duration SEND_COMMAND_MAX_WAIT_TIME_LOCAL_DIRECT = Duration.ofSeconds(20); // 60-remote
/**
* Max time to wait for a pending command to be confirmed in an *indirect* mode (where we're only
* sniffing/intercepting communications)
* <p>
* A healthy device seems to be polling Argo servers every minute (and if the server returns a pending command
* request, does a few more more frequent exchanges as well), so 2 minutes seem safe
*/
public static final Duration SEND_COMMAND_MAX_WAIT_TIME_LOCAL_INDIRECT = Duration.ofSeconds(120);
/**
* Max time to wait for a pending command to be confirmed in an *remote* mode (where we're talking to a remote Argo
* server)
* <p>
* The server seems to confirm a bit faster than our intercepting proxy and we want to minimize traffic our binding
* issues against remote side, hence a more conservative value
*/
public static final Duration SEND_COMMAND_MAX_WAIT_TIME_REMOTE = Duration.ofSeconds(60);
/**
* Time to wait for (confirmable) command to be reported back by the device (by changing its state to the requested
* value). If this period elapses w/o the device confirming, the command is considered not handled and REJECTED
* (would not be retried any more, and the reported device's state will be the actual one device sent, not the
* "in-flight" desired one)
*
* @implNote This is just a final "give up" time (not affecting any send logic). Should be no shorter than max try
* time
*/
public static final Duration PENDING_COMMAND_EXPIRE_TIME = SEND_COMMAND_MAX_WAIT_TIME_LOCAL_INDIRECT
.plus(Duration.ofSeconds(1));
/**
* Timeout for getting the HTTP response from Argo servers in pass-through(proxy) mode
*/
public static final Duration UPSTREAM_PROXY_HTTP_REQUEST_TIMEOUT = Duration.ofSeconds(30);
/////////////
// R&D-only switches
/////////////
/**
* Whether the binding shall wait for the device confirming commands have been received (by flipping to the desired
* state) or work in a fire and forget mode and stop tracking upon first send.
* <p>
* This applies only to confirmable commands (read-write) and is a default behavior of Argo's own web implementation
*
* @implNote This is a debug-only switch (makes little to no sense to disable it in real-world usage)
*/
public static final boolean AWAIT_DEVICE_CONFIRMATIONS_AFTER_COMMANDS = true;
}

View File

@ -0,0 +1,216 @@
/**
* 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.argoclima.internal;
import java.net.URI;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.argoclima.internal.configuration.IScheduleConfigurationProvider.ScheduleTimerType;
import org.openhab.binding.argoclima.internal.device.api.types.Weekday;
import org.openhab.core.config.core.ConfigDescription;
import org.openhab.core.config.core.ConfigDescriptionBuilder;
import org.openhab.core.config.core.ConfigDescriptionParameter;
import org.openhab.core.config.core.ConfigDescriptionParameter.Type;
import org.openhab.core.config.core.ConfigDescriptionParameterBuilder;
import org.openhab.core.config.core.ConfigDescriptionParameterGroup;
import org.openhab.core.config.core.ConfigDescriptionParameterGroupBuilder;
import org.openhab.core.config.core.ConfigDescriptionProvider;
import org.openhab.core.config.core.ParameterOption;
import org.openhab.core.thing.ThingRegistry;
import org.openhab.core.thing.ThingUID;
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 ArgoClimaConfigProvider} class provides dynamic configuration entries
* for the things supported by the binding (on top of static properties defined in
* {@code thing-types.xml})
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
@Component(service = { ConfigDescriptionProvider.class })
public class ArgoClimaConfigProvider implements ConfigDescriptionProvider {
private final Logger logger = LoggerFactory.getLogger(getClass());
private final ThingRegistry thingRegistry;
private final ArgoClimaTranslationProvider i18nProvider;
private static final int SCHEDULE_TIMERS_COUNT = 3;
public record ScheduleDefaults(String startTime, String endTime, EnumSet<Weekday> weekdays) {
/**
* @implNote Overriding the default-generated method, as it doesn't preserve {@code NonNull} annotation on the
* element set.
*/
public EnumSet<Weekday> weekdays() {
return weekdays;
}
}
private static final Map<ScheduleTimerType, ScheduleDefaults> SCHEDULE_DEFAULTS = Map.of(
ScheduleTimerType.SCHEDULE_1,
new ScheduleDefaults("08:00", "18:00",
EnumSet.of(
Weekday.MON, Weekday.TUE, Weekday.WED, Weekday.THU, Weekday.FRI, Weekday.SAT, Weekday.SUN)),
ScheduleTimerType.SCHEDULE_2,
new ScheduleDefaults("15:00", "20:00",
EnumSet.of(Weekday.MON, Weekday.TUE, Weekday.WED, Weekday.THU, Weekday.FRI)),
ScheduleTimerType.SCHEDULE_3, new ScheduleDefaults("11:00", "22:00", EnumSet.of(Weekday.SAT, Weekday.SUN)));
public static final ScheduleDefaults getScheduleDefaults(ScheduleTimerType scheduleTimerType) {
if (!EnumSet.allOf(ScheduleTimerType.class).contains(scheduleTimerType)) {
throw new IllegalArgumentException("Invalid schedule timer: " + scheduleTimerType.toString());
}
var result = SCHEDULE_DEFAULTS.get(scheduleTimerType);
Objects.requireNonNull(result);
return result;
}
@Activate
public ArgoClimaConfigProvider(final @Reference ThingRegistry thingRegistry,
final @Reference ArgoClimaTranslationProvider i18nProvider) {
this.thingRegistry = thingRegistry;
this.i18nProvider = i18nProvider;
}
/**
* Provides a collection of {@link ConfigDescription}s.
*
* @param locale locale
* @return the configuration descriptions provided by this provider (not
* null, could be empty)
*/
@Override
public Collection<ConfigDescription> getConfigDescriptions(@Nullable Locale locale) {
return Collections.emptySet(); // no dynamic values
}
/**
* Provides a {@link ConfigDescription} for the given URI.
*
* @param uri URI of the config description (may be either thing or thing-type URI)
* @param locale locale (not using this value, as our i18n provider comes with it pre-populated!)
* @return config description or null if no config description could be found
*
* @implNote {@code ConfigDescriptionParameterBuilder} doesn't have non-null-defaults, while
* {@code ConfigDescriptionBuilder} does... so while it's quite redundant, using Objects.requireNonNull()
* to keep number of warnings low
*/
@Override
@Nullable
public ConfigDescription getConfigDescription(URI uri, @Nullable Locale locale) {
if (!"thing".equalsIgnoreCase(uri.getScheme())) {
return null; // Deliberately not supporting "thing-type" (no dynamic parameters there)
}
ThingUID thingUID = new ThingUID(Objects.requireNonNull(uri.getSchemeSpecificPart()));
if (!thingUID.getBindingId().equals(ArgoClimaBindingConstants.BINDING_ID)) {
return null;
}
var thing = this.thingRegistry.get(thingUID);
if (thing == null) {
logger.trace("getConfigDescription: No thing found for uri: {}", uri);
return null;
}
var paramGroups = new ArrayList<ConfigDescriptionParameterGroup>();
for (int i = 1; i <= SCHEDULE_TIMERS_COUNT; ++i) {
paramGroups.add(ConfigDescriptionParameterGroupBuilder
.create(String.format(ArgoClimaBindingConstants.PARAMETER_SCHEDULE_GROUP_NAME, i))
.withLabel(
i18nProvider.getText("dynamic-config.argoclima.group.schedule.label", "Schedule {0} ", i))
.withDescription(i18nProvider.getText("dynamic-config.argoclima.group.schedule.description",
"Schedule timer - profile {0}.", i))
.build());
}
if (thing.isEnabled()) {
paramGroups.add(ConfigDescriptionParameterGroupBuilder.create("actions").withContext("actions")
.withLabel(i18nProvider.getText("dynamic-config.argoclima.group.actions.label", "Actions"))
.build());
}
var parameters = new ArrayList<ConfigDescriptionParameter>();
var daysOfWeek = List.<@Nullable ParameterOption> of(
new ParameterOption(Weekday.MON.toString(),
i18nProvider.getText("dynamic-config.argoclima.schedule.days.monday", "Monday")),
new ParameterOption(Weekday.TUE.toString(),
i18nProvider.getText("dynamic-config.argoclima.schedule.days.tuesday", "Tuesday")),
new ParameterOption(Weekday.WED.toString(),
i18nProvider.getText("dynamic-config.argoclima.schedule.days.wednesday", "Wednesday")),
new ParameterOption(Weekday.THU.toString(),
i18nProvider.getText("dynamic-config.argoclima.schedule.days.thursday", "Thursday")),
new ParameterOption(Weekday.FRI.toString(),
i18nProvider.getText("dynamic-config.argoclima.schedule.days.friday", "Friday")),
new ParameterOption(Weekday.SAT.toString(),
i18nProvider.getText("dynamic-config.argoclima.schedule.days.saturday", "Saturday")),
new ParameterOption(Weekday.SUN.toString(),
i18nProvider.getText("dynamic-config.argoclima.schedule.days.sunday", "Sunday")));
for (int i = 1; i <= SCHEDULE_TIMERS_COUNT; ++i) {
// NOTE: Deliberately *not* using .withContext("dayOfWeek") - doesn't seem to work correctly :(
parameters.add(Objects.requireNonNull(ConfigDescriptionParameterBuilder
.create(String.format(ArgoClimaBindingConstants.PARAMETER_SCHEDULE_X_DAYS, i), Type.TEXT)
.withRequired(true)
.withGroupName(String.format(ArgoClimaBindingConstants.PARAMETER_SCHEDULE_GROUP_NAME, i))//
.withLabel(i18nProvider.getText("dynamic-config.argoclima.schedule.days.label", "Days"))
.withDescription(i18nProvider.getText("dynamic-config.argoclima.schedule.days.description",
"Days when the schedule is run"))
.withOptions(daysOfWeek)
.withDefault(getScheduleDefaults(ScheduleTimerType.fromInt(i)).weekdays().toString())
.withMultiple(true).withMultipleLimit(7).build()));
// NOTE: Deliberately *not* using .withContext("time") - does work, but causes UI to detect each entry to
// the page as a change
parameters.add(Objects.requireNonNull(ConfigDescriptionParameterBuilder
.create(String.format(ArgoClimaBindingConstants.PARAMETER_SCHEDULE_X_ON_TIME, i), Type.TEXT)
.withRequired(true)
.withGroupName(String.format(ArgoClimaBindingConstants.PARAMETER_SCHEDULE_GROUP_NAME, i))
.withPattern("\\d{1-2}:\\d{1-2}")
.withLabel(i18nProvider.getText("dynamic-config.argoclima.schedule.on-time.label", "On Time"))
.withDescription(i18nProvider.getText("dynamic-config.argoclima.schedule.on-time.description",
"Time when the A/C turns on"))
.withDefault(getScheduleDefaults(ScheduleTimerType.fromInt(i)).startTime()).build()));
parameters.add(Objects.requireNonNull(ConfigDescriptionParameterBuilder
.create(String.format(ArgoClimaBindingConstants.PARAMETER_SCHEDULE_X_OFF_TIME, i), Type.TEXT)
.withRequired(true)
.withGroupName(String.format(ArgoClimaBindingConstants.PARAMETER_SCHEDULE_GROUP_NAME, i))
.withLabel(i18nProvider.getText("dynamic-config.argoclima.schedule.off-time.label", "Off Time"))
.withDescription(i18nProvider.getText("dynamic-config.argoclima.schedule.off-time.description",
"Time when the A/C turns off"))
.withDefault(getScheduleDefaults(ScheduleTimerType.fromInt(i)).endTime()).build()));
}
if (thing.isEnabled()) {
parameters.add(Objects.requireNonNull(ConfigDescriptionParameterBuilder
.create(ArgoClimaBindingConstants.PARAMETER_RESET_TO_FACTORY_DEFAULTS, Type.BOOLEAN)
.withRequired(false).withGroupName(ArgoClimaBindingConstants.PARAMETER_ACTIONS_GROUP_NAME)
.withLabel(i18nProvider.getText("dynamic-config.argoclima.schedule.reset.label", "Reset Settings"))
.withDescription(i18nProvider.getText("dynamic-config.argoclima.schedule.reset.description",
"Reset device settings to factory defaults"))
.withDefault("false").withVerify(true).build()));
}
return ConfigDescriptionBuilder.create(uri).withParameterGroups(paramGroups).withParameters(parameters).build();
}
}

View File

@ -0,0 +1,77 @@
/**
* 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.argoclima.internal;
import static org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants.*;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.argoclima.internal.handler.ArgoClimaHandlerLocal;
import org.openhab.binding.argoclima.internal.handler.ArgoClimaHandlerRemote;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.io.net.http.HttpClientFactory;
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;
/**
* The {@link ArgoClimaHandlerFactory} is responsible for creating things and thing
* handlers.
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
@Component(configurationPid = "binding." + ArgoClimaBindingConstants.BINDING_ID, service = ThingHandlerFactory.class)
public class ArgoClimaHandlerFactory extends BaseThingHandlerFactory {
private final HttpClientFactory httpClientFactory;
private final TimeZoneProvider timeZoneProvider;
private final ArgoClimaTranslationProvider i18nProvider;
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_ARGOCLIMA_LOCAL,
THING_TYPE_ARGOCLIMA_REMOTE);
@Activate
public ArgoClimaHandlerFactory(final @Reference HttpClientFactory httpClientFactory,
final @Reference TimeZoneProvider timeZoneProvider,
final @Reference ArgoClimaTranslationProvider i18nProvider) {
this.httpClientFactory = httpClientFactory;
this.timeZoneProvider = timeZoneProvider;
this.i18nProvider = i18nProvider;
}
@Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
}
@Override
protected @Nullable ThingHandler createHandler(Thing thing) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (THING_TYPE_ARGOCLIMA_LOCAL.equals(thingTypeUID)) {
return new ArgoClimaHandlerLocal(thing, httpClientFactory, timeZoneProvider, i18nProvider);
}
if (THING_TYPE_ARGOCLIMA_REMOTE.equals(thingTypeUID)) {
return new ArgoClimaHandlerRemote(thing, httpClientFactory, timeZoneProvider, i18nProvider);
}
return null;
}
}

View File

@ -0,0 +1,70 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.argoclima.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.i18n.LocaleProvider;
import org.openhab.core.i18n.TranslationProvider;
import org.osgi.framework.Bundle;
import org.osgi.framework.BundleContext;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
/**
* Provides a convenience wrapper around framework-provided {@link TranslationProvider}, pre-filling the bundle and
* locale parameters
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
@Component(service = { ArgoClimaTranslationProvider.class })
public class ArgoClimaTranslationProvider {
private final TranslationProvider i18nProvider;
private final LocaleProvider localeProvider;
private final @Nullable Bundle bundle;
@Activate
public ArgoClimaTranslationProvider(final @Reference TranslationProvider i18nProvider,
final @Reference LocaleProvider localeProvider, final BundleContext context) {
this.bundle = context.getBundle();
this.i18nProvider = i18nProvider;
this.localeProvider = localeProvider;
}
/**
* Similar to {@link TranslationProvider#getText(Bundle, String, String, java.util.Locale, Object...)}.
* Pre-fills {@code Bundle} and {@code Locale} params to reduce boilerplate.
*
* @param key the key to be translated (can be empty)
* @param defaultText the default text to be used (can be null or empty)
* @param arguments the arguments to be injected into the translation (each arg can be null)
* @return the translated text or the default text (can be null or empty)
*/
public @Nullable String getText(String key, @Nullable String defaultText, Object @Nullable... arguments) {
return i18nProvider.getText(bundle, key, defaultText, localeProvider.getLocale(), arguments);
}
/**
* Similar to {@link TranslationProvider#getText(Bundle, String, String, java.util.Locale)}.
* Pre-fills {@code Bundle} and {@code Locale} params to reduce boilerplate.
*
* @param key the key to be translated (can be empty)
* @param defaultText the default text to be used (can be null or empty)
* @return the translated text or the default text (can be null or empty)
*/
public @Nullable String getText(String key, @Nullable String defaultText) {
return i18nProvider.getText(bundle, key, defaultText, localeProvider.getLocale());
}
}

View File

@ -0,0 +1,385 @@
/**
* 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.argoclima.internal.configuration;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.EnumSet;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants;
import org.openhab.binding.argoclima.internal.ArgoClimaConfigProvider;
import org.openhab.binding.argoclima.internal.ArgoClimaTranslationProvider;
import org.openhab.binding.argoclima.internal.device.api.types.Weekday;
import org.openhab.binding.argoclima.internal.exception.ArgoConfigurationException;
import org.openhab.binding.argoclima.internal.utils.StringUtils;
import org.openhab.core.config.core.Configuration;
/**
* The {@link ArgoClimaConfigurationBase} class contains fields mapping thing configuration parameters.
* Contains common configuration parameters (same for all supported device types).
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public abstract class ArgoClimaConfigurationBase extends Configuration implements IScheduleConfigurationProvider {
/////////////////////
// TYPES
/////////////////////
@FunctionalInterface
public interface ConfigValueSupplier<T> {
public T get() throws ArgoConfigurationException;
}
/////////////////////
// Configuration parameters
// These names are defined in thing-types.xml and/or ArgoClimaConfigProvider and get injected on instantiation
// through {@link org.openhab.core.thing.binding.BaseThingHandler#getConfigAs getConfigAs}
/////////////////////
private int refreshInterval = 30; // in seconds
private String deviceCpuId = "";
private int oemServerPort = 80;
private String oemServerAddress = "31.14.128.210";
// Note this boilerplate is actually necessary as these values are injected by framework!
private Set<Weekday> schedule1DayOfWeek = ArgoClimaConfigProvider.getScheduleDefaults(ScheduleTimerType.SCHEDULE_1)
.weekdays();
private String schedule1OnTime = ArgoClimaConfigProvider.getScheduleDefaults(ScheduleTimerType.SCHEDULE_1)
.startTime();
private String schedule1OffTime = ArgoClimaConfigProvider.getScheduleDefaults(ScheduleTimerType.SCHEDULE_1)
.endTime();
private Set<Weekday> schedule2DayOfWeek = ArgoClimaConfigProvider.getScheduleDefaults(ScheduleTimerType.SCHEDULE_2)
.weekdays();
private String schedule2OnTime = ArgoClimaConfigProvider.getScheduleDefaults(ScheduleTimerType.SCHEDULE_2)
.startTime();
private String schedule2OffTime = ArgoClimaConfigProvider.getScheduleDefaults(ScheduleTimerType.SCHEDULE_2)
.endTime();
private Set<Weekday> schedule3DayOfWeek = ArgoClimaConfigProvider.getScheduleDefaults(ScheduleTimerType.SCHEDULE_3)
.weekdays();
private String schedule3OnTime = ArgoClimaConfigProvider.getScheduleDefaults(ScheduleTimerType.SCHEDULE_3)
.startTime();
private String schedule3OffTime = ArgoClimaConfigProvider.getScheduleDefaults(ScheduleTimerType.SCHEDULE_3)
.endTime();
public boolean resetToFactoryDefaults = false;
/////////////////////
// Other fields
/////////////////////
private static final DateTimeFormatter SCHEDULE_ON_OFF_TIME_FORMATTER = DateTimeFormatter.ofPattern("H:mm[:ss]");
protected @Nullable ArgoClimaTranslationProvider i18nProvider;
/**
* Initializes the configuration class post construction, injecting i18n provider for localized configuration
* exceptions
*
* @implNote This class requires default/parameterless c-tor for framework-side initialization (from file)
* @param i18nProvider Framework's translation provider
*/
public void initialize(ArgoClimaTranslationProvider i18nProvider) {
this.i18nProvider = i18nProvider;
}
/**
* Get the user-configured CPUID of the Argo device (used in matching to a concrete device in a stub mode)
*
* @return The configured CPUID (if provided by the user = not blank)
*/
public Optional<String> getDeviceCpuId() {
return this.deviceCpuId.isBlank() ? Optional.<String> empty() : Optional.of(this.deviceCpuId);
}
/**
* Get the refresh interval the device is polled with (in seconds)
*
* @return The interval value {@code 0} - to disable polling
*/
public int getRefreshInterval() {
return this.refreshInterval;
}
/**
* If true, allows the binding to directly communicate with the device (or vendor's server - for remote thing type).
* When false, binding will not communicate directly with the device and wait for it to call it (through
* intercepting/stub server)
* <p>
* <b>Mode-specific considerations</b>:
* <ul>
* <li>in {@code REMOTE_API_STUB} mode - will not issue any outbound connections on its own</li>
* <li>in {@code REMOTE_API_PROXY} mode - will still communicate with vendor's servers but ONLY when queried by the
* device (a pass-through)</li>
* </ul>
*
* @implNote While this is configured by its dedicated settings (for better UX) and valid only for Local Thing
* types, internal implementation uses {@code refreshInterval == 0} to signify no comms. This is because
* without a refresh, the binding would have to function in a fire and forget mode sending commands back
* to HVAC and never receiving any ACK... which makes little sense, hence is not supported
*
* @return True if the Thing is allowed to communicate outwards on its own, False otherwise
*/
public boolean useDirectConnection() {
return getRefreshInterval() > 0; // Uses virtual method overridden for local device!
}
/**
* The OEM server's address, used to pass through the communications to (in REMOTE_API_PROXY) mode
*
* @return The vendor's server IP address
* @throws ArgoConfigurationException In case the IP cannot be found
*/
public InetAddress getOemServerAddress() throws ArgoConfigurationException {
try {
return Objects.requireNonNull(InetAddress.getByName(oemServerAddress));
} catch (UnknownHostException e) {
throw ArgoConfigurationException.forInvalidParamValue(
ArgoClimaBindingConstants.PARAMETER_OEM_SERVER_ADDRESS, oemServerAddress, i18nProvider, e);
}
}
/**
* The OEM server's port, used to pass through the communications to (in REMOTE_API_PROXY) mode
*
* @return Vendor's server port. {@code -1} for no value
*/
public int getOemServerPort() {
return this.oemServerPort;
}
/**
* Converts "raw" {@code Set<Weekday>} into an {@code EnumSet<Weekday>}
*
* @implNote Because this configuration parameter is *dynamic* (and deliberately not defined in
* {@code thing-types.xml}) when OH is loading a textual thing file, it does not have a full definition
* yet, hence CANNOT infer its data type.
* The Thing.xtext definition for {@code ModelProperty} allows for arrays, but these are always implicit/
* For example {@code schedule1DayOfWeek="MON","TUE"} deserializes as a Collection (and is properly cast
* to enum later), however a {@code schedule1DayOfWeek="MON"} deserializes to a String, and causes a
* {@link ClassCastException} on access. This impl. accounts for that forced "as-String" interpretation on
* load, and coerces such values back to a collection.
* @param rawInput The value to process
* @param paramName Name of the textual parameter (for error messaging)
* @return Converted value
* @throws ArgoConfigurationException In case the conversion fails
*/
private EnumSet<Weekday> canonizeWeekdaysAfterDeserialization(Set<Weekday> rawInput, String paramName)
throws ArgoConfigurationException {
try {
var items = rawInput.toArray();
if (items.length == 1 && !(items[0] instanceof Weekday)) {
// Text based configuration -> falling back to string parse
var strValue = StringUtils.strip(items[0].toString(), "[]- \t\"'").trim();
var daysStr = StringUtils.splitByWholeSeparator(strValue, ",").stream();
var result = EnumSet.noneOf(Weekday.class);
daysStr.map(ds -> Weekday.valueOf(ds.strip())).forEach(wd -> result.add(wd));
return result;
} else {
// UI/API configuration (nicely strong-typed already)
return EnumSet.copyOf(rawInput);
}
} catch (ClassCastException | IllegalArgumentException e) {
throw ArgoConfigurationException.forInvalidParamValue(paramName, rawInput.toString(), i18nProvider, e);
}
}
record ConfigParam<K> (K paramValue, String paramName) {
}
@Override
public EnumSet<Weekday> getScheduleDayOfWeek(ScheduleTimerType scheduleType) throws ArgoConfigurationException {
ConfigParam<Set<Weekday>> configValue;
switch (scheduleType) {
case SCHEDULE_1:
configValue = new ConfigParam<>(schedule1DayOfWeek,
ArgoClimaBindingConstants.PARAMETER_SCHEDULE_X_DAYS.formatted(1));
break;
case SCHEDULE_2:
configValue = new ConfigParam<>(schedule2DayOfWeek,
ArgoClimaBindingConstants.PARAMETER_SCHEDULE_X_DAYS.formatted(2));
break;
case SCHEDULE_3:
configValue = new ConfigParam<>(schedule3DayOfWeek,
ArgoClimaBindingConstants.PARAMETER_SCHEDULE_X_DAYS.formatted(3));
break;
default:
throw new IllegalArgumentException("Invalid schedule timer: " + scheduleType.toString());
}
if (configValue.paramValue().isEmpty()) {
return ArgoClimaConfigProvider.getScheduleDefaults(scheduleType).weekdays();
}
return canonizeWeekdaysAfterDeserialization(configValue.paramValue(), configValue.paramName());
}
@Override
public LocalTime getScheduleOnTime(ScheduleTimerType scheduleType) throws ArgoConfigurationException {
ConfigParam<String> configValue;
switch (scheduleType) {
case SCHEDULE_1:
configValue = new ConfigParam<>(schedule1OnTime,
ArgoClimaBindingConstants.PARAMETER_SCHEDULE_X_ON_TIME.formatted(1));
break;
case SCHEDULE_2:
configValue = new ConfigParam<>(schedule2OnTime,
ArgoClimaBindingConstants.PARAMETER_SCHEDULE_X_ON_TIME.formatted(2));
break;
case SCHEDULE_3:
configValue = new ConfigParam<>(schedule3OnTime,
ArgoClimaBindingConstants.PARAMETER_SCHEDULE_X_ON_TIME.formatted(3));
break;
default:
throw new IllegalArgumentException("Invalid schedule timer: " + scheduleType.toString());
}
try {
return LocalTime.parse(configValue.paramValue(), SCHEDULE_ON_OFF_TIME_FORMATTER);
} catch (DateTimeParseException e) {
throw ArgoConfigurationException.forInvalidParamValue(configValue.paramName(), configValue.paramValue(),
i18nProvider, e);
}
}
@Override
public LocalTime getScheduleOffTime(ScheduleTimerType scheduleType) throws ArgoConfigurationException {
ConfigParam<String> configValue;
switch (scheduleType) {
case SCHEDULE_1:
configValue = new ConfigParam<>(schedule1OffTime,
ArgoClimaBindingConstants.PARAMETER_SCHEDULE_X_OFF_TIME.formatted(1));
break;
case SCHEDULE_2:
configValue = new ConfigParam<>(schedule2OffTime,
ArgoClimaBindingConstants.PARAMETER_SCHEDULE_X_OFF_TIME.formatted(2));
break;
case SCHEDULE_3:
configValue = new ConfigParam<>(schedule3OffTime,
ArgoClimaBindingConstants.PARAMETER_SCHEDULE_X_OFF_TIME.formatted(3));
break;
default:
throw new IllegalArgumentException("Invalid schedule timer: " + scheduleType.toString());
}
try {
return LocalTime.parse(configValue.paramValue(), SCHEDULE_ON_OFF_TIME_FORMATTER);
} catch (DateTimeParseException e) {
throw ArgoConfigurationException.forInvalidParamValue(configValue.paramName(), configValue.paramValue(),
i18nProvider, e);
}
}
/////////////////////
// Helper functions
/////////////////////
/**
* Utility function for logging only. Gets a parsed value from the supplier function or, exceptionally the raw
* value. Swallows exceptions.
*
* @param <T> Actual type of variable returned by the supplier (parsed)
* @param fn Parser function
* @return String param value (if parsed correctly), or the default value post-fixed with {@code [raw]} - on parse
* failure.
*/
protected static <@NonNull T> String getOrDefault(ConfigValueSupplier<T> fn) {
try {
return fn.get().toString();
} catch (ArgoConfigurationException e) {
return e.rawValue + "[raw]";
}
}
@Override
public final String toString() {
return String.format("Config: { %s, deviceCpuId=%s, refreshInterval=%d, oemServerPort=%d, oemServerAddress=%s,"
+ "schedule1DayOfWeek=%s, schedule1OnTime=%s, schedule1OffTime=%s, schedule2DayOfWeek=%s, schedule2OnTime=%s, schedule2OffTime=%s, schedule3DayOfWeek=%s, schedule3OnTime=%s, schedule3OffTime=%s, resetToFactoryDefaults=%s}",
getExtraFieldDescription(), deviceCpuId, refreshInterval, oemServerPort,
getOrDefault(this::getOemServerAddress),
getOrDefault(() -> getScheduleDayOfWeek(ScheduleTimerType.SCHEDULE_1)),
getOrDefault(() -> getScheduleOnTime(ScheduleTimerType.SCHEDULE_1)),
getOrDefault(() -> getScheduleOffTime(ScheduleTimerType.SCHEDULE_1)),
getOrDefault(() -> getScheduleDayOfWeek(ScheduleTimerType.SCHEDULE_2)),
getOrDefault(() -> getScheduleOnTime(ScheduleTimerType.SCHEDULE_2)),
getOrDefault(() -> getScheduleOffTime(ScheduleTimerType.SCHEDULE_2)),
getOrDefault(() -> getScheduleDayOfWeek(ScheduleTimerType.SCHEDULE_3)),
getOrDefault(() -> getScheduleOnTime(ScheduleTimerType.SCHEDULE_3)),
getOrDefault(() -> getScheduleOffTime(ScheduleTimerType.SCHEDULE_3)), resetToFactoryDefaults);
}
/**
* Return derived class'es extra configuration parameters (for a common {@link toString} implementation)
*
* @return Comma-separated list of configuration parameter=value pairs or empty String if derived class does not
* introduce any.
*/
protected abstract String getExtraFieldDescription();
/**
* Validate derived configuration
*
* @throws ArgoConfigurationException - on validation failure
*/
protected abstract void validateInternal() throws ArgoConfigurationException;
/**
* Validate current config
*
* @return Error message if config is invalid. Empty string - otherwise
*/
public final String validate() {
try {
if (refreshInterval < 0) {
throw ArgoConfigurationException.forParamBelowMin(ArgoClimaBindingConstants.PARAMETER_REFRESH_INTERNAL,
oemServerPort, i18nProvider, 0);
}
if (oemServerPort < 0 || oemServerPort > 65535) {
throw ArgoConfigurationException.forParamOutOfRange(ArgoClimaBindingConstants.PARAMETER_OEM_SERVER_PORT,
oemServerPort, i18nProvider, 0, 65535);
}
// want the side-effect of these calls
getOemServerAddress();
getScheduleDayOfWeek(ScheduleTimerType.SCHEDULE_1);
getScheduleOnTime(ScheduleTimerType.SCHEDULE_1);
getScheduleOffTime(ScheduleTimerType.SCHEDULE_1);
getScheduleDayOfWeek(ScheduleTimerType.SCHEDULE_2);
getScheduleOnTime(ScheduleTimerType.SCHEDULE_2);
getScheduleOffTime(ScheduleTimerType.SCHEDULE_2);
getScheduleDayOfWeek(ScheduleTimerType.SCHEDULE_3);
getScheduleOnTime(ScheduleTimerType.SCHEDULE_3);
getScheduleOffTime(ScheduleTimerType.SCHEDULE_3);
validateInternal();
return "";
} catch (Exception e) {
var msg = Optional.ofNullable(e.getLocalizedMessage());
var cause = Optional.ofNullable(e.getCause());
return msg.orElse("Unknown exception, message is null") // The message theoretically can be null
// (Exception's i-face) but in practice never is, so
// keeping cryptic non-i18nized text instead of
// throwing
.concat(cause.map(c -> "\n\t[" + c.getClass().getSimpleName() + "]").orElse(""));
}
}
}

View File

@ -0,0 +1,217 @@
/**
* 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.argoclima.internal.configuration;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants;
import org.openhab.binding.argoclima.internal.exception.ArgoConfigurationException;
/**
* The {@link ArgoClimaConfigurationLocal} class extends base configuration parameters with ones specific
* to local connection (including a remote API stub / proxy)
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class ArgoClimaConfigurationLocal extends ArgoClimaConfigurationBase {
public enum ConnectionMode {
LOCAL_CONNECTION,
REMOTE_API_STUB,
REMOTE_API_PROXY
}
public enum DeviceSidePasswordDisplayMode {
NEVER,
MASKED,
CLEARTEXT
}
private String hostname = "";
private ConnectionMode connectionMode = ConnectionMode.LOCAL_CONNECTION;
private int hvacListenPort = 1001;
private String localDeviceIP = "";
private boolean useLocalConnection = true;
private int stubServerPort = 8239; // Note the original Argo server listens on '80', but picking a non privileged
// port (>1024) as a default, since this needs remapping on firewall, and openHAB
// is typically listening on 80 or 8080
private List<String> stubServerListenAddresses = List.of("0.0.0.0");
private DeviceSidePasswordDisplayMode includeDeviceSidePasswordsInProperties = DeviceSidePasswordDisplayMode.NEVER;
private boolean matchAnyIncomingDeviceIp = false;
/**
* Retrieves the *target* IP address of the LOCAL Argo device (from hostname and/or IP)
*
* @return The IP address of the Argo device (for use in local communication)
* @throws ArgoConfigurationException if no IP address for the {@code hostname} could be found
*/
public InetAddress getHostname() throws ArgoConfigurationException {
try {
return Objects.requireNonNull(InetAddress.getByName(hostname));
} catch (UnknownHostException e) {
throw ArgoConfigurationException.forInvalidParamValue(ArgoClimaBindingConstants.PARAMETER_HOSTNAME,
hostname, i18nProvider, e);
}
}
/**
* Retrieves the local IPv4 address of the Argo device (in its current subnet) - if available/known
* <p>
* If the device is behind NAT, this address will be different from the one determined from
* {@link #getHostname() getHostname}
*
* @return Local IP address of the HVAC device (for use in matching remote responses to the device)
* @throws ArgoConfigurationException if the {@code localDeviceIP} is invalid
*/
public Optional<InetAddress> getLocalDeviceIP() throws ArgoConfigurationException {
try {
if (this.localDeviceIP.isBlank()) {
return Optional.<InetAddress> empty();
}
return Optional.ofNullable(InetAddress.getByName(localDeviceIP)); // it's actually not Nullable, but
// InetAddress doesn't have null
// annotations... so this useless runtime
// check spares us one compiler warning
// (yay! ;))
} catch (UnknownHostException e) {
throw ArgoConfigurationException.forInvalidParamValue(ArgoClimaBindingConstants.PARAMETER_LOCAL_DEVICE_IP,
localDeviceIP, i18nProvider, e);
}
}
/**
* Returns the local Argo device port (1001 by default, unless re-mapped on firewall)
*
* @return device's local port
*/
public int getHvacListenPort() {
return this.hvacListenPort;
}
/**
* Return the configured connection mode: local vs. remote API (with/without pass-through to Argo servers)
*
* @return The connection mode
*/
public ConnectionMode getConnectionMode() {
return this.connectionMode;
}
/**
* Get the stub server listen port
*
* @return Stub server listen port or {@code -1} if N/A
*/
public int getStubServerPort() {
return this.stubServerPort;
}
/**
* Get the stub server listen IP addresses (from hostnames)
*
* @return A set of listen addresses
* @throws ArgoConfigurationException if at least one of the {@code stubServerListenAddresses} is a hostname and
* cannot be resolved to an IP address
*/
public Set<InetAddress> getStubServerListenAddresses() throws ArgoConfigurationException {
var addresses = new LinkedHashSet<InetAddress>();
for (var t : stubServerListenAddresses) {
try {
addresses.add(Objects.requireNonNull(InetAddress.getByName(t)));
} catch (UnknownHostException e) {
throw ArgoConfigurationException.forInvalidParamValue(
ArgoClimaBindingConstants.PARAMETER_STUB_SERVER_LISTEN_ADDRESSES, t, i18nProvider, e);
}
}
return addresses;
}
@Override
public int getRefreshInterval() {
if (!this.useLocalConnection) {
return 0;
}
return super.getRefreshInterval();
}
/**
* Returns information whether the device-side incoming passwords are to be shown as properties (and if so: in the
* clear or replaced with ***)
*
* @return Configured value
*/
public DeviceSidePasswordDisplayMode getIncludeDeviceSidePasswordsInProperties() {
return this.includeDeviceSidePasswordsInProperties;
}
/**
* Should the incoming (intercepted) device-side updates be a strict match to local IP (if provided) or hostname
* (fallback)
*
* @return True - if requiring exact match, False - if IP mismatch is allowed
*/
public boolean getMatchAnyIncomingDeviceIp() {
return this.matchAnyIncomingDeviceIp;
}
@Override
protected String getExtraFieldDescription() {
return String.format(
"hostname=%s, localDeviceIP=%s, hvacListenPort=%d, connectionMode=%s, useLocalConnection=%s, stubServerPort=%d, stubServerListenAddresses=%s, includeDeviceSidePasswordsInProperties=%s, matchAnyIncomingDeviceIp=%s",
getOrDefault(this::getHostname), getOrDefault(this::getLocalDeviceIP), hvacListenPort, connectionMode,
useLocalConnection, stubServerPort, getOrDefault(this::getStubServerListenAddresses),
includeDeviceSidePasswordsInProperties, matchAnyIncomingDeviceIp);
}
@Override
protected void validateInternal() throws ArgoConfigurationException {
if (hostname.isEmpty()) {
throw ArgoConfigurationException.forEmptyRequiredParam(ArgoClimaBindingConstants.PARAMETER_HOSTNAME,
i18nProvider);
}
if (!useLocalConnection && connectionMode == ConnectionMode.LOCAL_CONNECTION) {
throw ArgoConfigurationException.forConflictingParams(
ArgoClimaBindingConstants.PARAMETER_USE_LOCAL_CONNECTION, "OFF",
ArgoClimaBindingConstants.PARAMETER_CONNECTION_MODE, ConnectionMode.LOCAL_CONNECTION, i18nProvider);
}
if (getRefreshInterval() == 0 && connectionMode == ConnectionMode.LOCAL_CONNECTION) {
throw ArgoConfigurationException.forConflictingParams(ArgoClimaBindingConstants.PARAMETER_REFRESH_INTERNAL,
getRefreshInterval(), ArgoClimaBindingConstants.PARAMETER_CONNECTION_MODE,
ConnectionMode.LOCAL_CONNECTION, i18nProvider);
}
if (hvacListenPort < 0 || hvacListenPort > 65535) {
throw ArgoConfigurationException.forParamOutOfRange(ArgoClimaBindingConstants.PARAMETER_HVAC_LISTEN_PORT,
hvacListenPort, i18nProvider, 0, 65535);
}
if (stubServerPort < 0 || stubServerPort > 65535) {
throw ArgoConfigurationException.forParamOutOfRange(ArgoClimaBindingConstants.PARAMETER_STUB_SERVER_PORT,
stubServerPort, i18nProvider, 0, 65535);
}
// want the side-effect of these calls!
getHostname();
getStubServerListenAddresses();
getLocalDeviceIP();
}
}

View File

@ -0,0 +1,97 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.argoclima.internal.configuration;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants;
import org.openhab.binding.argoclima.internal.exception.ArgoConfigurationException;
import org.openhab.binding.argoclima.internal.utils.PasswordUtils;
/**
* The {@link ArgoClimaConfigurationRemote} class contains fields mapping thing configuration parameters
* for a remote Argo device (comms via Argo servers)
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class ArgoClimaConfigurationRemote extends ArgoClimaConfigurationBase {
/**
* The duration after which the device would be considered non-responsive (and taken OFFLINE)
*/
public static final Duration LAST_SEEN_UNAVAILABILITY_THRESHOLD = Duration.ofMinutes(20);
/**
* Argo configuration parameters specific to remote connection
* These names are defined in thing-types.xml and get injected on instantiation
* through {@link org.openhab.core.thing.binding.BaseThingHandler#getConfigAs getConfigAs}
*/
private String username = "";
private String password = "";
/**
* Get the username (login) to use in authenticating to Argo server
*
* @return username (as configured by the user)
*/
public String getUsername() {
return this.username;
}
/**
* Get the masked password used in authenticating to Argo server (for logging)
*
* @return {@code ***}-masked string instead of the same length as configured password
*/
private final String getPasswordMasked() {
return PasswordUtils.maskPassword(password);
}
/**
* Get MD5 hash of the configured password (for Basic auth)
*
* @return MD5 hash of password
* @throws ArgoConfigurationException In case MD5 is not available in the security provider
*/
public String getPasswordHashed() throws ArgoConfigurationException {
try {
return PasswordUtils.md5HashPassword(password);
} catch (NoSuchAlgorithmException e) {
throw ArgoConfigurationException.forInvalidParamValue(ArgoClimaBindingConstants.PARAMETER_PASSWORD,
PasswordUtils.maskPassword(password), i18nProvider, e); // User-provided value is likely NOT at
// fault, but using this exception for
// generic error messaging (cause will be
// displayed anyway)
}
}
@Override
protected String getExtraFieldDescription() {
return String.format("username=%s, password=%s", username, getPasswordMasked());
}
@Override
protected void validateInternal() throws ArgoConfigurationException {
if (username.isBlank()) {
throw ArgoConfigurationException.forEmptyRequiredParam(ArgoClimaBindingConstants.PARAMETER_USERNAME,
i18nProvider);
}
if (password.isBlank()) {
throw ArgoConfigurationException.forEmptyRequiredParam(ArgoClimaBindingConstants.PARAMETER_PASSWORD,
i18nProvider);
}
}
}

View File

@ -0,0 +1,89 @@
/**
* 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.argoclima.internal.configuration;
import java.time.LocalTime;
import java.util.EnumSet;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.argoclima.internal.device.api.types.Weekday;
import org.openhab.binding.argoclima.internal.exception.ArgoConfigurationException;
/**
* Interface for schedule provider
* The device (its remote) supports 3 schedules, so the same is implemented herein.
* <p>
* Noteworthy, the device itself (when communicated-to) only takes the type of timer (schedule) and on/off times +
* weekdays, so technically number of schedules supported may be expanded beyond 3
*
* @implNote Only one schedule may be active at a time. Currently implemented through config, as it is easier to edit
* this way. Note that delay timer is instead implemented as a channel!
*
* @implNote While the boilerplate can be reduced, config-side these are modeled as individual properties (easier to
* edit), hence not doing anything fancy here
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public interface IScheduleConfigurationProvider {
/**
* The type of schedule timer (1|2|3)
*
* @author Mateusz Bronk - Initial contribution
*/
public enum ScheduleTimerType {
SCHEDULE_1,
SCHEDULE_2,
SCHEDULE_3;
public static ScheduleTimerType fromInt(int value) {
switch (value) {
case 1:
return SCHEDULE_1;
case 2:
return SCHEDULE_2;
case 3:
return SCHEDULE_3;
default:
throw new IllegalArgumentException(String.format("Invalid value for ScheduleTimerType: %d", value));
}
}
}
/**
* The days of week when schedule shall be active
*
* @param scheduleType Which schedule timer to target (1|2|3)
* @return The configured value
* @throws ArgoConfigurationException In case of configuration error
*/
public EnumSet<Weekday> getScheduleDayOfWeek(ScheduleTimerType scheduleType) throws ArgoConfigurationException;
/**
* The time of day schedule 1 shall turn the AC on
*
* @param scheduleType Which schedule timer to target (1|2|3)
* @return The configured value
* @throws ArgoConfigurationException In case of configuration error
*/
public LocalTime getScheduleOnTime(ScheduleTimerType scheduleType) throws ArgoConfigurationException;
/**
* The time of day schedule 1 shall turn the AC off
*
* @param scheduleType Which schedule timer to target (1|2|3)
* @return The configured value
* @throws ArgoConfigurationException In case of configuration error
*/
public LocalTime getScheduleOffTime(ScheduleTimerType scheduleType) throws ArgoConfigurationException;
}

View File

@ -0,0 +1,269 @@
/**
* 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.argoclima.internal.device.api;
import java.io.EOFException;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.SortedMap;
import java.util.TreeMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;
import java.util.function.Consumer;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.util.URIUtil;
import org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants;
import org.openhab.binding.argoclima.internal.ArgoClimaTranslationProvider;
import org.openhab.binding.argoclima.internal.configuration.ArgoClimaConfigurationBase;
import org.openhab.binding.argoclima.internal.device.api.protocol.ArgoApiDataElement;
import org.openhab.binding.argoclima.internal.device.api.protocol.ArgoDeviceStatus;
import org.openhab.binding.argoclima.internal.device.api.protocol.elements.IArgoCommandableElement.IArgoElement;
import org.openhab.binding.argoclima.internal.device.api.types.ArgoDeviceSettingType;
import org.openhab.binding.argoclima.internal.exception.ArgoApiCommunicationException;
import org.openhab.binding.argoclima.internal.exception.ArgoApiProtocolViolationException;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Common implementation of Argo API (across local and remote connection modes)
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public abstract class ArgoClimaDeviceApiBase implements IArgoClimaDeviceAPI {
private final Logger logger = LoggerFactory.getLogger(getClass());
private final HttpClient client;
protected final TimeZoneProvider timeZoneProvider;
protected final ArgoClimaTranslationProvider i18nProvider;
protected final ArgoDeviceStatus deviceStatus;
protected Consumer<SortedMap<String, String>> onDevicePropertiesUpdate;
protected SortedMap<String, String> deviceProperties;
private final String remoteEndName;
/**
* C-tor
*
* @param config The configuration class (common part)
* @param client The common HTTP client used for making connections from OH to the device
* @param timeZoneProvider The common TZ provider
* @param onDevicePropertiesUpdate Callback to invoke on device-side dynamic property update (ex. lastSeen)
* @param remoteEndName The name of the "remote end" party, for use in logging
* @param i18nProvider Framework's translation provider
*/
public ArgoClimaDeviceApiBase(ArgoClimaConfigurationBase config, HttpClient client,
TimeZoneProvider timeZoneProvider, ArgoClimaTranslationProvider i18nProvider,
Consumer<SortedMap<String, String>> onDevicePropertiesUpdate, String remoteEndName) {
this.client = client;
this.timeZoneProvider = timeZoneProvider;
this.i18nProvider = i18nProvider;
this.deviceStatus = new ArgoDeviceStatus(config);
this.onDevicePropertiesUpdate = onDevicePropertiesUpdate;
this.deviceProperties = new TreeMap<String, String>();
this.remoteEndName = remoteEndName.isBlank() ? "DEVICE" : remoteEndName.trim().toUpperCase();
}
/**
* Return the URL used for querying device state (poll)
*
* @return The "poll for status" URL (w/o any changes)
*/
protected abstract URL getDeviceStateQueryUrl();
/**
* Return the URL used for updating device state (a command)
*
* @return The "send command" URL (effecting changes)
*/
protected abstract URL getDeviceStateUpdateUrl();
/**
* Extract device status from just-polled API result (local or remote)
*
* @param apiResponse The response received from device (body of the response, ex. one obtained through
* {@link #pollForCurrentStatusFromDeviceSync(URL)}.
* @return The {@link DeviceStatus} parsed from response (with properties pre-parsed)
* @throws ArgoApiCommunicationException If the response body was not recognized as a valid protocol message
*/
protected abstract DeviceStatus extractDeviceStatusFromResponse(String apiResponse)
throws ArgoApiCommunicationException;
/**
* Helper class method for converting strings to URIs (assumes HTTP for the protocol)
*
* @implNote Throwing unchecked exceptions, as this function is used in practice only for URLs returned by
* {@link org.eclipse.jetty.util.URIUtil#newURI}, so a scenario where it would be malformed is extremely
* unlikely and we DO NOT want nice handling for it
*
* @param server The server address (hostname or IP)
* @param port The server port
* @param path The resource path (ex. '/')
* @param query The query parameters
*
* @return Converted URL
*/
protected static final URL newUrl(String server, int port, String path, String query) {
var uriStr = URIUtil.newURI("http", server, port, path, query);
try {
return new URL(uriStr);
} catch (MalformedURLException e) {
throw new IllegalArgumentException("Failed to build url from: " + uriStr, e);
}
}
/**
* Trigger device-side communication (synchronous!) and get the response
* <p>
* Note: The Argo API violates HTTP spec. and uses GET requests for both state retrieval (idempotent) as well as
* control! The query params of the URL determine the mode.
* <p>
* In case of binding/Thing shutdown, this function may terminate early (not waiting for I/O to complete) and return
* an empty string
*
* @implNote This method should not be used if {@link ArgoClimaBindingConstants#PARAMETER_USE_LOCAL_CONNECTION} is
* false, though the implementation is NOT enforcing it (SHOULD NOT != MAY NOT).
* @param url URL to call (should contain full protocol message to send to the device through HTTP GET (such as ones
* obtained through {@link #getDeviceStateQueryUrl} or {@link #getDeviceStateUpdateUrl()}
* @return The device-side reply (HTTP response body)
* @throws ArgoApiCommunicationException Thrown in case of communication issues (including timeouts) or if the
* API returned a response different from {@code HTTP 200 OK}
*/
protected String pollForCurrentStatusFromDeviceSync(URL url) throws ArgoApiCommunicationException {
try {
logger.trace("Communication: OPENHAB --> {}: [GET {}]", remoteEndName, url);
ContentResponse resp = this.client.GET(url.toString()); // sync
logger.trace(" [response]: OPENHAB <-- {}: [{} {} {} - {} bytes], body=[{}]", remoteEndName,
resp.getVersion(), resp.getStatus(), resp.getReason(), resp.getContent().length,
resp.getContentAsString());
if (resp.getStatus() != 200) {
throw new ArgoApiCommunicationException(
"API request yielded invalid response status {0} {1} (expected HTTP 200 OK). URL was: {2}",
"thing-status.cause.argoclima.invalid-api-response-status", i18nProvider, resp.getStatus(),
resp.getReason(), url);
}
return Objects.requireNonNull(resp.getContentAsString());
} catch (InterruptedException ex) {
logger.trace("Interrupted...");
return "";
} catch (ExecutionException ex) {
var cause = Optional.ofNullable(ex.getCause());
if (cause.isPresent() && cause.get() instanceof EOFException) {
throw new ArgoApiCommunicationException(
"Device did not respond on its socket (EOF). Check that the device is correctly communicating with Argo servers (or openHAB stub server)",
"thing-status.cause.argoclima.device-eof", i18nProvider);
}
throw new ArgoApiCommunicationException("Device communication error: {0}",
"thing-status.cause.argoclima.communication-error", i18nProvider, ex.getCause(),
Objects.requireNonNullElse(ex.getCause(), ex).getLocalizedMessage());
} catch (TimeoutException e) {
throw new ArgoApiCommunicationException("Timeout: {0}",
"thing-status.cause.argoclima.communication-error.timeout", i18nProvider, e.getLocalizedMessage());
}
}
/**
* Updates cached device properties with the values just received from device and notifies the framework through
* callback
*
* @param metadata The properties received from device
* @param status The status update from device
*/
protected void updateDevicePropertiesFromDeviceResponse(DeviceStatus.DeviceProperties metadata,
ArgoDeviceStatus status) {
var metaProperties = metadata.asPropertiesRaw(this.timeZoneProvider);
var responseProperties = Map.<String, String> of(ArgoClimaBindingConstants.PROPERTY_UNIT_FW,
status.getSetting(ArgoDeviceSettingType.UNIT_FIRMWARE_VERSION).toString(false));
synchronized (this) {
// not clearing the existing properties (grow-only)
this.deviceProperties.putAll(metaProperties);
this.deviceProperties.putAll(responseProperties);
}
this.onDevicePropertiesUpdate.accept(getCurrentDeviceProperties());
}
@Override
public final SortedMap<String, String> getCurrentDeviceProperties() {
return Collections.unmodifiableSortedMap(this.deviceProperties);
}
@Override
public Map<ArgoDeviceSettingType, State> queryDeviceForUpdatedState() throws ArgoApiCommunicationException {
var deviceResponse = extractDeviceStatusFromResponse(
pollForCurrentStatusFromDeviceSync(getDeviceStateQueryUrl()));
try {
this.deviceStatus.fromDeviceString(deviceResponse.getCommandString());
} catch (ArgoApiProtocolViolationException e) {
throw new ArgoApiCommunicationException("Unrecognized API response",
"thing-status.cause.argoclima.exception.unrecognized-response", i18nProvider, e);
}
this.updateDevicePropertiesFromDeviceResponse(deviceResponse.getProperties(), this.deviceStatus);
deviceResponse.throwIfStatusIsStale();
return this.deviceStatus.getCurrentStateMap();
}
@Override
public Map<ArgoDeviceSettingType, State> getLastStateReadFromDevice() {
return this.deviceStatus.getCurrentStateMap();
}
@Override
public void sendCommandsToDevice() throws ArgoApiCommunicationException {
var deviceResponse = pollForCurrentStatusFromDeviceSync(getDeviceStateUpdateUrl());
notifyCommandsPassedToDevice(); // Just sent directly
logger.trace("State update command finished. Device response: {}", deviceResponse);
}
@Override
public void notifyCommandsPassedToDevice() {
deviceStatus.getItemsWithPendingUpdates().forEach(x -> x.notifyCommandSent());
}
@Override
public boolean handleSettingCommand(ArgoDeviceSettingType settingType, Command command) {
return this.deviceStatus.getSetting(settingType).handleCommand(command);
}
@Override
public State getCurrentStateNoPoll(ArgoDeviceSettingType settingType) {
return this.deviceStatus.getSetting(settingType).getState();
}
@Override
public boolean hasPendingCommands() {
var itemsWithPendingUpdates = this.deviceStatus.getItemsWithPendingUpdates();
logger.trace("Items to update: {}", itemsWithPendingUpdates);
return !itemsWithPendingUpdates.isEmpty();
}
@Override
public List<ArgoApiDataElement<IArgoElement>> getItemsWithPendingUpdates() {
return this.deviceStatus.getItemsWithPendingUpdates();
}
}

View File

@ -0,0 +1,246 @@
/**
* 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.argoclima.internal.device.api;
import java.net.InetAddress;
import java.net.URL;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.SortedMap;
import java.util.function.Consumer;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.client.HttpClient;
import org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants;
import org.openhab.binding.argoclima.internal.ArgoClimaTranslationProvider;
import org.openhab.binding.argoclima.internal.configuration.ArgoClimaConfigurationLocal;
import org.openhab.binding.argoclima.internal.device.api.types.ArgoDeviceSettingType;
import org.openhab.binding.argoclima.internal.device.passthrough.requests.DeviceSidePostRtUpdateDTO;
import org.openhab.binding.argoclima.internal.device.passthrough.requests.DeviceSideUpdateDTO;
import org.openhab.binding.argoclima.internal.exception.ArgoApiCommunicationException;
import org.openhab.binding.argoclima.internal.exception.ArgoApiProtocolViolationException;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.types.State;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Argo protocol implementation for a LOCAL connection to the device
* <p>
* IMPORTANT: Local doesn't necessarily mean "directly reachable". This class is also used for devices behind NAT, where
* all the communication is happening indirect (through intercepted device-side polls, and modifying responses through
* stub/proxy server)
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class ArgoClimaLocalDevice extends ArgoClimaDeviceApiBase {
private final Logger logger = LoggerFactory.getLogger(getClass());
private final InetAddress ipAddress; // The direct IP address
private final Optional<InetAddress> localIpAddress; // The indirect IP address (local subnet) - possibly not
// reachable if behind NAT (optional)
private final Optional<String> cpuId; // The configured CPU id (if any) - for matching intercepted responses
private final String id;
private final Consumer<Map<ArgoDeviceSettingType, State>> onStateUpdate;
private final Consumer<ThingStatus> onReachableStatusChange;
private final int port;
private final boolean matchAnyIncomingDeviceIp;
/**
* C-tor
*
* @param config The Thing configuration
* @param targetDeviceIpAddress The IP address of the directly-connected device (for indirect mode, the device does
* NOT need to be reachable through this address!)
* @param port The port to talk to the directly-connected device
* @param localDeviceIpAddress Optional, local subnet IP of the device (ex. if behind NAT). Used to match
* intercepted responses (in indirect mode) to this thing. This may be
* {@link ArgoClimaConfigurationLocal#getMatchAnyIncomingDeviceIp() bypassed}
* @param cpuId Optional, CPUID of the Wi-Fi chip of the device. If provided, will be used to match intercepted
* responses (in indirect mode) to this thing
* @param client The common HTTP client used for issuing direct requests
* @param timeZoneProvider System-wide TZ provider, for parsing/displaying local dates
* @param i18nProvider Framework's translation provider
* @param onStateUpdate Callback to be invoked when device status gets updated(device-side channel updates)
* @param onReachableStatusChange Callback to be invoked when device's reachability status (online) changes
* @param onDevicePropertiesUpdate Callback to invoke when device properties get refreshed
* @param thingUid The UID of the Thing owning this server (used for logging)
*/
public ArgoClimaLocalDevice(ArgoClimaConfigurationLocal config, InetAddress targetDeviceIpAddress, int port,
Optional<InetAddress> localDeviceIpAddress, Optional<String> cpuId, HttpClient client,
TimeZoneProvider timeZoneProvider, ArgoClimaTranslationProvider i18nProvider,
Consumer<Map<ArgoDeviceSettingType, State>> onStateUpdate, Consumer<ThingStatus> onReachableStatusChange,
Consumer<SortedMap<String, String>> onDevicePropertiesUpdate, String thingUid) {
super(config, client, timeZoneProvider, i18nProvider, onDevicePropertiesUpdate, "");
this.ipAddress = targetDeviceIpAddress;
this.port = port;
this.localIpAddress = localDeviceIpAddress;
this.cpuId = cpuId;
this.matchAnyIncomingDeviceIp = config.getMatchAnyIncomingDeviceIp();
this.onStateUpdate = onStateUpdate;
this.onReachableStatusChange = onReachableStatusChange;
this.id = thingUid;
}
@Override
protected URL getDeviceStateQueryUrl() {
// Hard-coded values are part of ARGO protocol
return newUrl(Objects.requireNonNull(this.ipAddress.getHostName()), this.port, "/", "HMI=&UPD=0");
}
@Override
protected URL getDeviceStateUpdateUrl() {
// Hard-coded values are part of ARGO protocol
return newUrl(Objects.requireNonNull(this.ipAddress.getHostName()), this.port, "/",
String.format("HMI=%s&UPD=1", this.deviceStatus.getDeviceCommandStatus()));
}
@Override
public final ReachabilityStatus isReachable() {
try {
var status = extractDeviceStatusFromResponse(pollForCurrentStatusFromDeviceSync(getDeviceStateQueryUrl()));
try {
this.deviceStatus.fromDeviceString(status.getCommandString());
} catch (ArgoApiProtocolViolationException e) {
throw new ArgoApiCommunicationException("Unrecognized API response",
"thing-status.cause.argoclima.exception.unrecognized-response", i18nProvider, e);
}
this.updateDevicePropertiesFromDeviceResponse(status.getProperties(), this.deviceStatus);
return new ReachabilityStatus(true, "");
} catch (ArgoApiCommunicationException e) {
logger.debug("Device not reachable: {}", e.getMessage());
return new ReachabilityStatus(false,
Objects.requireNonNull(i18nProvider.getText("thing-status.argoclima.local-unreachable",
"Failed to communicate with Argo HVAC device at [http://{0}:{1,number,#}{2}]. {3}",
this.getDeviceStateQueryUrl().getHost(),
this.getDeviceStateQueryUrl().getPort() != -1 ? this.getDeviceStateQueryUrl().getPort()
: this.getDeviceStateQueryUrl().getDefaultPort(),
this.getDeviceStateQueryUrl().getPath(), e.getLocalizedMessage())));
}
}
@Override
protected DeviceStatus extractDeviceStatusFromResponse(String apiResponse) {
// local device response does not have all properties, but is always fresh
return new DeviceStatus(apiResponse, OffsetDateTime.now(), i18nProvider);
}
/**
* Update device state from intercepted message from device to remote server (device's own send of command)
* This is sent in response to cloud-side command (likely a form of acknowledgement)
*
* @implNote This function is a WORK IN PROGRESS (and not doing anything useful at the present!)
* @param fromDevice the POST message sent by the device, in acknowledgement of fulfilling remote-side command
*/
public void updateDeviceStateFromPostRtRequest(DeviceSidePostRtUpdateDTO fromDevice) {
if (this.cpuId.isEmpty()) {
logger.trace(
"Got post update confirmation from device {}, but was not able to match it to this device b/c no CPUID is configured. Configure {} setting to allow this mode...",
fromDevice.cpuId, ArgoClimaBindingConstants.PARAMETER_DEVICE_CPU_ID);
return;
}
if (!this.cpuId.get().equalsIgnoreCase(fromDevice.cpuId)) {
logger.trace("Got post update from device [ID={}], but this entity belongs to device [ID={}]. Ignoring...",
fromDevice.cpuId, this.cpuId.orElse("???"));
return;
}
// NOTICE (on possible future extension): The values from 'data' param of the response are NOT following the HMI
// syntax in the GET requests (much more data is available in this requests - and while actual responses seem
// empty... perhaps a response to this can provide iFeel temperatures?)
// There are some similarities -> ex. target/actual temperatures are at offset 112 & 113 of the array,
// so at the very least, could get the known values (but not as trivial as:
// # fromDevice.dataParam.split(ArgoDeviceStatus.HMI_ELEMENT_SEPARATOR).Arrays.stream(paramArray).skip(111)
// # .limit(ArgoDeviceStatus.HMI_UPDATE_ELEMENT_COUNT).toList()
// Overall, this needs more reverse-engineering (but works w/o this information, so not implementing for now)
}
/**
* Update device state from intercepted message from device to remote server (device's own polling)
* <p>
* Important: The device-sent message will only be used for update if it matches to configured value
* (this is to avoid updating status of a completely different device)
* <p>
* Most robust match is by CPUID, though if n/a, localIP is used as heuristic alternative as well
*
* @param deviceUpdate The device-side update request
*/
public void updateDeviceStateFromPushRequest(DeviceSideUpdateDTO deviceUpdate)
throws ArgoApiCommunicationException {
String hmiStringFromDevice = deviceUpdate.currentValues;
String deviceIP = deviceUpdate.deviceIp;
String deviceCpuId = deviceUpdate.cpuId;
if (this.cpuId.isPresent() && !this.cpuId.get().equalsIgnoreCase(deviceCpuId)) {
logger.trace(
"Got poll update from device [ID={} | IP={}], but this entity belongs to device [ID={}]. Ignoring...",
deviceCpuId, deviceIP, this.cpuId.get());
return; // direct mismatch
}
if (!this.localIpAddress.orElse(this.ipAddress).getHostAddress().equalsIgnoreCase(deviceIP)) {
if (this.matchAnyIncomingDeviceIp) {
logger.debug(
"Got poll update from device {}[IP={}], which is not a match to this device [{}={}]. Ignoring the mismatch due to matchAnyIncomingDeviceIp==true...",
deviceCpuId, deviceIP, this.localIpAddress.isPresent() ? "localIP" : "hostname",
this.localIpAddress.orElse(this.ipAddress).getHostAddress());
} else {
if (this.cpuId.isEmpty() && this.localIpAddress.isEmpty()) {
logger.info(
"[{}] Got poll update from device {}[IP={}], but was not able to match it to this device with IP={}. Configure {} and/or {} settings to allow detection...",
id, deviceCpuId, deviceIP, this.ipAddress.getHostAddress(),
ArgoClimaBindingConstants.PARAMETER_DEVICE_CPU_ID,
ArgoClimaBindingConstants.PARAMETER_LOCAL_DEVICE_IP);
} else {
logger.trace(
"Got poll update from device [ID={} | IP={}], but this entity belongs to device [ID={} | IP={}]. Ignoring...",
deviceCpuId, deviceIP, this.cpuId.orElse("???"),
this.localIpAddress.orElse(this.ipAddress).getHostAddress());
}
return; // IP address heuristic mismatch
}
}
this.onReachableStatusChange.accept(ThingStatus.ONLINE); // Device communicated with us, so we consider it
// ONLINE
try {
this.deviceStatus.fromDeviceString(hmiStringFromDevice);
} catch (ArgoApiProtocolViolationException e) {
throw new ArgoApiCommunicationException("Unrecognized API response",
"thing-status.cause.argoclima.exception.unrecognized-response", i18nProvider, e);
}
this.onStateUpdate.accept(this.deviceStatus.getCurrentStateMap()); // Update channels from device's state
var properties = new DeviceStatus.DeviceProperties(OffsetDateTime.now(), deviceUpdate);
synchronized (this) {
// update shared properties (which may be updated using direct method as well)
this.deviceProperties.putAll(properties.asPropertiesRaw(this.timeZoneProvider));
}
this.onDevicePropertiesUpdate.accept(getCurrentDeviceProperties());
}
/**
* Get latest "command" string to be sent back to the device in response to its own poll
* If there are no updates pending, this string will be similar to a canned "nothing to do" response
*
* @return Command string to send back to device
*/
public String getCurrentCommandString() {
return this.deviceStatus.getDeviceCommandStatus();
}
}

View File

@ -0,0 +1,161 @@
/**
* 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.argoclima.internal.device.api;
import java.net.InetAddress;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.text.MessageFormat;
import java.util.Objects;
import java.util.Optional;
import java.util.SortedMap;
import java.util.function.Consumer;
import java.util.regex.Pattern;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.client.HttpClient;
import org.openhab.binding.argoclima.internal.ArgoClimaTranslationProvider;
import org.openhab.binding.argoclima.internal.configuration.ArgoClimaConfigurationRemote;
import org.openhab.binding.argoclima.internal.device.api.DeviceStatus.DeviceProperties;
import org.openhab.binding.argoclima.internal.exception.ArgoApiCommunicationException;
import org.openhab.binding.argoclima.internal.exception.ArgoApiProtocolViolationException;
import org.openhab.core.i18n.TimeZoneProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Argo protocol implementation for a REMOTE connection to the device
* <p>
* The HVAC device MUST be communicating with actual Argo servers for this method work.
* This means the device is either directly connected to the Internet (w/o traffic intercept), or there's an
* intercepting Stub server already running in a PASS-THROUGH mode (sniffing the messages but passing through to the
* actual vendor's servers)
*
* <p>
* Use of this mode is actually NOT recommended for advanced users as cleartext device and Wi-Fi passwords are sent to
* Argo servers through unencrypted HTTP connection (sic!). If the Argo UI access is desired (ex. for FW update or IR
* remote-like experience), consider using this mode only on a dedicated Wi-Fi network (and possibly through VPN)
*
* @author Mateusz Bronk - Initial contribution
*
*/
@NonNullByDefault
public class ArgoClimaRemoteDevice extends ArgoClimaDeviceApiBase {
private final Logger logger = LoggerFactory.getLogger(getClass());
private final InetAddress oemServerHostname;
private final int oemServerPort;
private final String usernameUrlEncoded;
private final String passwordMD5Hash;
private static final Pattern REMOTE_API_RESPONSE_EXPECTED = Pattern.compile(
"^[\\\\{][|](?<commands>[^|]+)[|](?<localIP>[^|]+)[|](?<lastSeen>[^|]+)[|][\\\\}]\\s*$",
Pattern.CASE_INSENSITIVE); // Capture group names are used in code!
/**
* C-tor
*
* @param config The Thing configuration
* @param client The common HTTP client used for issuing requests to the remote server
* @param timeZoneProvider System-wide TZ provider, for parsing/displaying local dates
* @param i18nProvider Framework's translation provider
* @param oemServerHostname The address of the remote (vendor's) server
* @param oemServerPort The port of remote (vendor's) server
* @param username The username used for authenticating to the remote server (will be URL-encoded before send)
* @param passwordMD5 A MD5 hash of the password used for authenticating to the remote server (custom Basic-like
* auth)
* @param onDevicePropertiesUpdate Callback to invoke when device properties get refreshed
*/
public ArgoClimaRemoteDevice(ArgoClimaConfigurationRemote config, HttpClient client,
TimeZoneProvider timeZoneProvider, ArgoClimaTranslationProvider i18nProvider, InetAddress oemServerHostname,
int oemServerPort, String username, String passwordMD5,
Consumer<SortedMap<String, String>> onDevicePropertiesUpdate) {
super(config, client, timeZoneProvider, i18nProvider, onDevicePropertiesUpdate, "REMOTE_API");
this.oemServerHostname = oemServerHostname;
this.oemServerPort = oemServerPort;
this.usernameUrlEncoded = Objects.requireNonNull(URLEncoder.encode(username, StandardCharsets.UTF_8));
this.passwordMD5Hash = passwordMD5;
}
@Override
public final ReachabilityStatus isReachable() {
try {
var status = extractDeviceStatusFromResponse(pollForCurrentStatusFromDeviceSync(getDeviceStateQueryUrl()));
try {
this.deviceStatus.fromDeviceString(status.getCommandString());
} catch (ArgoApiProtocolViolationException e) {
throw new ArgoApiCommunicationException("Unrecognized API response",
"thing-status.cause.argoclima.exception.unrecognized-response", i18nProvider, e);
}
this.updateDevicePropertiesFromDeviceResponse(status.getProperties(), this.deviceStatus);
status.throwIfStatusIsStale();
return new ReachabilityStatus(true, "");
} catch (ArgoApiCommunicationException e) {
logger.debug("Device not reachable: {}", e.getMessage());
return new ReachabilityStatus(false,
Objects.requireNonNull(MessageFormat.format(
"Failed to communicate with Argo HVAC remote device at [http://{0}:{1,number,#}{2}]. {3}",
this.getDeviceStateQueryUrl().getHost(),
this.getDeviceStateQueryUrl().getPort() != -1 ? this.getDeviceStateQueryUrl().getPort()
: this.getDeviceStateQueryUrl().getDefaultPort(),
this.getDeviceStateQueryUrl().getPath(), e.getMessage())));
}
}
@Override
protected URL getDeviceStateQueryUrl() {
// Hard-coded values are part of ARGO protocol
return newUrl(Objects.requireNonNull(this.oemServerHostname.getHostName()), this.oemServerPort, "/UI/UI.php",
String.format("CM=UI_TC&USN=%s&PSW=%s&HMI=&UPD=0", this.usernameUrlEncoded, this.passwordMD5Hash));
}
@Override
protected URL getDeviceStateUpdateUrl() {
// Hard-coded values are part of ARGO protocol
return newUrl(Objects.requireNonNull(this.oemServerHostname.getHostName()), this.oemServerPort, "/UI/UI.php",
String.format("CM=UI_TC&USN=%s&PSW=%s&HMI=%s&UPD=1", this.usernameUrlEncoded, this.passwordMD5Hash,
this.deviceStatus.getDeviceCommandStatus()));
}
@Override
protected DeviceStatus extractDeviceStatusFromResponse(String apiResponse) throws ArgoApiCommunicationException {
if (apiResponse.isBlank()) {
throw new ArgoApiCommunicationException("The remote API response was empty. Check username and password",
"thing-status.cause.argoclima.empty-remote-response", i18nProvider);
}
var matcher = REMOTE_API_RESPONSE_EXPECTED.matcher(apiResponse);
if (!matcher.matches()) {
throw new ArgoApiCommunicationException("The remote API response [%s] was not recognized",
"thing-status.cause.argoclima.unrecognized-remote-response", i18nProvider, apiResponse);
}
// Group names must match regex above
var properties = new DeviceProperties(Objects.requireNonNull(matcher.group("localIP")),
Objects.requireNonNull(matcher.group("lastSeen")), Optional.of(
getWebUiUrl(Objects.requireNonNull(this.oemServerHostname.getHostName()), this.oemServerPort)));
return new DeviceStatus(Objects.requireNonNull(matcher.group("commands")), properties, i18nProvider);
}
/**
* Return the full URL to the Vendor's web application
*
* @param hostName The OEM server host
* @param port The OEM server port
* @return Full URL to the UI webapp
*/
public static URL getWebUiUrl(String hostName, int port) {
return newUrl(hostName, port, "/UI/WEBAPP/webapp.php", "");
}
}

View File

@ -0,0 +1,234 @@
/**
* 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.argoclima.internal.device.api;
import java.net.URL;
import java.time.DateTimeException;
import java.time.Duration;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.Collections;
import java.util.Optional;
import java.util.SortedMap;
import java.util.TreeMap;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants;
import org.openhab.binding.argoclima.internal.ArgoClimaTranslationProvider;
import org.openhab.binding.argoclima.internal.configuration.ArgoClimaConfigurationRemote;
import org.openhab.binding.argoclima.internal.device.passthrough.requests.DeviceSideUpdateDTO;
import org.openhab.binding.argoclima.internal.exception.ArgoApiCommunicationException;
import org.openhab.core.i18n.TimeZoneProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Represents the current device status, as-communicated by the device (either push or pull model)
* <p>
* Includes both the "raw" {@link #getCommandString() commandString} as well as {@link #getProperties() properties}
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
class DeviceStatus {
//////////////
// TYPES
//////////////
/**
* Helper class for dealing with device properties
*
* @author Mateusz Bronk - Initial contribution
*/
static class DeviceProperties {
private static final Logger LOGGER = LoggerFactory.getLogger(DeviceProperties.class);
private final Optional<String> localIP;
private final Optional<OffsetDateTime> lastSeen;
private final Optional<URL> vendorUiUrl;
private Optional<String> cpuId = Optional.empty();
private Optional<String> webUiUsername = Optional.empty();
private Optional<String> webUiPassword = Optional.empty();
private Optional<String> unitFWVersion = Optional.empty();
private Optional<String> wifiFWVersion = Optional.empty();
private Optional<String> wifiSSID = Optional.empty();
private Optional<String> wifiPassword = Optional.empty();
private Optional<String> localTime = Optional.empty();
/**
* C-tor (from remote server query response)
*
* @param localIP The local IP of the Argo device (or empty string if N/A)
* @param lastSeenStr The ISO-8601-formatted date/time of last update (or empty string if N/A)
* @param vendorUiAddress The optional full URL to vendor's web UI
*/
public DeviceProperties(String localIP, String lastSeenStr, Optional<URL> vendorUiAddress) {
this.localIP = localIP.isEmpty() ? Optional.empty() : Optional.of(localIP);
this.vendorUiUrl = vendorUiAddress;
this.lastSeen = dateFromISOString(lastSeenStr, "LastSeen");
}
/**
* C-tor (from live poll response)
*
* @param lastSeen The date/time of last update (when the response got received)
*/
public DeviceProperties(OffsetDateTime lastSeen) {
this.localIP = Optional.empty();
this.lastSeen = Optional.of(lastSeen);
this.vendorUiUrl = Optional.empty();
}
/**
* C-tor (from intercepted device-side query to remote)
*
* @param lastSeen The date/time of last update (when the message got intercepted)
* @param properties The intercepted device-side request (most rich with properties)
*/
public DeviceProperties(OffsetDateTime lastSeen, DeviceSideUpdateDTO properties) {
this.localIP = Optional.of(properties.setup.localIP.orElse(properties.deviceIp));
this.lastSeen = Optional.of(lastSeen);
this.vendorUiUrl = Optional.of(ArgoClimaRemoteDevice.getWebUiUrl(properties.remoteServerId, 80));
this.cpuId = Optional.of(properties.cpuId);
this.webUiUsername = Optional.of(properties.setup.username.orElse(properties.username));
this.webUiPassword = properties.setup.password;
this.unitFWVersion = Optional.of(properties.setup.unitVersionInstalled.orElse(properties.unitFirmware));
this.wifiFWVersion = Optional.of(properties.setup.wifiVersionInstalled.orElse(properties.wifiFirmware));
this.wifiSSID = properties.setup.wifiSSID;
this.wifiPassword = properties.setup.wifiPassword;
this.localTime = properties.setup.localTime;
}
private static Optional<OffsetDateTime> dateFromISOString(String isoDateTime, String contextualName) {
if (isoDateTime.isEmpty()) {
return Optional.empty();
}
try {
return Optional.of(OffsetDateTime.from(DateTimeFormatter.ISO_DATE_TIME.parse(isoDateTime)));
} catch (DateTimeException ex) {
// Swallowing exception (no need to handle - proceed as if the date was never provided)
LOGGER.debug("Failed to parse [{}] timestamp: {}. Exception: {}", contextualName, isoDateTime,
ex.getMessage());
return Optional.empty();
}
}
private static String dateTimeToStringLocal(OffsetDateTime toConvert, TimeZoneProvider timeZoneProvider) {
var timeAtZone = toConvert.atZoneSameInstant(timeZoneProvider.getTimeZone());
return timeAtZone.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG));
}
/**
* Returns duration between last update and now. If last update is N/A, picking lowest possible time value
*
* @return Time elapsed since last device-side update
*/
Duration getLastSeenDelta() {
return Duration.between(lastSeen.orElse(OffsetDateTime.MIN).toInstant(), Instant.now());
}
/**
* Return the properties in a map (ready to pass on to openHAB engine)
*
* @param timeZoneProvider TZ provider, for parsing date/time values
* @return Properties map
*/
SortedMap<String, String> asPropertiesRaw(TimeZoneProvider timeZoneProvider) {
var result = new TreeMap<String, String>();
this.lastSeen.map((value) -> result.put(ArgoClimaBindingConstants.PROPERTY_LAST_SEEN,
dateTimeToStringLocal(value, timeZoneProvider)));
this.localIP.map(value -> result.put(ArgoClimaBindingConstants.PROPERTY_LOCAL_IP_ADDRESS, value));
this.vendorUiUrl.map(value -> result.put(ArgoClimaBindingConstants.PROPERTY_WEB_UI, value.toString()));
this.cpuId.map(value -> result.put(ArgoClimaBindingConstants.PROPERTY_CPU_ID, value));
this.webUiUsername.map(value -> result.put(ArgoClimaBindingConstants.PROPERTY_WEB_UI_USERNAME, value));
this.webUiPassword.map(value -> result.put(ArgoClimaBindingConstants.PROPERTY_WEB_UI_PASSWORD, value));
this.unitFWVersion.map(value -> result.put(ArgoClimaBindingConstants.PROPERTY_UNIT_FW, value));
this.wifiFWVersion.map(value -> result.put(ArgoClimaBindingConstants.PROPERTY_WIFI_FW, value));
this.wifiSSID.map(value -> result.put(ArgoClimaBindingConstants.PROPERTY_WIFI_SSID, value));
this.wifiPassword.map(value -> result.put(ArgoClimaBindingConstants.PROPERTY_WIFI_PASSWORD, value));
this.localTime.map(value -> result.put(ArgoClimaBindingConstants.PROPERTY_LOCAL_TIME, value));
return Collections.unmodifiableSortedMap(result);
}
}
//////////////
// FIELDS
//////////////
private final ArgoClimaTranslationProvider i18nProvider;
private String commandString;
private DeviceProperties properties;
/**
* C-tor (from command string and properties - either from remote server response or device-side poll intercept)
*
* @param commandString The device-side {@code HMI} string, carrying its updates and commands
* @param properties The parsed device-side properties
* @param i18nProvider Framework's translation provider
* @implNote Consider: rewrite to a factory instead of this
*/
public DeviceStatus(String commandString, DeviceProperties properties, ArgoClimaTranslationProvider i18nProvider) {
this.commandString = commandString;
this.properties = properties;
this.i18nProvider = i18nProvider;
}
/**
* C-tor (from just-received status response - live poll)
*
* @param commandString The command string received
* @param lastSeenDateTime The date/time when the request has been received
* @param i18nProvider Framework's translation provider
*/
public DeviceStatus(String commandString, OffsetDateTime lastSeenDateTime,
ArgoClimaTranslationProvider i18nProvider) {
this(commandString, new DeviceProperties(lastSeenDateTime), i18nProvider);
}
/**
* Retrieve the device {@code HMI} string, carrying its updates and commands
*
* @return The status/command string
*/
public String getCommandString() {
return this.commandString;
}
/**
* Retrieve device-side properties
*
* @return Device properties
*/
public DeviceProperties getProperties() {
return this.properties;
}
/**
* Throw exception if last update time is older than
* {@link ArgoClimaConfigurationRemote#LAST_SEEN_UNAVAILABILITY_THRESHOLD the threshold}
*
* @throws ArgoApiCommunicationException If status is stale
*/
public void throwIfStatusIsStale() throws ArgoApiCommunicationException {
var delta = this.properties.getLastSeenDelta();
if (delta.toSeconds() > ArgoClimaConfigurationRemote.LAST_SEEN_UNAVAILABILITY_THRESHOLD.toSeconds()) {
throw new ArgoApiCommunicationException(
// "or more", since this message is also used in thing status (and we're not updating
// offline->offline). Actual "Last seen" can always be retrieved from properties
"Device was last seen {0} (or more) mins ago (threshold is set at {1} min). Please ensure the HVAC is connected to Wi-Fi and communicating with Argo servers",
"thing-status.cause.argoclima.remote-device-stale", i18nProvider, delta.toMinutes(),
ArgoClimaConfigurationRemote.LAST_SEEN_UNAVAILABILITY_THRESHOLD.toMinutes());
}
}
}

View File

@ -0,0 +1,121 @@
/**
* 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.argoclima.internal.device.api;
import java.util.List;
import java.util.Map;
import java.util.SortedMap;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.argoclima.internal.device.api.protocol.ArgoApiDataElement;
import org.openhab.binding.argoclima.internal.device.api.protocol.elements.IArgoCommandableElement.IArgoElement;
import org.openhab.binding.argoclima.internal.device.api.types.ArgoDeviceSettingType;
import org.openhab.binding.argoclima.internal.exception.ArgoApiCommunicationException;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
/**
* Interface for communication with Argo device(regardless of method)
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public interface IArgoClimaDeviceAPI {
public static record ReachabilityStatus(Boolean isReachable, String unreachabilityReason) {
}
/**
* Check if Argo device is reachable (this check MAY trigger device communications!)
* <p>
* For local connection the checking is live (and synchronous!).
* For remote connection, the status is updated based off of last device's communication
*
* @return A 2-tuple with status: {@code <REACHABLE, LOCALIZED_ERROR (if unreachable)>}
*/
ReachabilityStatus isReachable();
/**
* Query the Argo device for updated state.
* <p>
* This ALWAYS triggers new device communication
*
* @return A map of {@code Setting->Value} read from device
* @throws ArgoApiCommunicationException thrown when unable to communicate with the Argo device
*/
Map<ArgoDeviceSettingType, State> queryDeviceForUpdatedState() throws ArgoApiCommunicationException;
/**
* Returns last-retrieved device state
* <p>
* This does *NOT* re-query the device
*
* @return A map of {@code Setting->Value} read from cache
*/
Map<ArgoDeviceSettingType, State> getLastStateReadFromDevice();
/**
* Returns currently known properties of the device (from last-read state)
*
* @apiNote Does *not* query the device on its own
*
* @return A key-value map of device properties (both static/from configuration as well as the dynamic - read from
* device)
*/
SortedMap<String, String> getCurrentDeviceProperties();
/**
* Directly send any pending commands to the device (upon synchronizing with freshest device-side state)
*
* @throws ArgoApiCommunicationException thrown when unable to communicate with the Argo device
*/
void sendCommandsToDevice() throws ArgoApiCommunicationException;
/**
* Notify that the pending commands have been passed to the device and are now pending confirmation from its end
*
* @implNote Used mostly for indirect mode, where the time when commands are consumed is dependent on device's own
* polling (can't trigger any device-facing comms in an indirect mode)
*/
void notifyCommandsPassedToDevice();
/**
* Handle any setting command from UI
*
* @param settingType The name of setting receiving the value
* @param command The command/new value
* @return True - if command has been handled, False - otherwise
*/
boolean handleSettingCommand(ArgoDeviceSettingType settingType, Command command);
/**
* Get the current value of a setting
*
* @param settingType The name of setting queried
* @return Current value of the setting
*/
State getCurrentStateNoPoll(ArgoDeviceSettingType settingType);
/**
* Check if there are any commands pending send to the device
*
* @return True if there are commands pending, False otherwise
*/
boolean hasPendingCommands();
/**
* Get items which have pending updates
*
* @return List of settings that have updates pending
*/
List<ArgoApiDataElement<IArgoElement>> getItemsWithPendingUpdates();
}

View File

@ -0,0 +1,248 @@
/**
* 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.argoclima.internal.device.api.protocol;
import java.util.List;
import java.util.Optional;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.argoclima.internal.device.api.protocol.elements.IArgoCommandableElement;
import org.openhab.binding.argoclima.internal.device.api.protocol.elements.IArgoCommandableElement.IArgoElement;
import org.openhab.binding.argoclima.internal.device.api.types.ArgoDeviceSettingType;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
/**
* Wrapper for Argo API protocol knobs, providing an overlay functionality for converting them between framework values
* and raw protocol values, as well as command confirmation support
* <p>
* Supports R/O (update-only), W/O (set-only) as well as R/W (update and set) knobs
* <p>
* Since the Status(query) and Command(send) commands have different syntax and item ordering, this class is tracking
* respective position of an element in a protocol using {@link #queryResponseIndex} and
* {@link #statusUpdateRequestIndex}, respectively
*
* @param <T> The underlying param type (with its internal logic of converting to/from Argo protocol
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class ArgoApiDataElement<T extends IArgoElement> implements IArgoCommandableElement {
/**
* Type of the data element
*
* @author Mateusz Bronk - Initial contribution
*/
public enum DataElementType {
READ_WRITE,
READ_ONLY,
WRITE_ONLY
}
/** The kind(type) of setting - aka. the *actual* thing it controls */
public final ArgoDeviceSettingType settingType;
/** The index of this API element in a device-side update */
public final int queryResponseIndex;
/** The index of this API element in a remote-side command */
public final int statusUpdateRequestIndex;
private DataElementType type;
private T rawValue;
/**
* Private c-tor
*
* @param settingType Kind of this knob (what it controls)
* @param rawValue The raw API protocol value
* @param queryIndex The index of this element in a device-side status update (or {@code -1} if N/A)
* @param updateIndex The index of this element in a cloud-side command (or {@code -1} if N/A)
* @param type The direction of this element (R/O, R/W, W/O)
*/
private ArgoApiDataElement(ArgoDeviceSettingType settingType, T rawValue, int queryIndex, int updateIndex,
DataElementType type) {
this.settingType = settingType;
this.queryResponseIndex = queryIndex;
this.statusUpdateRequestIndex = updateIndex;
this.type = type;
this.rawValue = rawValue;
}
/**
* Named c-tor for a R/W element
*
* @param settingType Kind of this knob (what it controls)
* @param rawValue The raw API protocol value
* @param queryIndex The index of this element in a device-side status update
* @param updateIndex The index of this element in a cloud-side command
* @return The wrapped protocol API element
*/
public static ArgoApiDataElement<IArgoElement> readWriteElement(ArgoDeviceSettingType settingType,
IArgoElement rawValue, int queryIndex, int updateIndex) {
return new ArgoApiDataElement<>(settingType, rawValue, queryIndex, updateIndex, DataElementType.READ_WRITE);
}
/**
* Named c-tor for a R/O element
*
* @param settingType Kind of this knob (what it controls)
* @param rawValue The raw API protocol value
* @param queryIndex The index of this element in a device-side status update
* @return The wrapped protocol API element
*/
public static ArgoApiDataElement<IArgoElement> readOnlyElement(ArgoDeviceSettingType settingType,
IArgoElement rawValue, int queryIndex) {
return new ArgoApiDataElement<>(settingType, rawValue, queryIndex, -1, DataElementType.READ_ONLY);
}
/**
* Named c-tor for a W/O element
*
* @param settingType Kind of this knob (what it controls)
* @param rawValue The raw API protocol value
* @param updateIndex The index of this element in a cloud-side command
* @return The wrapped protocol API element
*/
public static ArgoApiDataElement<IArgoElement> writeOnlyElement(ArgoDeviceSettingType settingType,
IArgoElement rawValue, int updateIndex) {
return new ArgoApiDataElement<>(settingType, rawValue, -1, updateIndex, DataElementType.WRITE_ONLY);
}
@Override
public void abortPendingCommand() {
this.rawValue.abortPendingCommand();
}
@Override
public boolean isUpdatePending() {
return this.rawValue.isUpdatePending();
}
@Override
public final boolean hasInFlightCommand() {
return this.rawValue.hasInFlightCommand();
}
@Override
public void notifyCommandSent() {
this.rawValue.notifyCommandSent();
}
@Override
public String toString() {
return toString(true);
}
/**
* Extended {@code toString()} method, allowing to also include the kind of knob
*
* @param includeType If true, includes the setting type (what it controls) in the string representation
* @return String representation
*/
public String toString(boolean includeType) {
var prefix = "";
if (includeType) {
prefix = this.settingType.toString() + "=";
}
return prefix + rawValue.toString();
}
/**
* Output parsed value of this element (reported in a new device-side update) in OH framework-compatible
* representation
* <p>
* This call does not update internal representation of this element!
*
* @param responseElements All "state" response elements sent by the device (device always sends state of ALL knobs)
* @return OH-compatible representation of current device state
*/
public State fromDeviceResponse(List<String> responseElements) {
if (this.type == DataElementType.READ_WRITE || this.type == DataElementType.READ_ONLY) {
return this.rawValue.updateFromApiResponse(responseElements.get(queryResponseIndex));
}
return UnDefType.NULL; // Write-only elements do not have any state reported
}
public State fromDeviceCommand(List<String> responseElements) {
if (this.type == DataElementType.READ_WRITE || this.type == DataElementType.WRITE_ONLY) {
return this.rawValue.updateFromApiResponse(responseElements.get(statusUpdateRequestIndex));
}
return UnDefType.NULL; // Write-only elements do not have any state reported
}
/**
* Output this element's currently-stored value in OH framework-compatible representation
*
* @return OH-compatible representation of current device state
*/
public State getState() {
return rawValue.toState();
}
/**
* Handle framework-side command targeting this element
*
* @param command The command to handle
* @return Status on whether the command has been handled (accepted). Note "handled" here doesn't mean
* sent and confirmed by the device, merely recognized by the framework and accepted for subsequent
* device-side communication (which happens asynchronously to this call)
*/
public boolean handleCommand(Command command) {
if (this.type != DataElementType.WRITE_ONLY && this.type != DataElementType.READ_WRITE) {
return false; // attempting to write a R/O value
}
boolean waitForConfirmation = this.type != DataElementType.WRITE_ONLY;
return rawValue.handleCommand(command, waitForConfirmation);
}
public record deviceCommandRequest(Integer updateIndex, String apiValue) {
}
/**
* Convert this elements' current value to a device-compatible command request
* <p>
* Value is returned only if this item has a pending update (or is always sent fresh as part of protocol)
*
* @return A pair of (updateIndex, ApiValue) representing this element as a command (if it had update)
*/
public Optional<deviceCommandRequest> toDeviceResponse() {
if (this.rawValue.isUpdatePending() || this.rawValue.isAlwaysSent()) {
return Optional
.of(new deviceCommandRequest(this.statusUpdateRequestIndex, this.rawValue.getDeviceApiValue()));
}
return Optional.empty();
}
/**
* Check if this element should be sent to device (either has withstanding command or is always sent)
*
* @return True if the element needs sending to the device. False - otherwise
*/
public boolean shouldBeSentToDevice() {
return this.rawValue.isUpdatePending() || this.rawValue.isAlwaysSent();
}
/**
* Check if this element can be read (either allows reading, or doesn't, but there's a cached value available
* already)
*
* @return True if this element can be read. False - otherwise
*/
public boolean isReadable() {
return this.type == DataElementType.READ_ONLY || this.type == DataElementType.READ_WRITE
|| (this.type == DataElementType.WRITE_ONLY && this.rawValue.toState() != UnDefType.UNDEF);
}
}

View File

@ -0,0 +1,257 @@
/**
* 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.argoclima.internal.device.api.protocol;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.TreeMap;
import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.argoclima.internal.configuration.IScheduleConfigurationProvider;
import org.openhab.binding.argoclima.internal.device.api.protocol.elements.ActiveTimerModeParam;
import org.openhab.binding.argoclima.internal.device.api.protocol.elements.CurrentTimeParam;
import org.openhab.binding.argoclima.internal.device.api.protocol.elements.CurrentWeekdayParam;
import org.openhab.binding.argoclima.internal.device.api.protocol.elements.DelayMinutesParam;
import org.openhab.binding.argoclima.internal.device.api.protocol.elements.EnumParam;
import org.openhab.binding.argoclima.internal.device.api.protocol.elements.FwVersionParam;
import org.openhab.binding.argoclima.internal.device.api.protocol.elements.IArgoCommandableElement.IArgoElement;
import org.openhab.binding.argoclima.internal.device.api.protocol.elements.OnOffParam;
import org.openhab.binding.argoclima.internal.device.api.protocol.elements.RangeParam;
import org.openhab.binding.argoclima.internal.device.api.protocol.elements.TemperatureParam;
import org.openhab.binding.argoclima.internal.device.api.protocol.elements.TimeParam;
import org.openhab.binding.argoclima.internal.device.api.protocol.elements.TimeParam.TimeParamType;
import org.openhab.binding.argoclima.internal.device.api.protocol.elements.WeekdayParam;
import org.openhab.binding.argoclima.internal.device.api.types.ArgoDeviceSettingType;
import org.openhab.binding.argoclima.internal.device.api.types.FanLevel;
import org.openhab.binding.argoclima.internal.device.api.types.FlapLevel;
import org.openhab.binding.argoclima.internal.device.api.types.OperationMode;
import org.openhab.binding.argoclima.internal.device.api.types.TemperatureScale;
import org.openhab.binding.argoclima.internal.exception.ArgoApiProtocolViolationException;
import org.openhab.core.types.State;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The actual HVAC device status tracked by this binding. Converts to and from Argo protocol messages
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class ArgoDeviceStatus implements IArgoSettingProvider {
private final Logger logger = LoggerFactory.getLogger(getClass());
private final IScheduleConfigurationProvider scheduleSettingsProvider;
/**
* A placeholder value in the protocol indicating no value/null (or no command) carried instead of an actual data
* element. Useful for allowing to change only a few settings, not the entire state at once
*/
public static final String NO_VALUE = "N";
/**
* Number of data elements carried in a device-side "HMI" update - FROM the device
*
* @implNote Not sure what HMI stands for, but is used by Argo for a name for a query param, so adopting this name
*/
public static final int HMI_UPDATE_ELEMENT_COUNT = 39;
/**
* Number of data elements carried in a remote-side status/"HMI" command sent TO the device
*/
public static final int HMI_COMMAND_ELEMENT_COUNT = 36;
public static final String HMI_ELEMENT_SEPARATOR = ",";
/**
* The actual protocol elements, by their kind, type and read/write indexes in the response
*
* @implNote In the future consider applying builder pattern to make it more readable w/o IDE
*/
private final List<ArgoApiDataElement<IArgoElement>> allElements = List.of(
ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.TARGET_TEMPERATURE,
new TemperatureParam(this, 19.0, 36.0, 0.5), 0, 0),
ArgoApiDataElement.readOnlyElement(ArgoDeviceSettingType.ACTUAL_TEMPERATURE,
new TemperatureParam(this, 19.0, 36.0, 0.1), 1), // Unfortunately iFeel temperature seems impossible
// to be set remotely (needs IR remote)
ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.POWER, new OnOffParam(this), 2, 2),
ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.MODE, new EnumParam<>(this, OperationMode.class),
3, 3),
ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.FAN_LEVEL, new EnumParam<>(this, FanLevel.class),
4, 4),
ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.FLAP_LEVEL,
new EnumParam<>(this, FlapLevel.class), 5, 5),
ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.I_FEEL_TEMPERATURE, new OnOffParam(this), 6, 6),
ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.FILTER_MODE, new OnOffParam(this), 7, 7),
ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.ECO_MODE, new OnOffParam(this), 8, 8),
ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.TURBO_MODE, new OnOffParam(this), 9, 9),
ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.NIGHT_MODE, new OnOffParam(this), 10, 10),
ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.LIGHT, new OnOffParam(this), 11, 11),
ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.ACTIVE_TIMER, new ActiveTimerModeParam(this), 12,
12),
ArgoApiDataElement.writeOnlyElement(ArgoDeviceSettingType.CURRENT_DAY_OF_WEEK,
new CurrentWeekdayParam(this), 18),
ArgoApiDataElement.writeOnlyElement(ArgoDeviceSettingType.TIMER_N_ENABLED_DAYS, new WeekdayParam(this), 19),
ArgoApiDataElement.writeOnlyElement(ArgoDeviceSettingType.CURRENT_TIME, new CurrentTimeParam(this), 20),
ArgoApiDataElement.writeOnlyElement(ArgoDeviceSettingType.TIMER_0_DELAY_TIME,
new DelayMinutesParam(this, TimeParam.fromHhMm(0, 10), TimeParam.fromHhMm(19, 50), 10,
Optional.of(60)),
21),
ArgoApiDataElement.writeOnlyElement(ArgoDeviceSettingType.TIMER_N_ON_TIME,
new TimeParam(this, TimeParamType.ON), 22),
ArgoApiDataElement.writeOnlyElement(ArgoDeviceSettingType.TIMER_N_OFF_TIME,
new TimeParam(this, TimeParamType.OFF), 23),
ArgoApiDataElement.writeOnlyElement(ArgoDeviceSettingType.RESET_TO_FACTORY_SETTINGS, new OnOffParam(this),
24),
ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.ECO_POWER_LIMIT, new RangeParam(this, 30, 99), 22,
25),
ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.DISPLAY_TEMPERATURE_SCALE,
new EnumParam<>(this, TemperatureScale.class), 24, 26),
ArgoApiDataElement.readOnlyElement(ArgoDeviceSettingType.UNIT_FIRMWARE_VERSION, new FwVersionParam(this),
23));
/**
* The same elements as in {@link #allElements}, but grouped by kind/type for easier access
*
* @implNote Not using {@code Collectors.toMap()} due to possible false-positive(!) unchecked warnings w/ the
* accumulator|stream
*/
private final Map<ArgoDeviceSettingType, ArgoApiDataElement<IArgoElement>> dataElements = allElements.stream()
.collect(TreeMap::new, (m, v) -> m.put(v.settingType, v), TreeMap::putAll);
/**
* C-tor
*
* @param scheduleSettingsProvider schedule settings provider
*/
public ArgoDeviceStatus(IScheduleConfigurationProvider scheduleSettingsProvider) {
this.scheduleSettingsProvider = scheduleSettingsProvider;
}
@Override
public ArgoApiDataElement<IArgoElement> getSetting(ArgoDeviceSettingType type) {
if (dataElements.containsKey(type)) {
return Objects.requireNonNull(dataElements.get(type));
}
throw new IllegalArgumentException("Wrong setting type: " + type.toString());
}
/**
* Get the current HVAC state in a SettingKind=CurrentValue compact format
*/
@Override
public String toString() {
return dataElements.entrySet().stream().sorted(Map.Entry.comparingByKey())
.map(x -> String.format("%s=%s", x.getKey(), x.getValue().toString(false)))
.collect(Collectors.joining(", ", "{", "}"));
}
@Override
public IScheduleConfigurationProvider getScheduleProvider() {
return this.scheduleSettingsProvider;
}
/**
* Get a full current HVAC state in a framework-compatible format
*
* @return OH-compatible HVAC state, by element kind
*/
public Map<ArgoDeviceSettingType, State> getCurrentStateMap() {
return dataElements.entrySet().stream().sorted((a, b) -> a.getKey().compareTo(b.getKey()))
.filter(x -> x.getValue().isReadable())
.collect(TreeMap::new, (m, v) -> m.put(v.getKey(), v.getValue().getState()), TreeMap::putAll);
}
/**
* Update *this* state from device-side update
*
* @param deviceOutput The device-side 'HMI' update
* @throws ArgoApiProtocolViolationException If API response doesn't match protocol format
*/
public void fromDeviceString(String deviceOutput) throws ArgoApiProtocolViolationException {
var values = Arrays.asList(deviceOutput.split(HMI_ELEMENT_SEPARATOR));
if (values.size() != HMI_UPDATE_ELEMENT_COUNT) {
throw new ArgoApiProtocolViolationException(MessageFormat.format(
"Invalid device API response: [{0}]. Expected to contain {1} elements while has {2}.", deviceOutput,
HMI_UPDATE_ELEMENT_COUNT, values.size()));
}
synchronized (this) {
dataElements.entrySet().stream().forEach(v -> v.getValue().fromDeviceResponse(values));
}
logger.trace("Current HVAC state(after update): {}", this.toString());
}
/**
* Convert *this* state to a device-facing command
* <p>
* Does NOT represent entire state (to avoid triggering actions which were just due to stale data), but rather sends
* only pending commands and "static" parts of the protocol, such as current time
*
* @implNote The value 'N' in the protocol seems to be for "NULL" (or "no update") and is used as placeholder for
* values that are not changing
* @return The command ready to be sent to the device, effecting *this* state (its withstanding/pending part)
*/
public String getDeviceCommandStatus() {
var commands = new ArrayList<String>(
Objects.requireNonNull(Collections.nCopies(HMI_COMMAND_ELEMENT_COUNT, NO_VALUE)));
var itemsToSend = dataElements.entrySet().stream().filter(x -> x.getValue().shouldBeSentToDevice()).toList();
if (logger.isDebugEnabled() || logger.isTraceEnabled()) {
var stringifiedItemsToSend = itemsToSend.stream().map(x -> x.getKey().toString())
.collect(Collectors.joining(", "));
if (hasUpdatesPending()) {
logger.debug("Sending {} updates to device {}", itemsToSend.size(), stringifiedItemsToSend);
} else {
logger.trace("Sending {} updates to device {}", itemsToSend.size(), stringifiedItemsToSend);
}
}
itemsToSend.stream().map(x -> x.getValue().toDeviceResponse()).forEach(p -> {
try {
commands.set(p.orElseThrow().updateIndex(), p.orElseThrow().apiValue());
} catch (IndexOutOfBoundsException e) {
throw new IllegalArgumentException(String.format(
"Attempting to set device command %d := %s, while only commands 0..%d are supported",
p.orElseThrow().updateIndex(), p.orElseThrow().apiValue(), commands.size()));
}
});
return String.join(HMI_ELEMENT_SEPARATOR, commands);
}
/**
* Check if this state has any updates pending (to be sent or confirmed by the HVAC device)
* The concrete items with update pending can be retrieved using {@link #getItemsWithPendingUpdates()}
* <p>
* The "static" parts of protocol (such as current time) do not count as updates!
*
* @return True if there are any updates pending. False - otherwise
*/
public boolean hasUpdatesPending() {
return this.dataElements.values().stream().anyMatch(x -> x.isUpdatePending());
}
/**
* Retrieve the elements of this state that have updates pending
*
* @return List of items with withstanding updates
*/
public List<ArgoApiDataElement<IArgoElement>> getItemsWithPendingUpdates() {
return this.dataElements.values().stream().filter(x -> x.isUpdatePending())
.sorted((x, y) -> Integer.compare(x.statusUpdateRequestIndex, y.statusUpdateRequestIndex))
.collect(Collectors.toList());
}
}

View File

@ -0,0 +1,42 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.argoclima.internal.device.api.protocol;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.argoclima.internal.configuration.IScheduleConfigurationProvider;
import org.openhab.binding.argoclima.internal.device.api.protocol.elements.IArgoCommandableElement.IArgoElement;
import org.openhab.binding.argoclima.internal.device.api.types.ArgoDeviceSettingType;
/**
* Interface for accessing HVAC-specific settings (knobs that can be controlled or report status)
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public interface IArgoSettingProvider {
/**
* Retrieve a concrete HVAC protocol element by its kind
*
* @param type The kind of element (setting) to return
* @return The controllable element of requested kind
* @throws RuntimeException In case the element is N/A
*/
public ArgoApiDataElement<IArgoElement> getSetting(ArgoDeviceSettingType type);
/**
* Get the schedule provider (for configuring schedule timers)
*
* @return Current schedule provider
*/
public IScheduleConfigurationProvider getScheduleProvider();
}

View File

@ -0,0 +1,102 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.argoclima.internal.device.api.protocol.elements;
import java.util.EnumSet;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.argoclima.internal.device.api.protocol.IArgoSettingProvider;
import org.openhab.binding.argoclima.internal.device.api.types.ArgoDeviceSettingType;
import org.openhab.binding.argoclima.internal.device.api.types.TimerType;
import org.openhab.binding.argoclima.internal.exception.ArgoConfigurationException;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.types.Command;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Timer mode parameter (handling schedule timers as well as delay timer) - special class of enum, as the timers are not
* fully standalone elements
*
* @author Mateusz Bronk - Initial contribution
*
*/
@NonNullByDefault
public class ActiveTimerModeParam extends EnumParam<TimerType> {
private final Logger logger = LoggerFactory.getLogger(getClass());
/**
* C-tor
*
* @param settingsProvider the settings provider (getting device state as well as schedule configuration)
*/
public ActiveTimerModeParam(IArgoSettingProvider settingsProvider) {
super(settingsProvider, TimerType.class);
}
/**
* {@inheritDoc}
* <p>
* Does pre-work for schedule timers and - if one of them is selected - injects (sends commands) to appropriate
* elements.
* Coordinates multiple timer parameters (ex. for TIMER1, need to fetch schedule1 params for day of week, on time
* and off time), and finally lets the super class handle THIS setting
*/
@Override
protected HandleCommandResult handleCommandInternalEx(Command command) {
if (!(command instanceof StringType)) {
return HandleCommandResult.rejected(); // Unsupported command type, nothing to do anyway
}
var requestedValue = fromType(command, TimerType.class);
if (requestedValue.isEmpty()) {
return HandleCommandResult.rejected(); // Value not valid for a timer enum, rejecting command as a whole
}
TimerType newTimerType = requestedValue.orElseThrow(); // Boilerplate, guaranteed no-throw at this point
if (!EnumSet.of(TimerType.SCHEDULE_TIMER_1, TimerType.SCHEDULE_TIMER_2, TimerType.SCHEDULE_TIMER_3)
.contains(newTimerType)) {
return super.handleCommandInternalEx(command); // Not a schedule timer requested -> handle regularly
}
var scheduleTimerKind = TimerType.toScheduleTimerType(newTimerType);
try { // for getting values from settings
var activeDays = settingsProvider.getScheduleProvider().getScheduleDayOfWeek(scheduleTimerKind);
var scheduleOnTime = settingsProvider.getScheduleProvider().getScheduleOnTime(scheduleTimerKind);
var scheduleOffTime = settingsProvider.getScheduleProvider().getScheduleOffTime(scheduleTimerKind);
logger.debug("New timer value is: {}. Days={}, On={}, Off={}", newTimerType, activeDays, scheduleOnTime,
scheduleOffTime);
// get the elements that need to update with additional commands (now that the timer has been selected)
var timerDays = settingsProvider.getSetting(ArgoDeviceSettingType.TIMER_N_ENABLED_DAYS);
var timerOn = settingsProvider.getSetting(ArgoDeviceSettingType.TIMER_N_ON_TIME);
var timerOff = settingsProvider.getSetting(ArgoDeviceSettingType.TIMER_N_OFF_TIME);
// send the respective commands (as DecimalTypes, to cut on extra conversions)
timerOn.handleCommand(
new DecimalType(TimeParam.fromHhMm(scheduleOnTime.getHour(), scheduleOnTime.getMinute())));
timerOff.handleCommand(
new DecimalType(TimeParam.fromHhMm(scheduleOffTime.getHour(), scheduleOffTime.getMinute())));
timerDays.handleCommand(new DecimalType(WeekdayParam.toRawValue(activeDays)));
// finally go back to handling the timer type (as a regular enum)
return super.handleCommandInternalEx(command);
} catch (ArgoConfigurationException e) {
logger.debug("Invalid schedule configuration for {}. Error: {}", newTimerType, e.getMessage());
return HandleCommandResult.rejected(); // This technically won't ever happen as invalid config would fail
// binding startup (aka. way before control ever reaches this place)
}
}
}

View File

@ -0,0 +1,514 @@
/**
* 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.argoclima.internal.device.api.protocol.elements;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants;
import org.openhab.binding.argoclima.internal.configuration.IScheduleConfigurationProvider.ScheduleTimerType;
import org.openhab.binding.argoclima.internal.device.api.protocol.ArgoDeviceStatus;
import org.openhab.binding.argoclima.internal.device.api.protocol.IArgoSettingProvider;
import org.openhab.binding.argoclima.internal.device.api.protocol.elements.IArgoCommandableElement.IArgoElement;
import org.openhab.binding.argoclima.internal.device.api.types.ArgoDeviceSettingType;
import org.openhab.binding.argoclima.internal.device.api.types.TimerType;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Base implementation of common functionality across all API elements
* (ex. handling pending commands and their confirmations)
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public abstract class ArgoApiElementBase implements IArgoElement {
///////////
// TYPES
///////////
/**
* Helper class for handling (pending) commands sent to the device (and awaiting confirmation)
*
* @author Mateusz Bronk - Initial contribution
*/
public static class HandleCommandResult {
public final boolean handled;
public final Optional<String> deviceCommandToSend;
public final Optional<State> plannedState;
private final Instant updateRequestedTime;
private boolean deferred = false;
private boolean requiresDeviceConfirmation = true;
/**
* Private C-tor
*
* @param handled If the command was handled
* @param deviceCommandToSend The actual command to send to device (only if {@code handled=True})
* @param plannedState The expected state of the device after the command (reaching it will serve as
* confirmation). present only if {@code handled=True}.
*/
private HandleCommandResult(boolean handled, Optional<String> deviceCommandToSend,
Optional<State> plannedState) {
this.updateRequestedTime = Instant.now();
this.handled = handled;
this.deviceCommandToSend = deviceCommandToSend;
this.plannedState = plannedState;
}
/**
* Named c-tor for rejected command
*
* @return Rejected command ({@code handled = False})
*/
public static HandleCommandResult rejected() {
return new HandleCommandResult(false, Optional.empty(), Optional.empty());
}
/**
* Named c-tor for accepted command
* <p>
* By default the command starts with: {@link #isConfirmable() confirmable}{@code =True} and
* {@link #isDeferred() deferred}{@code =False}, which means caller expect device-side confirmation and the
* command is effective immediately after sending to the device (standalone command)
*
* @param deviceCommandToSend The actual command to send to device
* @param plannedState The expected state of the device after the command (if {@link #isConfirmable()
* confirmable} is {@code True}, reaching it will serve as confirmation)
* @return Accepted command ({@code confirmable=True & deferred=False} - changeable via
* {@link #setConfirmable(boolean)} or {@link #setDeferred(boolean)})
*/
public static HandleCommandResult accepted(String deviceCommandToSend, State plannedState) {
return new HandleCommandResult(true, Optional.of(deviceCommandToSend), Optional.of(plannedState));
}
/**
* Check if this command is stale (has been issued before
* {@link ArgoClimaBindingConstants#PENDING_COMMAND_EXPIRE_TIME} ago.
*
* @implNote This class does NOT track actual command completion (only their issuance), hence it is expected
* that a completed command will be simply removed by the caller.
* @implNote For the same reason, even though this check only makes sense for {@code confirmable} commands - it
* is not checked herein and responsibility of the caller
* @return True if the command is obsolete (has been issued more than expire time ago)
*/
public boolean hasExpired() {
return Duration.between(updateRequestedTime, Instant.now())
.compareTo(ArgoClimaBindingConstants.PENDING_COMMAND_EXPIRE_TIME) > 0;
}
/**
* Check if the command is confirmable (for R/W params, where the device acknowledges receipt of the command)
*
* @return True if the command is confirmable. False for write-only parameters
*/
public boolean isConfirmable() {
return requiresDeviceConfirmation;
}
/**
* Set confirmable status (update from default: true)
*
* @param requiresDeviceConfirmation New {@code confirmable} value
* @return This object (for chaining)
*/
public HandleCommandResult setConfirmable(boolean requiresDeviceConfirmation) {
this.requiresDeviceConfirmation = requiresDeviceConfirmation;
return this;
}
/**
* Check if the command is deferred
* <p>
* A command is considered "deferred", if it isn't standalone, and - even when sent to the device - doesn't
* yield an immediate effect.
* For example, setting a delay timer value, when the device is not in a timer mode doesn't make any meaningful
* change to the device (until said mode is entered, which is controlled by different API element)
*
* @return True if the command is deferred (has no immediate effect). False - otherwise
*/
public boolean isDeferred() {
return deferred;
}
/**
* Set deferred status (update from default: false)
*
* @see #isDeferred()
* @param deferred New {@code deferred} value
* @return This object (for chaining)
*/
public HandleCommandResult setDeferred(boolean deferred) {
this.deferred = deferred;
return this;
}
@Override
public String toString() {
return String.format("HandleCommandResult(wasHandled=%s,deviceCommand=%s,plannedState=%s,isObsolete=%s)",
handled, deviceCommandToSend, plannedState, hasExpired());
}
}
/**
* Types of command finalization (reason why command is no longer tracked/retried)
*
* @author Mateusz Bronk - Initial contribution
*/
public enum CommandFinalizationReason {
/** Command is confirmable and device confirmed now having the desired state */
CONFIRMED_APPLIED,
/** Command is not-confirmable has been just sent to the device (in good faith) */
SENT_NON_CONFIRMABLE,
/** Pending command has been aborted by the caller */
ABORTED,
/**
* Pending (confirmable) command has not received confirmation within
* {@link ArgoClimaBindingConstants#PENDING_COMMAND_EXPIRE_TIME}
*/
EXPIRED
}
///////////
// FIELDS
///////////
private static final Logger LOGGER = LoggerFactory.getLogger(ArgoApiElementBase.class);
protected final IArgoSettingProvider settingsProvider;
/**
* Last status value received from device (has most accurate device-side state, but may be stale if there are
* in-flight commands!)
*/
private Optional<String> lastRawValueFromDevice = Optional.empty();
/**
* Active (in-flight) change request (upon accepting framework's Command) issued against this element. Tracked since
* acceptance (before send to the device) all the way to finalization (confirmed/successful, but also aborted,
* non-confirmable etc.)
*/
private Optional<HandleCommandResult> inFlightCommand = Optional.empty();
/**
* Internal (element type-specific) method for handling the command (accepting or rejecting it).
*
* @implNote Tracking of command result is handled by this class through {@link #handleCommand(Command, boolean)}
*
* @param command The command to handle
* @return Handling result (an accepted or rejected command, with handling traits such as confirmable/deferred)
*/
protected abstract HandleCommandResult handleCommandInternalEx(Command command);
/**
* Internal (element type-specific) method for handling the element status update.
*
* @implNote Tracking of command confirmations and/or expiration is handled by this class through
* {@link #updateFromApiResponse(String)}
* @param responseValue The raw API value (from device)
*/
protected abstract void updateFromApiResponseInternal(String responseValue);
/**
* C-tor
*
* @param settingsProvider the settings provider (getting device state as well as schedule configuration)
*/
public ArgoApiElementBase(IArgoSettingProvider settingsProvider) {
this.settingsProvider = settingsProvider;
}
@Override
public final State updateFromApiResponse(String responseValue) {
var noPendingUpdates = !isUpdatePending(); // Capturing the current in-flight state (before modifying this
// object and introducing side-effects)
synchronized (this) {
this.lastRawValueFromDevice = Optional.of(responseValue); // Persist last value from device (Side-effect:
// may change behavior of isUpdatePending()
if (noPendingUpdates) {
this.updateFromApiResponseInternal(responseValue); // No in-flight commands => Update THIS object with
// the new state
if (!this.hasInFlightCommand()) {
// No in-flight command, we're done
return this.toState();
}
}
}
// There's an ongoing confirmable command (not yet acknowledged), so we're *NOT* simply taking device-side
// value as the ACTUAL one (b/c it is slow to respond and we don't want values flapping). Instead, we try to
// see if the value is matching what we'd expect to change (confirming our command)
var expectedStateValue = getInFlightCommandsRawValueOrDefault();
if (responseValue.equals(expectedStateValue)) { // Comparing by raw values, not by planned state
confirmPendingCommand(CommandFinalizationReason.CONFIRMED_APPLIED);
} else if (this.inFlightCommand.map(x -> x.hasExpired()).orElse(false)) {
confirmPendingCommand(CommandFinalizationReason.EXPIRED);
} else {
LOGGER.debug("Update made, but values mismatch... {} (device) != {} (command)", responseValue,
expectedStateValue);
}
return this.toState(); // Return previous state (of the pending command, not the one device just reported)
}
@Override
public final void notifyCommandSent() {
if (this.isUpdatePending()) {
inFlightCommand.ifPresent(cmd -> {
if (!cmd.isConfirmable()) {
confirmPendingCommand(CommandFinalizationReason.SENT_NON_CONFIRMABLE);
}
});
}
}
@Override
public final void abortPendingCommand() {
confirmPendingCommand(CommandFinalizationReason.ABORTED);
}
@Override
public String toString() {
return String.format("RAW[%s]", lastRawValueFromDevice.orElse("N/A"));
}
@Override
public final boolean isUpdatePending() {
if (!hasInFlightCommand()) {
return false;
}
// Check if the device is not already reporting the requested state (nothing pending if so)
// (not inlining this code for better readability)
var deviceReportsValueAlready = lastRawValueFromDevice
.map(devValue -> devValue.equals(getInFlightCommandsRawValueOrDefault())).orElse(false);
return !deviceReportsValueAlready;
}
/**
* {@inheritDoc}
* <p>
* Wrapper implementation for handling confirmations/deferrals. Delegates actual work to
* {@link #handleCommandInternalEx(Command)}
*/
@Override
public final boolean handleCommand(Command command, boolean isConfirmable) {
var result = this.handleCommandInternalEx(command);
if (result.handled) {
if (!isConfirmable) {
// The value is not confirmable (upon sending to the device, we'll just assume it will flip to the
// desired state)
result.setConfirmable(false);
}
if (!result.isDeferred()) {
// Deferred commands do not count as in-flight (will get intercepted when other command uses their
// value)
synchronized (this) {
this.inFlightCommand = Optional.of(result);
}
}
}
return result.handled;
}
/**
* {@inheritDoc}
* <p>
* Default implementation of a typical param, which is NOT always sent (to be further overridden in inheriting
* classes)
*/
@Override
public boolean isAlwaysSent() {
return false;
}
/**
* {@inheritDoc}
* <p>
* Default implementation (to be further overridden in inheriting classes) getting pending command or
* {@code NO_VALUE} special value to not effect any change
*/
@Override
public String getDeviceApiValue() {
if (!isUpdatePending()) {
return ArgoDeviceStatus.NO_VALUE;
}
return this.inFlightCommand.get().deviceCommandToSend.get();
}
/**
* Helper method to check if any one of the schedule timers is currently running
*
* @return Index of one of the schedule timers (1|2|3) which is currently active on the device. Empty optional -
* otherwise
*/
protected final Optional<ScheduleTimerType> isScheduleTimerEnabled() {
var currentTimer = EnumParam
.fromType(settingsProvider.getSetting(ArgoDeviceSettingType.ACTIVE_TIMER).getState(), TimerType.class);
if (currentTimer.isEmpty()) {
return Optional.empty();
}
switch (currentTimer.orElseThrow()) {
case SCHEDULE_TIMER_1:
case SCHEDULE_TIMER_2:
case SCHEDULE_TIMER_3:
return Optional.of(TimerType.toScheduleTimerType(currentTimer.orElseThrow()));
default:
return Optional.empty();
}
}
/**
* Called when an in-flight command reaches a final state (successful or not) and no longer requires tracking
*
* @param reason The reason for finalizing the command (for logging)
*/
private final void confirmPendingCommand(CommandFinalizationReason reason) {
var commandName = inFlightCommand.map(c -> c.plannedState.map(s -> s.toFullString()).orElse("N/A"))
.orElse("Unknown");
switch (reason) {
case CONFIRMED_APPLIED:
LOGGER.debug("[{}] Update confirmed!", commandName);
break;
case ABORTED:
LOGGER.debug("[{}] Command aborted!", commandName);
break;
case EXPIRED:
LOGGER.debug("[{}] Long-pending update found. Cancelling...!", commandName);
break;
case SENT_NON_CONFIRMABLE:
LOGGER.debug("[{}] Update confirmed (in good faith)!", commandName);
break;
}
synchronized (this) {
this.inFlightCommand = Optional.empty();
}
}
@Override
public final boolean hasInFlightCommand() {
if (inFlightCommand.isEmpty()) {
return false; // no withstanding command
}
// If last command was not handled correctly -> there's nothing to update
return inFlightCommand.map(c -> c.handled).orElse(false);
}
private final String getInFlightCommandsRawValueOrDefault() {
final String valueNotAvailablePlaceholder = "N/A";
return inFlightCommand.map(c -> c.deviceCommandToSend.orElse(valueNotAvailablePlaceholder))
.orElse(valueNotAvailablePlaceholder);
}
/////////////
// HELPERS
/////////////
/**
* Utility function trying to convert from String to int
*
* @param value Value to convert
* @return Converted value (if successful) or empty (on failure)
*/
protected static Optional<Integer> strToInt(String value) {
try {
return Optional.of(Integer.parseInt(value));
} catch (NumberFormatException e) {
LOGGER.trace("The value {} is not a valid integer. Error: {}", value, e.getMessage());
return Optional.empty();
}
}
/**
* Normalize the value to be within range (and multiple of step, if any)
*
* @param <T> The number type
* @param newValue Value to convert
* @param minValue Lower bound
* @param maxValue Upper bound
* @param step Optional step for the value (result will be rounded to nearest step)
* @param unitDescription Unit description (for logging)
* @return Range within MIN..MAX bounds (which is a multiple of step). Returned as a {@code Number} for the caller
* to convert back to the desired type. Note we're not casting back to {@code T} as it would need to be an
* unchecked cast
*/
protected static <T extends Number & Comparable<T>> Number adjustRange(T newValue, final T minValue,
final T maxValue, final Optional<T> step, final String unitDescription) {
if (newValue.compareTo(minValue) < 0) {
LOGGER.debug("Requested value: [{}{}] would exceed minimum value: [{}{}]. Setting: {}{}.", newValue,
unitDescription, minValue, unitDescription, minValue, unitDescription); // The over-repetition is
// due to SLF4J formatter
// not supporting numbered
// params, and using full
// MessageFormat is not only
// an overkill but also
// SLOWER
return minValue;
}
if (newValue.compareTo(maxValue) > 0) {
LOGGER.debug("Requested value: [{}{}] would exceed maximum value: [{}{}]. Setting: {}{}.", newValue,
unitDescription, maxValue, unitDescription, maxValue, unitDescription); // See comment above
return maxValue;
}
if (step.isEmpty()) {
return newValue; // No rounding to step value
}
return Math.round(newValue.doubleValue() / step.orElseThrow().doubleValue()) * step.orElseThrow().doubleValue();
}
/**
* Normalizes the incoming value (respecting steps), with amplification of movement
* <p>
* Ex. if the step is 10, current value is 50 and the new value is 51... while 50 is still a closest, we're moving
* to a full next step (60), not to ignore user's intent to change something
*
* @param newValue Value to convert
* @param currentValue The current value to amplify (in case normalization wouldn't otherwise change anything). If
* empty, this method doesn't amplify anything
* @param minValue Lower bound
* @param maxValue Upper bound
* @param step Optional step for the value (result will be rounded to nearest step)
* @param unitDescription Unit description (for logging)
* @return Sanitized value (with amplified movement). Returned as a {@code Number} for the caller
* to convert back to the desired type. Note we're not casting back to {@code T} as it would need to be an
* unchecked cast
*/
protected static <T extends Number & Comparable<T>> Number adjustRangeWithAmplification(T newValue,
Optional<T> currentValue, final T minValue, final T maxValue, final T step, final String unitDescription) {
Number normalized = adjustRange(newValue, minValue, maxValue, Optional.of(step), unitDescription);
if (currentValue.isEmpty() || normalized.doubleValue() == newValue.doubleValue()
|| newValue.compareTo(minValue) < 0 || newValue.compareTo(maxValue) > 0) {
return normalized; // there was no previous value or normalization didn't remove any precision or reached a
// boundary -> new normalized value wins
}
final Number thisValue = currentValue.orElseThrow();
if (normalized.doubleValue() != thisValue.doubleValue()) {
return normalized; // the normalized value changed enough to be meaningful on its own-> use it
}
// Value before normalization has moved, but not enough to move a step (and would have been ignored). Let's
// amplify that effect and add a new step
var movementDirection = Math.signum((newValue.doubleValue() - normalized.doubleValue()));
return normalized.doubleValue() + movementDirection * step.doubleValue();
}
}

View File

@ -0,0 +1,90 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.argoclima.internal.device.api.protocol.elements;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.argoclima.internal.device.api.protocol.IArgoSettingProvider;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The element reporting current time to the device
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class CurrentTimeParam extends ArgoApiElementBase {
private final Logger logger = LoggerFactory.getLogger(getClass());
/**
* C-tor
*
* @param settingsProvider the settings provider (getting device state as well as schedule configuration)
*/
public CurrentTimeParam(IArgoSettingProvider settingsProvider) {
super(settingsProvider);
}
private static ZonedDateTime utcNow() {
return ZonedDateTime.now(Objects.requireNonNull(ZoneId.of("UTC")));
}
/**
* {@inheritDoc}
*
* @implNote This element doesn't really get any device-side commands
*/
@Override
protected void updateFromApiResponseInternal(String responseValue) {
logger.debug("Got state: {} for a parameter that doesn't support it!", responseValue);
}
@Override
public State toState() {
return new DateTimeType(utcNow());
}
/**
* {@inheritDoc}
* <p>
* The current time is always sent
*/
@Override
public boolean isAlwaysSent() {
return true;
}
/**
* {@inheritDoc}
* <p>
* Specialized implementation, always providing latest *now* value
*/
@Override
public String getDeviceApiValue() {
var t = utcNow();
return Integer.toString(TimeParam.fromHhMm(t.getHour(), t.getMinute()));
}
@Override
protected HandleCommandResult handleCommandInternalEx(Command command) {
logger.debug("Got command for a parameter that doesn't support it!");
return HandleCommandResult.rejected(); // Does not handle any commands
}
}

View File

@ -0,0 +1,95 @@
/**
* 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.argoclima.internal.device.api.protocol.elements;
import java.time.DayOfWeek;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.TextStyle;
import java.util.Locale;
import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.argoclima.internal.device.api.protocol.IArgoSettingProvider;
import org.openhab.binding.argoclima.internal.device.api.types.Weekday;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The element reporting current day of week to the device
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class CurrentWeekdayParam extends ArgoApiElementBase {
private final Logger logger = LoggerFactory.getLogger(getClass());
/**
* C-tor
*
* @param settingsProvider the settings provider (getting device state as well as schedule configuration)
*/
public CurrentWeekdayParam(IArgoSettingProvider settingsProvider) {
super(settingsProvider);
}
private static DayOfWeek utcToday() {
return ZonedDateTime.now(Objects.requireNonNull(ZoneId.of("UTC"))).getDayOfWeek();
}
/**
* {@inheritDoc}
*
* @implNote This element doesn't really get any device-side commands
*/
@Override
protected void updateFromApiResponseInternal(String responseValue) {
logger.debug("Got state: {} for a parameter that doesn't support it!", responseValue);
}
@Override
public State toState() {
return new org.openhab.core.library.types.StringType(
utcToday().getDisplayName(TextStyle.SHORT_STANDALONE, Locale.US));
}
/**
* {@inheritDoc}
* <p>
* The current day of week is always sent
*/
@Override
public boolean isAlwaysSent() {
return true;
}
/**
* {@inheritDoc}
* <p>
* Specialized implementation, always providing latest *today* value
*
* @implNote deliberately using ordinal, not getIntValue() here as the latter is for bitmasks!
*/
@Override
public String getDeviceApiValue() {
return Integer.toString(Weekday.ofDay(utcToday()).ordinal());
}
@Override
protected HandleCommandResult handleCommandInternalEx(Command command) {
logger.debug("Got command for a parameter that doesn't support it!");
return HandleCommandResult.rejected(); // Does not handle any commands
}
}

View File

@ -0,0 +1,220 @@
/**
* 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.argoclima.internal.device.api.protocol.elements;
import java.util.Optional;
import javax.measure.quantity.Time;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.argoclima.internal.device.api.protocol.ArgoDeviceStatus;
import org.openhab.binding.argoclima.internal.device.api.protocol.IArgoSettingProvider;
import org.openhab.binding.argoclima.internal.device.api.types.ArgoDeviceSettingType;
import org.openhab.binding.argoclima.internal.device.api.types.TimerType;
import org.openhab.core.library.types.IncreaseDecreaseType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.Units;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
/**
* Delay timer element (accepting values in minutes and constrained in both range and precision)
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class DelayMinutesParam extends ArgoApiElementBase {
private final int minValue;
private final int maxValue;
private final int step;
private Optional<Integer> currentValue;
/**
* C-tor
*
* @param settingsProvider the settings provider (getting device state as well as schedule configuration)
* @param min Minimum value of this timer (in minutes)
* @param max Maximum value of this timer (in minutes)
* @param step Minimum step of the timer (values will be rounded to nearest step, increments/decrements will move by
* step)
* @param initialValue The initial value of this setting, in minutes (since the value is write-only, need to provide
* a value for the increments/decrements to work)
*/
public DelayMinutesParam(IArgoSettingProvider settingsProvider, int min, int max, int step,
Optional<Integer> initialValue) {
super(settingsProvider);
this.minValue = min;
this.maxValue = max;
this.step = step;
this.currentValue = initialValue;
}
/**
* Converts the raw value to framework-compatible {@link State}
*
* @param value Value to convert
* @return Converted value (or {@code UNDEF} on conversion failure)
*/
private static State valueToState(Optional<Integer> value) {
if (value.isEmpty()) {
return UnDefType.UNDEF;
}
return new QuantityType<Time>(value.get(), Units.MINUTE);
}
/**
* @see {@link ArgoApiElementBase#adjustRange}
*/
private int adjustRange(int newValue) {
return ArgoApiElementBase.adjustRange(newValue, minValue, maxValue, Optional.of(step), " min").intValue();
}
/**
* @see {@link ArgoApiElementBase#adjustRangeWithAmplification}
*/
private int adjustRangeWithAmplification(int newValue) {
return ArgoApiElementBase.adjustRangeWithAmplification(newValue, currentValue, minValue, maxValue, step, " min")
.intValue();
}
/**
* {@inheritDoc}
*
* @implNote The currently used context of this class (on/off schedule time) has WRITE-ONLY elements, hence this
* method is unlikely to ever be called
*/
@Override
protected void updateFromApiResponseInternal(String responseValue) {
strToInt(responseValue).ifPresent(raw -> {
currentValue = Optional.of(adjustRange(raw));
});
}
@Override
public State toState() {
return valueToState(currentValue);
}
@Override
public String toString() {
if (currentValue.isEmpty()) {
return "???";
}
return currentValue.get().toString() + " min";
}
/**
* {@inheritDoc}
* <p>
* Timer delay value is always sent to the device together with Timer=Delay command
* (so that the clock resets)
*/
@Override
public boolean isAlwaysSent() {
return isDelayTimerBeingActivated();
}
/**
* {@inheritDoc}
* <p>
* The delay timer value should be send whenever there's an active change (command) to a delay timer (technically
* flipping from Delay timer back to the Delay timer, w/o changing the delay value should re-arm the timer)
*/
@Override
public String getDeviceApiValue() {
var defaultResult = super.getDeviceApiValue();
if (!ArgoDeviceStatus.NO_VALUE.equals(defaultResult) || currentValue.isEmpty()
|| !isDelayTimerBeingActivated()) {
return defaultResult; // There's already a pending command recognized by binding, or delay timer is has no
// pending command -
// we're good to go with the default
}
// There's a pending change to Delay timer -> let's send our value then
return Integer.toString(currentValue.orElseThrow());
}
/**
* Checks if Delay timer is currently being commanded to become active on the device (pending commands!)
*
* @return True, if delay timer is currently being activated on the device, False otherwise
*/
private boolean isDelayTimerBeingActivated() {
var setting = settingsProvider.getSetting(ArgoDeviceSettingType.ACTIVE_TIMER);
var currentTimerValue = EnumParam.fromType(setting.getState(), TimerType.class);
var isDelayCurrentlySet = currentTimerValue.map(t -> t.equals(TimerType.DELAY_TIMER)).orElse(false);
return isDelayCurrentlySet && setting.hasInFlightCommand();
}
/**
* Checks if Delay timer is active already (or being commanded to do so)
* <p>
* Used to defer timer value updates in case there's no timer action ongoing (no need to send the timer value to the
* device)
*
* @return True, if delay timer is currently active on the device, False otherwise
*/
private final boolean isDelayTimerCurrentlyActive() {
var currentTimer = EnumParam
.fromType(settingsProvider.getSetting(ArgoDeviceSettingType.ACTIVE_TIMER).getState(), TimerType.class);
return currentTimer.map(t -> t.equals(TimerType.DELAY_TIMER)).orElse(false);
}
/**
* {@inheritDoc}
*
* @implNote Since this method rounds to next step and some updates may be missed, we're forcing any direction
* movements to move a full step through {@link #adjustRangeWithAmplification(int)}
*/
@Override
protected HandleCommandResult handleCommandInternalEx(Command command) {
int newRawValue;
if (command instanceof Number numberCommand) {
newRawValue = numberCommand.intValue(); // Raw value, not unit-aware
if (command instanceof QuantityType<?> quantityTypeCommand) { // let's try to get it with unit
// (opportunistically)
var inMinutes = quantityTypeCommand.toUnit(Units.MINUTE);
if (null != inMinutes) {
newRawValue = inMinutes.intValue();
}
}
} else if (command instanceof IncreaseDecreaseType increaseDecreaseTypeCommand) {
var base = this.currentValue.orElse(adjustRange((this.minValue + this.maxValue) / 2));
if (IncreaseDecreaseType.INCREASE.equals(increaseDecreaseTypeCommand)) {
base += step;
} else if (IncreaseDecreaseType.DECREASE.equals(increaseDecreaseTypeCommand)) {
base -= step;
}
newRawValue = base;
} else {
return HandleCommandResult.rejected(); // unsupported type of command
}
newRawValue = adjustRangeWithAmplification(newRawValue);
// Not checking if current value is the same as requested (delay timer set resets the clock)
this.currentValue = Optional.of(newRawValue);
// Accept the command (and if it was sent when no timer was active, make it deferred)
return HandleCommandResult.accepted(Integer.toString(newRawValue), valueToState(Optional.of(newRawValue)))
.setDeferred(!isDelayTimerCurrentlyActive());
}
}

View File

@ -0,0 +1,149 @@
/**
* 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.argoclima.internal.device.api.protocol.elements;
import java.util.EnumSet;
import java.util.Optional;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.argoclima.internal.device.api.protocol.IArgoSettingProvider;
import org.openhab.binding.argoclima.internal.device.api.types.IArgoApiEnum;
import org.openhab.core.library.types.StringType;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.openhab.core.types.Type;
import org.openhab.core.types.UnDefType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Enum-type of a param (supports mapping to/from enumerations implementing {@link IArgoApiEnum}
*
* @implNote Some enums (ex. timer type) may require unique handling of updates, hence this class'es implementation of
* {@link #handleCommandInternalEx(Command)} is not final
*
* @author Mateusz Bronk - Initial contribution
*
* @param <E> The type of underlying enum
*/
@NonNullByDefault
public class EnumParam<E extends Enum<E> & IArgoApiEnum> extends ArgoApiElementBase {
private static final Logger LOGGER = LoggerFactory.getLogger(EnumParam.class);
private Optional<E> currentValue;
private final Class<E> cls;
/**
* C-tor
*
* @param settingsProvider the settings provider (getting device state as well as schedule configuration)
* @param cls The type of underlying Enum (implementing {@link IArgoApiEnum} for mapping to/from integer values)
*/
public EnumParam(IArgoSettingProvider settingsProvider, Class<E> cls) {
super(settingsProvider);
this.cls = cls;
this.currentValue = Optional.empty();
}
/**
* Gets the raw enum value from {@link Type} ({@link Command} or {@link State}) which are themselves strings
*
* @see #valueToState(Optional) for a reverse conversion
*
* @param <E> The type of underlying enum - implementing {@link IArgoApiEnum}
* @param value Value to convert
* @param cls The class of underlying Enum (implementing {@link IArgoApiEnum} for mapping to/from integer values)
* @return Converted value (or empty, on conversion failure)
*/
public static <E extends Enum<E> & IArgoApiEnum> Optional<E> fromType(Type value, Class<E> cls) {
if (value instanceof StringType stringTypeCommand) {
String newValue = stringTypeCommand.toFullString();
try {
return Optional.of(Enum.valueOf(cls, newValue));
} catch (IllegalArgumentException ex) {
LOGGER.debug("Failed to convert value: {} to enum. {}", value, ex.getMessage());
return Optional.empty();
}
}
return Optional.empty(); // Not a string Command/State -> ignoring the conversion
}
/**
* Converts enum value to framework-compatible {@link State}
*
* @see {@link #fromType(Type, Class)} for a reverse conversion
* @param <E> The type of underlying enum - implementing {@link IArgoApiEnum}
* @param value The value to convert (wrapped into an optional)
* @return Converted value. {@link UnDefType.UNDEF} if n/a
*/
private static <E extends Enum<E> & IArgoApiEnum> State valueToState(Optional<E> value) {
if (value.isEmpty()) {
return UnDefType.UNDEF;
}
return new StringType(value.orElseThrow().toString());
}
@Override
protected void updateFromApiResponseInternal(String responseValue) {
strToInt(responseValue).ifPresent(raw -> {
this.currentValue = this.fromInt(raw);
});
}
@Override
public State toState() {
return valueToState(currentValue);
}
@Override
public String toString() {
if (currentValue.isEmpty()) {
return "???";
}
return currentValue.get().toString();
}
/**
* {@inheritDoc}
* <p>
* Default behavior - may be overridden for specialized enums
*/
@Override
protected HandleCommandResult handleCommandInternalEx(Command command) {
if (!(command instanceof StringType)) {
return HandleCommandResult.rejected(); // Unsupported command type
}
var requestedValue = fromType(command, cls);
if (requestedValue.isEmpty()) {
return HandleCommandResult.rejected(); // Value not valid for this enum
}
E val = requestedValue.orElseThrow(); // boilerplate, guaranteed to always succeed
if (currentValue.map(cv -> (cv.compareTo(val) == 0)).orElse(false)) {
return HandleCommandResult.rejected(); // Current value is the same as requested - nothing to do
}
this.currentValue = requestedValue; // We allow it!
return HandleCommandResult.accepted(Integer.toString(val.getIntValue()), valueToState(requestedValue));
}
/**
* Convert from int value to this enum
*
* @param value Int value (must match the underlying enum's {@link IArgoApiEnum#getIntValue()}
* @return Converted value or empty if no match
*/
private Optional<E> fromInt(int value) {
return EnumSet.allOf(this.cls).stream().filter(p -> p.getIntValue() == value).findFirst();
}
}

View File

@ -0,0 +1,71 @@
/**
* 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.argoclima.internal.device.api.protocol.elements;
import java.util.Optional;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.argoclima.internal.device.api.protocol.IArgoSettingProvider;
import org.openhab.core.library.types.StringType;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
/**
* Read-only element communicating the unit firmware version
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class FwVersionParam extends ArgoApiElementBase {
private Optional<String> currentValue = Optional.empty();
/**
* C-tor
*
* @param settingsProvider the settings provider (getting device state as well as schedule configuration)
*/
public FwVersionParam(IArgoSettingProvider settingsProvider) {
super(settingsProvider);
}
private static State valueToState(Optional<String> value) {
if (value.isEmpty()) {
return UnDefType.UNDEF;
}
return new StringType("0" + value.get());
}
@Override
protected void updateFromApiResponseInternal(String responseValue) {
this.currentValue = Optional.of(responseValue);
}
@Override
public State toState() {
return valueToState(currentValue);
}
@Override
public String toString() {
if (currentValue.isEmpty()) {
return "???";
}
return "0" + currentValue.get();
}
@Override
protected HandleCommandResult handleCommandInternalEx(Command command) {
return HandleCommandResult.rejected(); // this is not handling any commands
}
}

View File

@ -0,0 +1,135 @@
/**
* 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.argoclima.internal.device.api.protocol.elements;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
/**
* Interface for Argo API parameter (individual HMI element)
* Carries high-level command-management options
*
* @see IArgoElement
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public interface IArgoCommandableElement {
/////////
// TYPES
/////////
/**
* Specialized interface for individual HMI elements, implementing low-level manipulation on their values
*
* @author Mateusz Bronk - Initial contribution
*/
interface IArgoElement extends IArgoCommandableElement {
/**
* Returns the raw Argo command to be sent to the device (if update is pending)
*
* @return Command to send to device (if update pending), or
* {@link org.openhab.binding.argoclima.internal.device.api.protocol.ArgoDeviceStatus#NO_VALUE NO_VALUE}
* - otherwise
*/
public String getDeviceApiValue();
/**
* Handles channel command
*
* @param command The command to handle
* @param isConfirmable Whether the command result is confirmable by the device
* @return True - if command has been handled (= accepted by the framework and ready to be sent to device),
* False -
* otherwise
*/
public boolean handleCommand(Command command, boolean isConfirmable);
/**
* Returns true if the value is always sent to the device on next communication cycle (regardless of whether
* this
* value has new updates or received a direct command).
* Example: current time
* <p>
* Note items marked as always-sent do NOT count towards pending updates (unless they had received a direct
* command). Ex. the always-sent comment will be sent together with any other "direct" commands, but won't
* trigger
* an update cycle on its own, and rather be appended to the user-triggered values on each update (for example,
* time
* update is NOT sent to the device each minute, but gets synchronized on every command)
*
* @return True if the value is always sent in an update cycle
*/
public boolean isAlwaysSent();
/**
* Return **current** state of the element (including side-effects of any pending commands)
*
* @return Device's state as {@link State}
*/
public State toState();
/**
* Updates this API element's state from device's response
*
* @param responseValue Raw API input
* @return State after update
*/
public State updateFromApiResponse(String responseValue);
}
/**
* Notify that the withstanding command has just been sent to the device (and is now pending device-side
* confirmation - if confirmable)
*
* @implNote Used for write-only params, to indicate they have been (hopefully) correctly sent to the device
*/
public void notifyCommandSent();
/**
* Abort pending command targeting this knob (do not send it anymore, consider current device-side state as stable)
*/
public void abortPendingCommand();
/**
* Checks if there's any command in flight (pending to be sent to the device, or sent and awaiting confirmation - if
* confirmable)
* <p>
* This method is similar to {@link #isUpdatePending()}, but doesn't consider device's current state, only the
* existence of non-finalized command
*
* @return True if command pending, False otherwise
*/
public boolean hasInFlightCommand();
/**
* Checks if there's any update withstanding to be sent to the device (pending = not yet sent or not confirmed by
* the device yet)
* <p>
* This method is similar to {@link #hasInFlightCommand()}, but also considers device's current state (if the device
* reports the desired/commanded state already, it's considered not to have any update pending)
*
* @return True if update pending, False otherwise
*/
public boolean isUpdatePending();
/**
* Return string representation of the current state of the device in a human-friendly format (for logging)
* Returns mostly a protocol-like value, not necessarily the framework-converted one
*
* @return String representation of the element
*/
@Override
public String toString();
}

View File

@ -0,0 +1,91 @@
/**
* 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.argoclima.internal.device.api.protocol.elements;
import java.security.InvalidParameterException;
import java.util.Optional;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.argoclima.internal.device.api.protocol.ArgoDeviceStatus;
import org.openhab.binding.argoclima.internal.device.api.protocol.IArgoSettingProvider;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
/**
* The API element representing ON/OFF knob
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class OnOffParam extends ArgoApiElementBase {
private Optional<Boolean> currentValue = Optional.empty();
private static final String VALUE_ON = "1";
private static final String VALUE_OFF = "0";
/**
* C-tor
*
* @param settingsProvider the settings provider (getting device state as well as schedule configuration)
*/
public OnOffParam(IArgoSettingProvider settingsProvider) {
super(settingsProvider);
}
private static State valueToState(Optional<Boolean> value) {
return value.<State> map(v -> OnOffType.from(v)).orElse(UnDefType.UNDEF);
}
@Override
protected void updateFromApiResponseInternal(String responseValue) {
if (OnOffParam.VALUE_ON.equals(responseValue)) {
this.currentValue = Optional.of(true);
} else if (OnOffParam.VALUE_OFF.equals(responseValue)) {
this.currentValue = Optional.of(false);
} else if (ArgoDeviceStatus.NO_VALUE.equals(responseValue)) {
this.currentValue = Optional.empty();
} else {
throw new InvalidParameterException(String.format("Invalid value of parameter: {}", responseValue));
}
}
@Override
public State toState() {
return valueToState(currentValue);
}
@Override
public String toString() {
if (currentValue.isEmpty()) {
return "???";
}
return currentValue.get() ? "ON" : "OFF";
}
@Override
protected HandleCommandResult handleCommandInternalEx(Command command) {
if (command instanceof OnOffType onOffTypeCommand) {
if (OnOffType.ON.equals(onOffTypeCommand)) {
var targetValue = Optional.of(true);
currentValue = targetValue;
return HandleCommandResult.accepted(VALUE_ON, valueToState(targetValue));
} else if (OnOffType.OFF.equals(onOffTypeCommand)) {
var targetValue = Optional.of(false);
currentValue = targetValue;
return HandleCommandResult.accepted(VALUE_OFF, valueToState(targetValue));
}
}
return HandleCommandResult.rejected();
}
}

View File

@ -0,0 +1,121 @@
/**
* 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.argoclima.internal.device.api.protocol.elements;
import java.util.Optional;
import javax.measure.Unit;
import javax.measure.quantity.Dimensionless;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.argoclima.internal.device.api.protocol.IArgoSettingProvider;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.Units;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* API element representing an integer in range of allowed values
*
* @implNote Since ECO power limit is the only value this is used for now, the {@link #UNIT} is hard-coded to percent
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class RangeParam extends ArgoApiElementBase {
private static final Unit<Dimensionless> UNIT = Units.PERCENT;
private final Logger logger = LoggerFactory.getLogger(getClass());
private final double minValue;
private final double maxValue;
private Optional<Number> currentValue = Optional.empty();
/**
* C-tor
*
* @param settingsProvider the settings provider (getting device state as well as schedule configuration)
* @param min Minimum settable value
* @param max Maximum settable value
*/
public RangeParam(IArgoSettingProvider settingsProvider, double min, double max) {
super(settingsProvider);
this.minValue = min;
this.maxValue = max;
}
private static State valueToState(Optional<Number> value) {
if (value.isEmpty()) {
return UnDefType.UNDEF;
}
return new QuantityType<Dimensionless>(value.get(), UNIT);
}
/**
* Normalize value to be in range of MIN..MAX
*
* @implNote Even though min-max ranges are floating-point, this is operating on integers, as currently there's no
* use of this class which goes beyond integers
* @param newValue The value to normalize (as int)
* @return Normalized value
*/
private int normalizeValue(int newValue) {
if (newValue < minValue) {
logger.debug("Requested value: {} would exceed minimum value: {}. Setting: {}.", newValue, minValue,
(int) minValue);
return (int) minValue;
}
if (newValue > maxValue) {
logger.debug("Requested value: {} would exceed maximum value: {}. Setting: {}.", newValue, maxValue,
(int) maxValue);
return (int) maxValue;
}
return newValue;
}
@Override
protected void updateFromApiResponseInternal(String responseValue) {
strToInt(responseValue).ifPresent(raw -> {
currentValue = Optional.of(raw);
});
}
@Override
public State toState() {
return valueToState(currentValue);
}
@Override
public String toString() {
if (currentValue.isEmpty()) {
return "???";
}
return currentValue.get().toString();
}
@Override
protected HandleCommandResult handleCommandInternalEx(Command command) {
if (command instanceof Number numberCommand) {
final int newValue = normalizeValue(numberCommand.intValue());
if (currentValue.map(cv -> (cv.intValue() == newValue)).orElse(false)) {
return HandleCommandResult.rejected(); // Current value is the same as requested - nothing to do
}
this.currentValue = Optional.of(newValue);
return HandleCommandResult.accepted(Integer.toString(newValue), valueToState(this.currentValue));
}
return HandleCommandResult.rejected();
}
}

View File

@ -0,0 +1,140 @@
/**
* 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.argoclima.internal.device.api.protocol.elements;
import java.util.Optional;
import javax.measure.quantity.Temperature;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.argoclima.internal.device.api.protocol.IArgoSettingProvider;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.SIUnits;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
/**
* The element for controlling/receiving temperature
* <p>
* Device API always communicates in degrees Celsius, even if the display unit (configurable) is Fahrenheit.
* <p>
* While the settable temperature seems to be by 0.5 °C (at least this is what the remote API does), the reported temp.
* is by 0.1 °C and technically the device accepts setting values with such precision. This is not practiced though, not
* to introduce unknown side-effects
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class TemperatureParam extends ArgoApiElementBase {
private final double minValue;
private final double maxValue;
private final double step;
private Optional<Double> currentValue = Optional.empty();
/**
* C-tor
*
* @param settingsProvider the settings provider (getting device state as well as schedule configuration)
* @param min Minimum value of this timer (in minutes)
* @param max Maximum value of this timer (in minutes)
* @param step Minimum step of the timer (values will be rounded to nearest step, increments/decrements will move by
* step). Step dictates the resolution of this param
*/
public TemperatureParam(IArgoSettingProvider settingsProvider, double min, double max, double step) {
super(settingsProvider);
this.minValue = min;
this.maxValue = max;
this.step = step;
}
/**
* Converts the raw value to framework-compatible {@link State} (always in degrees Celsius
*
* @param value Value to convert
* @return Converted value (or empty, on conversion failure)
*/
private static State valueToState(Optional<Double> value) {
if (value.isEmpty()) {
return UnDefType.UNDEF;
}
return new QuantityType<Temperature>(value.get(), SIUnits.CELSIUS);
}
/**
* @see {@link ArgoApiElementBase#adjustRangeWithAmplification}
*/
private double adjustRangeWithAmplification(double newValue) {
var normalized = ArgoApiElementBase
.adjustRangeWithAmplification(newValue, currentValue, minValue, maxValue, step, " °C").doubleValue();
return Math.round(normalized * 10.0) / 10.0; // single-digit precision
}
/**
* {@inheritDoc}
*
* @implNote The raw API uses integers and degrees Celsius. Temperature is multiplied by 10.
* @implNote Deliberately not normalizing incoming value (if the device reported it, let's consider it valid, even
* if it is out of range!)
*/
@Override
protected void updateFromApiResponseInternal(String responseValue) {
strToInt(responseValue).ifPresent(raw -> {
this.currentValue = Optional.of(raw / 10.0);
});
}
@Override
public State toState() {
return valueToState(currentValue);
}
@Override
public String toString() {
if (currentValue.isEmpty()) {
return "???";
}
return currentValue.get().toString() + " °C";
}
/**
* {@inheritDoc}
*
* @implNote The raw API uses integers and degrees Celsius. Temperature is multiplied by 10.
*/
@Override
protected HandleCommandResult handleCommandInternalEx(Command command) {
double newRawValue;
if (command instanceof Number numberCommand) {
newRawValue = numberCommand.doubleValue(); // Raw value, not unit-aware
if (command instanceof QuantityType<?> quantityTypeCommand) { // let's try to get it with unit
// (opportunistically)
var inCelsius = quantityTypeCommand.toUnit(SIUnits.CELSIUS);
if (null != inCelsius) {
newRawValue = inCelsius.doubleValue();
}
}
} else {
return HandleCommandResult.rejected(); // unsupported type of command
}
newRawValue = adjustRangeWithAmplification(newRawValue);
this.currentValue = Optional.of(newRawValue);
// Accept the command
return HandleCommandResult.accepted(Integer.toUnsignedString((int) (newRawValue * 10.0)),
valueToState(Optional.of(newRawValue)));
}
}

View File

@ -0,0 +1,249 @@
/**
* 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.argoclima.internal.device.api.protocol.elements;
import java.time.LocalTime;
import java.util.Optional;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.argoclima.internal.device.api.protocol.ArgoDeviceStatus;
import org.openhab.binding.argoclima.internal.device.api.protocol.IArgoSettingProvider;
import org.openhab.binding.argoclima.internal.exception.ArgoConfigurationException;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.library.unit.Units;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Time element (accepting values in HH:MM) - eg. for schedule timers on/off
*
* @see CurrentTimeParam
* @see DelayMinutesParam
* @implNote These other "time" params could technically be sharing common codebase, though for simplicity sake it was
* easier to implement them as unrelated (possible future refactor oppty)
*
* @implNote This class could use {@link LocalTime} for internal storage, but raw int has been chosen instead to cut on
* back and forth conversions, dealing with seconds etc... (and is simple-enough)
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class TimeParam extends ArgoApiElementBase {
/**
* Kind of schedule parameter (on or off)
*/
public enum TimeParamType {
ON,
OFF
}
private static final Logger LOGGER = LoggerFactory.getLogger(TimeParam.class);
private static final int MIN_VALUE = 0; // 0:00
private static final int MAX_VALUE = 23 * 60 + 59; // 23:59
private final TimeParamType paramType;
private Optional<Integer> currentValue = Optional.empty();
/**
* C-tor (allows full range of values: {@code 0:00 <> 25:59})
*
* @implNote Even though the Argo HVAC supports 3 schedule timers, when sent to a device, there's only one
* on/off/weekday option, hence value of this setting changes indirectly (when changing Schedule timer
* cycle)
* @param settingsProvider the settings provider (getting device state as well as schedule configuration)
* @param paramType The kind of parameter (ON or OFF time). This element requires this knowledge to be able to
* retrieve default value from settings (based off of currently selected timer value)
*/
public TimeParam(IArgoSettingProvider settingsProvider, TimeParamType paramType) {
super(settingsProvider);
this.paramType = paramType;
}
/**
* Gets the raw time value from hours and minutes (normalized to be in range of [{@link #MIN_VALUE} ,
* {@link #MAX_VALUE}]
*
* @param hour Hour to convert (0..23)
* @param minute Minute to convert (0..59)
* @return The Argo API raw value for the time
*/
public static int fromHhMm(int hour, int minute) {
return normalizeTime(hour * 60 + minute);
}
/**
* Converts the raw value to framework-compatible {@link State}
*
* @implNote While the data is technically TIME, and could be represented as
* {@link org.openhab.core.library.types.DateTimeType DateTimeType}, the OH framework doesn't seem to
* provide a class for time of day only (w/o Date component).
* A next best semantically-correct way of representing this value would be by
* {@link org.openhab.core.library.types.QuantityType QuantityType&lt;Time&gt;(..., Units.MINUTE)}}, yet
* this displays somewhat weirdly (as it is more suited for duration, not time of day).
* <p>
* Hence the value is represented as a {@link org.openhab.core.library.types.StringType StringType}, which
* makes it display "normally", and is OK for this use case, as these schedule parameters are actually
* **NOT** mapped to any channel (and instead sourced from config), thus not causing any awkward usage for
* the user
*
* @param value Value to convert
* @return Converted value (or empty, on conversion failure)
*/
private static State valueToState(Optional<Integer> value) {
if (value.isEmpty()) {
return UnDefType.UNDEF;
}
return new StringType(rawValueToHHMMString(value.orElseThrow()));
}
private static int normalizeTime(int newValue) {
if (newValue < MIN_VALUE) {
LOGGER.debug("Requested value: {} would exceed minimum value: {}. Setting: {}.", newValue, MIN_VALUE,
MIN_VALUE);
return MIN_VALUE;
}
if (newValue > MAX_VALUE) {
LOGGER.debug("Requested value: {} would exceed maximum value: {}. Setting: {}.", newValue, MAX_VALUE,
MAX_VALUE);
return MAX_VALUE;
}
return newValue;
}
private static String rawValueToHHMMString(int rawValue) {
int hh = rawValue / 60;
int mm = rawValue % 60;
return String.format("%02d:%02d", hh, mm);
}
/**
* {@inheritDoc}
*
* @implNote The currently used context of this class (on/off schedule time) has WRITE-ONLY elements, hence this
* method is unlikely to ever be called
*/
@Override
protected void updateFromApiResponseInternal(String responseValue) {
strToInt(responseValue).ifPresent(raw -> {
this.currentValue = Optional.of(normalizeTime(raw));
});
}
@Override
public State toState() {
return valueToState(currentValue);
}
@Override
public String toString() {
if (currentValue.isEmpty()) {
return "???";
}
return rawValueToHHMMString(currentValue.get().intValue());
}
/**
* {@inheritDoc}
* <p>
* Timer on/off values are always sent to the device together with other values (as long as there are other updates,
* and any schedule timer is currently active)
*/
@Override
public boolean isAlwaysSent() {
return isScheduleTimerEnabled().isPresent();
}
/**
* {@inheritDoc}
* <p>
* Specialized implementation allowing to get a value from default config provider (if it wansn't set before)
* Since the value is write-only and framework's value may be N/A we need to re-fetch it in such case.
*/
@Override
public String getDeviceApiValue() {
var defaultResult = super.getDeviceApiValue();
var activeScheduleTimer = isScheduleTimerEnabled();
if (!ArgoDeviceStatus.NO_VALUE.equals(defaultResult) || activeScheduleTimer.isEmpty()) {
return defaultResult; // There's already a pending command recognized by binding, or schedule timer is off -
// we're good to go with the default
}
if (currentValue.isPresent()) {
// We have a value, and schedule timer is enabled, so let's send it
// Consideration: Only send those as long as the pending command is *schedule timer change*, not *any
// change*?... Seems to not be required though so... YAGNI
return Integer.toString(currentValue.orElseThrow());
}
// OOPS - We have a schedule timer active already, but no value (and have to provide something). Let's fetch it
// from the configuration
var timerId = activeScheduleTimer.orElseThrow();
try {
LocalTime configuredValue;
if (paramType == TimeParamType.ON) {
configuredValue = settingsProvider.getScheduleProvider().getScheduleOnTime(timerId);
} else {
configuredValue = settingsProvider.getScheduleProvider().getScheduleOffTime(timerId);
}
// let's initialize our value from the config's one (lazily)
currentValue = Optional.of(fromHhMm(configuredValue.getHour(), configuredValue.getMinute()));
return Integer.toString(currentValue.orElseThrow());
} catch (ArgoConfigurationException e) {
LOGGER.debug("Retrieving default configured value for {} timer failed. Error: {}", paramType,
e.getMessage());
return defaultResult;
}
}
/**
* {@inheritDoc}
* <p>
* Gets the local time value from Numbers as well as HH:MM string representation.
*
* @see #valueToState
*/
@Override
protected HandleCommandResult handleCommandInternalEx(Command command) {
int newRawValue;
if (command instanceof Number numberCommand) {
newRawValue = numberCommand.intValue(); // Raw value, not unit-aware
if (command instanceof QuantityType<?> quantityTypeCommand) { // let's try to get it with unit
// (opportunistically)
var inMinutes = quantityTypeCommand.toUnit(Units.MINUTE);
if (null != inMinutes) {
newRawValue = inMinutes.intValue();
}
}
} else if (command instanceof StringType stringTypeCommand) {
var asTime = LocalTime.parse(stringTypeCommand.toFullString());
newRawValue = fromHhMm(asTime.getHour(), asTime.getMinute());
} else {
return HandleCommandResult.rejected(); // unsupported type of command
}
newRawValue = normalizeTime(newRawValue);
// Not checking if current value is the same as requested (this is a send-always value, so no real need)
this.currentValue = Optional.of(newRawValue);
// Accept the command (and if it was sent when no timer was active, make it deferred)
return HandleCommandResult.accepted(Integer.toString(newRawValue), valueToState(Optional.of(newRawValue)))
.setDeferred(isScheduleTimerEnabled().isEmpty());
}
}

View File

@ -0,0 +1,217 @@
/**
* 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.argoclima.internal.device.api.protocol.elements;
import java.util.EnumSet;
import java.util.Objects;
import java.util.Optional;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.argoclima.internal.device.api.protocol.ArgoDeviceStatus;
import org.openhab.binding.argoclima.internal.device.api.protocol.IArgoSettingProvider;
import org.openhab.binding.argoclima.internal.device.api.types.Weekday;
import org.openhab.binding.argoclima.internal.exception.ArgoConfigurationException;
import org.openhab.binding.argoclima.internal.utils.StringUtils;
import org.openhab.core.library.types.StringType;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Weekdays element (accepting sets of days for schedule to run on)
*
* @see TimeParam
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class WeekdayParam extends ArgoApiElementBase {
private final Logger logger = LoggerFactory.getLogger(getClass());
private Optional<EnumSet<Weekday>> currentValue = Optional.empty();
/**
* C-tor
*
* @param settingsProvider the settings provider (getting device state as well as schedule configuration)
*/
public WeekdayParam(IArgoSettingProvider settingsProvider) {
super(settingsProvider);
}
/**
* Converts the internal {@code EnumSet}-based storage to raw ARGO API value ("flags enum" - represented as
* int/bitmap)
*
* @param values The set of days to convert
* @implNote This impl. assumes all the values are in range of the underlying enum type (no craziness such as
* casting 1000 to Weekday)
* @return Int representation of the set of weekdays
*/
public static int toRawValue(EnumSet<Weekday> values) {
int ret = 0;
for (Weekday val : values) {
ret |= val.getIntValue();
}
return ret;
}
/**
* Unpacks the Argo API integer with flags-packed weekdays into EnumSet
*
* @implNote This is not checking if the int value is not having values outside of the Enum values (these will be
* silently skipped on conversion!). Could do a bitmask-based sanity check, but... Occam's Razor ;)
*
* @param value The raw value to convert
* @return Unpacked value
*/
public static EnumSet<Weekday> fromRawValue(int value) {
EnumSet<Weekday> ret = EnumSet.noneOf(Weekday.class);
for (Weekday val : EnumSet.allOf(Weekday.class)) {
if ((val.getIntValue() & value) != 0) {
ret.add(val);
}
}
return ret;
}
/**
* Converts the raw value to framework-compatible {@link State}
*
* @implNote While the raw data is technically an integer, and could be represented as
* {@link org.openhab.core.library.types.DecimalType DecimalType}, a {@code String} was chosen for better
* readability
* <p>
* This parameter is actually **NOT** mapped to any channel (and instead sourced from config), thus not
* causing any awkward usage for the user
*
* @param value Value to convert
* @return Converted value (or empty, on conversion failure)
*/
private static State valueToState(Optional<EnumSet<Weekday>> value) {
if (value.isEmpty()) {
return UnDefType.UNDEF;
}
return new StringType(value.orElseThrow().toString());
}
/**
* {@inheritDoc}
*
* @implNote The currently used context of this class (schedule timer) has WRITE-ONLY elements, hence this
* method is unlikely to ever be called
*/
@Override
protected void updateFromApiResponseInternal(String responseValue) {
strToInt(responseValue).ifPresent(raw -> {
this.currentValue = Optional.of(fromRawValue(raw));
});
}
@Override
public State toState() {
return valueToState(currentValue);
}
@Override
public String toString() {
if (currentValue.isEmpty()) {
return "???";
}
return Objects.requireNonNull(currentValue.orElseThrow().toString());
}
/**
* {@inheritDoc}
* <p>
* Timer weekday values are always sent to the device together with other values (as long as there are other
* updates,
* and any schedule timer is currently active)
*/
@Override
public boolean isAlwaysSent() {
return isScheduleTimerEnabled().isPresent();
}
/**
* {@inheritDoc}
* <p>
* Specialized implementation allowing to get a value from default config provider (if it wansn't set before)
* Since the value is write-only and framework's value may be N/A we need to re-fetch it in such case.
*/
@Override
public String getDeviceApiValue() {
var defaultResult = super.getDeviceApiValue();
var activeScheduleTimer = isScheduleTimerEnabled();
if (!ArgoDeviceStatus.NO_VALUE.equals(defaultResult) || activeScheduleTimer.isEmpty()) {
return defaultResult; // There's already a pending command recognized by binding, or schedule timer is off -
// we're good to go with the default
}
if (currentValue.isPresent()) {
// We have a value, and schedule timer is enabled, so let's send it
// Consideration: Only send those as long as the pending command is *schedule timer change*, not *any
// change*?... Seems to not be required though so... YAGNI
return Integer.toString(toRawValue(currentValue.get()));
}
// OOPS - We have a schedule timer active already, but no value (and have to provide something). Let's fetch it
// from the configuration
var timerId = activeScheduleTimer.orElseThrow();
try {
EnumSet<Weekday> configuredValue = settingsProvider.getScheduleProvider().getScheduleDayOfWeek(timerId);
// let's initialize our value from the config's one (lazily)
currentValue = Optional.of(configuredValue);
return Integer.toString(toRawValue(currentValue.get()));
} catch (ArgoConfigurationException e) {
logger.debug("Retrieving configured weekdays value for timer failed. Error: {}", e.getMessage());
return defaultResult;
}
}
/**
* {@inheritDoc}
* <p>
* Gets the local time value from Numbers as well as comma-separated String representation such as
* {@code [SUN, MON, TUE, WED, THU, FRI, SAT]}
*
* @see #valueToState
*/
@Override
protected HandleCommandResult handleCommandInternalEx(Command command) {
EnumSet<Weekday> newValue;
if (command instanceof Number numberCommand) {
var rawValue = numberCommand.intValue();
newValue = fromRawValue(rawValue);
} else if (command instanceof StringType stringTypeCommand) {
var toParse = StringUtils.strip(stringTypeCommand.toFullString(), "[]{}()");
EnumSet<Weekday> parsed = EnumSet.noneOf(Weekday.class);
for (String s : toParse.split(",")) {
parsed.add(Weekday.valueOf(s.strip()));
}
newValue = parsed;
} else {
return HandleCommandResult.rejected(); // unsupported type of command
}
// Not checking if current value is the same as requested (this is a send-always value, so no real need)
this.currentValue = Optional.of(newValue);
// Accept the command (and if it was sent when no timer was active, make it deferred)
return HandleCommandResult.accepted(Integer.toString(toRawValue(newValue)), valueToState(Optional.of(newValue)))
.setDeferred(isScheduleTimerEnabled().isEmpty());
}
}

View File

@ -0,0 +1,47 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.argoclima.internal.device.api.types;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Type representing the concrete Argo API element knob
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public enum ArgoDeviceSettingType {
TARGET_TEMPERATURE,
ACTUAL_TEMPERATURE,
POWER,
MODE,
FAN_LEVEL,
FLAP_LEVEL,
I_FEEL_TEMPERATURE,
FILTER_MODE,
ECO_MODE,
TURBO_MODE,
NIGHT_MODE,
LIGHT,
ECO_POWER_LIMIT,
RESET_TO_FACTORY_SETTINGS,
UNIT_FIRMWARE_VERSION,
DISPLAY_TEMPERATURE_SCALE,
CURRENT_TIME,
CURRENT_DAY_OF_WEEK,
ACTIVE_TIMER,
TIMER_0_DELAY_TIME,
TIMER_N_ENABLED_DAYS,
TIMER_N_ON_TIME,
TIMER_N_OFF_TIME
}

View File

@ -0,0 +1,42 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.argoclima.internal.device.api.types;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Type representing Argo HVAC Fan levels (int values are matching device's API)
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public enum FanLevel implements IArgoApiEnum {
AUTO(0),
LEVEL_1(1),
LEVEL_2(2),
LEVEL_3(3),
LEVEL_4(4),
LEVEL_5(5),
LEVEL_6(6);
private int value;
FanLevel(int intValue) {
this.value = intValue;
}
@Override
public int getIntValue() {
return this.value;
}
}

View File

@ -0,0 +1,45 @@
/**
* 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.argoclima.internal.device.api.types;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Type representing Argo devices flap setting (swing). Int values are matching device's API.
* <p>
* Note: While this is supported by Argo remote protocol, the Ulisse device doesn't react to these settings
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public enum FlapLevel implements IArgoApiEnum {
AUTO(0),
LEVEL_1(1),
LEVEL_2(2),
LEVEL_3(3),
LEVEL_4(4),
LEVEL_5(5),
LEVEL_6(6),
LEVEL_7(7);
private int value;
FlapLevel(int intValue) {
this.value = intValue;
}
@Override
public int getIntValue() {
return this.value;
}
}

View File

@ -0,0 +1,25 @@
/**
* 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.argoclima.internal.device.api.types;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Enum extension interface, providing raw integer value which is value's representation in the Argo protocol
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public interface IArgoApiEnum {
public int getIntValue();
}

View File

@ -0,0 +1,40 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.argoclima.internal.device.api.types;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Type representing Argo HVAC operation mode (int values are matching device's API)
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public enum OperationMode implements IArgoApiEnum {
COOL(1),
DRY(2),
WARM(3),
FAN(4),
AUTO(5);
private int value;
OperationMode(int intValue) {
this.value = intValue;
}
@Override
public int getIntValue() {
return this.value;
}
}

View File

@ -0,0 +1,38 @@
/**
* 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.argoclima.internal.device.api.types;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Type representing Argo HVAC displayed temperature scale (int values are matching device's API)
*
* @implNote This setting does not influence API (always in Celsius)
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public enum TemperatureScale implements IArgoApiEnum {
SCALE_CELSIUS(0),
SCALE_FARHENHEIT(1);
private int value;
TemperatureScale(int intValue) {
this.value = intValue;
}
@Override
public int getIntValue() {
return this.value;
}
}

View File

@ -0,0 +1,89 @@
/**
* 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.argoclima.internal.device.api.types;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.argoclima.internal.configuration.IScheduleConfigurationProvider.ScheduleTimerType;
/**
* Type representing Argo currently selected timer. Int values are matching device's API.
* <p>
* The device supports a "delay" timer + 3 configurable "schedule" timers. All the schedule timers share the same API
* fields for configuring days of week when they are active as well as start/stop time
*
* @see ScheduleTimerType - for schedule-specific enum (used in binding configuration)
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public enum TimerType implements IArgoApiEnum {
NO_TIMER(0),
DELAY_TIMER(1),
SCHEDULE_TIMER_1(2),
SCHEDULE_TIMER_2(3),
SCHEDULE_TIMER_3(4);
private int value;
TimerType(int intValue) {
this.value = intValue;
}
@Override
public int getIntValue() {
return this.value;
}
/**
* Converts to {@link ScheduleTimerType}
*
* @implNote This function will throw, if passed a non-schedule-timer type. Not using optional response, given its
* simple usage and extra boilerplate it would do. Needs care when being used though!
* @param val Value to convert
* @return Converted value
* @throws IllegalArgumentException - on passing a timer which is not one of schedule timers
*/
public static ScheduleTimerType toScheduleTimerType(TimerType val) {
switch (val) {
case SCHEDULE_TIMER_1:
return ScheduleTimerType.SCHEDULE_1;
case SCHEDULE_TIMER_2:
return ScheduleTimerType.SCHEDULE_2;
case SCHEDULE_TIMER_3:
return ScheduleTimerType.SCHEDULE_3;
default:
throw new IllegalArgumentException(
String.format("Unable to convert TimerType: %s to ScheduleTimerType", val));
}
}
/**
* Converts from {@link ScheduleTimerType}
*
* @param val Value to convert
* @return Converted value
* @throws IllegalArgumentException - on passing an out-of-range enum (extremely unlikely!)
*/
public static TimerType fromScheduleTimerType(ScheduleTimerType val) {
switch (val) {
case SCHEDULE_1:
return TimerType.SCHEDULE_TIMER_1;
case SCHEDULE_2:
return TimerType.SCHEDULE_TIMER_2;
case SCHEDULE_3:
return TimerType.SCHEDULE_TIMER_3;
default:
throw new IllegalArgumentException(
String.format("Unable to convert ScheduleTimerType: %s to TimerType", val));
}
}
}

View File

@ -0,0 +1,75 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.argoclima.internal.device.api.types;
import java.time.DayOfWeek;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Custom Day of Week class implementation (with integer values matching Argo API) and support of stacking into
* EnumSet (flags-like)
*
* @implNote Ordering is important! The ordinal values start from 0 (0-SUN, 1-MON, ...) and are also used - for
* {@link org.openhab.binding.argoclima.internal.device.api.protocol.elements.CurrentWeekdayParam}
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public enum Weekday implements IArgoApiEnum {
SUN(0x01), // ordinal: 0
MON(0x02), // ordinal: 1
TUE(0x04), // ordinal: 2
WED(0x08), // ordinal: 3
THU(0x10), // ordinal: 4
FRI(0x20), // ordinal: 5
SAT(0x40); // ordinal: 6
private int value;
Weekday(int intValue) {
this.value = intValue;
}
@Override
public int getIntValue() {
return this.value;
}
/**
* Maps {@link java.time.DayOfWeek java.time.DayOfWeek} to Argo API custom enum ({@link Weekday})
*
* @param d The DayOfWeek to convert
* @return Argo-compatible Weekday for {@code d}
*/
public static Weekday ofDay(DayOfWeek d) {
switch (d) {
case SUNDAY:
return Weekday.SUN;
case MONDAY:
return Weekday.MON;
case TUESDAY:
return Weekday.TUE;
case WEDNESDAY:
return Weekday.WED;
case THURSDAY:
return Weekday.THU;
case FRIDAY:
return Weekday.FRI;
case SATURDAY:
return Weekday.SAT;
default:
throw new IllegalArgumentException("Invalid day of week");
}
}
}

View File

@ -0,0 +1,181 @@
/**
* 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.argoclima.internal.device.passthrough;
import static org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants.BINDING_ID;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import javax.servlet.http.HttpServletResponse;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.util.StringContentProvider;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.util.HttpCookieStore;
import org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* HTTP client, forwarding (proxy-like) original device's request (downstream) to a remote server
* (upstream) and passing the response through back to the device (with ability to intercept
* content and change it - MitM)
*
* @implNote The HTTP client is custom (as it needs to simulate the actual Argo device, with all its quirks), hence
* using separate instance and threadpool for it.
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class PassthroughHttpClient {
private static final Logger LOGGER = LoggerFactory.getLogger(PassthroughHttpClient.class);
private static final String RPC_POOL_NAME = BINDING_ID + "_apiProxy";
private static final List<String> HEADERS_TO_IGNORE = List.of("content-length", "content-type", "content-encoding",
"host", "accept-encoding");
private final HttpClient rawHttpClient;
private boolean isStarted = false;
/** The hostname of OEM vendor's (upstream) server to talk to */
public final String upstreamTargetHost;
/** The port of OEM vendor's (upstream) server to talk to */
public final int upstreamTargetPort;
/**
* C-tor, creates new HTTP client (requires starting!)
*
* @param upstreamIpAddress The hostname of OEM vendor's (upstream) server to talk to
* @param upstreamPort The port of OEM vendor's (upstream) server to talk to
* @param clientFactory Framework-provided factory for creating new Jetty's HTTP clients
*/
public PassthroughHttpClient(String upstreamIpAddress, int upstreamPort, HttpClientFactory clientFactory) {
// Impl. note: Using Openhab's (globally-configurable) settings for custom threadpool. We technically may need
// less threads (and longer TTL) here... but not fiddling with thread pool settings post-creation, to avoid
// corner cases
this.rawHttpClient = clientFactory.createHttpClient(RPC_POOL_NAME);
this.rawHttpClient.setFollowRedirects(false);
this.rawHttpClient.setUserAgentField(null); // The device doesn't set it, and we want to be a transparent proxy
this.rawHttpClient.setCookieStore(new HttpCookieStore.Empty());
this.rawHttpClient.setRequestBufferSize(1024);
this.rawHttpClient.setResponseBufferSize(1024);
this.upstreamTargetHost = upstreamIpAddress;
this.upstreamTargetPort = upstreamPort;
}
/**
* Start pass-through HTTP client (simulating the device).
*
* @throws Exception In case of startup failure
*/
public synchronized void start() throws Exception {
if (this.isStarted) {
stop();
}
this.rawHttpClient.start();
this.rawHttpClient.getContentDecoderFactories().clear(); // Prevent decoding gzip (device doesn't support it).
// Stops sending Accept header
this.isStarted = true;
}
/**
* Stops the pass-through HTTP client
*
* @throws Exception In case of stop failure
*/
public synchronized void stop() throws Exception {
this.rawHttpClient.stop();
this.rawHttpClient.destroy();
this.isStarted = false;
}
/**
* Pass the downstream HTTP request through to upstream server (as-is)
*
* @param downstreamHttpRequest The device-side request to pass on
* @param downstreamHttpRequestBody The body of the request (provided separately, because the stream has been read
* already, as it is also used for sniffing)
* @return The response from remote side
* @throws InterruptedException if send thread is interrupted
* @throws TimeoutException if send times out
* @throws ExecutionException if execution fails
*/
public ContentResponse passthroughRequest(Request downstreamHttpRequest, String downstreamHttpRequestBody)
throws InterruptedException, TimeoutException, ExecutionException {
var request = this.rawHttpClient.newRequest(this.upstreamTargetHost, this.upstreamTargetPort)
.method(downstreamHttpRequest.getMethod()).path(downstreamHttpRequest.getOriginalURI())
.version(downstreamHttpRequest.getHttpVersion())
.content(new StringContentProvider(downstreamHttpRequestBody))
.timeout(ArgoClimaBindingConstants.UPSTREAM_PROXY_HTTP_REQUEST_TIMEOUT.toMillis(),
TimeUnit.MILLISECONDS);
// re-add headers from downstream request to this one (except explicitly-ignored list)
for (var headerName : Collections.list(downstreamHttpRequest.getHeaderNames())) {
if (HEADERS_TO_IGNORE.stream().noneMatch(x -> x.equalsIgnoreCase(headerName))) {
request.header(headerName, downstreamHttpRequest.getHeader(headerName));
}
}
LOGGER.trace("Pass-through: DEVICE --> UPSTREAM_API: [{} {}], body=[{}]", request.getMethod(), request.getURI(),
downstreamHttpRequestBody);
return Objects.requireNonNull(request.send());
}
/**
* Forward upstream server's response back to the device-side (possibly overriding the body)
*
* @param response The response received from remote side (vendor's server)
* @param targetResponse The response to send to the device side (from this interceptor)
* @param overrideBodyToReturn If provided, replace the response body from upstream with THIS content (useful when
* communicating with the device indirectly, and want to "send" it a command (send = let it pool for it
* on its own)
* @throws IOException If response writing fails
*/
public static void forwardUpstreamResponse(ContentResponse response, HttpServletResponse targetResponse,
Optional<String> overrideBodyToReturn) throws IOException {
targetResponse.setContentType(Objects.requireNonNullElse(response.getMediaType(), "text/html"));
// NOTE: Argo servers send responses **without** charset, whereas Jetty's default includes it.
// The device seems to be fine w/ it, note though it is a difference in the protocol
// Merely setting the Encoding to null or overriding the header to MimeTypes.getContentTypeWithoutCharset(x)
// has no-effect as Jetty overrides it at writer creation. Would require more sophisticated filtering
// and possibly subclassing org.eclipse.jetty.server.Response to get 1:1 matching w/ remote response, so leaving
// as-is.
targetResponse.setCharacterEncoding(Objects.requireNonNullElse(response.getEncoding(), "ASCII"));
for (var header : response.getHeaders()) {
if (HEADERS_TO_IGNORE.stream().noneMatch(x -> x.equalsIgnoreCase(header.getName()))) {
targetResponse.setHeader(Objects.requireNonNull(header.getName()), header.getValue());
}
}
String responseBodyToReturn = overrideBodyToReturn.orElse(response.getContentAsString());
targetResponse.getWriter().write(responseBodyToReturn);
targetResponse.setStatus(response.getStatus());
LOGGER.trace(" [response]: DEVICE <-- UPSTREAM_API: [{} {} {} - {} bytes], body=[{}]", response.getVersion(),
response.getStatus(), response.getReason(), response.getContent().length, responseBodyToReturn);
}
}

View File

@ -0,0 +1,584 @@
/**
* 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.argoclima.internal.device.passthrough;
import static org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants.BINDING_ID;
import java.io.IOException;
import java.net.InetAddress;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;
import java.util.stream.Collectors;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.server.Connector;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
import org.eclipse.jetty.server.handler.AbstractHandler;
import org.eclipse.jetty.util.UrlEncoded;
import org.eclipse.jetty.util.thread.QueuedThreadPool;
import org.openhab.binding.argoclima.internal.ArgoClimaTranslationProvider;
import org.openhab.binding.argoclima.internal.configuration.ArgoClimaConfigurationLocal.DeviceSidePasswordDisplayMode;
import org.openhab.binding.argoclima.internal.device.api.ArgoClimaLocalDevice;
import org.openhab.binding.argoclima.internal.device.passthrough.requests.DeviceSidePostRtUpdateDTO;
import org.openhab.binding.argoclima.internal.device.passthrough.requests.DeviceSideUpdateDTO;
import org.openhab.binding.argoclima.internal.device.passthrough.responses.RemoteGetUiFlgResponseDTO;
import org.openhab.binding.argoclima.internal.device.passthrough.responses.RemoteGetUiFlgResponseDTO.UiFlgResponseCommmands;
import org.openhab.binding.argoclima.internal.exception.ArgoApiCommunicationException;
import org.openhab.binding.argoclima.internal.exception.ArgoRemoteServerStubStartupException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Implements a stub HTTP server which simulates Argo remote APIs
* When used, may alleviate the need for device-side polling
* <p>
* Can work in both full simulation (serving local responses) as well as a pass-through, relaying the traffic back to
* OEM's server, while sniffing it and - optionally - intercepting (ex. to inject a pending command)
* <p>
* Use of this mode requires firewall/routing configuration in such a way that HVAC-originated requests
* targeting Argo remote server are instead targeted at OpenHAB instance!
* <p>
* IMPORTANT: Argo HVAC, even when functioning in full-local mode (controlled directly, via local IP), **requires**
* connection to a "remote" server, and will drop Wi-Fi connection if it doesn't receive a valid protocolar response.
* Hence in order to isolate HVAC from OEM's server, having a simulated/stubbed local API server is required even for
* using the local APIs only
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class RemoteArgoApiServerStub {
/////////////
// TYPES
/////////////
/**
* The type of API request as sent by the device
*
* @implNote The values come from reverse-engineering the communication and base on guesswork (may not be 100%
* correct)
*
* @author Mateusz Bronk - Initial contribution
*/
public enum DeviceRequestType {
/** Purpose unknown */
GET_UI_ACN,
/** Get current time from server */
GET_UI_NTP,
/** Submit current status (in GET param) and get latest command from remote-side */
GET_UI_FLG,
/** UI Update? - NOTE: Not known when the device sends this... */
GET_UI_UPD,
/** Wi-Fi firmware update request */
GET_OU_FW,
/** Unit firmware update request */
GET_UI_FW,
/** Confirm server-side request is fulfilled, respond with extended status */
POST_UI_RT,
/** Unrecognized command type */
UNKNOWN
}
/**
* HTTP request handler, receiving the device-side requests and reacting to them
*
* @author Mateusz Bronk - Initial contribution
*/
public class ArgoDeviceRequestHandler extends AbstractHandler {
private final DeviceSidePasswordDisplayMode includeDeviceSidePasswordsInProperties;
/**
* C-tor
*
* @param includeDeviceSidePasswordsInProperties Whether to include the device-sent passwords as thing
* properties and how (masked vs. cleartext). Note this affects OH display side only. Plain passwords
* are ALWAYS sent to Argo as-is in a passthrough mode!)
*/
public ArgoDeviceRequestHandler(DeviceSidePasswordDisplayMode includeDeviceSidePasswordsInProperties) {
this.includeDeviceSidePasswordsInProperties = includeDeviceSidePasswordsInProperties;
}
/**
* Handle the intercepted request
* <p>
* {@inheritDoc}
*/
@Override
public void handle(@Nullable String target, @Nullable Request baseRequest, @Nullable HttpServletRequest request,
@Nullable HttpServletResponse response) throws IOException, ServletException {
Objects.requireNonNull(target);
Objects.requireNonNull(baseRequest);
Objects.requireNonNull(request);
Objects.requireNonNull(response);
var body = getRequestBodyAsString(baseRequest);
var requestType = detectRequestType(request, body);
// Stage1: Use the sniffed response to update internal state
switch (requestType) {
case GET_UI_FLG:
var updateDto = DeviceSideUpdateDTO.fromDeviceRequest(request,
this.includeDeviceSidePasswordsInProperties);
logger.trace("Got device-side update: {}", updateDto);
deviceApi.ifPresent(x -> {
try {
x.updateDeviceStateFromPushRequest(updateDto);
} catch (ArgoApiCommunicationException e) {
logger.trace(
"Received a GET UI_FLG message from Argo device, but it wasn't a valid protocolar message. Ignoring...");
}
}); // Use for new update
break;
case POST_UI_RT:
var postRtDto = DeviceSidePostRtUpdateDTO.fromDeviceRequestBody(body);
logger.trace("Got device-side POST: {}", postRtDto);
deviceApi.ifPresent(x -> x.updateDeviceStateFromPostRtRequest(postRtDto)); // Use for new update
break;
case GET_UI_NTP:
case UNKNOWN:
default:
break; // other device-side polls do not bring valuable information to update status with
}
// Stage2A: If in pass-through mode, get and forward the upstream response (with possible post-process)
if (passthroughClient.isPresent()) {
if (requestType.equals(DeviceRequestType.UNKNOWN)) {
logger.trace(
"The request received by the Argo server stub has unknown syntax. Not forwarding it to upstream server as a precaution");
// fall-through to default (canned) response
} else {
Optional<ContentResponse> upstreamResponse = Optional.empty();
try {
// CONSIDER: This implementation does NOT do any request pre-processing (ex. scrambling Wi-Fi
// password Argo has no need of knowing). It may be a nice enhancement in the future
upstreamResponse = Optional.of(passthroughClient.get().passthroughRequest(baseRequest, body));
} catch (InterruptedException | TimeoutException | ExecutionException e) {
// Deliberately not handling the upstream request exception here and allowing to fall-through to
// a "response faking" logic
logger.debug("Passthrough client fail: {}", e.getMessage());
}
if (upstreamResponse.isPresent()) { // On upstream request failure, fall back to stubbed response
var overridenBody = postProcessUpstreamResponse(requestType, upstreamResponse.get(), deviceApi);
PassthroughHttpClient.forwardUpstreamResponse(upstreamResponse.get(), response,
Optional.of(overridenBody));
baseRequest.setHandled(true);
return;
}
}
}
// Stage2B: In stub mode, serve a canned response to make the device happy
// The values used are there to simulate the actual server
response.setContentType("text/html");
response.setCharacterEncoding("ASCII");
response.setHeader("Server", "Microsoft-IIS/8.5"); // overrides Jetty's default (can be disabled by
// setSendServerVersion(false))
response.setHeader("Content-type", "text/html");
response.setHeader("X-Powered-By", "PHP/5.4.11");
response.setHeader("Access-Control-Allow-Origin", "*");
response.setStatus(HttpServletResponse.SC_OK);
baseRequest.setHandled(true);
if (baseRequest.getOriginalURI().contains("UI_NTP")) { // a little more lax parsing than request type (just
// in case of syntax variances)
response.getWriter().println(getNtpResponse(Instant.now()));
} else if (deviceApi.isPresent() && DeviceRequestType.GET_UI_FLG.equals(requestType)) {
// handle GET_UI_FLG always feeding "our" status
response.getWriter().println(createSyntheticGetUiFlgResponse(deviceApi.orElseThrow()));
} else {
// all other commands are NOT handled
response.getWriter().println(getFakeResponse(requestType)); // Reply with this canned text to ALL other
// device requests (it doesn't seem to care
// :))
}
}
}
/////////////
// FIELDS
/////////////
private static final String RPC_POOL_NAME = "OH-jetty-" + BINDING_ID + "_serverStub"; // For the new server's
// threadpool
private static final String REMOTE_SERVER_PATH = "/UI/UI.php";
private final Logger logger = LoggerFactory.getLogger(getClass());
private final Set<InetAddress> listenIpAddresses;
private final int listenPort;
private final String id;
private final DeviceSidePasswordDisplayMode includeDeviceSidePasswordsInProperties;
private final ArgoClimaTranslationProvider i18nProvider;
private final Optional<ArgoClimaLocalDevice> deviceApi;
private Optional<Server> server = Optional.empty();
private Optional<PassthroughHttpClient> passthroughClient = Optional.empty();
/**
* C-tor
*
* @param listenIpAddresses The set of IP addresses the server should listen at (one
* {@link org.eclipse.jetty.server.ServerConnector connector} will be created per each)
* @param listenPort The port all connectors should listen on
* @param thingUid The UID of the Thing owning this server (used for logging)
* @param passthroughClient Optional upstream service HTTP client - in stopped state (if provided, will be used for
* pass-through)
* @param deviceApi The current device API state tracked by the binding (used to update state from intercepted
* responses, and injecting commands)
* @param includeDeviceSidePasswordsInProperties Whether to include the device-sent passwords as thing properties
* and how (masked vs. cleartext). Note this does NOT prevent sending these values to Argo servers in a
* pass-through mode (not a remote security feature!)
* @param i18nProvider Framework's translation provider
*/
public RemoteArgoApiServerStub(Set<InetAddress> listenIpAddresses, int listenPort, String thingUid,
Optional<PassthroughHttpClient> passthroughClient, Optional<ArgoClimaLocalDevice> deviceApi,
DeviceSidePasswordDisplayMode includeDeviceSidePasswordsInProperties,
ArgoClimaTranslationProvider i18nProvider) {
this.listenIpAddresses = listenIpAddresses;
this.listenPort = listenPort;
this.id = thingUid;
this.passthroughClient = passthroughClient;
this.deviceApi = deviceApi;
this.includeDeviceSidePasswordsInProperties = includeDeviceSidePasswordsInProperties;
this.i18nProvider = i18nProvider;
}
/**
* Start the stub server (and upstream API client, if used)
*
* @throws ArgoRemoteServerStubStartupException on startup failure (of either the server or the client)
*/
public synchronized void start() throws ArgoRemoteServerStubStartupException {
// High log level is deliberate (it's no small feat to open a new HTTP socket!)
logger.info("[{}] Starting Argo API Stub listening at: {}", this.id,
this.listenIpAddresses.stream().map(x -> String.format("%s:%s", x.toString(), this.listenPort))
.collect(Collectors.joining(", ", "[", "]")));
try {
startJettyServer();
} catch (Exception e) {
server.ifPresent(s -> {
// Cleaning up after ourselves async (as the server may have multiple connectors open and take some time
// to stop, actually)
s.setStopTimeout(1000L);
try {
new Thread() {
@Override
public void run() {
try {
s.stop();
} catch (Exception stopException) {
logger.debug(
"Server startup has failed and subsequent stop has failed as well... Error: {}",
stopException.getMessage());
}
}
}.start();
} catch (Exception stopThreadStartException) {
logger.debug("Server startup has failed and subsequent stop has failed as well... Error: {}",
stopThreadStartException.getMessage());
}
});
throw new ArgoRemoteServerStubStartupException("Server startup failure: {0}",
"thing-status.argoclima.stub-server.start-failure.internal", i18nProvider, e,
e.getLocalizedMessage());
}
if (this.passthroughClient.isPresent()) {
try {
this.passthroughClient.get().start();
} catch (Exception e) {
passthroughClient.ifPresent(s -> {
try {
// Stopping synchronously (as a client that failed startup is anyway not doing anything)
s.stop();
} catch (Exception stopException) {
logger.debug(
"PassthroughClient startup has failed and subsequent stop has failed as well... Error: {}",
stopException.getMessage());
}
});
throw new ArgoRemoteServerStubStartupException(
"Passthrough API client (for host={0}, port={1,number,#}) failed to start: {2}",
"thing-status.argoclima.passtrough-client.start-failure", i18nProvider, e,
this.passthroughClient.get().upstreamTargetHost,
this.passthroughClient.get().upstreamTargetPort, e.getLocalizedMessage());
}
}
}
/**
* Stop the stub server (and upstream API client, if used)
*
* @implNote This swallows exceptions (as we can't do anything meaningful with them at this point, anyway
*/
public synchronized void shutdown() {
if (this.server.isPresent()) {
try {
server.get().stop();
server.get().destroy();
this.server = Optional.empty();
} catch (Exception e) {
logger.debug("Unable to stop Remote Argo API Server Stub (listening on port {}). Error: {}",
this.listenPort, e.getMessage());
}
}
if (this.passthroughClient.isPresent()) {
try {
passthroughClient.get().stop();
passthroughClient = Optional.empty();
} catch (Exception e) {
logger.debug("Unable to stop Remote Argo API Passthrough HTTP client. Error: {}", e.getMessage());
}
}
}
/**
* Creates and starts custom HTTP server for simulating the Argo HTTP server
* The server will listen on port {@link #listenPort} on {@link #listenIpAddresses}
*
* @throws Exception If the server fails to start
*/
private void startJettyServer() throws Exception {
if (this.server.isPresent()) {
server.get().stop();
server.get().destroy();
}
var server = new Server();
this.server = Optional.of(server);
var connectors = this.listenIpAddresses.stream().map(addr -> {
var connector = new ServerConnector(server);
connector.setHost(addr.getHostName());
connector.setPort(this.listenPort);
return connector;
}).toArray(Connector[]::new);
server.setConnectors(connectors);
var tp = server.getThreadPool();
if (tp instanceof QueuedThreadPool qtp) {
qtp.setName(RPC_POOL_NAME);
qtp.setDaemon(true); // Lower our priority (just in case)
}
server.setHandler(new ArgoDeviceRequestHandler(this.includeDeviceSidePasswordsInProperties));
server.start();
}
/**
* Reads the entire request body (ASCII) to a buffer
*
* @param downstreamHttpRequest The request sent by the HVAC device-side
* @return Request body as string
* @throws IOException In case of read errors
*/
public static String getRequestBodyAsString(Request downstreamHttpRequest) throws IOException {
return downstreamHttpRequest.getReader().lines().collect(Collectors.joining(System.lineSeparator()));
}
/**
* Returns the Argo-like NTP response for a date provided as param
*
* @param time The date/time to return in the response
* @return Argo protocol response for NTP request (simulating real server)
*/
private String getNtpResponse(Instant time) {
DateTimeFormatter fmt = DateTimeFormatter
.ofPattern("'NTP 'yyyy-MM-dd'T'HH:mm:ssxxx' UI SERVER (M.A.V. srl)'", Locale.ENGLISH)
.withZone(Objects.requireNonNull(ZoneId.of("GMT")));
return fmt.format(time);
}
/**
* Return a harmless Argo protocol response, causing the device parsing to be happy
*
* @param requestType the request type
*
* @implNote Note this is NOT a valid protocolar response to any particular request, just happens to be good enough
* to keep
* device happy-enough to continue the conversation
* @return The default API response (fake)
*/
private String getFakeResponse(DeviceRequestType requestType) {
switch (requestType) {
case POST_UI_RT:
return "|}|}";
default:
return "{|0|0|1|0|0|0|N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,2,N,592,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N|}[|0|||]ACN_FREE <br>\t\t";
}
}
/**
* Detect the type of incoming request based off of query params (or body, if POST request)
*
* @param request The request
* @param requestBody The request body as string
* @return Parsed request type (or {@link DeviceRequestType#UNKNOWN} if unable to detect)
*/
public DeviceRequestType detectRequestType(HttpServletRequest request, String requestBody) {
logger.trace("Incoming request: {} {}://{}:{}{}?{}", request.getMethod(), request.getScheme(),
request.getLocalAddr(), request.getLocalPort(), request.getPathInfo(), request.getQueryString());
// if (!request.getPathInfo().equalsIgnoreCase(REMOTE_SERVER_PATH)) {
if (!REMOTE_SERVER_PATH.equalsIgnoreCase(request.getPathInfo())) {
logger.debug("Unknown Argo device-side request path {}. Ignoring...", request.getPathInfo());
return DeviceRequestType.UNKNOWN;
}
var command = request.getParameter("CM");
if ("GET".equalsIgnoreCase(request.getMethod())) {
if ("UI_NTP".equalsIgnoreCase(command)) {
return DeviceRequestType.GET_UI_NTP; // Get time: GET /UI/UI.php?CM=UI_NTP (
}
if ("UI_FLG".equalsIgnoreCase(command)) {
return DeviceRequestType.GET_UI_FLG; // Param update: GET
// /UI/UI.php?CM=UI_FLG?USN=%s&PSW=%s&IP=%s&FW_OU=_svn.%s&FW_UI=_svn.%s&CPU_ID=%s&HMI=%s&TZ=%s&SETUP=%s&SERVER_ID=%s
}
if ("UI_UPD".equalsIgnoreCase(command)) {
return DeviceRequestType.GET_UI_UPD; // Unknown: GET /UI/UI.php?CM=UI_UPD?USN=%s&PSW=%s&CPU_ID=%s
}
if ("OU_FW".equalsIgnoreCase(command)) {
return DeviceRequestType.GET_OU_FW;
}
if ("UI_FW".equalsIgnoreCase(command)) {
return DeviceRequestType.GET_UI_FW; // Unit FW update request GET
// /UI/UI.php?CM=UI_FW&PK=%d&USN=%s&PSW=%s&CPU_ID=%s
}
if ("UI_ACN".equalsIgnoreCase(command)) {
return DeviceRequestType.GET_UI_ACN; // Unknown: GET /UI/UI.php?CM=UI_ACN&USN=%s&PSW=%s&CPU_ID=%s
// (AT+CIPSERVER=0?)
}
}
var commandFromBody = new UrlEncoded(requestBody).getString("CM");
if ("UI_RT".equalsIgnoreCase(commandFromBody) && "POST".equalsIgnoreCase(request.getMethod())) {
return DeviceRequestType.POST_UI_RT; // Unknown: POST /UI/UI.php body:
// CM=UI_RT&USN=%s&PSW=%s&CPU_ID=%s&DEL=%d&DATA=
// WiFi_Psw=UserName=Password=ServerID=TimeZone=uisetup.ddns.net |
// www.termauno.com | 95.254.67.59
}
logger.debug("Unknown command: CM(query)=[{}], CM(body)=[{}]", command, commandFromBody);
return DeviceRequestType.UNKNOWN;
}
/**
* Post-process the upstream response (injecting any our pending commands to the response)
*
* @param requestType The original request type
* @param upstreamResponse The original upstream response
* @param deviceApi The Argo device API tracked by this binding (channels and commands)
* @return Post-processed response body
*/
private String postProcessUpstreamResponse(DeviceRequestType requestType, ContentResponse upstreamResponse,
Optional<ArgoClimaLocalDevice> deviceApi) {
var originalResponseBody = Objects.requireNonNull(upstreamResponse.getContentAsString());
if (upstreamResponse.getStatus() != 200) {
logger.trace(
"Remote server response for {} command had HTTP status {}. Not parsing further & won't intercept",
requestType, upstreamResponse.getStatus());
return originalResponseBody;
}
switch (requestType) {
case GET_UI_FLG: // Only intercepting GET_UI_FLG response
var responseDto = RemoteGetUiFlgResponseDTO
.fromResponseString(Objects.requireNonNull(upstreamResponse.getContentAsString()));
deviceApi.ifPresent(api -> {
if (api.hasPendingCommands() && responseDto.preamble.flag5hasNewUpdate == 0) { // Will hijack
// body only if
// web-side
// didn't have
// anything for
// us on its own
String before = "";
if (logger.isTraceEnabled()) {
before = responseDto.toResponseString();
}
responseDto.preamble.flag0requestPostUiRt = 1; // Request POST confirmation of having
// applied this config (as we don't want to
// wait too long)
responseDto.preamble.flag5hasNewUpdate = 1; // Indicate this request carries new content
// Full replace of cloud-side commands (note we could *merge* with it, but seems to be an
// overkill)
responseDto.commands = UiFlgResponseCommmands.fromResponseString(api.getCurrentCommandString());
if (logger.isTraceEnabled()) {
var after = responseDto.toResponseString();
logger.trace("REPLACING the response body from [{}] to [{}]", before, after);
}
api.notifyCommandsPassedToDevice(); // Notify the withstanding commands have been consumed by
// the device
}
});
return responseDto.toResponseString();
case POST_UI_RT:
case GET_UI_NTP:
case UNKNOWN:
default:
return originalResponseBody;
}
}
/**
* Create a synthetic response to GET UI_FLG device-side request (factoring current device state)
* <p>
* This is *always* sending the {@code ACN_FREE <br>
* \t\t} suffix (seems to be a Connection:close equivalent of sorts). The real Argo server doesn't do that on all
* occasions and tends to keep the connection open, though implementing this way seems working (and more robust not
* to have to manage server-side socket lifespan)
*
* @param devApi The device API
* @return UI_FLG response body (string)
*/
private String createSyntheticGetUiFlgResponse(ArgoClimaLocalDevice devApi) {
var responseDto = new RemoteGetUiFlgResponseDTO();
if (devApi.hasPendingCommands()) {
responseDto.preamble.flag0requestPostUiRt = 1;
responseDto.preamble.flag5hasNewUpdate = 1;
}
responseDto.commands = UiFlgResponseCommmands.fromResponseString(devApi.getCurrentCommandString());
devApi.notifyCommandsPassedToDevice(); // Notify the withstanding commands are about to be consumed by the
// device
// responseDto.acnSuffix is always the default (see impl. note)
return responseDto.toResponseString();
}
}

View File

@ -0,0 +1,102 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.argoclima.internal.device.passthrough.requests;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.util.MultiMap;
import org.eclipse.jetty.util.UrlEncoded;
/**
* Device's update - sent from AC to manufacturer's remote server (via POST ...CM=UI_RT command)
*
* @implNote These updates seem to only be sent if requested by the remote side (when the response to {@code GET UI_FLG}
* contains a
* {@link org.openhab.binding.argoclima.internal.device.passthrough.responses.RemoteGetUiFlgResponseDTO.UiFlgResponsePreamble#flag0requestPostUiRt
* flag0requestPostUiRt} bit set in the preamble}
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class DeviceSidePostRtUpdateDTO {
/** The name of the POST command carried in body. Seems fixed to {@code UI_RT} for this format */
public final String command;
/** The username for the remote server (and hence the UI) */
public final String username;
/** A MD5 hash of password to the remote server (and hence the UI) */
public final String passwordHash;
/** The CPU_ID (unique and immutable HVAC identifier) send by the device */
public final String cpuId;
/** Unknown purpose, seems to be set to 1 in all requests observed. DEL is for delta? */
public final String delParam;
/**
* Unknown format - has multiple comma-separated values, and looks like a massive superset of the HMI string
* typically sent (156 values vs 39)
*
* @implNote The ordering of values is different from the HMI string, though there are similarities. Since it
* doesn't seem to carry anything immediately obvious or attractive, this is not being parsed at this
* point. Likely conveys all the "schedule" settings as well as configuration parameters though...
*/
public final String dataParam;
/**
* Private c-tor (from response body, which seems to be URL-encoded query-like param set)
*
* @param bodyArgumentMap The payload, decomposed into K->V map
*/
private DeviceSidePostRtUpdateDTO(Map<String, String> bodyArgumentMap) {
this.command = Objects.requireNonNullElse(bodyArgumentMap.get("CM"), "");
this.username = Objects.requireNonNullElse(bodyArgumentMap.get("USN"), "");
this.passwordHash = Objects.requireNonNullElse(bodyArgumentMap.get("PSW"), "");
this.cpuId = Objects.requireNonNullElse(bodyArgumentMap.get("CPU_ID"), "");
this.delParam = Objects.requireNonNullElse(bodyArgumentMap.get("DEL"), "");
this.dataParam = Objects.requireNonNullElse(bodyArgumentMap.get("DATA"), "");
}
/**
* Named c-tor (constructs this DTO from device-side request body)
*
* @implNote Headers or URL do not seem to carry any meaningful (variable) information, hence not parsing them
* @implNote This class does only shallow parsing for now (ex. does not decode the 'data' element to an array)
* @param requestBody The body of the device-side request to parse
* @return Pre-parsed DTO
*/
public static DeviceSidePostRtUpdateDTO fromDeviceRequestBody(String requestBody) {
var paramsParsed = new MultiMap<@Nullable String>(); // @Nullable here due to UrlEncoded API
UrlEncoded.decodeTo(requestBody, paramsParsed, StandardCharsets.US_ASCII);
Map<String, String> flattenedParams = paramsParsed.keySet().stream().collect(TreeMap::new,
(m, v) -> m.put(Objects.requireNonNull(v), Objects.requireNonNull(paramsParsed.getString(v))),
TreeMap::putAll);
return new DeviceSidePostRtUpdateDTO(flattenedParams);
}
@Override
public String toString() {
return String.format(
"Device-side POST update:\n\tCommand=%s,\n\tCredentials=[username=%s, password(MD5)=%s],\n\tCPU_ID=%s,\n\tDEL=%s,\n\tDATA=%s.",
this.command, this.username, this.passwordHash, this.cpuId, this.delParam, this.dataParam);
}
}

View File

@ -0,0 +1,285 @@
/**
* 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.argoclima.internal.device.passthrough.requests;
import java.nio.BufferUnderflowException;
import java.nio.ByteBuffer;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.TreeMap;
import javax.servlet.http.HttpServletRequest;
import javax.xml.bind.DatatypeConverter;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.argoclima.internal.configuration.ArgoClimaConfigurationLocal.DeviceSidePasswordDisplayMode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Device's update - sent from AC to manufacturer's remote server (via GET ...?CM=GET_UI_FLG command)
* <p>
* These are the most common updates the device sends routinely to the vendor server
*
* @implNote The "SETUP" part is a particular goldmine for interesting stuff not available anywhere else
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class DeviceSideUpdateDTO {
/////////////
// TYPES
/////////////
/**
* Provides parsing of "SETUP" part of query string, which seems to be base-16 encoded binary blob
*
* @implNote The values are based on guesswork and reverse engineering. Notable unknowns:
* - 4-byte value on bits 128-131 (TZ config?)
* - 20-byte value on bits 176-195 (?? - some value seems to be embedded on bytes 4..15 of it)
* - 34-byte value on bits 222-255 (?? - seem to be some reserved field + trailing padding "ABCD..u ")
*
* @author Mateusz Bronk - Initial contribution
*/
public class UiFlgSetupParam {
/** The value as sent by the device (Base16) */
public final String rawString;
/** The binary value upon conversion (empty on conversion failure) */
private Optional<byte[]> bytes = Optional.empty();
/** The Wi-Fi SSID embedded on bytes 0..31 of the blob (or empty - on parse failure) */
public Optional<String> wifiSSID = Optional.empty();
/** The Wi-Fi password embedded on bytes 32..63 of the blob (or empty - on parse failure) */
public Optional<String> wifiPassword = Optional.empty();
/** The UI username embedded on bytes 64..79 of the blob (or empty - on parse failure) */
public Optional<String> username = Optional.empty();
/** The UI password embedded on bytes 80..111 of the blob (or empty - on parse failure) */
public Optional<String> password = Optional.empty();
/** The local IPv4 of the device embedded on bytes 112-127 of the blob (or empty - on parse failure) */
public Optional<String> localIP = Optional.empty();
/**
* The installed(?) Wi-Fi firmware version embedded on bytes 132-137 of the blob (or empty - on parse failure)
*/
public Optional<String> wifiVersionInstalled = Optional.empty();
/**
* The available(?) Wi-Fi firmware version embedded on bytes 138-143 of the blob (or empty - on parse failure)
*/
public Optional<String> wifiVersionAvailable = Optional.empty();
/**
* The installed(?) Unit firmware version embedded on bytes 164-169 of the blob (or empty - on parse failure)
*/
public Optional<String> unitVersionInstalled = Optional.empty();
/**
* The available(?) Unit firmware version embedded on bytes 170-175 of the blob (or empty - on parse failure)
*/
public Optional<String> unitVersionAvailable = Optional.empty();
/**
* ISO-formated local time (ending with whitespace) embedded on bytes 196-221 of the blob (or empty - on parse
* failure)
*
* @implNote Parsed as a 32-byte array for simplicity sake
*/
public Optional<String> localTime = Optional.empty();
/**
* C-tor
*
* @param rawString The raw 'setup' param string send by device
* @param includeDeviceSidePasswordsInProperties Whether to include the device-sent passwords as thing
* properties and how (masked with {@code ***} vs. cleartext). Note this is not a security feature
* (passwords are still sent!)
*/
public UiFlgSetupParam(String rawString, DeviceSidePasswordDisplayMode includeDeviceSidePasswordsInProperties) {
this.rawString = rawString;
try {
this.bytes = Optional.ofNullable(DatatypeConverter.parseHexBinary(rawString));
var bb = ByteBuffer.wrap(this.bytes.orElseThrow());
// helper structures to parse with
var byte32arr = new byte[32];
var byte16arr = new byte[16];
var byte6arr = new byte[6];
bb.get(byte32arr);
this.wifiSSID = Optional.of(new String(byte32arr).trim());
bb.get(byte32arr);
switch (includeDeviceSidePasswordsInProperties) {
case CLEARTEXT:
this.wifiPassword = Optional.of(new String(byte32arr).trim()); // yep, it is passed through to
// vendor's servers **as
// plaintext**. Over plain HTTP!
// :///
break;
case MASKED:
this.wifiPassword = Optional.of(new String(byte32arr).trim().replaceAll(".", "*"));
break;
case NEVER:
default:
this.wifiPassword = Optional.empty();
break;
}
bb.position(0x40);
bb.get(byte16arr);
this.username = Optional.of(new String(byte16arr).trim());
bb.get(byte32arr);
switch (includeDeviceSidePasswordsInProperties) {
case CLEARTEXT:
this.password = Optional.of(new String(byte32arr).trim());
break;
case MASKED:
this.password = Optional.of(new String(byte32arr).trim().replaceAll(".", "*"));
break;
case NEVER:
default:
this.password = Optional.empty();
break;
}
bb.get(byte16arr);
this.localIP = Optional.of(new String(byte16arr).trim());
bb.position(0x84);
bb.get(byte6arr);
this.wifiVersionInstalled = Optional.of(new String(byte6arr).trim());
bb.get(byte6arr);
this.wifiVersionAvailable = Optional.of(new String(byte6arr).trim());
bb.position(0xa4);
bb.get(byte6arr);
this.unitVersionInstalled = Optional.of(new String(byte6arr).trim());
bb.get(byte6arr);
this.unitVersionAvailable = Optional.of(new String(byte6arr).trim());
bb.position(0xc4);
bb.get(byte32arr);
this.localTime = Optional.of(new String(byte32arr).trim());
} catch (IllegalArgumentException | BufferUnderflowException ex) {
logger.trace("Unrecognized device setup string: {}. Exception: {}", rawString, ex.getMessage());
this.bytes = Optional.empty(); // Removing raw bytes just to indicate we failed parsing somewhere
}
}
@Override
public String toString() {
return String.format(
"Device-side setup data:\n\twifi=[SSID=%s, password=%s],\n\tUser:password=%s:%s,\n\tIP=%s,\n\tWifi FW=[Installed=%s | Available=%s],\n\tUnit FW=[Installed=%s | Available=%s]\n\tLocal time=%s",
this.wifiSSID.orElse("???"), this.wifiPassword.orElse("???"), this.username.orElse("???"),
this.password.orElse("???"), this.localIP.orElse("???"), this.wifiVersionInstalled.orElse("???"),
this.wifiVersionAvailable.orElse("???"), this.unitVersionInstalled.orElse("???"),
this.unitVersionAvailable.orElse("???"), this.localTime.orElse("???"));
}
}
/////////////
// FIELDS
/////////////
private final Logger logger = LoggerFactory.getLogger(getClass());
/** The {@code CM} part of the request. Seems to be fixed to {@code UI_FLG} for this format */
public final String command;
/** The {@code USN} part of the request. Carries the web UI username */
public final String username;
/** The {@code PSW} part of the request. Carries a MD5 of web UI password */
public final String passwordHash;
/** The {@code IP} part of the request. Carries a local IP of the HVAC device */
public final String deviceIp;
/** The {@code FW_OU} part of the request. Carries current version of unit's firmware */
public final String unitFirmware;
/** The {@code FW_UI} part of the request. Carries current version of Wi-Fi firmware */
public final String wifiFirmware;
/** The {@code CPU_ID} part of the request. Carries a unique HVAC device chip ID */
public final String cpuId;
/**
* The {@code HMI} part of the request. Carries current status of the HVAC and may be parsed using
* {@link org.openhab.binding.argoclima.internal.device.api.protocol.ArgoDeviceStatus#fromDeviceString(String) }
*/
public final String currentValues;
/** The {@code TZ} part of the request. Carries device's local timezone(?) */
public final String timezoneId;
/** The {@code SETUP} part of the request. Carries rich setup data, including passwords etc. */
public final UiFlgSetupParam setup;
/** The {@code SERVER_ID} part of the request. Carries a the vendor's remote server DNS name */
public final String remoteServerId;
/**
* Private c-tor (from pre-parsed request)
*
* @param parameterMap The body parameters converted to a K->V map
* @param includeDeviceSidePasswordsInProperties Whe. Note this is not a security feature (passwords are still
* sent!)
*/
private DeviceSideUpdateDTO(Map<String, String> parameterMap,
DeviceSidePasswordDisplayMode includeDeviceSidePasswordsInProperties) {
this.command = Objects.requireNonNullElse(parameterMap.get("CM"), "");
this.username = Objects.requireNonNullElse(parameterMap.get("USN"), "");
this.passwordHash = Objects.requireNonNullElse(parameterMap.get("PSW"), "");
this.deviceIp = Objects.requireNonNullElse(parameterMap.get("IP"), "");
this.unitFirmware = Objects.requireNonNullElse(parameterMap.get("FW_OU"), "");
this.wifiFirmware = Objects.requireNonNullElse(parameterMap.get("FW_UI"), "");
this.cpuId = Objects.requireNonNullElse(parameterMap.get("CPU_ID"), "");
this.currentValues = Objects.requireNonNullElse(parameterMap.get("HMI"), "");
this.timezoneId = Objects.requireNonNullElse(parameterMap.get("TZ"), "");
this.setup = new UiFlgSetupParam(Objects.requireNonNullElse(parameterMap.get("SETUP"), ""),
includeDeviceSidePasswordsInProperties);
this.remoteServerId = Objects.requireNonNullElse(parameterMap.get("SERVER_ID"), "");
}
/**
* Named c-tor (from device-side request)
*
* @param request The request sent by the device
* @param includeDeviceSidePasswordsInProperties If true, do not mask passwords the device sends with {@code ***}
* Note this is not a security feature (passwords are still sent!)
* @return Parsed DTO
*/
public static DeviceSideUpdateDTO fromDeviceRequest(HttpServletRequest request,
DeviceSidePasswordDisplayMode includeDeviceSidePasswordsInProperties) {
Map<String, String> flattenedParams = request.getParameterMap().entrySet().stream()
.collect(TreeMap::new,
(m, v) -> m.put(Objects.requireNonNull(v.getKey()),
(v.getValue().length < 1) ? "" : Objects.requireNonNull(v.getValue()[0])),
TreeMap::putAll);
return new DeviceSideUpdateDTO(flattenedParams, includeDeviceSidePasswordsInProperties);
}
@Override
public String toString() {
return String.format(
"Device-side update:\n\tCommand=%s,\n\tCredentials=[username=%s, password(MD5)=%s],\n\tIP=%s,\n\tFW=[Unit=%s | Wifi=%s],\n\tCPU_ID=%s,\n\tParameters=%s,\n\tSetup={%s},\n\tRemoteServer=%s.",
this.command, this.username, this.passwordHash, this.deviceIp, this.unitFirmware, this.wifiFirmware,
this.cpuId, this.currentValues, this.setup.toString().replaceAll("(?m)^", "\t"), this.remoteServerId);
}
}

View File

@ -0,0 +1,330 @@
/**
* 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.argoclima.internal.device.passthrough.responses;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.argoclima.internal.device.api.protocol.ArgoDeviceStatus;
/**
* Cloud-side response to GET UI_FLG command - sent from manufacturer's remote server back to HVAC
*
* @implNote Example full response is like
* {@code {|1|0|1|0|0|0|N,N,N,2,0,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N|}[|0|||]]}
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class RemoteGetUiFlgResponseDTO {
/////////////
// TYPES
/////////////
/**
* The preamble part of the response containing flags
*
* @implNote Example: {@code |1|0|1|0|0|0|}
* @author Mateusz Bronk - Initial contribution
*/
public static final class UiFlgResponsePreamble {
static final Pattern PREAMBLE_RX = Pattern.compile("^[|](\\d[|]){6}$");
/** Request the HVAC to send an immediate update via POST UI_RT (used on cloud-side updates) */
public int flag0requestPostUiRt = 0;
/** Unknown purpose, always zero */
public int flag1alwaysZero = 0;
/** Unknown purpose, always one */
public int flag2alwaysOne = 1;
/** Request to update Wi-Fi firmware of the device */
public int flag3updateWifiFW = 0;
/** Request to update Unit firmware of the device */
public int flag4updateUnitFW = 0;
/** Cloud has new updates for the device - request to apply (silently, with no beep) */
public int flag5hasNewUpdate = 0;
/**
* Default C-tor (empty, if constructed vanilla)
*/
public UiFlgResponsePreamble() {
}
/**
* Private c-tor (from pre-parsed preamble headers)
*
* @param flags Parsed preamble
*/
private UiFlgResponsePreamble(final List<Integer> flags) {
if (flags.size() != 6) {
throw new IllegalArgumentException("flags");
}
this.flag0requestPostUiRt = flags.get(0); // When Device sends DEL=1, remote API requests it
this.flag1alwaysZero = flags.get(1);
this.flag2alwaysOne = flags.get(2);
this.flag3updateWifiFW = flags.get(3);
this.flag4updateUnitFW = flags.get(4);
this.flag5hasNewUpdate = flags.get(5);
}
/**
* Named c-tor
*
* @param preambleString The preamble string from parsed response
* @return This DTO
*/
public static UiFlgResponsePreamble fromResponseString(String preambleString) {
// Preamble: |1|0|1|1|0|0|
if (!PREAMBLE_RX.matcher(preambleString).matches()) {
throw new IllegalArgumentException("preambleString");
}
var flags = Stream.of(preambleString.substring(1).split("[|]")).<Integer> map(Integer::parseInt)
.collect(Collectors.toUnmodifiableList());
return new UiFlgResponsePreamble(flags);
}
/**
* Converts internal representation back to Argo-compatible preamble
*
* @return Preamble in proto-friendly format
*/
public String toResponseString() {
return String.format("|%d|%d|%d|%d|%d|%d|", flag0requestPostUiRt, flag1alwaysZero, flag2alwaysOne,
flag3updateWifiFW, flag4updateUnitFW, flag5hasNewUpdate);
}
}
/**
* The "body" of the response (36-element), compatible with HMI command syntax produced by
* {@link ArgoDeviceStatus#getDeviceCommandStatus()}
*
* @implNote Example: {@code N,N,N,2,0,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N}
* @author Mateusz Bronk - Initial contribution
*/
public static final class UiFlgResponseCommmands {
private final List<String> commands;
/**
* Default C-tor (empty, if constructed vanilla)
*/
public UiFlgResponseCommmands() {
commands = new ArrayList<String>(Objects.requireNonNull(
Collections.nCopies(ArgoDeviceStatus.HMI_COMMAND_ELEMENT_COUNT, ArgoDeviceStatus.NO_VALUE)));
}
/**
* Private c-tor (from pre-parsed "body")
*
* @param commands The device commands to execute (HMI-like syntax)
*/
private UiFlgResponseCommmands(List<String> commands) {
if (commands.size() != ArgoDeviceStatus.HMI_COMMAND_ELEMENT_COUNT) {
throw new IllegalArgumentException("commands");
}
this.commands = new ArrayList<>(commands);
}
/**
* Named c-tor
*
* @param commandString The "command" part of parsed response
* @return This DTO
*/
public static UiFlgResponseCommmands fromResponseString(String commandString) {
var values = commandString.split(ArgoDeviceStatus.HMI_ELEMENT_SEPARATOR);
if (values.length != ArgoDeviceStatus.HMI_COMMAND_ELEMENT_COUNT) {
throw new IllegalArgumentException("commandString");
}
return new UiFlgResponseCommmands(Arrays.asList(values));
}
/**
* Converts internal representation back to Argo-compatible body
*
* @return Commands in proto-friendly format
*/
public String toResponseString() {
return String.join(ArgoDeviceStatus.HMI_ELEMENT_SEPARATOR, commands);
}
}
/**
* The "suffix" part of the response (of unknown purpose)
*
* @implNote Example: {@code [|0|||]}
* @author Mateusz Bronk - Initial contribution
*/
public static final class UiFlgResponseUpd {
static final String CANNED_RESPONSE = "[|0|||]";
final String contents;
/**
* Default C-tor (empty, if constructed vanilla)
*/
public UiFlgResponseUpd() {
this.contents = CANNED_RESPONSE;
}
/**
* Private c-tor (from pre-parsed "body")
*
* @param contents Actual postamble contents (pre-parsed)
*/
private UiFlgResponseUpd(String contents) {
this.contents = contents;
}
/**
* Named c-tor
*
* @param updString The actual UPD (postamble) string sent
* @return This DTO
*/
public static UiFlgResponseUpd fromResponseString(String updString) {
return new UiFlgResponseUpd(updString);
}
/**
* Converts internal representation back to Argo-compatible postamble
*
* @return Postamble in proto-friendly format
*/
public String toResponseString() {
return contents;
}
}
/**
* The trailing part of the response (seems to be included as a server indicating something to the effect of
* 'Connection: Close')
*
* @implNote Example: {@code ACN_FREE <br>\t\t}
* @author Mateusz Bronk - Initial contribution
*/
public static final class UiFlgResponseACN {
static final String CANNED_RESPONSE = "ACN_FREE <br>\t\t";
final String contents;
/**
* Default C-tor (do a connection close)
*/
public UiFlgResponseACN() {
this.contents = CANNED_RESPONSE;
}
/**
* Private c-tor (from parsed part of response)
*
* @param contents The pre-parsed suffix
*/
private UiFlgResponseACN(String contents) {
this.contents = contents;
}
/**
* Named c-tor (from raw response)
*
* @param updString The trailing part of response
* @return This DTO
*/
public static UiFlgResponseACN fromResponseString(String updString) {
return new UiFlgResponseACN(updString);
}
/**
* Converts internal representation back to Argo-compatible suffix
*
* @return Suffix in proto-friendly format
*/
public String toResponseString() {
return contents;
}
}
/////////////
// FIELDS
/////////////
static final Pattern GET_UI_FLG_RESPONSE_PATTERN = Pattern.compile(
"^[\\{](?<preamble>([|]\\d)+[|])(?<commands>[^|]+)[|][\\}](?<updsuffix>\\[[^\\]]+\\])(?<acn>.*$)",
Pattern.CASE_INSENSITIVE);
static final String RESPONSE_FORMAT = "{%s%s|}%s%s";
public UiFlgResponsePreamble preamble;
public UiFlgResponseCommmands commands;
public UiFlgResponseUpd updSuffix;
public UiFlgResponseACN acnSuffix;
/**
* Default c-tor (synthetic response)
*/
public RemoteGetUiFlgResponseDTO() {
this.preamble = new UiFlgResponsePreamble();
this.commands = new UiFlgResponseCommmands();
this.updSuffix = new UiFlgResponseUpd();
this.acnSuffix = new UiFlgResponseACN();
}
/**
* Private c-tor (from pre-parsed actual response)
*
* @param preamble The preamble part of actual response
* @param commands The command part of actual response
* @param updSuffix The postamble part of actual response
* @param acnSuffix The connection suffix part of actual response
*/
private RemoteGetUiFlgResponseDTO(UiFlgResponsePreamble preamble, UiFlgResponseCommmands commands,
UiFlgResponseUpd updSuffix, UiFlgResponseACN acnSuffix) {
this.preamble = preamble;
this.commands = commands;
this.updSuffix = updSuffix;
this.acnSuffix = acnSuffix;
}
/**
* Named c-for (from actual upstream response)
*
* @param getUiFlgResponse The response body
* @return This DTO
*/
public static RemoteGetUiFlgResponseDTO fromResponseString(String getUiFlgResponse) {
var matcher = GET_UI_FLG_RESPONSE_PATTERN.matcher(getUiFlgResponse);
if (!matcher.matches()) {
throw new IllegalArgumentException("getUiFlgResponse");
}
return new RemoteGetUiFlgResponseDTO(
UiFlgResponsePreamble.fromResponseString(Objects.requireNonNull(matcher.group("preamble"))),
UiFlgResponseCommmands.fromResponseString(Objects.requireNonNull(matcher.group("commands"))),
UiFlgResponseUpd.fromResponseString(Objects.requireNonNull(matcher.group("updsuffix"))),
UiFlgResponseACN.fromResponseString(Objects.requireNonNull(matcher.group("acn"))));
}
/**
* Converts internal representation back to Argo-compatible suffix
*
* @return UI_FLG response body in proto-friendly format
*/
public String toResponseString() {
return String.format(RESPONSE_FORMAT, this.preamble.toResponseString(), this.commands.toResponseString(),
this.updSuffix.toResponseString(), this.acnSuffix.toResponseString());
}
}

View File

@ -0,0 +1,70 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.argoclima.internal.exception;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.argoclima.internal.ArgoClimaTranslationProvider;
/**
* The class {@code ArgoApiCommunicationException} is thrown in case of any issues with communication with the Argo HVAC
* device (incl. indirect communication, via sniffing)
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class ArgoApiCommunicationException extends ArgoLocalizedException {
private static final long serialVersionUID = -6618438267962155601L;
public ArgoApiCommunicationException(String defaultMessage, String localizedMessageKey,
ArgoClimaTranslationProvider i18nProvider, @Nullable Throwable cause,
Object @Nullable... messageFormatArguments) {
super(defaultMessage, localizedMessageKey, i18nProvider, cause, messageFormatArguments);
}
public ArgoApiCommunicationException(String defaultMessage, String localizedMessageKey,
ArgoClimaTranslationProvider i18nProvider, @Nullable Throwable cause) {
super(defaultMessage, localizedMessageKey, i18nProvider, cause);
}
public ArgoApiCommunicationException(String defaultMessage, String localizedMessageKey,
ArgoClimaTranslationProvider i18nProvider, Object @Nullable... messageFormatArguments) {
super(defaultMessage, localizedMessageKey, i18nProvider, messageFormatArguments);
}
public ArgoApiCommunicationException(String defaultMessage, String localizedMessageKey,
ArgoClimaTranslationProvider i18nProvider) {
super(defaultMessage, localizedMessageKey, i18nProvider);
}
/**
* {@inheritDoc}
*
* @implNote We want cause of all these exceptions included in the message by default
*/
@Override
public @Nullable String getMessage() {
return super.getMessage(true);
}
/**
* {@inheritDoc}
*
* @implNote We want cause of all these exceptions included in the message by default
*/
@Override
public @Nullable String getLocalizedMessage() {
return super.getLocalizedMessage(true);
}
}

View File

@ -0,0 +1,35 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.argoclima.internal.exception;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The class {@code ArgoApiProtocolViolationException} is thrown for if any API protocol violation occurs. These errors
* are rare and not propagated to the end-user directly (as Thing status), so not localized
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class ArgoApiProtocolViolationException extends Exception {
private static final long serialVersionUID = -3438043281963104252L;
public ArgoApiProtocolViolationException(String message) {
super(message);
}
public ArgoApiProtocolViolationException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,150 @@
/**
* 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.argoclima.internal.exception;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.argoclima.internal.ArgoClimaTranslationProvider;
/**
* The class {@code ArgoConfigurationException} is thrown in case of any configuration-related issue (ex. invalid value
* format)
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class ArgoConfigurationException extends ArgoLocalizedException {
private static final long serialVersionUID = 174501670495658964L;
public final @Nullable String rawValue;
private ArgoConfigurationException(String paramName, @Nullable String paramValue, String defaultMessage,
String localizedMessageKey, @Nullable ArgoClimaTranslationProvider i18nProvider,
Object @Nullable... messageFormatArguments) {
super(defaultMessage, localizedMessageKey, i18nProvider, messageFormatArguments);
this.rawValue = paramValue;
}
private ArgoConfigurationException(String paramName, @Nullable String paramValue, String defaultMessage,
String localizedMessageKey, @Nullable ArgoClimaTranslationProvider i18nProvider, Throwable cause,
Object @Nullable... messageFormatArguments) {
super(defaultMessage, localizedMessageKey, i18nProvider, cause, messageFormatArguments);
this.rawValue = paramValue;
}
/**
* Named c-tor: for all kinds of invalid params (caused by underlying exception)
*
* @param <T> Type of param
* @param paramName Config key of param
* @param paramValue Config value
* @param i18nProvider Framework's translation provider
* @param cause Inner cause
* @return new {@code ArgoConfigurationException}
*/
public static <@NonNull T> ArgoConfigurationException forInvalidParamValue(String paramName, T paramValue,
@Nullable ArgoClimaTranslationProvider i18nProvider, Throwable cause) {
return new ArgoConfigurationException(paramName, paramValue.toString(), "Invalid \"{0}\" value: {1}",
"thing-status.argoclima.configuration.invalid-format", i18nProvider, cause, paramName, paramValue);
}
/**
* Named c-tor: for empty required params
*
* @param paramName Config key of param
* @param i18nProvider Framework's translation provider
* @return new {@code ArgoConfigurationException}
*/
public static ArgoConfigurationException forEmptyRequiredParam(String paramName,
@Nullable ArgoClimaTranslationProvider i18nProvider) {
return new ArgoConfigurationException(paramName, "", "\"{0}\" is empty",
"thing-status.argoclima.configuration.empty-value", i18nProvider, paramName);
}
/**
* Named c-tor: For numeric params which are out of range
*
* @param <T> Type of param (numeric)
* @param paramName Config key of param
* @param paramValue Config value
* @param i18nProvider Framework's translation provider
* @param rangeBegin Min value (inclusive)
* @param rangeEnd Max value (inclusive)
* @return new {@code ArgoConfigurationException}
*/
public static <@NonNull T extends Number> ArgoConfigurationException forParamOutOfRange(String paramName,
T paramValue, @Nullable ArgoClimaTranslationProvider i18nProvider, T rangeBegin, T rangeEnd) {
return new ArgoConfigurationException(paramName, paramValue.toString(),
"\"{0}\" must be in range [{1,number,#}..{2,number,#}]",
"thing-status.argoclima.configuration.value-not-in-range", i18nProvider, paramName, rangeBegin,
rangeEnd);
}
/**
* Named c-tor: For numeric params which are below minimum value
*
* @param <T> Type of param (numeric)
* @param paramName Config key of param
* @param paramValue Config value
* @param i18nProvider Framework's translation provider
* @param minValue Min value (inclusive)
* @return new {@code ArgoConfigurationException}
*/
public static <@NonNull T extends Number> ArgoConfigurationException forParamBelowMin(String paramName,
T paramValue, @Nullable ArgoClimaTranslationProvider i18nProvider, T minValue) {
return new ArgoConfigurationException(paramName, paramValue.toString(), "\"{0}\" must be >= {1,number,#}",
"thing-status.argoclima.configuration.value-below-min", i18nProvider, paramName, minValue);
}
/**
* Named c-tor: For interdependent parameters that caused conflict
*
* @param <T1> Type of 1st param
* @param <T2> Value of 1st param
* @param paramName Config key of 1st param
* @param paramValue Config value of 1st param
* @param conflictingParamName Config key of 2nd param
* @param conflictingParamValue Config value of 2nd param
* @param i18nProvider Framework's translation provider
* @return new {@code ArgoConfigurationException}
*/
public static <@NonNull T1, @NonNull T2> ArgoConfigurationException forConflictingParams(String paramName,
T1 paramValue, String conflictingParamName, T2 conflictingParamValue,
@Nullable ArgoClimaTranslationProvider i18nProvider) {
return new ArgoConfigurationException(paramName, paramValue.toString(),
"Cannot set \"{0}\" to {1}, when \"{2}\" is {3}",
"thing-status.argoclima.configuration.invalid-combination", i18nProvider, paramName, paramValue,
conflictingParamName, conflictingParamValue);
}
/**
* {@inheritDoc}
*
* @implNote We want cause of all these exceptions included in the message by default
*/
@Override
public @Nullable String getMessage() {
return super.getMessage(true);
}
/**
* {@inheritDoc}
*
* @implNote We want cause of all these exceptions included in the message by default
*/
@Override
public @Nullable String getLocalizedMessage() {
return super.getLocalizedMessage(true);
}
}

View File

@ -0,0 +1,140 @@
/**
* 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.argoclima.internal.exception;
import java.text.MessageFormat;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.argoclima.internal.ArgoClimaTranslationProvider;
/**
* Base for localized exceptions (their messages are used for thing status)
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class ArgoLocalizedException extends Exception {
private static final long serialVersionUID = 8729362177716420196L;
protected final @Nullable ArgoClimaTranslationProvider i18nProvider;
private final String localizedMessageKey;
private final List<@Nullable Object> localizedMessageParams; // using list for null annotations to be
// happy
protected ArgoLocalizedException(String defaultMessage, String localizedMessageKey,
@Nullable ArgoClimaTranslationProvider i18nProvider, @Nullable Throwable cause,
Object @Nullable... messageFormatArguments) {
super(MessageFormat.format(defaultMessage, messageFormatArguments), cause);
this.localizedMessageKey = localizedMessageKey;
this.localizedMessageParams = Arrays.asList(messageFormatArguments);
this.i18nProvider = i18nProvider;
}
protected ArgoLocalizedException(String defaultMessage, String localizedMessageKey,
@Nullable ArgoClimaTranslationProvider i18nProvider, @Nullable Throwable cause) {
super(defaultMessage, cause);
this.localizedMessageKey = localizedMessageKey;
this.localizedMessageParams = List.<@Nullable Object> of();
this.i18nProvider = i18nProvider;
}
protected ArgoLocalizedException(String defaultMessage, String localizedMessageKey,
@Nullable ArgoClimaTranslationProvider i18nProvider, Object @Nullable... messageFormatArguments) {
this(defaultMessage, localizedMessageKey, i18nProvider, (Throwable) null, messageFormatArguments);
}
protected ArgoLocalizedException(String defaultMessage, String localizedMessageKey,
@Nullable ArgoClimaTranslationProvider i18nProvider) {
this(defaultMessage, localizedMessageKey, i18nProvider, (Throwable) null);
}
@Override
public @Nullable String getLocalizedMessage() {
return this.getLocalizedMessage(false);
}
@Override
public @Nullable String getMessage() {
return this.getMessage(false);
}
/**
* Similar to {@link #getLocalizedMessage()}, but additionally can embed cause's message
*
* @param includeCause Whether to embed cause message
* @return Localized exception message
*/
public final String getLocalizedMessage(boolean includeCause) {
if (i18nProvider == null) {
return getMessage(includeCause); // fallback
}
var i18nProvider = Objects.requireNonNull(this.i18nProvider);
@Nullable
String localizedMessage;
if (!localizedMessageParams.isEmpty()) {
localizedMessage = i18nProvider.getText(localizedMessageKey, null, localizedMessageParams.toArray());
} else {
localizedMessage = i18nProvider.getText(localizedMessageKey, null);
}
if (localizedMessage == null || localizedMessage.isBlank()) {
// default to EN-US message (fallback to class name on failure)
localizedMessage = Objects.requireNonNullElse(this.getMessage(), this.getClass().getSimpleName());
}
String localizedMessageNonNull = Objects.requireNonNull(localizedMessage); // This is 100% redundant, but
// Eclipse wasn't able to correctly
// interpret Optional.ofNullable()
// inside a map... so doing if-based
// logic, lists vs. arrays and this
// instead - avoids suppression :)
if (this.getCause() != null) {
var causeMessage = Objects.requireNonNull(this.getCause()).getLocalizedMessage();
if (causeMessage != null && !(localizedMessageNonNull.endsWith(causeMessage))) {
// Sometimes the cause is already embedded in the message at throw site. If it isn't though... let's add
localizedMessageNonNull += ". " + i18nProvider
.getText("thing-status.cause.argoclima.exception.caused-by", "Caused by: {0}", causeMessage);
}
}
return localizedMessageNonNull;
}
/**
* Similar to {@link #getMessage()}, but additionally can embed cause's message
*
* @implNote Guaranteed non-null. Will default to class name in case message was null
*
* @param includeCause Whether to embed cause message
* @return EN-US exception message
*/
public final String getMessage(boolean includeCause) {
@Nullable
String message = super.getMessage();
if (message != null && this.getCause() != null) {
var causeMessage = Objects.requireNonNull(this.getCause()).getLocalizedMessage();
if (causeMessage != null && !(message.endsWith(causeMessage))) {
// Sometimes the cause is already embedded in the message at throw site. If it isn't though... let's add
// it
message += MessageFormat.format(". Caused by: {0}", causeMessage);
}
}
return Objects.requireNonNullElse(message, this.getClass().getSimpleName());
}
}

View File

@ -0,0 +1,51 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.argoclima.internal.exception;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.argoclima.internal.ArgoClimaTranslationProvider;
/**
* The class {@code ArgoRemoteServerStubStartupException} is thrown in case of any issues when starting the stub Argo
* server (for intercepting mode)
*
* @see org.openhab.binding.argoclima.internal.device.passthrough.RemoteArgoApiServerStub
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class ArgoRemoteServerStubStartupException extends ArgoLocalizedException {
private static final long serialVersionUID = 3798832375487523670L;
public ArgoRemoteServerStubStartupException(String defaultMessage, String localizedMessageKey,
ArgoClimaTranslationProvider i18nProvider, @Nullable Throwable cause,
Object @Nullable... messageFormatArguments) {
super(defaultMessage, localizedMessageKey, i18nProvider, cause, messageFormatArguments);
}
public ArgoRemoteServerStubStartupException(String defaultMessage, String localizedMessageKey,
ArgoClimaTranslationProvider i18nProvider, @Nullable Throwable cause) {
super(defaultMessage, localizedMessageKey, i18nProvider, cause);
}
public ArgoRemoteServerStubStartupException(String defaultMessage, String localizedMessageKey,
ArgoClimaTranslationProvider i18nProvider, Object @Nullable... messageFormatArguments) {
super(defaultMessage, localizedMessageKey, i18nProvider, messageFormatArguments);
}
public ArgoRemoteServerStubStartupException(String defaultMessage, String localizedMessageKey,
ArgoClimaTranslationProvider i18nProvider) {
super(defaultMessage, localizedMessageKey, i18nProvider);
}
}

View File

@ -0,0 +1,848 @@
/**
* 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.argoclima.internal.handler;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.SortedMap;
import java.util.TreeMap;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants;
import org.openhab.binding.argoclima.internal.ArgoClimaTranslationProvider;
import org.openhab.binding.argoclima.internal.configuration.ArgoClimaConfigurationBase;
import org.openhab.binding.argoclima.internal.device.api.IArgoClimaDeviceAPI;
import org.openhab.binding.argoclima.internal.device.api.types.ArgoDeviceSettingType;
import org.openhab.binding.argoclima.internal.exception.ArgoApiCommunicationException;
import org.openhab.binding.argoclima.internal.exception.ArgoApiProtocolViolationException;
import org.openhab.binding.argoclima.internal.exception.ArgoConfigurationException;
import org.openhab.binding.argoclima.internal.exception.ArgoRemoteServerStubStartupException;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseThingHandler;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@code ArgoClimaHandlerBase} is an abstract base class for common logic (across local and remote thing
* implementations) responsible for handling commands, which are sent to one of the channels.
*
* @see ArgoClimaHandlerLocal
* @see ArgoClimaHandlerRemote
*
* @param <ConfigT> Type of configuration class used:
* {@link org.openhab.binding.argoclima.internal.configuration.ArgoClimaConfigurationLocal
* ArgoClimaConfigurationLocal} or
* {@link org.openhab.binding.argoclima.internal.configuration.ArgoClimaConfigurationRemote
* ArgoClimaConfigurationRemote}
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public abstract class ArgoClimaHandlerBase<ConfigT extends ArgoClimaConfigurationBase> extends BaseThingHandler {
enum StateRequestType {
REQUEST_FRESH_STATE,
GET_CACHED_STATE
}
private final Logger logger = LoggerFactory.getLogger(getClass());
private final boolean awaitConfirmationUponSendingCommands;
private final Duration sendCommandStatusPollFrequency;
private final Duration sendCommandResubmitFrequency;
private final Duration sendCommandMaxWaitTime;
private final Duration sendCommandMaxWaitTimeIndirectMode;
protected final ArgoClimaTranslationProvider i18nProvider;
// Set-up through initialize()
private Optional<IArgoClimaDeviceAPI> deviceApi = Optional.empty();
private Optional<ConfigT> config = Optional.empty();
// Threading/job related stuff
private Optional<ScheduledFuture<?>> refreshTask = Optional.empty();
private Optional<Future<?>> initializeFuture = Optional.empty();
private Optional<Future<?>> deviceCommandSenderFuture = Optional.empty();
private AtomicLong lastRefreshTime = new AtomicLong(Instant.now().toEpochMilli());
private AtomicInteger failedApiCallsCounter = new AtomicInteger(0);
/**
* C-tor
*
* @param thing The @code Thing} this handler serves (provided by the framework through
* {@link org.openhab.binding.argoclima.internal.ArgoClimaHandlerFactory ArgoClimaHandlerFactory}
* @param awaitConfirmationAfterSend If true, will wait for device to confirm the update after sending a command to
* it
* @param poolFrequencyAfterSend The status refresh frequency for updated status after issuing a command (relevant
* only if {@code awaitConfirmationAfterSend == true})
* @param sendRetryFrequency The retry frequency (to re-issue a command if no confirmation received). Relevant only
* if {@code awaitConfirmationAfterSend == true}, should be higher than {@code poolFrequencyAfterSend}
* @param sendMaxRetryTimeDirect Max time to wait for device-side confirmation in direct mode (when this binding is
* issuing the comms). (relevant only if {@code awaitConfirmationAfterSend == true})
* @param sendMaxWaitTimeIndirect Max time to wait for device-side confirmation in indirect mode (when this binding
* is only sniffing/intercepting the comms and injecting commands into a server replies). Typically
* longer than {@code sendMaxRetryTimeDirect}. Relevant only if
* {@code awaitConfirmationAfterSend == true})
* @param i18nProvider Framework's translation provider
*/
public ArgoClimaHandlerBase(Thing thing, boolean awaitConfirmationAfterSend, Duration poolFrequencyAfterSend,
Duration sendRetryFrequency, Duration sendMaxRetryTimeDirect, Duration sendMaxWaitTimeIndirect,
final ArgoClimaTranslationProvider i18nProvider) {
super(thing);
this.awaitConfirmationUponSendingCommands = awaitConfirmationAfterSend;
this.sendCommandStatusPollFrequency = poolFrequencyAfterSend;
this.sendCommandResubmitFrequency = sendRetryFrequency;
this.sendCommandMaxWaitTime = sendMaxRetryTimeDirect;
this.sendCommandMaxWaitTimeIndirectMode = sendMaxWaitTimeIndirect;
this.i18nProvider = i18nProvider;
}
/**
* Initializes the thing config with concrete type and transforms it to the given class.
*
* @return config
* @throws ArgoConfigurationException in case of configuration errors
*/
protected abstract ConfigT getConfigInternal() throws ArgoConfigurationException;
/**
* Creates and initializes concrete device API (the actual communication path to the device).
* In case the API has passive passive components (such as pass-through server), they are started as well
* (their lifecycle is tracked by this class)
*
* @param config The Thing configuration
* @return Initialized Device API
* @throws ArgoConfigurationException In case the API initialization fails due to Thing configuration issues
* @throws ArgoRemoteServerStubStartupException In case the Device API startup involved launching an intercepting
* server (thing type and configuration-dependent), and the startup has failed
*/
protected abstract IArgoClimaDeviceAPI initializeDeviceApi(ConfigT config)
throws ArgoRemoteServerStubStartupException, ArgoConfigurationException;
/**
* {@inheritDoc}
*
* @implNote Initializes thing config and device API, and continues the thing initialization asynchronously through
* {@link #initializeThing()} - as this method must return quickly. Also launches device state regular
* polling (if configured) -
* {@link #startAutomaticRefresh()}. While the poll will also (re)initialize the device, a dedicated
* initialization logic is kept b/c polling may be disabled by the user (and there's no harm in triggering
* both poll and refresh -> first to complete will win).
* @implNote If either of the initialize/poll threads are launched, both {@link #config} and {@link #deviceApi} are
* guaranteed to have values (so their use is safe from any other method from this class except for
* {@code dispose()}, as they are either invoked by the threads started herein, or guaranteed by the
* framework to not get called if the device is not initialized. Hence a check for successful
* initialization is NOT performed on each and every method.
*/
@Override
public final void initialize() {
// Step0: If this a re-initialize (ex. config change), let's stop everything and start anew (not supporting
// graceful updates to the refresher threads and/or passthrough server)
this.config.ifPresent(c -> stopRunningTasks());
// Step1: Init config
try {
this.config = Optional.of(getConfigInternal());
} catch (ArgoConfigurationException ex) {
logger.debug("[{}] {}", getThing().getUID().getId(), ex.getMessage()); // the non-i18nzed message is logged
// explicitly (not redundant with
// updateStatus's logging)
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, ex.getLocalizedMessage());
return;
}
logger.debug("[{}] Running with config: {}", getThing().getUID(), config.get().toString());
var configValidationError = config.get().validate();
if (!configValidationError.isEmpty()) {
var message = i18nProvider.getText("thing-status.argoclima.invalid-config",
"Invalid thing configuration. {0}", configValidationError);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, message);
return;
}
// Step2: Init device API (this will start passthrough server & client threads, if configured)
try {
this.deviceApi = Optional.of(initializeDeviceApi(config.get()));
} catch (ArgoRemoteServerStubStartupException | ArgoConfigurationException e) {
logger.debug("[{}] Failed to initialize Device API. Error: {}", getThing().getUID(), e.getMessage()); // the
// non-i18nzed
// message
// is
// logged
// explicitly
// (not
// redundant
// with
// updateStatus's
// logging)
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR, e.getLocalizedMessage());
return;
} catch (Exception e) {
logger.debug("[{}] Failed to initialize Device API. Unknown Error: {}", getThing().getUID(),
e.getMessage()); // the non-i18nzed message is logged explicitly (not redundant with updateStatus's
// logging)
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR,
i18nProvider.getText("thing-status.argoclima.handler-init-failure",
"Error while initializing Thing: {0}", e.getLocalizedMessage()));
return;
}
// Step 3: Set the thing status to UNKNOWN temporarily and let the background task decide the real status.
// the framework is then able to reuse the resources from the thing handler initialization.
updateStatus(ThingStatus.UNKNOWN);
// Step 4: Start polling (if configured)
if (this.config.get().getRefreshInterval() > 0) {
lastRefreshTime.set(Instant.now().toEpochMilli()); // Skips 1st refresh cycle (no need, initializer will do
// it instead)
startAutomaticRefresh();
}
// Step 5: Kick off the "real" initialization logic :)
synchronized (this) {
initializeFuture = Optional.ofNullable(scheduler.submit(this::initializeThing));
}
}
/**
* {@inheritDoc}
*
* @implNote This is deliberately made final, as the class-specific disposal has been moved to
* {@link ArgoClimaHandlerBase#stopRunningTasks()}, to semantically separate a stop-on-dispose on a
* regular stop (which is also done on re-initialize)
*/
@Override
public final void dispose() {
logger.trace("{}: Thing {} is disposing", getThing().getUID().getId(), thing.getUID());
stopRunningTasks();
logger.trace("{}: Disposed", getThing().getUID().getId());
}
/**
* @implNote This is overridden in {@link org.openhab.binding.argoclima.internal.handler.ArgoClimaHandlerLocal} to
* handle disposal of the
*/
protected synchronized void stopRunningTasks() {
if (this.deviceApi.isPresent()) {
// Setting the device API as empty first, as its absence also serves as a marker of disposal started
// Note it may still be alive after that, as the shared HTTP client may have pending I/O withstanding.
// These will be cleaned up when the respective refresher tasks stop (later in this function)
deviceApi = Optional.empty();
}
try {
stopRefreshTask(); // Stop polling for new updates
} catch (Exception e) {
logger.trace("Exception during handler disposal", e);
}
try {
initializeFuture.ifPresent(initter -> initter.cancel(true));
} catch (Exception e) {
logger.trace("Exception during handler disposal", e);
}
try {
cancelPendingDeviceCommandSenderJob();
} catch (Exception e) {
logger.trace("Exception during handler disposal", e);
}
}
/**
* {@inheritDoc}
*
* @implNote The Argo API always gets every readable params in one go and sends multiple commands in one request.
* Hence, if the requested command is a {@link RefreshType}, the {@code channelUID} is ignored, since
* everything will be updated anyway (refresh one == refresh them all).
* @implNote The actual device comms are made on a separate thread (and have a baked-in debounce time to avoid
* sending multiple requests). The device responses (even in direct communication mode) are somewhat slow
* and may take 1-2 cycles for the new value to apply, which is why the binding waits for them to be
* confirmed (and uses a device-reported state only after it gives up trying to effect the command).
* @implNote In a remote or indirect (pass-through) modes, we're constrained by the device own poll cycles (seem to
* occur every minute, assuming the device is up and has successful uplink to Argo servers). Hence, an
* update may take long to apply (circa 1-2 min). During this time, if new commands are send to the
* Thing, they get stacked with the existing ones (each command tracks its expire time separately though!)
* @implNote While {@link ArgoDeviceSettingType#RESET_TO_FACTORY_SETTINGS} is an API parameter it is modeled as
* configuration property and NOT a channel (hence not available here). Similarly
* {@link ArgoDeviceSettingType#UNIT_FIRMWARE_VERSION} is a property, not a Channel.
*/
@Override
public final void handleCommand(ChannelUID channelUID, Command command) {
if (command instanceof RefreshType) {
sendCommandsToDeviceAwaitConfirmation(true); // Irrespective of channel (all in one go). Note this has no
// effect for indirect/pass-through mode. We're bound to
// device's own poll cycles anyway
return;
}
boolean hasUpdates = false;
// Channel -> ArgoDeviceSettingType mapping (could be within enum, but kept here for better visibility)
if (ArgoClimaBindingConstants.CHANNEL_POWER.equals(channelUID.getId())) {
hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.POWER, command, channelUID);
}
if (ArgoClimaBindingConstants.CHANNEL_ACTIVE_TIMER.equals(channelUID.getId())) {
hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.ACTIVE_TIMER, command, channelUID);
}
if (ArgoClimaBindingConstants.CHANNEL_CURRENT_TEMPERATURE.equals(channelUID.getId())) {
hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.ACTUAL_TEMPERATURE, command, channelUID);
}
if (ArgoClimaBindingConstants.CHANNEL_ECO_MODE.equals(channelUID.getId())) {
hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.ECO_MODE, command, channelUID);
}
if (ArgoClimaBindingConstants.CHANNEL_FAN_SPEED.equals(channelUID.getId())) {
hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.FAN_LEVEL, command, channelUID);
}
if (ArgoClimaBindingConstants.CHANNEL_FILTER_MODE.equals(channelUID.getId())) {
hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.FILTER_MODE, command, channelUID);
}
if (ArgoClimaBindingConstants.CHANNEL_SWING_MODE.equals(channelUID.getId())) {
hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.FLAP_LEVEL, command, channelUID);
}
if (ArgoClimaBindingConstants.CHANNEL_I_FEEL_ENABLED.equals(channelUID.getId())) {
hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.I_FEEL_TEMPERATURE, command, channelUID);
}
if (ArgoClimaBindingConstants.CHANNEL_DEVICE_LIGHTS.equals(channelUID.getId())) {
hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.LIGHT, command, channelUID);
}
if (ArgoClimaBindingConstants.CHANNEL_MODE.equals(channelUID.getId())) {
hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.MODE, command, channelUID);
}
if (ArgoClimaBindingConstants.CHANNEL_MODE_EX.equals(channelUID.getId())) {
hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.MODE, command, channelUID);
}
if (ArgoClimaBindingConstants.CHANNEL_NIGHT_MODE.equals(channelUID.getId())) {
hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.NIGHT_MODE, command, channelUID);
}
if (ArgoClimaBindingConstants.CHANNEL_SET_TEMPERATURE.equals(channelUID.getId())) {
hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.TARGET_TEMPERATURE, command, channelUID);
}
if (ArgoClimaBindingConstants.CHANNEL_TURBO_MODE.equals(channelUID.getId())) {
hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.TURBO_MODE, command, channelUID);
}
if (ArgoClimaBindingConstants.CHANNEL_TEMPERATURE_DISPLAY_UNIT.equals(channelUID.getId())) {
hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.DISPLAY_TEMPERATURE_SCALE, command,
channelUID);
}
if (ArgoClimaBindingConstants.CHANNEL_ECO_POWER_LIMIT.equals(channelUID.getId())) {
hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.ECO_POWER_LIMIT, command, channelUID);
}
if (ArgoClimaBindingConstants.CHANNEL_DELAY_TIMER.equals(channelUID.getId())) {
hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.TIMER_0_DELAY_TIME, command, channelUID);
}
if (hasUpdates) {
sendCommandsToDeviceAwaitConfirmation(false); // Schedule sending to device (without forcing value refresh)
}
}
/**
* Convert received HVAC state: {@code deviceState} into Thing channel updates
*
* @param deviceState The state read/received from device (may also be cached)
* @implNote Not all device-reported elements are modeled as channels, and may be reflected as configuration or
* properties (ex.: {@code UNIT_FIRMWARE_VERSION}, or schedule timer on/off/weekdays params)
* @implNote A single device update may update more than one channel. For example the device mode is represented as
* BOTH {@code CHANNEL_MODE} and {@code CHANNEL_MODE_EX}. This is because the remote protocol supports
* more values than the typical HVAC device. Hence, the full list of modes is available in its own
* advanced ("_EX") channel, and the regular one is providing most common options for better usability.
* Both Channels get updated off of the same API field though.
* @apiNote This method is also called asynchronously from an intercepting/stub server
*/
protected final void updateChannelsFromDevice(Map<ArgoDeviceSettingType, State> deviceState) {
if (deviceApi.isEmpty()) {
return; // The thing handler is disposing. No need to update channels
}
for (Entry<ArgoDeviceSettingType, State> entry : deviceState.entrySet()) {
var channelNames = Set.<String> of();
switch (entry.getKey()) {
case ACTIVE_TIMER:
channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_ACTIVE_TIMER);
break;
case ACTUAL_TEMPERATURE:
channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_CURRENT_TEMPERATURE);
break;
case DISPLAY_TEMPERATURE_SCALE:
channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_TEMPERATURE_DISPLAY_UNIT);
break;
case ECO_MODE:
channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_ECO_MODE);
break;
case ECO_POWER_LIMIT:
channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_ECO_POWER_LIMIT);
break;
case FAN_LEVEL:
channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_FAN_SPEED);
break;
case FILTER_MODE:
channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_FILTER_MODE);
break;
case FLAP_LEVEL:
channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_SWING_MODE);
break;
case I_FEEL_TEMPERATURE:
channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_I_FEEL_ENABLED);
break;
case LIGHT:
channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_DEVICE_LIGHTS);
break;
case MODE: // As 2 channels. See thing-type.xml for description of these
channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_MODE,
ArgoClimaBindingConstants.CHANNEL_MODE_EX);
break;
case NIGHT_MODE:
channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_NIGHT_MODE);
break;
case POWER:
channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_POWER);
break;
case TARGET_TEMPERATURE:
channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_SET_TEMPERATURE);
break;
case TIMER_0_DELAY_TIME:
channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_DELAY_TIMER);
break;
case TURBO_MODE:
channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_TURBO_MODE);
break;
case CURRENT_DAY_OF_WEEK: // not reflected anywhere (write-only part of protocol)
case CURRENT_TIME:
break;
case TIMER_N_ENABLED_DAYS: // Timer schedule is represented as config, not as channel
case TIMER_N_OFF_TIME:
case TIMER_N_ON_TIME:
break;
case RESET_TO_FACTORY_SETTINGS: // Represented as config
break;
case UNIT_FIRMWARE_VERSION: // Represented as property
break;
default:
break;
}
// Send updates to the framework
channelNames.forEach(chnl -> updateState(chnl, entry.getValue()));
}
}
/**
* Informs the underlying DeviceAPI about a framework-issued command and returns status if the update was
* commissioned.
*
* @param settingType The API-side setting type receiving a command
* @param command The command sent by the framework
* @param channelUID Original channel the command got issued through
* @return True if the command was handled and is now in-flight (about to be sent to device). False - otherwise
*/
private final boolean handleIndividualSettingCommand(ArgoDeviceSettingType settingType, Command command,
ChannelUID channelUID) {
if (command instanceof RefreshType) {
return true; // Refresh commands always trigger an update
}
// Pass value to underlying handler (if handled, it will make it in-flight and communicated to device on next
// comms cycle)
boolean updateInitiated = this.deviceApi.orElseThrow().handleSettingCommand(settingType, command);
if (updateInitiated) {
// Get updated device state and inform framework immediately that the binding accepted it. Note this
// technically doesn't yet mean the device changed its state nor even that the command got sent just this
// minute, but given some values are write-only (never confirmed) and the value *is* committed to be sent,
// we're confirming at this point (so that the value doesn't linger as "predicted")
State currentState = this.deviceApi.orElseThrow().getCurrentStateNoPoll(settingType);
logger.trace("State of {} after update: {}", channelUID, currentState);
updateState(channelUID, currentState);
}
return updateInitiated;
}
/**
* Updates dynamic Thing properties from values read from device
*
* @param entries The new properties to append/replace (this does not clear existing properties!)
*
* @implNote Unfortunately framework's {@link BaseThingHandler#updateProperties(Map<String, String>)} implementation
* clones the map into a {@code HashMap}, which means the edited properties will lose their sorting, yet
* still providing it via a {@code TreeMap} in hopes framework may respect the ordering some day ;)
* @apiNote This method is also called asynchronously from an intercepting/stub server
*/
protected final void updateThingProperties(SortedMap<String, String> entries) {
if (deviceApi.isEmpty()) {
return; // The thing handler is disposing. No need to update properties
}
TreeMap<String, String> currentProps = new TreeMap<>(this.editProperties()); // This unfortunately loses sorting
entries.entrySet().stream().forEach(x -> currentProps.put(x.getKey(), x.getValue()));
this.updateProperties(currentProps);
}
/**
* Updates the status of the thing to ONLINE (no details)
*
* @apiNote This method is also called asynchronously from an intercepting/stub server
*/
protected final void updateThingStatusToOnline(ThingStatus newStatus) {
if (ThingStatus.ONLINE.equals(newStatus)) {
// only one-way update from callback
updateStatus(ThingStatus.ONLINE);
} else {
logger.trace("The remote stub server attempted to update the thing status to {}. The request was ignored",
newStatus);
}
}
/**
* Trigger channel update from HVAC device. If {@code useCachedState==false}, will trigger outbound communications
* to the device (or remote Argo server, depending on mode).
* <p>
* For local mode with pass-through, if {@code Use Local Connection} is on, the state is always sniffed from device
* pool, so the triggered update has no effect
*
* @param requestType If {@link StateRequestType#GET_CACHED_STATE} allows to use cached state. Otherwise triggers
* device communications (if permitted by other settings)
* @throws ArgoApiCommunicationException If communication with the device fails
*/
private final void updateStateFromDevice(StateRequestType requestType) throws ArgoApiCommunicationException {
if (deviceApi.isEmpty()) {
return;
}
var devApi = deviceApi.orElseThrow();
updateChannelsFromDevice(
StateRequestType.GET_CACHED_STATE.equals(requestType) ? devApi.getLastStateReadFromDevice()
: devApi.queryDeviceForUpdatedState());
updateThingProperties(devApi.getCurrentDeviceProperties());
}
/**
* Start polling for device status at an interval configured via
* {@link ArgoClimaBindingConstants#PARAMETER_REFRESH_INTERNAL} (in seconds)
*
* @implNote Since both {@link #initializeThing() initialize} as well as {@link #startAutomaticRefresh() refresh}
* are doing similar device communication, the 1st refresh cycle is purposefully omitted so that
* initialization has chances to finish. Ex. first refresh time is
* {@code Thing initialize time + refresh frequency (s)}. This is accomplished through a
* {@link #lastRefreshTime} member instead of simply delaying the scheduler, as it gives more flexibility
* @implNote If N refreshes ({@link ArgoClimaBindingConstants#MAX_API_RETRIES})fail in a row, the Thing will be
* considered offline and require re-initialization on next refresh.
* @implNote In order not to flood the device (or Argo servers), the binding is *not* doing any "obsessing" and
* ad-hoc retries of failed connections, but instead waits till next refresh cycle
*/
private final synchronized void startAutomaticRefresh() {
Runnable refresher = () -> {
try {
// Fail-safe: Do not trigger if time since last refresh is lower than frequency
if (isMinimumRefreshTimeExceeded()) {
// If the device is offline, try to re-initialize it
if (getThing().getStatus() == ThingStatus.OFFLINE) {
logger.trace("{}: Re-initialize device", getThing().getUID());
initializeThing();
return;
}
updateStateFromDevice(StateRequestType.REQUEST_FRESH_STATE);
failedApiCallsCounter.set(0); // we're good!
}
} catch (RuntimeException | ArgoApiCommunicationException e) {
var retryCount = failedApiCallsCounter.getAndIncrement() + 1; // 1-based
logger.trace("[{}] Polling for device-side update for HVAC device failed [{} of {}]. Error=[{}]",
getThing().getUID(), retryCount, ArgoClimaBindingConstants.MAX_API_RETRIES, e.getMessage());
if (retryCount >= ArgoClimaBindingConstants.MAX_API_RETRIES) {
var statusMsg = i18nProvider.getText("thing-status.argoclima.poll-failed",
"Polling for device-side update failed. Unable to communicate with HVAC device for past {0} refresh cycles. Last error: {1}",
ArgoClimaBindingConstants.MAX_API_RETRIES, e.getLocalizedMessage());
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, statusMsg);
// Not resetting the counter here (will track every failure till we reinitialize & poll successfully
}
}
};
if (refreshTask.isEmpty()) {
refreshTask = Optional.ofNullable(scheduler.scheduleWithFixedDelay(refresher, 0,
config.get().getRefreshInterval(), TimeUnit.SECONDS));
logger.trace("{}: Automatic refresh started ({} second interval)", getThing().getUID().getId(),
config.get().getRefreshInterval());
}
}
/**
* Checks if time since last refresh is greater than interval (and advances the last checked time if so)
*
* @return True if time elapsed since last refresh is greater than interval (in which case last checked marker is
* also advanced). False - otherwise
*/
private final boolean isMinimumRefreshTimeExceeded() {
long currentTime = Instant.now().toEpochMilli();
long timeSinceLastRefresh = currentTime - lastRefreshTime.get();
if (timeSinceLastRefresh < config.get().getRefreshInterval() * 1000) {
return false;
}
lastRefreshTime.lazySet(currentTime);
return true;
}
/**
* Synchronous initializer of the Thing. Expected to be called from a worker thread/future.
* Performs device (or remote API) direct communication, unless explicitly disabled by settings
*
* @implNote In order not to flood the device (or Argo servers), the binding is *not* doing any "obsessing" and
* retries of failed connections, but instead waits till {@link #startAutomaticRefresh() refresher} to
* kick-off a retry (or a device-side pool happens, in intercepting/sniffing mode)
* @implNote Since {@code RESET} is modeled as a write-only (one shot) configuration setting (in line with how Main
* UI handles those for other bindings, like ZWave), if it was set and the thing comes online... let's
* send the reset to the device and **CLEAR** the config property (so that we won't reset every time the
* device comes up)
*/
private final void initializeThing() {
if (this.config.get().getRefreshInterval() == 0) {
updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NOT_YET_READY,
"@text/thing-status.argoclima.awaiting-request");
return;
}
String message = "";
try {
var reachabilityTestResult = this.deviceApi.get().isReachable();
if (reachabilityTestResult.isReachable()) {
updateStatus(ThingStatus.ONLINE); // YAY!
updateStateFromDevice(StateRequestType.GET_CACHED_STATE); // The reachability test actually was a full
// protocolar message (b/c there's no other
// :)), so it has conveniently fetched us all
// updates. Let's use them now that the Thing
// is healthy!
// Handle reset config knob (if true) as one-shot command
if (config.isPresent() && config.orElseThrow().resetToFactoryDefaults) {
var resetSent = this.deviceApi.get()
.handleSettingCommand(ArgoDeviceSettingType.RESET_TO_FACTORY_SETTINGS, OnOffType.ON);
if (resetSent) {
logger.info("[{}] Resetting HVAC device to factory defaults. RESET: {}", getThing().getUID(),
this.deviceApi.map(
d -> d.getCurrentStateNoPoll(ArgoDeviceSettingType.RESET_TO_FACTORY_SETTINGS)
.toString())
.orElse(""));
sendCommandsToDeviceAwaitConfirmation(false); // Schedule sending to device
config.orElseThrow().resetToFactoryDefaults = false;
var configUpdated = editConfiguration();
configUpdated.put(ArgoClimaBindingConstants.PARAMETER_RESET_TO_FACTORY_DEFAULTS, false);
updateConfiguration(configUpdated); // Update (note this can't update text-based configs, so
// this would kick-off on every Thing reinitialize
}
}
return;
}
message = reachabilityTestResult.unreachabilityReason();
} catch (Exception e) {
// Since isReachable is a no-throw, hitting an exception (ex. during device-side message parsing) is very
// unlikely, though in case a stray one happens -> let's embed it in the user-facing message
logger.debug("{}: Initialization exception", getThing().getUID(), e);
message = e.getLocalizedMessage();
}
if (getThing().getStatus() != ThingStatus.OFFLINE) {
// Update to offline. If offline already, let's not update the reason not to flood framework with boring
// updates (first error wins user's attention)
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, message);
}
}
/**
* Start sending pending commands to the device. Await their confirmation by the device (write-only parameters
* faithfully confirm on send).
* <p>
* This method is async and starts a new update job. At most one job is supported (on re-entry, while an update is
* running, the old update is stopped and replaced with the new one). Implementation does support staggering
* requests though (ex. calling it multiple times in short time period will cause all updates to be sent in one go)
*
* @implNote In order to limit flapping and outgoing I/O, the actual work is delayed by
* {@link ArgoClimaBindingConstants.SEND_COMMAND_DEBOUNCE_TIME} to allow multiple commands to fit in one
* go. It's a naive implementation, starting/stopping a thread, but we're within a threadpool so this is
* ~fine :)
*
* @implNote On retries and confirmations: The commands are re-sent every {@link #sendCommandResubmitFrequency} and
* the device state is checked (for value confirmation) every {@link #sendCommandStatusPollFrequency}
* until either all withstanding commands are confirmed or total wait time expires. These
* timers are checked every tick which is by {@link ArgoClimaBindingConstants.SEND_COMMAND_DUTY_CYCLE}
*
* @implNote For local devices, this implementation can be called in a {@code Use Local Connection == false}
* ({@link ArgoClimaBindingConstants#PARAMETER_USE_LOCAL_CONNECTION ref}) mode. In this case, a "re-send"
* or a "update from device" commands are not really triggering any device communication (merely update
* internal statuses), and we're waiting the device to call us. For this reason, while the regular
* completion time (when we can talk to the device direct) is typically shorter
* ({@link #sendCommandMaxWaitTime}), in this mode this API will wait a
* {@link #sendCommandMaxWaitTimeIndirectMode}
*
* @param forceRefresh If true, force an active no-op("ping") command to the device to get freshest state
*/
private final void sendCommandsToDeviceAwaitConfirmation(boolean forceRefresh) {
if (sendCommandStatusPollFrequency.isNegative() || sendCommandResubmitFrequency.isNegative()
|| sendCommandMaxWaitTime.isNegative() || sendCommandMaxWaitTimeIndirectMode.isNegative()) {
throw new IllegalArgumentException("The frequency cannot be negative");
}
// Note: While a lot of checks could be done before the thread launches, it deliberately has been moved to
// WITHIN the thread, b/c this function may be called many times in case multiple items receive command at once
Runnable commandSendWorker = () -> {
// Stage0: Naive debounce (not to overflow the device if multiple commands are sent at once). We *want* to
// get interrupted at this stage!
try {
Thread.sleep(ArgoClimaBindingConstants.SEND_COMMAND_DEBOUNCE_TIME.toMillis());
} catch (InterruptedException e) {
return; // Got interrupted while within debounce window (which was the point!)
}
// Stage1: Calculate what to do
var valuesToUpdate = this.deviceApi.orElseThrow().getItemsWithPendingUpdates();
logger.debug("[{}] Will UPDATE the following items: {}", getThing().getUID(), valuesToUpdate);
var config = this.config.orElseThrow();
var deviceApi = this.deviceApi.orElseThrow();
final var maxWorkTime = config.useDirectConnection() ? sendCommandMaxWaitTime
: sendCommandMaxWaitTimeIndirectMode;
final var giveUpTime = Instant.now().plus(maxWorkTime);
var nextCommandSendTime = Objects.requireNonNull(Instant.MIN); // 1st send is instant
var nextStateUpdateTime = Instant.now().plus(sendCommandStatusPollFrequency); // 1st poll is delayed
Optional<Exception> lastException = Optional.empty();
// Stage 2: Start spinnin' ;)
while (true) { // Handles both polling as well as retries
try {
// 2.1: Send command to the device
if (Instant.now().isAfter(nextCommandSendTime)) {
nextCommandSendTime = Instant.now().plus(sendCommandResubmitFrequency);
if (!deviceApi.hasPendingCommands()) {
if (forceRefresh) {
updateStateFromDevice(
config.useDirectConnection() ? StateRequestType.REQUEST_FRESH_STATE
: StateRequestType.GET_CACHED_STATE);
} else {
logger.trace("Nothing to update... skipping"); // update might have occurred async
}
return; // no command sending state to device was issued, we're safe to consider our job
// D-O-N-E
}
if (config.useDirectConnection()) {
// Have a command and send it *now* (triggers I/O)
deviceApi.sendCommandsToDevice();
} else {
logger.trace(
"Not sending the device update directly - waiting for device-side poll to happen");
}
// Check if the device confirmed in the same message exchange where we sent our update (very
// unlikely for 1st-time commands, but quite possible if we retried or the command was a
// write-only)
if (!this.deviceApi.get().hasPendingCommands()) {
logger.trace("All pending commands got confirmed on 1st try after a (re)send!");
return; // Woo-hoo! Device is happy, we're DONE!
}
}
if (!awaitConfirmationUponSendingCommands) {
return; // Nobody want's confirmations? Okay, we have less work to do (aka, we're done!)
}
// 2.2: Let's wait for the device to confirm flipping to the just commanded state
// Note: the device takes long to process commands which is why we're not querying immediately after
// send, and give it few seconds before re-confirming
if (Instant.now().isAfter(nextStateUpdateTime)) {
nextStateUpdateTime = Instant.now().plus(sendCommandStatusPollFrequency);
updateStateFromDevice(config.useDirectConnection() ? StateRequestType.REQUEST_FRESH_STATE
: StateRequestType.GET_CACHED_STATE);
if (this.deviceApi.get().hasPendingCommands()) {
// No biggie, we just didn't get the confirmation yet. This exception will be swallowed (on
// next try) or logged (if we run out of tries)
throw new ArgoApiProtocolViolationException("Update not confirmed. Value was not set");
}
return; // Woo-hoo! Device is happy, we're A-OK!
}
// empty loop cycle (no command, no update), just spinning...
} catch (Exception ex) {
lastException = Optional.of(ex);
}
// 2.3: If we're still within the working window, let's roll the dice once more
if (Instant.now().isBefore(giveUpTime)) {
try {
Thread.sleep(ArgoClimaBindingConstants.SEND_COMMAND_DUTY_CYCLE.toMillis());
} catch (InterruptedException e) {
return; // Cancelled during duty cycle (we want interrupts to happen here!)
}
logger.trace("Failed to update. Will retry...");
continue;
}
// 2.3B: Out of tries. Do one last check before we fail
if (!this.deviceApi.get().hasPendingCommands()) {
logger.trace("All pending commands got confirmed on last try!");
return; // Woo-hoo! Device is happy, we're DONE!
}
// 2.4: Max time exceeded and update failed to send or not confirmed. Giving up :(
valuesToUpdate.stream().forEach(x -> x.abortPendingCommand());
updateChannelsFromDevice(deviceApi.getLastStateReadFromDevice()); // Update channels back to device
// values upon abort
// The device wasn't nice with us, so we're going to consider it offline
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, i18nProvider.getText(
"thing-status.argoclima.confirmation-not-received",
"Could not control HVAC device. Command(s): {0} were not confirmed by the device within {1} s",
valuesToUpdate.isEmpty() ? "REFRESH" : valuesToUpdate.toString(), maxWorkTime.toSeconds()));
logger.debug("[{}] Device command failed: {}", this.getThing().getUID().toString(),
lastException.map(ex -> ex.getMessage()).orElse("No error details"));
break;
}
};
synchronized (this) {
cancelPendingDeviceCommandSenderJob();
deviceCommandSenderFuture = Optional.ofNullable(scheduler.submit(commandSendWorker));
}
}
private final synchronized void stopRefreshTask() {
refreshTask.ifPresent(rt -> {
rt.cancel(true);
});
refreshTask = Optional.empty();
}
private final synchronized void cancelPendingDeviceCommandSenderJob() {
deviceCommandSenderFuture.ifPresent(x -> {
if (!x.isDone()) {
logger.trace("Cancelling previous update job");
x.cancel(true);
}
});
deviceCommandSenderFuture = Optional.empty();
}
}

View File

@ -0,0 +1,169 @@
/**
* 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.argoclima.internal.handler;
import java.util.Objects;
import java.util.Optional;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.util.MultiException;
import org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants;
import org.openhab.binding.argoclima.internal.ArgoClimaTranslationProvider;
import org.openhab.binding.argoclima.internal.configuration.ArgoClimaConfigurationLocal;
import org.openhab.binding.argoclima.internal.configuration.ArgoClimaConfigurationLocal.ConnectionMode;
import org.openhab.binding.argoclima.internal.device.api.ArgoClimaLocalDevice;
import org.openhab.binding.argoclima.internal.device.api.IArgoClimaDeviceAPI;
import org.openhab.binding.argoclima.internal.device.passthrough.PassthroughHttpClient;
import org.openhab.binding.argoclima.internal.device.passthrough.RemoteArgoApiServerStub;
import org.openhab.binding.argoclima.internal.exception.ArgoConfigurationException;
import org.openhab.binding.argoclima.internal.exception.ArgoRemoteServerStubStartupException;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.thing.Thing;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link ArgoClimaHandlerLocal} is responsible for handling commands, which are
* sent to one of the channels. Supports local device (either through direct connection or pass-through)
*
* @see ArgoClimaHandlerBase
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class ArgoClimaHandlerLocal extends ArgoClimaHandlerBase<ArgoClimaConfigurationLocal> {
private final Logger logger = LoggerFactory.getLogger(getClass());
private final HttpClient commonHttpClient;
private final TimeZoneProvider timeZoneProvider;
private final HttpClientFactory clientFactory;
private Optional<RemoteArgoApiServerStub> serverStub = Optional.empty();
/**
* C-tor
*
* @param thing The @code Thing} this handler serves (provided by the framework through
* {@link org.openhab.binding.argoclima.internal.ArgoClimaHandlerFactory ArgoClimaHandlerFactory}
* @param clientFactory The framework's HTTP client factory (injected by the runtime to the
* {@code ArgoClimaHandlerFactory})
* @param timeZoneProvider The framework's time zone provider (injected by the runtime to the
* {@code ArgoClimaHandlerFactory})
* @param i18nProvider Framework's translation provider
*/
public ArgoClimaHandlerLocal(Thing thing, HttpClientFactory clientFactory, TimeZoneProvider timeZoneProvider,
final ArgoClimaTranslationProvider i18nProvider) {
super(thing, ArgoClimaBindingConstants.AWAIT_DEVICE_CONFIRMATIONS_AFTER_COMMANDS,
ArgoClimaBindingConstants.POLL_FREQUENCY_AFTER_COMMAND_SENT_LOCAL,
ArgoClimaBindingConstants.SEND_COMMAND_RETRY_FREQUENCY_LOCAL,
ArgoClimaBindingConstants.SEND_COMMAND_MAX_WAIT_TIME_LOCAL_DIRECT,
ArgoClimaBindingConstants.SEND_COMMAND_MAX_WAIT_TIME_LOCAL_INDIRECT, i18nProvider);
this.commonHttpClient = clientFactory.getCommonHttpClient();
this.clientFactory = clientFactory;
this.timeZoneProvider = timeZoneProvider;
}
@Override
protected ArgoClimaConfigurationLocal getConfigInternal() throws ArgoConfigurationException {
try {
var ret = getConfigAs(ArgoClimaConfigurationLocal.class); // This can **theoretically** return null if class
// is not default-constructible (but this one is,
// so not handling!)
ret.initialize(i18nProvider);
return ret;
} catch (IllegalArgumentException ex) {
throw ArgoConfigurationException.forInvalidParamValue("Error loading thing configuration",
"thing-status.argoclima.configuration.load-error", i18nProvider, ex);
}
}
/**
* {@inheritDoc}
* <p>
* For any {@code REMOTE_API_*} <b>Connection mode</b>, this starts a new HTTP server (with its own thread pool!)
* listening for HVAC connections.
* <p>
* Additionally for a {@code REMOTE_API_PROXY}, a custom HTTP client is also created, proxying the calls from device
* to Argo servers. The device follows a strange protocol (not fully-compatible with HTTP spec, it seems), which
* drives the need for a custom client with separate set of settings
*
* @implNote The intercepting proxy (if enabled) WILL asynchronously trigger channel/state/properties updates
* through respective callbacks (these are thread-safe!)
*/
@Override
protected IArgoClimaDeviceAPI initializeDeviceApi(ArgoClimaConfigurationLocal config)
throws ArgoRemoteServerStubStartupException, ArgoConfigurationException {
var deviceApi = new ArgoClimaLocalDevice(config, config.getHostname(), config.getHvacListenPort(),
config.getLocalDeviceIP(), config.getDeviceCpuId(), commonHttpClient, timeZoneProvider, i18nProvider,
this::updateChannelsFromDevice, this::updateThingStatusToOnline, this::updateThingProperties,
thing.getUID().toString());
if (config.getConnectionMode() == ConnectionMode.REMOTE_API_PROXY
|| config.getConnectionMode() == ConnectionMode.REMOTE_API_STUB) {
var passthroughClient = Optional.<PassthroughHttpClient> empty();
if (config.getConnectionMode() == ConnectionMode.REMOTE_API_PROXY) {
// new passthrough client for PROXY mode (its lifecycle will be managed by proxy server on startup)
passthroughClient = Optional.of(
new PassthroughHttpClient(Objects.requireNonNull(config.getOemServerAddress().getHostAddress()),
config.getOemServerPort(), clientFactory));
}
var simulatedServer = new RemoteArgoApiServerStub(config.getStubServerListenAddresses(),
config.getStubServerPort(), this.getThing().getUID().toString(), passthroughClient,
Optional.of(deviceApi), config.getIncludeDeviceSidePasswordsInProperties(), i18nProvider);
serverStub = Optional.of(simulatedServer);
try {
simulatedServer.start();
} catch (Exception e1) {
var message = e1.getLocalizedMessage();
if (e1.getCause() instanceof MultiException multiEx) {
// This may cause multiple exceptions in case multiple bind addresses are in use
var multiCause = Objects.requireNonNull(multiEx.getCause());
message = multiCause.toString(); // deliberately not using getLocalizedMessage, as we want the list
}
throw new ArgoRemoteServerStubStartupException(
"[{0} mode] Failed to start RPC server at port: {1,number,#}. Error: {2}",
"thing-status.argoclima.stub-server.start-failure", i18nProvider, config.getConnectionMode(),
config.getStubServerPort(), message);
}
}
return deviceApi;
}
/**
* {@inheritDoc}
* <p>
* In addition to common binding cleanup, also stops passthrough server. The custom HTTP client's lifecycle is
* managed by the server itself, hence will be
* disposed with it
*/
@Override
protected void stopRunningTasks() {
// Stop all common tasks
super.stopRunningTasks();
try {
synchronized (this) {
serverStub.ifPresent(s -> s.shutdown());
serverStub = Optional.empty();
}
} catch (Exception e) {
logger.debug("Exception during handler disposal", e);
}
logger.trace("{}: Disposed", getThing().getUID().getId());
}
}

View File

@ -0,0 +1,89 @@
/**
* 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.argoclima.internal.handler;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.client.HttpClient;
import org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants;
import org.openhab.binding.argoclima.internal.ArgoClimaTranslationProvider;
import org.openhab.binding.argoclima.internal.configuration.ArgoClimaConfigurationRemote;
import org.openhab.binding.argoclima.internal.device.api.ArgoClimaRemoteDevice;
import org.openhab.binding.argoclima.internal.device.api.IArgoClimaDeviceAPI;
import org.openhab.binding.argoclima.internal.exception.ArgoConfigurationException;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.thing.Thing;
/**
* The {@link ArgoClimaHandlerRemote} is responsible for handling commands, which are
* sent to one of the channels. Supports remote device (talking to Argo servers)
*
* @see ArgoClimaHandlerBase
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public class ArgoClimaHandlerRemote extends ArgoClimaHandlerBase<ArgoClimaConfigurationRemote> {
private final HttpClient client;
private final TimeZoneProvider timeZoneProvider;
/**
* C-tor
*
* @param thing The @code Thing} this handler serves (provided by the framework through
* {@link org.openhab.binding.argoclima.internal.ArgoClimaHandlerFactory ArgoClimaHandlerFactory}
* @param clientFactory The framework's HTTP client factory (injected by the runtime to the
* {@code ArgoClimaHandlerFactory})
* @param timeZoneProvider The framework's time zone provider (injected by the runtime to the
* {@code ArgoClimaHandlerFactory})
* @param i18nProvider Framework's translation provider
*/
public ArgoClimaHandlerRemote(Thing thing, HttpClientFactory clientFactory, TimeZoneProvider timeZoneProvider,
final ArgoClimaTranslationProvider i18nProvider) {
super(thing, ArgoClimaBindingConstants.AWAIT_DEVICE_CONFIRMATIONS_AFTER_COMMANDS,
ArgoClimaBindingConstants.POLL_FREQUENCY_AFTER_COMMAND_SENT_REMOTE,
ArgoClimaBindingConstants.SEND_COMMAND_RETRY_FREQUENCY_REMOTE,
ArgoClimaBindingConstants.SEND_COMMAND_MAX_WAIT_TIME_REMOTE,
ArgoClimaBindingConstants.SEND_COMMAND_MAX_WAIT_TIME_REMOTE, i18nProvider);
this.client = clientFactory.getCommonHttpClient();
this.timeZoneProvider = timeZoneProvider;
}
@Override
protected ArgoClimaConfigurationRemote getConfigInternal() throws ArgoConfigurationException {
try {
var ret = getConfigAs(ArgoClimaConfigurationRemote.class); // This can **theoretically** return null if
// class is not default-constructible (but this
// one is, so not handling!)
ret.initialize(i18nProvider);
return ret;
} catch (IllegalArgumentException ex) {
throw ArgoConfigurationException.forInvalidParamValue("Error loading thing configuration",
"thing-status.argoclima.configuration.load-error", i18nProvider, ex);
}
}
/**
* {@inheritDoc}
* <p>
* Initializes API state. Since this mode uses shared HTTP client, this is not creating any new resources, merely
* initializes state
*/
@Override
protected IArgoClimaDeviceAPI initializeDeviceApi(ArgoClimaConfigurationRemote config)
throws ArgoConfigurationException {
return new ArgoClimaRemoteDevice(config, client, timeZoneProvider, i18nProvider, config.getOemServerAddress(),
config.getOemServerPort(), config.getUsername(), config.getPasswordHashed(),
this::updateThingProperties);
}
}

View File

@ -0,0 +1,53 @@
/**
* 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.argoclima.internal.utils;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import javax.xml.bind.DatatypeConverter;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@code PasswordUtils} class provides password manipulation utilities for use in Argo API
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public final class PasswordUtils {
/**
* Get MD5 hash of the configured password (for Basic auth)
*
* @return MD5 hash of password
* @throws NoSuchAlgorithmException In case MD5 is not available in the security provider
* (an impossible condition, hence not handling extra)
*/
public static String md5HashPassword(String password) throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(password.getBytes());
byte[] digest = md.digest();
return DatatypeConverter.printHexBinary(digest).toLowerCase();
}
/**
* Get the masked password used in authenticating to Argo server (for logging)
*
* @implNote Password length is preserved (which may be considered a security weakness, but is useful for
* troubleshooting and given state of Argo API's security... likely is an overkill already :)
* @return {@code ***}-masked string instead of the same length as configured password
*/
public static String maskPassword(String password) {
return password.replaceAll(".", "*");
}
}

View File

@ -0,0 +1,79 @@
/**
* 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.argoclima.internal.utils;
import java.text.MessageFormat;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.regex.Pattern;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@code StringUtils} class provides {@code String} manipulation utilities using standard Java facilities
*
* @implNote The interface is modeled on {@link org.apache.commons.lang3.StringUtils} interface (which is not used as it
* seems frowned upon - ex. due to no support for null-safe annotations)
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
public final class StringUtils {
/**
* Strips any leading and trailing characters that match the input list from the {@code String}.
* Similar to {@link String#trim()} but strips any characters, not only whitespaces.
*
* @implNote API-compatible with {@link org.apache.commons.lang3.StringUtils#strip(String, String)} (except for
* {@code NonNull} annotation)
* @implNote Not a performance-optimized implementation (trying at one would be reinventing the wheel). Uses regular
* expressions internally.
*
* @param str the String to remove characters from
* @param stripChars the characters to remove (not null, to strip whitespaces, use {@link String#trim()})
* @return the stripped String, {@code null} if null String input
*/
public static String strip(String str, final String stripChars) {
if (str.isEmpty()) {
return str;
}
var rxCaptureRange = "[" + Pattern.quote(stripChars) + "]";
var stripCharsCaptureRegex = Objects.requireNonNull(MessageFormat.format("^{0}+|{0}+$", rxCaptureRange));
return str.replaceAll(stripCharsCaptureRegex, "");
}
/**
* Splits the provided text by provided separator. Adjacent separators are treated as one.
* Similar to {@link String#split(String)} but patter is not a regex and removes adjacent separators.
*
* @implNote API-compatible with {@link org.apache.commons.lang3.StringUtils#splitByWholeSeparator(String, String)}
* (except for {@code NonNull} annotation and different return type
*
* @param str the String to split
* @param separator String containing the String to be used as a delimiter
* @return an list of split Strings
*/
public static List<String> splitByWholeSeparator(final String str, final String separator) {
var multiSeparatorPattern = "(" + Pattern.quote(separator) + ")+";
var stripBeginAndEndPatterns = Objects
.requireNonNull(MessageFormat.format("^{0}+|{0}+$", multiSeparatorPattern));
var withoutLeadingAndTrailingSeparators = str.replaceAll(stripBeginAndEndPatterns, "");
if (withoutLeadingAndTrailingSeparators.isEmpty()) {
return List.of();
}
return Arrays.asList(withoutLeadingAndTrailingSeparators.split(multiSeparatorPattern));
}
}

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<addon:addon id="argoclima" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
<type>binding</type>
<name>ArgoClima Binding</name>
<description>This is the binding for ArgoClima. Allows to control Argo HVAC devices.</description>
<connection>hybrid</connection>
</addon:addon>

View File

@ -0,0 +1,176 @@
# add-on
addon.argoclima.name = ArgoClima Binding
addon.argoclima.description = This is the binding for ArgoClima. Allows to control Argo HVAC devices.
# thing types
thing-type.argoclima.local.label = ArgoClima A/C - Local
thing-type.argoclima.local.description = ArgoClima Ulisse Eco 13-compatible Air Conditioner <i>(local LAN connection)</i> <br/> Independent mode - does not require Internet connectivity (see <b>connectionMode</b> setting for more options).
thing-type.argoclima.remote.label = ArgoClima A/C - Remote
thing-type.argoclima.remote.description = ArgoClima Ulisse Eco 13-compatible Air Conditioner <i>(remote connection via Argo servers)</i> <br/><i>Requires Internet connectivity for <b>both</b> openHAB and the HVAC device separately.</i>
# thing types config
thing-type.config.argoclima.local.connectionMode.label = Connection Mode
thing-type.config.argoclima.local.connectionMode.description = Type of connection to use.<br/> <b>- <code>LOCAL_CONNECTION</code></b> - Directly communicates with the AC device over LAN (default mode).<br/> <b>- <code>REMOTE_API_STUB</code></b> - openHAB simulates vendor's remote server <i>(most functionality implemented, w/o FW update support)</i>. Requires traffic from the AC re-routed to this OH instance (network-level configuration, see README). Allows <u>full-local control</u> (w/o Internet access to the device). Can also control units which are not directly reachable over LAN, e.g. behind NAT (set <code>Local IP address</code>).<br /> <b>- <code>REMOTE_API_PROXY</code></b> - openHAB behaves as a proxy to the remote Manufacturer's server (injects OH-side commands). Allows to still use the Vendor's application. WARNING: This mode (similarly to using vanilla Argo WebApp) may be considered insecure <i>(plain HTTP communications, passwords transmitted in the clear, unsigned FW updates...)</i>.<br />
thing-type.config.argoclima.local.connectionMode.option.LOCAL_CONNECTION = LOCAL_CONNECTION - Local only (no remote server stub). (**Default**)
thing-type.config.argoclima.local.connectionMode.option.REMOTE_API_STUB = REMOTE_API_STUB - openHAB as ArgoClima server stub. [Advanced] (**Recommended**)
thing-type.config.argoclima.local.connectionMode.option.REMOTE_API_PROXY = REMOTE_API_PROXY - openHAB as ArgoClima server w/ pass-through to OEM app. [Advanced]
thing-type.config.argoclima.local.deviceCpuId.label = CPU ID
thing-type.config.argoclima.local.deviceCpuId.description = CPU ID of the device. Optional, value is used for detecting device update in proxy mode.
thing-type.config.argoclima.local.group.connection.label = Connection
thing-type.config.argoclima.local.group.connection.description = Connection settings.
thing-type.config.argoclima.local.group.serverStub.label = Argo Server Stub
thing-type.config.argoclima.local.group.serverStub.description = Configuration of openHAB acting as Argo API server (requires proxying device-side traffic to this OH instance). <i>These settings are effective only for <code>REMOTE_API_STUB | REMOTE_API_PROXY</code> connection modes.</i>
thing-type.config.argoclima.local.hostname.label = Hostname
thing-type.config.argoclima.local.hostname.description = Hostname or IP address of the HVAC device.<br/><i>If <b>useLocalConnection</b> setting is <b>enabled</b>, this address will be used for (command push + status polling) communication with the device.</i>
thing-type.config.argoclima.local.hvacListenPort.label = Device API Listen Port
thing-type.config.argoclima.local.hvacListenPort.description = Port at which the local device listens on.<br/> <i>This setting is effective only if <code>Use Local Connection</code> is <code>true</code>. The default value 1001 is hard-coded in Argo Firmware. You would only need to override it if the device is behind NAT, and you have configured a custom port forwarding rule using port other than 1001</i>
thing-type.config.argoclima.local.includeDeviceSidePasswordsInProperties.label = Include Device-Side Passwords
thing-type.config.argoclima.local.includeDeviceSidePasswordsInProperties.description = Whether to include the intercepted passwords to the HVAC as well as the Wi-Fi (sent by the device to Argo servers) as Thing propertis.<br/> <i> (applicable only to <code>REMOTE_*</code> connection modes)</i><br/> <b>IMPORTANT:</b> Regardless of this setting, the values ARE sent in the clear (and w/o HTTPS) by the device to the vendor servers. To prevent them from leaking, you may want to create a firewall rule redirecting HVAC traffic to openHAB <i>(instead of talking to Argo servers)</i>, and use <code>REMOTE_API_STUB</code> mode of this binding.
thing-type.config.argoclima.local.includeDeviceSidePasswordsInProperties.option.NEVER = NEVER - The passwords are not exposed as Thing properties. (**Default**)
thing-type.config.argoclima.local.includeDeviceSidePasswordsInProperties.option.MASKED = MASKED - Intercepted Wi-Fi and HVAC passwords are displayed as properties, but masked with ***.
thing-type.config.argoclima.local.includeDeviceSidePasswordsInProperties.option.CLEARTEXT = CLEARTEXT - Intercepted Wi-Fi and HVAC passwords are displayed as Thing properties, in cleartext.
thing-type.config.argoclima.local.localDeviceIP.label = Local IP Address
thing-type.config.argoclima.local.localDeviceIP.description = Local IP address of the device in its local subnet (may be different from <b>hostname</b>, if behind NAT).<br/> Optional, value is used for detecting device-side updates in one of the <code>REMOTE</code> modes.
thing-type.config.argoclima.local.matchAnyIncomingDeviceIp.label = Match ANY Incoming Device IP
thing-type.config.argoclima.local.matchAnyIncomingDeviceIp.description = If enabled, will <b>not</b> attempt to match the incoming Argo requests by IP (neither <code>'Local IP address (behind NAT)'</code> nor <code>'Hostname'</code>), and instead accept <b>ANY</b> device-side Argo protocol request as matching <b>THIS</b> Thing. <br/> Applicable only to <code>REMOTE_*</code> modes. <i>(not recommended, use only for <b>single</b> Argo HVAC in the network, when its local IP is not known</i>)
thing-type.config.argoclima.local.oemServerAddress.label = Argo Remote Server Address
thing-type.config.argoclima.local.oemServerAddress.description = The OEM server's port, used to pass through the communications to. <br/> <i>This setting is effective only for <code>REMOTE_API_PROXY</code> connection mode</i>
thing-type.config.argoclima.local.oemServerPort.label = Argo Remote Server Port
thing-type.config.argoclima.local.oemServerPort.description = The OEM server's port, used to pass through the communications to<br/> <i>This setting is effective only for <code>REMOTE_API_PROXY</code> connection mode</i>
thing-type.config.argoclima.local.refreshInterval.label = Refresh Interval
thing-type.config.argoclima.local.refreshInterval.description = Interval the device is polled in seconds. This setting is only effective if <code>Use Local Connection</code> is <code>ON</code>.<br/> Set to <code>0</code> to disable polling (requires any of <code>REMOTE_*</code> connection modes)
thing-type.config.argoclima.local.stubServerListenAddresses.label = Stub Server Listen Adresses
thing-type.config.argoclima.local.stubServerListenAddresses.description = List of interfaces the stub server will listen on<br/> <i>This setting is effective only for <code>REMOTE_API_STUB | REMOTE_API_PROXY</code> connection modes</i>
thing-type.config.argoclima.local.stubServerPort.label = Stub Server Listen Port
thing-type.config.argoclima.local.stubServerPort.description = Port at which the Stub server will listen on.<br/> <i>This setting is effective only for <code>REMOTE_*</code> connection modes</i>
thing-type.config.argoclima.local.useLocalConnection.label = Use Local Connection
thing-type.config.argoclima.local.useLocalConnection.description = If enabled, will directly communicate with the device to send commands and poll for its status.<br/> Must be enabled for <code>LOCAL_CONNECTION</code> mode. If disabled (in any of the <code>REMOTE_*</code> connection modes), no push communication will be initiated from OH-side, and commands will be sent on next device-side poll cycle (possible delay)
thing-type.config.argoclima.remote.group.connection.label = Connection
thing-type.config.argoclima.remote.group.connection.description = Connection settings.
thing-type.config.argoclima.remote.group.oemServerConnection.label = Argo Server Connection Details
thing-type.config.argoclima.remote.group.oemServerConnection.description = Configuration of Argo remote server.
thing-type.config.argoclima.remote.oemServerAddress.label = Argo Remote Server Address
thing-type.config.argoclima.remote.oemServerAddress.description = The OEM server's hostname, used for communications.<br/> <i>This is the same host as for the Web Application (address provided in device's user manual). Example: uisetup.ddns.net</i>
thing-type.config.argoclima.remote.oemServerPort.label = Argo Remote Server Port
thing-type.config.argoclima.remote.oemServerPort.description = The OEM server's port. <i>Default 80.</i>
thing-type.config.argoclima.remote.password.label = Password
thing-type.config.argoclima.remote.password.description = Password to access the device
thing-type.config.argoclima.remote.refreshInterval.label = Refresh Interval
thing-type.config.argoclima.remote.refreshInterval.description = Interval the vendor device API is polled in <i>(in seconds)</i>.
thing-type.config.argoclima.remote.username.label = Username
thing-type.config.argoclima.remote.username.description = Username
# channel group types
channel-group-type.argoclima.ac-controls-group.label = A/C Controls
channel-group-type.argoclima.modes-group.label = Operation Modes
channel-group-type.argoclima.modes-group.channel.eco-mode.label = Eco Mode
channel-group-type.argoclima.modes-group.channel.night-mode.label = Night Mode
channel-group-type.argoclima.modes-group.channel.turbo-mode.label = Turbo Mode
channel-group-type.argoclima.settings-group.label = Settings
channel-group-type.argoclima.timers-group.label = Timers
channel-group-type.argoclima.unsupported-group.label = Unsupported Modes
channel-group-type.argoclima.unsupported-group.description = These channels are available on the original Argo remote (and app), but the Ulisse DCI device does not support it.
channel-group-type.argoclima.unsupported-group.channel.filter-mode.label = Filter Mode
# channel types
channel-type.argoclima.active-timer.label = Active Timer
channel-type.argoclima.active-timer.state.option.NO_TIMER = No Timer
channel-type.argoclima.active-timer.state.option.DELAY_TIMER = Delay Timer
channel-type.argoclima.active-timer.state.option.SCHEDULE_TIMER_1 = Schedule Timer 1
channel-type.argoclima.active-timer.state.option.SCHEDULE_TIMER_2 = Schedule Timer 2
channel-type.argoclima.active-timer.state.option.SCHEDULE_TIMER_3 = Schedule Timer 3
channel-type.argoclima.current-temperature.label = Actual Temperature
channel-type.argoclima.delay-timer.label = Delay Timer Value
channel-type.argoclima.device-lights.label = Device Lights
channel-type.argoclima.eco-mode-modifier.label = Eco Mode
channel-type.argoclima.eco-power-limit.label = Power Limit In Eco Mode
channel-type.argoclima.fan-speed.label = Fan Speed
channel-type.argoclima.fan-speed.state.option.AUTO = AUTO
channel-type.argoclima.fan-speed.state.option.LEVEL_1 = Level 1
channel-type.argoclima.fan-speed.state.option.LEVEL_2 = Level 2
channel-type.argoclima.fan-speed.state.option.LEVEL_3 = Level 3
channel-type.argoclima.fan-speed.state.option.LEVEL_4 = Level 4
channel-type.argoclima.fan-speed.state.option.LEVEL_5 = Level 5
channel-type.argoclima.fan-speed.state.option.LEVEL_6 = Level 6
channel-type.argoclima.filter-mode-modifier.label = Filter Mode
channel-type.argoclima.ifeel.label = Use iFeel Temperature
channel-type.argoclima.mode-basic.label = Mode
channel-type.argoclima.mode-basic.state.option.COOL = Cool
channel-type.argoclima.mode-basic.state.option.DRY = Dry
channel-type.argoclima.mode-basic.state.option.FAN = Fan
channel-type.argoclima.mode-basic.state.option.AUTO = Auto
channel-type.argoclima.mode-extended.label = Extended Mode
channel-type.argoclima.mode-extended.state.option.COOL = Cool
channel-type.argoclima.mode-extended.state.option.WARM = Heat
channel-type.argoclima.mode-extended.state.option.DRY = Dry
channel-type.argoclima.mode-extended.state.option.FAN = Fan
channel-type.argoclima.mode-extended.state.option.AUTO = Auto
channel-type.argoclima.night-mode-modifier.label = Night Mode
channel-type.argoclima.set-temperature.label = Set Temperature
channel-type.argoclima.set-temperature.description = The device's target temperature
channel-type.argoclima.swing-mode.label = Airflow Direction
channel-type.argoclima.swing-mode.state.option.AUTO = Swing
channel-type.argoclima.swing-mode.state.option.LEVEL_1 = Swing - Upper Half
channel-type.argoclima.swing-mode.state.option.LEVEL_2 = Static - Lowest
channel-type.argoclima.swing-mode.state.option.LEVEL_3 = Static - Low
channel-type.argoclima.swing-mode.state.option.LEVEL_4 = Static - Mid-low
channel-type.argoclima.swing-mode.state.option.LEVEL_5 = Static - Mid-high
channel-type.argoclima.swing-mode.state.option.LEVEL_6 = Static - High
channel-type.argoclima.swing-mode.state.option.LEVEL_7 = Static - Highest
channel-type.argoclima.temperature-display-unit.label = Temperature Display Unit
channel-type.argoclima.temperature-display-unit.state.option.SCALE_CELSIUS = Degrees Celsius
channel-type.argoclima.temperature-display-unit.state.option.SCALE_FARHENHEIT = Fahrenheit
channel-type.argoclima.turbo-mode-modifier.label = Turbo Mode
# config (dynamic)
dynamic-config.argoclima.group.schedule.label = Schedule {0,number,#}
dynamic-config.argoclima.group.schedule.description = Schedule timer - profile {0,number,#}
dynamic-config.argoclima.group.actions.label = Actions
dynamic-config.argoclima.schedule.days.monday = Monday
dynamic-config.argoclima.schedule.days.tuesday = Tuesday
dynamic-config.argoclima.schedule.days.wednesday = Wednesday
dynamic-config.argoclima.schedule.days.thursday = Thursday
dynamic-config.argoclima.schedule.days.friday = Friday
dynamic-config.argoclima.schedule.days.saturday = Saturday
dynamic-config.argoclima.schedule.days.sunday = Sunday
dynamic-config.argoclima.schedule.days.label = Days
dynamic-config.argoclima.schedule.days.description = Days when the schedule is run
dynamic-config.argoclima.schedule.on-time.label = On Time
dynamic-config.argoclima.schedule.on-time.description = Time when the A/C turns on
dynamic-config.argoclima.schedule.off-time.label = Off Time
dynamic-config.argoclima.schedule.off-time.description = Time when the A/C turns off
dynamic-config.argoclima.schedule.reset.label = Reset Settings
dynamic-config.argoclima.schedule.reset.description = Reset device settings to factory defaults
# thing statuses
thing-status.argoclima.configuration.load-error = Error loading thing configuration
thing-status.argoclima.configuration.invalid-format = Invalid "{0}" value: {1}
thing-status.argoclima.configuration.empty-value = "{0}" is empty
thing-status.argoclima.configuration.value-not-in-range = "{0}" must be in range [{1,number,#}..{2,number,#}]
thing-status.argoclima.configuration.value-below-min = "{0}" must be >= {1,number,#}
thing-status.argoclima.configuration.invalid-combination = Cannot set "{0}" to {1}, when "{2}" is {3}
thing-status.argoclima.local-unreachable = Failed to communicate with Argo HVAC device at [http://{0}:{1,number,#}{2}]. {3}
thing-status.cause.argoclima.exception.caused-by = Caused by: {0}
thing-status.cause.argoclima.exception.unrecognized-response = Unrecognized API response
thing-status.cause.argoclima.invalid-api-response-status = API request yielded invalid response status {0} {1} (expected HTTP 200 OK). URL was: {2}
thing-status.cause.argoclima.device-eof = Device did not respond on its socket (EOF). Check that the device is correctly communicating with Argo servers (or openHAB stub server)
thing-status.cause.argoclima.communication-error = Device communication error: {0}
thing-status.cause.argoclima.communication-error.timeout = Timeout: {0}
thing-status.cause.argoclima.empty-remote-response = The remote API response was empty. Check username and password
thing-status.cause.argoclima.unrecognized-remote-response = The remote API response [{0}] was not recognized
thing-status.cause.argoclima.remote-device-stale = Device was last seen {0} (or more) mins ago (threshold is set at {1} min). Please ensure the HVAC is connected to Wi-Fi and communicating with Argo servers
thing-status.argoclima.awaiting-request = Direct communication with device is disabled. Awaiting device-side request
thing-status.argoclima.invalid-config = Invalid thing configuration. {0}
thing-status.argoclima.poll-failed = Polling for device-side update failed. Unable to communicate with HVAC device for past {0} refresh cycles. Last error: {1}
thing-status.argoclima.confirmation-not-received = Could not control HVAC device. Command(s): {0} were not confirmed by the device within {1} s
thing-status.argoclima.handler-init-failure = Error while initializing Thing: {0}
thing-status.argoclima.stub-server.start-failure = [{0} mode] Failed to start RPC server at port: {1,number,#}. Error: {2}
thing-status.argoclima.stub-server.start-failure.internal = Server startup failure: {0}
thing-status.argoclima.passtrough-client.start-failure = Passthrough API client (for host={0}, port={1,number,#}) failed to start: {2}

View File

@ -0,0 +1,501 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="argoclima"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<thing-type id="local">
<label>ArgoClima A/C - Local</label>
<description><![CDATA[ArgoClima Ulisse Eco 13-compatible Air Conditioner <i>(local LAN connection)</i>
<br/>
Independent mode - does not require Internet connectivity (see <b>connectionMode</b> setting for more options).
]]></description>
<category>HVAC</category>
<channel-groups>
<channel-group id="ac-controls" typeId="ac-controls-group"/>
<channel-group id="modes" typeId="modes-group"/>
<channel-group id="timers" typeId="timers-group"/>
<channel-group id="unsupported" typeId="unsupported-group"/>
<channel-group id="settings" typeId="settings-group"/>
</channel-groups>
<properties>
<property name="vendor">ArgoClima</property>
<property name="modelId">Ulisse Eco 13 DCI - compatible device</property>
<property name="protocol">Wi-Fi - local network</property>
</properties>
<representation-property>hostname</representation-property>
<config-description>
<parameter-group name="connection">
<label>Connection</label>
<description>Connection settings.</description>
</parameter-group>
<parameter-group name="serverStub">
<label>Argo Server Stub</label>
<description><![CDATA[Configuration of openHAB acting as Argo API server (requires proxying device-side traffic to this OH instance).
<i>These settings are effective only for <code>REMOTE_API_STUB | REMOTE_API_PROXY</code> connection modes.</i>]]></description>
</parameter-group>
<!-- Note: dynamic parameters are also in use (see ArgoClimaConfigProvider) -->
<parameter name="hostname" type="text" required="true" readOnly="false" groupName="connection">
<context>network-address</context>
<label>Hostname</label>
<description><![CDATA[Hostname or IP address of the HVAC device.<br/><i>If <b>useLocalConnection</b> setting is <b>enabled</b>, this address will be used for (command push + status polling) communication with the device.</i>]]></description>
</parameter>
<parameter name="hvacListenPort" type="integer" min="0" max="65535" required="false" groupName="connection">
<label>Device API Listen Port</label>
<description><![CDATA[Port at which the local device listens on.<br/>
<i>This setting is effective only if <code>Use Local Connection</code> is <code>true</code>. The default value 1001 is hard-coded in Argo Firmware. You would only need to override it if the device is behind NAT, and you have configured a custom port forwarding rule using port other than 1001</i>]]>
</description>
<default>1001</default>
<advanced>true</advanced>
</parameter>
<parameter name="localDeviceIP" type="text" required="false" readOnly="false" groupName="connection">
<context>network-address</context>
<label>Local IP Address</label>
<description><![CDATA[Local IP address of the device in its local subnet (may be different from <b>hostname</b>, if behind NAT).<br/>
Optional, value is used for detecting device-side updates in one of the <code>REMOTE</code> modes.]]></description>
<advanced>true</advanced>
</parameter>
<parameter name="deviceCpuId" type="text" required="false" readOnly="false" groupName="connection">
<label>CPU ID</label>
<description>CPU ID of the device. Optional, value is used for detecting device update in proxy mode.</description>
<advanced>true</advanced>
</parameter>
<parameter name="connectionMode" type="text" required="true" groupName="connection">
<label>Connection Mode</label>
<description><![CDATA[Type of connection to use.<br/>
<b>- <code>LOCAL_CONNECTION</code></b> - Directly communicates with the AC device over LAN (default mode).<br/>
<b>- <code>REMOTE_API_STUB</code></b> - openHAB simulates vendor's remote server <i>(most functionality implemented, w/o FW update support)</i>. Requires traffic from the AC re-routed to this OH instance (network-level configuration, see README). Allows <u>full-local control</u> (w/o Internet access to the device). Can also control units which are not directly reachable over LAN, e.g. behind NAT (set <code>Local IP address</code>).<br />
<b>- <code>REMOTE_API_PROXY</code></b> - openHAB behaves as a proxy to the remote Manufacturer's server (injects OH-side commands). Allows to still use the Vendor's application. WARNING: This mode (similarly to using vanilla Argo WebApp) may be considered insecure <i>(plain HTTP communications, passwords transmitted in the clear, unsigned FW updates...)</i>.<br />
]]>
</description>
<default>LOCAL_CONNECTION</default>
<options>
<option value="LOCAL_CONNECTION">LOCAL_CONNECTION - Local only (no remote server stub). (**Default**)</option>
<option value="REMOTE_API_STUB">REMOTE_API_STUB - openHAB as ArgoClima server stub. [Advanced] (**Recommended**)</option>
<option value="REMOTE_API_PROXY">REMOTE_API_PROXY - openHAB as ArgoClima server w/ pass-through to OEM app. [Advanced]</option>
</options>
<advanced>false</advanced>
</parameter>
<parameter name="useLocalConnection" type="boolean" required="false" groupName="connection">
<label>Use Local Connection</label>
<description><![CDATA[If enabled, will directly communicate with the device to send commands and poll for its status.<br/>
Must be enabled for <code>LOCAL_CONNECTION</code> mode. If disabled (in any of the <code>REMOTE_*</code> connection modes), no push communication will be initiated from OH-side, and commands will be sent on next device-side poll cycle (possible delay)]]></description>
<default>true</default>
<advanced>true</advanced>
</parameter>
<parameter name="refreshInterval" type="integer" unit="s" min="0" groupName="connection" required="false">
<label>Refresh Interval</label>
<description><![CDATA[Interval the device is polled in seconds. This setting is only effective if <code>Use Local Connection</code> is <code>ON</code>.<br/>
Set to <code>0</code> to disable polling (requires any of <code>REMOTE_*</code> connection modes)]]></description>
<default>30</default>
<advanced>true</advanced>
</parameter>
<parameter name="stubServerPort" type="integer" min="0" max="65535" required="false" groupName="serverStub">
<label>Stub Server Listen Port</label>
<description><![CDATA[Port at which the Stub server will listen on.<br/>
<i>This setting is effective only for <code>REMOTE_*</code> connection modes</i>
]]>
</description>
<default>8239</default>
<advanced>true</advanced>
</parameter>
<parameter name="stubServerListenAddresses" type="text" multiple="true" required="false"
groupName="serverStub">
<context>network-address</context>
<label>Stub Server Listen Adresses</label>
<description><![CDATA[List of interfaces the stub server will listen on<br/>
<i>This setting is effective only for <code>REMOTE_API_STUB | REMOTE_API_PROXY</code> connection modes</i>
]]>
</description>
<default>0.0.0.0</default>
<advanced>true</advanced>
</parameter>
<parameter name="oemServerAddress" type="text" required="false" groupName="serverStub">
<context>network-address</context>
<label>Argo Remote Server Address</label>
<description><![CDATA[The OEM server's port, used to pass through the communications to. <br/>
<i>This setting is effective only for <code>REMOTE_API_PROXY</code> connection mode</i>
]]>
</description>
<default>31.14.128.210</default>
<advanced>true</advanced>
</parameter>
<parameter name="oemServerPort" type="integer" min="0" max="65535" required="false" groupName="serverStub">
<label>Argo Remote Server Port</label>
<description><![CDATA[The OEM server's port, used to pass through the communications to<br/>
<i>This setting is effective only for <code>REMOTE_API_PROXY</code> connection mode</i>
]]>
</description>
<default>80</default>
<advanced>true</advanced>
</parameter>
<parameter name="includeDeviceSidePasswordsInProperties" type="text" required="false"
groupName="serverStub">
<label>Include Device-Side Passwords</label>
<description><![CDATA[Whether to include the intercepted passwords to the HVAC as well as the Wi-Fi (sent by the device to Argo servers) as Thing propertis.<br/>
<i> (applicable only to <code>REMOTE_*</code> connection modes)</i><br/>
<b>IMPORTANT:</b> Regardless of this setting, the values ARE sent in the clear (and w/o HTTPS) by the device to the vendor servers.
To prevent them from leaking, you may want to create a firewall rule redirecting HVAC traffic to openHAB <i>(instead of talking to Argo servers)</i>, and use <code>REMOTE_API_STUB</code> mode of this binding.]]></description>
<default>NEVER</default>
<options>
<option value="NEVER">NEVER - The passwords are not exposed as Thing properties. (**Default**)</option>
<option value="MASKED">MASKED - Intercepted Wi-Fi and HVAC passwords are displayed as properties, but masked with
***.</option>
<option value="CLEARTEXT">CLEARTEXT - Intercepted Wi-Fi and HVAC passwords are displayed as Thing properties, in
cleartext.</option>
</options>
<advanced>true</advanced>
</parameter>
<parameter name="matchAnyIncomingDeviceIp" type="boolean" required="false" groupName="serverStub">
<label>Match ANY Incoming Device IP</label>
<description><![CDATA[If enabled, will <b>not</b> attempt to match the incoming Argo requests by IP (neither <code>'Local IP address (behind NAT)'</code> nor <code>'Hostname'</code>),
and instead accept <b>ANY</b> device-side Argo protocol request as matching <b>THIS</b> Thing.
<br/> Applicable only to <code>REMOTE_*</code> modes.
<i>(not recommended, use only for <b>single</b> Argo HVAC in the network, when its local IP is not known</i>)]]></description>
<default>false</default>
<advanced>true</advanced>
</parameter>
</config-description>
</thing-type>
<thing-type id="remote">
<label>ArgoClima A/C - Remote</label>
<description><![CDATA[ArgoClima Ulisse Eco 13-compatible Air Conditioner <i>(remote connection via Argo servers)</i>
<br/><i>Requires Internet connectivity for <b>both</b> openHAB and the HVAC device separately.</i>
]]></description>
<category>HVAC</category>
<channel-groups>
<channel-group id="ac-controls" typeId="ac-controls-group"/>
<channel-group id="modes" typeId="modes-group"/>
<channel-group id="timers" typeId="timers-group"/>
<channel-group id="unsupported" typeId="unsupported-group"/>
<channel-group id="settings" typeId="settings-group"/>
</channel-groups>
<properties>
<property name="vendor">ArgoClima</property>
<property name="modelId">Ulisse Eco 13 DCI - compatible device</property>
<property name="protocol">Remote connection</property>
</properties>
<representation-property>username</representation-property>
<config-description>
<parameter-group name="connection">
<label>Connection</label>
<description>Connection settings.</description>
</parameter-group>
<parameter-group name="oemServerConnection">
<label>Argo Server Connection Details</label>
<description><![CDATA[Configuration of Argo remote server.]]></description>
</parameter-group>
<parameter name="username" type="text" required="true" readOnly="false" groupName="connection">
<label>Username</label>
<description>Username</description>
</parameter>
<parameter name="password" type="text" required="true" groupName="connection">
<context>password</context>
<label>Password</label>
<description>Password to access the device</description>
</parameter>
<parameter name="refreshInterval" type="integer" unit="s" min="10" required="false" groupName="connection">
<label>Refresh Interval</label>
<description><![CDATA[Interval the vendor device API is polled in <i>(in seconds)</i>.]]></description>
<default>30</default>
<advanced>true</advanced>
</parameter>
<parameter name="oemServerAddress" type="text" required="false" groupName="oemServerConnection">
<context>network-address</context>
<label>Argo Remote Server Address</label>
<description><![CDATA[The OEM server's hostname, used for communications.<br/>
<i>This is the same host as for the Web Application (address provided in device's user manual). Example: uisetup.ddns.net</i>
]]>
</description>
<default>31.14.128.210</default>
<advanced>true</advanced>
</parameter>
<parameter name="oemServerPort" type="integer" min="0" max="65535" required="false"
groupName="oemServerConnection">
<label>Argo Remote Server Port</label>
<description><![CDATA[The OEM server's port. <i>Default 80.</i>]]>
</description>
<default>80</default>
<advanced>true</advanced>
</parameter>
</config-description>
</thing-type>
<channel-group-type id="ac-controls-group">
<label>A/C Controls</label>
<category>HVAC</category>
<channels>
<channel id="power" typeId="system.power"/>
<channel id="mode" typeId="mode-basic"/>
<channel id="set-temperature" typeId="set-temperature"/>
<channel id="current-temperature" typeId="system.indoor-temperature"/>
<channel id="fan-speed" typeId="fan-speed"/>
</channels>
</channel-group-type>
<channel-group-type id="modes-group">
<label>Operation Modes</label>
<channels>
<channel id="eco-mode" typeId="eco-mode-modifier">
<label>Eco Mode</label>
</channel>
<channel id="turbo-mode" typeId="turbo-mode-modifier">
<label>Turbo Mode</label>
</channel>
<channel id="night-mode" typeId="night-mode-modifier">
<label>Night Mode</label>
</channel>
</channels>
</channel-group-type>
<channel-group-type id="timers-group">
<label>Timers</label>
<category>Time</category>
<channels>
<channel id="active-timer" typeId="active-timer"/>
<channel id="delay-timer" typeId="delay-timer"/>
</channels>
</channel-group-type>
<channel-group-type id="unsupported-group">
<label>Unsupported Modes</label>
<description><![CDATA[These channels are available on the original Argo remote (and app), but the Ulisse DCI device does not support it.]]></description>
<channels>
<channel id="mode-ex" typeId="mode-extended"/>
<channel id="swing-mode" typeId="swing-mode"/>
<channel id="filter-mode" typeId="filter-mode-modifier">
<label>Filter Mode</label>
</channel>
</channels>
</channel-group-type>
<channel-group-type id="settings-group">
<label>Settings</label>
<channels>
<channel id="ifeel-enabled" typeId="ifeel"/>
<channel id="device-lights" typeId="device-lights"/>
<channel id="temperature-display-unit" typeId="temperature-display-unit"/>
<channel id="eco-power-limit" typeId="eco-power-limit"/>
</channels>
</channel-group-type>
<!--
*** CHANNELS ***
-->
<channel-type id="mode-basic">
<item-type>String</item-type>
<label>Mode</label>
<category>Climate</category>
<tags>
<tag>Control</tag>
<tag>Climate</tag>
</tags>
<state readOnly="false">
<options>
<option value="COOL">Cool</option>
<option value="DRY">Dry</option>
<option value="FAN">Fan</option>
<option value="AUTO">Auto</option>
</options>
</state>
</channel-type>
<channel-type id="set-temperature">
<item-type>Number:Temperature</item-type>
<label>Set Temperature</label>
<description>The device's target temperature</description>
<category>Temperature</category>
<tags>
<tag>Setpoint</tag>
<tag>Temperature</tag>
</tags>
<state min="10" max="36" step="0.5" pattern="%.1f %unit%" readOnly="false"/>
</channel-type>
<channel-type id="current-temperature">
<item-type>Number:Temperature</item-type>
<label>Actual Temperature</label>
<category>Temperature</category>
<tags>
<tag>Measurement</tag>
<tag>Temperature</tag>
</tags>
<state readOnly="true" pattern="%.1f %unit%"/>
</channel-type>
<channel-type id="fan-speed">
<item-type>String</item-type>
<label>Fan Speed</label>
<category>Fan</category>
<state readOnly="false">
<options>
<option value="AUTO">AUTO</option>
<option value="LEVEL_1">Level 1</option>
<option value="LEVEL_2">Level 2</option>
<option value="LEVEL_3">Level 3</option>
<option value="LEVEL_4">Level 4</option>
<option value="LEVEL_5">Level 5</option>
<option value="LEVEL_6">Level 6</option>
</options>
</state>
</channel-type>
<channel-type id="eco-mode-modifier">
<item-type>Switch</item-type>
<label>Eco Mode</label>
<category>Vacation</category>
<state readOnly="false"/>
</channel-type>
<channel-type id="turbo-mode-modifier">
<item-type>Switch</item-type>
<label>Turbo Mode</label>
<category>Party</category>
<state readOnly="false"/>
</channel-type>
<channel-type id="filter-mode-modifier" advanced="true">
<item-type>Switch</item-type>
<label>Filter Mode</label>
<category>Switch</category>
<state readOnly="false"/>
</channel-type>
<channel-type id="night-mode-modifier">
<item-type>Switch</item-type>
<label>Night Mode</label>
<category>Moon</category>
<state readOnly="false"/>
</channel-type>
<channel-type id="active-timer" advanced="true">
<item-type>String</item-type>
<label>Active Timer</label>
<category>Calendar</category>
<state readOnly="false">
<options>
<option value="NO_TIMER">No Timer</option>
<option value="DELAY_TIMER">Delay Timer</option>
<option value="SCHEDULE_TIMER_1">Schedule Timer 1</option>
<option value="SCHEDULE_TIMER_2">Schedule Timer 2</option>
<option value="SCHEDULE_TIMER_3">Schedule Timer 3</option>
</options>
</state>
</channel-type>
<channel-type id="delay-timer" advanced="true">
<item-type>Number:Time</item-type> <!-- Number:Time -->
<label>Delay Timer Value</label>
<category>Time</category>
<!-- Argo remote supports a delay param in range of 0:10...19:50 (in 10-minute increments)
Note we're not using %unit% here, as default time unit is seconds... which won't be too readable here
-->
<tags>
<tag>Setpoint</tag>
<tag>Level</tag> <!-- using Level instead of Duration semantic property, as it renders better Main UI widget by default -->
</tags>
<state readOnly="false" min="10" max="1190" step="10.0" pattern="%.0f min"/>
</channel-type>
<channel-type id="mode-extended" advanced="true">
<item-type>String</item-type>
<label>Extended Mode</label>
<category>Heating</category>
<state readOnly="false">
<options>
<option value="COOL">Cool</option>
<option value="WARM">Heat</option>
<option value="DRY">Dry</option>
<option value="FAN">Fan</option>
<option value="AUTO">Auto</option>
</options>
</state>
</channel-type>
<channel-type id="swing-mode" advanced="true">
<item-type>String</item-type>
<label>Airflow Direction</label>
<category>Flow</category>
<state readOnly="false">
<options>
<option value="AUTO">Swing</option>
<option value="LEVEL_1">Swing - Upper Half</option>
<option value="LEVEL_2">Static - Lowest</option>
<option value="LEVEL_3">Static - Low</option>
<option value="LEVEL_4">Static - Mid-low</option>
<option value="LEVEL_5">Static - Mid-high</option>
<option value="LEVEL_6">Static - High</option>
<option value="LEVEL_7">Static - Highest</option>
</options>
</state>
</channel-type>
<channel-type id="ifeel">
<item-type>Switch</item-type>
<label>Use iFeel Temperature</label>
<category>Network</category>
<state readOnly="false"/>
</channel-type>
<channel-type id="device-lights">
<item-type>Switch</item-type>
<label>Device Lights</label>
<category>Light</category>
<state readOnly="false"/>
</channel-type>
<channel-type id="temperature-display-unit" advanced="true">
<item-type>String</item-type>
<label>Temperature Display Unit</label>
<category>Settings</category>
<state readOnly="false">
<options>
<option value="SCALE_CELSIUS">Degrees Celsius</option>
<option value="SCALE_FARHENHEIT">Fahrenheit</option>
</options>
</state>
</channel-type>
<channel-type id="eco-power-limit" advanced="true">
<item-type unitHint="%">Number:Dimensionless</item-type>
<label>Power Limit In Eco Mode</label>
<category>Price</category>
<tags>
<tag>Setpoint</tag>
<tag>Level</tag>
</tags>
<state readOnly="false" min="30" max="99" step="1" pattern="%.0f %unit%"/>
</channel-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,72 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.argoclima.internal.utils;
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
/**
* UT for {@link org.openhab.binding.argoclima.internal.utils.StringUtils
*
* @author Mateusz Bronk - Initial contribution
*/
@NonNullByDefault
class StringUtilsTest {
/**
* Test method for
* {@link org.openhab.binding.argoclima.internal.utils.StringUtils#strip(java.lang.String, java.lang.String)}.
*/
@Test
void testStrip() {
assertEquals("", StringUtils.strip("", "a n y t h \t\n g"));
assertEquals(" abc", StringUtils.strip(" abcyx", "xyz"));
assertEquals("", StringUtils.strip(" ", " "));
assertEquals("", StringUtils.strip(" ", "\s"), "Whitespace as Java-escaped char");
assertEquals("\t", StringUtils.strip(" \t", " "), "Match only exact whitespace type");
assertEquals("", StringUtils.strip(" \t", " \t"), "Match only exact whitespace type");
assertEquals("test test", StringUtils.strip(" test test ", " "), "No middle removal");
assertEquals("in", StringUtils.strip("begin", "geb"));
assertEquals("e", StringUtils.strip("end\n", "dn\n"));
assertEquals("TEST", StringUtils.strip("TEST", "test"), "Case-sensitive");
assertEquals("", StringUtils.strip("abcxyz", "aabbcczzyyxx"), "Repteat pattern characters");
assertEquals("text ", StringUtils.strip(".\\(){}text -[]", ".\\()[]{}-"), "Regex special characters");
assertEquals("quoted", StringUtils.strip("\"quoted\"", "\""));
assertEquals("quoted", StringUtils.strip("'quoted'", "'"));
assertEquals("bracketed", StringUtils.strip("[ -\t'bracketed' ]", "[]- \t\"'"));
assertEquals("SUN, MON", StringUtils.strip("[SUN, MON]", "[]{}()"));
}
/**
* Test method for
* {@link org.openhab.binding.argoclima.internal.utils.StringUtils#splitByWholeSeparator(String, String)}.
*/
@Test
void testSplitByWholeSeparator() {
assertIterableEquals(List.<String> of(), StringUtils.splitByWholeSeparator("", "anything"));
assertIterableEquals(List.<String> of(), StringUtils.splitByWholeSeparator("###", "#"));
assertIterableEquals(List.of("one", "two=-three"), StringUtils.splitByWholeSeparator("one-=two=-three", "-="));
assertIterableEquals(List.of("ab", "de", "fg"), StringUtils.splitByWholeSeparator("ab de fg", " "));
assertIterableEquals(List.of("ab", "de", "fg"), StringUtils.splitByWholeSeparator("ab de fg", " "));
assertIterableEquals(List.of("ab", "de", "fg"), StringUtils.splitByWholeSeparator(" ab de fg ", " "));
assertIterableEquals(List.of("ab", "cd", "ef"), StringUtils.splitByWholeSeparator("ab:cd:ef", ":"));
assertIterableEquals(List.of("ab", "cd", "ef"), StringUtils.splitByWholeSeparator("ab-!-cd-!-ef", "-!-"));
assertIterableEquals(List.of("ab", "cd", "ef"), StringUtils.splitByWholeSeparator("ab#cd#ef", "#"));
assertIterableEquals(List.of("ab", "cd", "ef"), StringUtils.splitByWholeSeparator("ab###cd##ef", "#"));
assertIterableEquals(List.of("ab", "cd", "ef"), StringUtils.splitByWholeSeparator("####ab#cd#ef#", "#"));
}
}

View File

@ -62,6 +62,7 @@
<module>org.openhab.binding.androidtv</module>
<module>org.openhab.binding.anel</module>
<module>org.openhab.binding.anthem</module>
<module>org.openhab.binding.argoclima</module>
<module>org.openhab.binding.astro</module>
<module>org.openhab.binding.asuswrt</module>
<module>org.openhab.binding.atlona</module>