From a82cdfedfb2d417a62acf6c733e40f68da132b06 Mon Sep 17 00:00:00 2001 From: paphko Date: Mon, 29 Nov 2021 09:45:29 +0100 Subject: [PATCH] [anel] Initial contribution of the Anel NET-PwrCtrl binding for OH3 (#10952) * Initial contribution of the Anel NET-PwrCtrl binding for OH3. Signed-off-by: Patrick Koenemann * Adjustments based on code review. Signed-off-by: Patrick Koenemann * Further adjustments according to second review. Signed-off-by: Patrick Koenemann * Checkstyle warnings revmoed. Signed-off-by: Patrick Koenemann --- CODEOWNERS | 1 + bom/openhab-addons/pom.xml | 5 + bundles/org.openhab.binding.anel/NOTICE | 13 + bundles/org.openhab.binding.anel/README.md | 231 ++++++++++++ bundles/org.openhab.binding.anel/pom.xml | 17 + .../src/main/feature/feature.xml | 9 + .../anel/internal/AnelConfiguration.java | 67 ++++ .../binding/anel/internal/AnelHandler.java | 356 ++++++++++++++++++ .../anel/internal/AnelHandlerFactory.java | 48 +++ .../anel/internal/AnelUdpConnector.java | 263 +++++++++++++ .../binding/anel/internal/IAnelConstants.java | 123 ++++++ .../internal/auth/AnelAuthentication.java | 98 +++++ .../discovery/AnelDiscoveryService.java | 210 +++++++++++ .../internal/state/AnelCommandHandler.java | 116 ++++++ .../anel/internal/state/AnelState.java | 308 +++++++++++++++ .../anel/internal/state/AnelStateUpdater.java | 216 +++++++++++ .../main/resources/OH-INF/binding/binding.xml | 9 + .../main/resources/OH-INF/config/config.xml | 39 ++ .../resources/OH-INF/thing/thing-types.xml | 201 ++++++++++ .../anel/internal/AnelAuthenticationTest.java | 94 +++++ .../anel/internal/AnelCommandHandlerTest.java | 179 +++++++++ .../binding/anel/internal/AnelStateTest.java | 185 +++++++++ .../anel/internal/AnelStateUpdaterTest.java | 142 +++++++ .../anel/internal/AnelUdpConnectorTest.java | 185 +++++++++ .../anel/internal/IAnelTestStatus.java | 47 +++ bundles/pom.xml | 1 + 26 files changed, 3163 insertions(+) create mode 100644 bundles/org.openhab.binding.anel/NOTICE create mode 100644 bundles/org.openhab.binding.anel/README.md create mode 100644 bundles/org.openhab.binding.anel/pom.xml create mode 100644 bundles/org.openhab.binding.anel/src/main/feature/feature.xml create mode 100644 bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/AnelConfiguration.java create mode 100644 bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/AnelHandler.java create mode 100644 bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/AnelHandlerFactory.java create mode 100644 bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/AnelUdpConnector.java create mode 100644 bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/IAnelConstants.java create mode 100644 bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/auth/AnelAuthentication.java create mode 100644 bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/discovery/AnelDiscoveryService.java create mode 100644 bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/state/AnelCommandHandler.java create mode 100644 bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/state/AnelState.java create mode 100644 bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/state/AnelStateUpdater.java create mode 100644 bundles/org.openhab.binding.anel/src/main/resources/OH-INF/binding/binding.xml create mode 100644 bundles/org.openhab.binding.anel/src/main/resources/OH-INF/config/config.xml create mode 100644 bundles/org.openhab.binding.anel/src/main/resources/OH-INF/thing/thing-types.xml create mode 100644 bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelAuthenticationTest.java create mode 100644 bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelCommandHandlerTest.java create mode 100644 bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelStateTest.java create mode 100644 bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelStateUpdaterTest.java create mode 100644 bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelUdpConnectorTest.java create mode 100644 bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/IAnelTestStatus.java diff --git a/CODEOWNERS b/CODEOWNERS index f8022d6d8332..3b49605401ad 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -23,6 +23,7 @@ /bundles/org.openhab.binding.ambientweather/ @mhilbush /bundles/org.openhab.binding.amplipi/ @kaikreuzer /bundles/org.openhab.binding.androiddebugbridge/ @GiviMAD +/bundles/org.openhab.binding.anel/ @paphko /bundles/org.openhab.binding.astro/ @gerrieg /bundles/org.openhab.binding.atlona/ @tmrobert8 /bundles/org.openhab.binding.autelis/ @digitaldan diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index 9a6dd839a540..4fe676379221 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -106,6 +106,11 @@ org.openhab.binding.androiddebugbridge ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.anel + ${project.version} + org.openhab.addons.bundles org.openhab.binding.astro diff --git a/bundles/org.openhab.binding.anel/NOTICE b/bundles/org.openhab.binding.anel/NOTICE new file mode 100644 index 000000000000..38d625e34923 --- /dev/null +++ b/bundles/org.openhab.binding.anel/NOTICE @@ -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 diff --git a/bundles/org.openhab.binding.anel/README.md b/bundles/org.openhab.binding.anel/README.md new file mode 100644 index 000000000000..e2189cb5c8f1 --- /dev/null +++ b/bundles/org.openhab.binding.anel/README.md @@ -0,0 +1,231 @@ +# Anel NET-PwrCtrl Binding + +Monitor and control Anel NET-PwrCtrl devices. + +NET-PwrCtrl devices are power sockets / relays that can be configured via browser but they can also be controlled over the network, e.g. with an Android or iPhone app - and also with openHAB via this binding. +Some NET-PwrCtrl devices also have 8 I/O ports which can either be used to directly switch the sockets / relays, or they can be used as general input / output switches in openHAB. + + +## Supported Things + +There are three kinds of devices ([overview on manufacturer's homepage](https://en.anel.eu/?src=/produkte/produkte.htm)): + +| [Anel NET-PwrCtrl HUT](https://en.anel.eu/?src=/produkte/hut_2/hut_2.htm)
( _advanced-firmware_ ) | [Anel NET-PwrCtrl IO](https://en.anel.eu/?src=/produkte/io/io.htm)
( _advanced-firmware_ ) | [Anel NET-PwrCtrl HOME](https://de.anel.eu/?src=produkte/home/home.htm)
( _home_ )
(only German version) | +| --- | --- | --- | +| [![Anel NET-PwrCtrl HUT 2](https://de.anel.eu/image/leisten/HUT2LV-P_500.jpg)](https://de.anel.eu/?src=produkte/hut_2/hut_2.htm) | [![Anel NET-PwrCtrl IO](https://de.anel.eu/image/leisten/IO-Stecker.png)](https://de.anel.eu/?src=produkte/io/io.htm) | [![Anel NET-PwrCtrl HOME](https://de.anel.eu/image/leisten/HOME-DE-500.gif)](https://de.anel.eu/?src=produkte/home/home.htm) | + +Thing type IDs: + +* *home*: The smallest device, the _HOME_, is the only one with only three power sockets and only available in Germany. +* *simple-firmware*: The _PRO_ and _REDUNDANT_ have eight power sockets and a similar (simplified) firmware as the _HOME_. +* *advanced-firmware*: All others (_ADV_, _IO_, and the different _HUT_ variants) have eight power sockets / relays, eight IO ports, and an advanced firmware. + +An [additional sensor](https://en.anel.eu/?src=/produkte/sensor_1/sensor_1.htm) may be used for monitoring temperature, humidity, and brightness. +The sensor can be attached to a _HUT_ device via an Ethernet cable (max length is 50m). + + +## Discovery + +Devices can be discovered automatically if their UDP ports are configured as follows: + +* 75 / 77 (default) +* 750 / 770 +* 7500 / 7700 +* 7750 / 7770 + +If a device is found for a specific port (excluding the default port), the subsequent port is also scanned, e.g. 7500/7700 → 7501/7701 → 7502/7702 → etc. + +Depending on the network switch and router devices, discovery may or may not work on wireless networks. +It should work reliably though on local wired networks. + + +## Thing Configuration + +Each Thing requires the following configuration parameters. + +| Parameter | Type | Default | Required | Description | +|-----------------------|---------|-------------|----------|-------------| +| Hostname / IP address | String | net-control | yes | Hostname or IP address of the device | +| Send Port | Integer | 75 | yes | UDP port to send data to the device (in the anel web UI, it's the receive port!) | +| Receive Port | Integer | 77 | yes | UDP port to receive data from the device (in the anel web UI, it's the send port!) | +| User | String | user7 | yes | User to access the device (make sure it has rights to change relay / IO states!) | +| Password | String | anel | yes | Password of the given user | + +For multiple devices, please use exclusive UDP ports for each device. +Ports above 1024 are recommended because they are outside the range of system ports. + +Possible entries in your thing file could be (thing types _home_, _simple-firmware_, and _advanced-firmware_ are explained above in _Supported Things_): + +``` +anel:home:mydevice1 [hostname="192.168.0.101", udpSendPort=7500, udpReceivePort=7700, user="user7", password="anel"] +anel:simple-firmware:mydevice2 [hostname="192.168.0.102", udpSendPort=7501, udpReceivePort=7701, user="user7", password="anel"] +anel:advanced-firmware:mydevice3 [hostname="192.168.0.103", udpSendPort=7502, udpReceivePort=7702, user="user7", password="anel"] +anel:advanced-firmware:mydevice4 [hostname="192.168.0.104", udpSendPort=7503, udpReceivePort=7703, user="user7", password="anel"] +``` + + +## Channels + +Depending on the thing type, the following channels are available. + +| Channel ID | Item Type | Supported Things | Read Only | Description | +|--------------------|--------------------|-------------------|-----------|-------------| +| prop#name | String | all | yes | Name of the device | +| prop#temperature | Number:Temperature | simple / advanced | yes | Temperature of the integrated sensor | +| sensor#temperature | Number:Temperature | advanced | yes | Temperature of the optional external sensor | +| sensor#humidity | Number | advanced | yes | Humidity of the optional external sensor | +| sensor#brightness | Number | advanced | yes | Brightness of the optional external sensor | +| r1#name | String | all | yes | Name of relay / socket 1 | +| r2#name | String | all | yes | Name of relay / socket 2 | +| r3#name | String | all | yes | Name of relay / socket 3 | +| r4#name | String | simple / advanced | yes | Name of relay / socket 4 | +| r5#name | String | simple / advanced | yes | Name of relay / socket 5 | +| r6#name | String | simple / advanced | yes | Name of relay / socket 6 | +| r7#name | String | simple / advanced | yes | Name of relay / socket 7 | +| r8#name | String | simple / advanced | yes | Name of relay / socket 8 | +| r1#state | Switch | all | no * | State of relay / socket 1 | +| r2#state | Switch | all | no * | State of relay / socket 2 | +| r3#state | Switch | all | no * | State of relay / socket 3 | +| r4#state | Switch | simple / advanced | no * | State of relay / socket 4 | +| r5#state | Switch | simple / advanced | no * | State of relay / socket 5 | +| r6#state | Switch | simple / advanced | no * | State of relay / socket 6 | +| r7#state | Switch | simple / advanced | no * | State of relay / socket 7 | +| r8#state | Switch | simple / advanced | no * | State of relay / socket 8 | +| r1#locked | Switch | all | yes | Whether or not relay / socket 1 is locked | +| r2#locked | Switch | all | yes | Whether or not relay / socket 2 is locked | +| r3#locked | Switch | all | yes | Whether or not relay / socket 3 is locked | +| r4#locked | Switch | simple / advanced | yes | Whether or not relay / socket 4 is locked | +| r5#locked | Switch | simple / advanced | yes | Whether or not relay / socket 5 is locked | +| r6#locked | Switch | simple / advanced | yes | Whether or not relay / socket 6 is locked | +| r7#locked | Switch | simple / advanced | yes | Whether or not relay / socket 7 is locked | +| r8#locked | Switch | simple / advanced | yes | Whether or not relay / socket 8 is locked | +| io1#name | String | advanced | yes | Name of IO port 1 | +| io2#name | String | advanced | yes | Name of IO port 2 | +| io3#name | String | advanced | yes | Name of IO port 3 | +| io4#name | String | advanced | yes | Name of IO port 4 | +| io5#name | String | advanced | yes | Name of IO port 5 | +| io6#name | String | advanced | yes | Name of IO port 6 | +| io7#name | String | advanced | yes | Name of IO port 7 | +| io8#name | String | advanced | yes | Name of IO port 8 | +| io1#state | Switch | advanced | no ** | State of IO port 1 | +| io2#state | Switch | advanced | no ** | State of IO port 2 | +| io3#state | Switch | advanced | no ** | State of IO port 3 | +| io4#state | Switch | advanced | no ** | State of IO port 4 | +| io5#state | Switch | advanced | no ** | State of IO port 5 | +| io6#state | Switch | advanced | no ** | State of IO port 6 | +| io7#state | Switch | advanced | no ** | State of IO port 7 | +| io8#state | Switch | advanced | no ** | State of IO port 8 | +| io1#mode | Switch | advanced | yes | Mode of port 1: _ON_ = input, _OFF_ = output | +| io2#mode | Switch | advanced | yes | Mode of port 2: _ON_ = input, _OFF_ = output | +| io3#mode | Switch | advanced | yes | Mode of port 3: _ON_ = input, _OFF_ = output | +| io4#mode | Switch | advanced | yes | Mode of port 4: _ON_ = input, _OFF_ = output | +| io5#mode | Switch | advanced | yes | Mode of port 5: _ON_ = input, _OFF_ = output | +| io6#mode | Switch | advanced | yes | Mode of port 6: _ON_ = input, _OFF_ = output | +| io7#mode | Switch | advanced | yes | Mode of port 7: _ON_ = input, _OFF_ = output | +| io8#mode | Switch | advanced | yes | Mode of port 8: _ON_ = input, _OFF_ = output | + +\* Relay / socket state is read-only if it is locked; otherwise it is changeable.
+\** IO port state is read-only if its mode is _input_, it is changeable if its mode is _output_. + + +## Full Example + +`.things` file: + +``` +Thing anel:advanced-firmware:anel1 "Anel1" [hostname="192.168.0.100", udpSendPort=7500, udpReceivePort=7700, user="user7", password="anel"] +``` + +`.items` file: + +``` +// device properties +String anel1name "Anel1 Name" {channel="anel:advanced-firmware:anel1:prop#name"} +Number:Temperature anel1temperature "Anel1 Temperature" {channel="anel:advanced-firmware:anel1:prop#temperature"} + +// external sensor properties +Number:Temperature anel1sensorTemperature "Anel1 Sensor Temperature" {channel="anel:advanced-firmware:anel1:sensor#temperature"} +Number anel1sensorHumidity "Anel1 Sensor Humidity" {channel="anel:advanced-firmware:anel1:sensor#humidity"} +Number anel1sensorBrightness "Anel1 Sensor Brightness" {channel="anel:advanced-firmware:anel1:sensor#brightness"} + +// relay names and states +String anel1relay1name "Anel1 Relay1 name" {channel="anel:advanced-firmware:anel1:r1#name"} +Switch anel1relay1locked "Anel1 Relay1 locked" {channel="anel:advanced-firmware:anel1:r1#locked"} +Switch anel1relay1state "Anel1 Relay1" {channel="anel:advanced-firmware:anel1:r1#state"} +Switch anel1relay2state "Anel1 Relay2" {channel="anel:advanced-firmware:anel1:r2#state"} +Switch anel1relay3state "Anel1 Relay3" {channel="anel:advanced-firmware:anel1:r3#state"} +Switch anel1relay4state "Anel1 Relay4" {channel="anel:advanced-firmware:anel1:r4#state"} +Switch anel1relay5state "Light Bedroom" {channel="anel:advanced-firmware:anel1:r5#state"} +Switch anel1relay6state "Doorbell" {channel="anel:advanced-firmware:anel1:r6#state"} +Switch anel1relay7state "Socket TV" {channel="anel:advanced-firmware:anel1:r7#state"} +Switch anel1relay8state "Socket Terrace" {channel="anel:advanced-firmware:anel1:r8#state"} + +// IO port names and states +String anel1io1name "Anel1 IO1 name" {channel="anel:advanced-firmware:anel1:io1#name"} +Switch anel1io1mode "Anel1 IO1 mode" {channel="anel:advanced-firmware:anel1:io1#mode"} +Switch anel1io1state "Anel1 IO1" {channel="anel:advanced-firmware:anel1:io1#state"} +Switch anel1io2state "Anel1 IO2" {channel="anel:advanced-firmware:anel1:io2#state"} +Switch anel1io3state "Anel1 IO3" {channel="anel:advanced-firmware:anel1:io3#state"} +Switch anel1io4state "Anel1 IO4" {channel="anel:advanced-firmware:anel1:io4#state"} +Switch anel1io5state "Switch Bedroom" {channel="anel:advanced-firmware:anel1:io5#state"} +Switch anel1io6state "Doorbell" {channel="anel:advanced-firmware:anel1:io6#state"} +Switch anel1io7state "Switch Office" {channel="anel:advanced-firmware:anel1:io7#state"} +Switch anel1io8state "Reed Contact Door" {channel="anel:advanced-firmware:anel1:io8#state"} +``` + +`.sitemap` file: + +``` +sitemap anel label="Anel NET-PwrCtrl" { + Frame label="Device and Sensor" { + Text item=anel1name label="Anel1 Name" + Text item=anel1temperature label="Anel1 Temperature [%.1f °C]" + Text item=anel1sensorTemperature label="Anel1 Sensor Temperature [%.1f °C]" + Text item=anel1sensorHumidity label="Anel1 Sensor Humidity [%.1f]" + Text item=anel1sensorBrightness label="Anel1 Sensor Brightness [%.1f]" + } + Frame label="Relays" { + Text item=anel1relay1name label="Relay 1 name" labelcolor=[anel1relay1locked==ON="green",anel1relay1locked==OFF="maroon"] + Switch item=anel1relay1state + Switch item=anel1relay2state + Switch item=anel1relay3state + Switch item=anel1relay4state + Switch item=anel1relay5state + Switch item=anel1relay6state + Switch item=anel1relay7state + Switch item=anel1relay8state + } + Frame label="IO Ports" { + Text item=anel1io1name label="IO 1 name" labelcolor=[anel1io1mode==OFF="green",anel1io1mode==ON="maroon"] + Switch item=anel1io1state + Switch item=anel1io2state + Switch item=anel1io3state + Switch item=anel1io4state + Switch item=anel1io5state + Switch item=anel1io6state + Switch item=anel1io7state + Switch item=anel1io8state + } +} +``` + +The relay / IO port names are rarely useful because you probably set similar (static) labels for the state items.
+The locked state / IO mode is also rarely relevant in practice, because it typically doesn't change. + +`.rules` file: + +``` +rule "doorbell only at daytime" +when Item anel1io6state changed then + if (now.getHoursOfDay >= 6 && now.getHoursOfDay <= 22) { + anel1relay6state.sendCommand(if (anel1io6state.state != ON) ON else OFF) + } + someNotificationItem.sendCommand("Someone just rang the doorbell") +end +``` + + +## Reference Documentation + +The UDP protocol of Anel devices is explained [here](https://forum.anel.eu/viewtopic.php?f=16&t=207). + diff --git a/bundles/org.openhab.binding.anel/pom.xml b/bundles/org.openhab.binding.anel/pom.xml new file mode 100644 index 000000000000..325a69c4de77 --- /dev/null +++ b/bundles/org.openhab.binding.anel/pom.xml @@ -0,0 +1,17 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 3.2.0-SNAPSHOT + + + org.openhab.binding.anel + + openHAB Add-ons :: Bundles :: Anel Binding + + diff --git a/bundles/org.openhab.binding.anel/src/main/feature/feature.xml b/bundles/org.openhab.binding.anel/src/main/feature/feature.xml new file mode 100644 index 000000000000..a4b8497ba31d --- /dev/null +++ b/bundles/org.openhab.binding.anel/src/main/feature/feature.xml @@ -0,0 +1,9 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.binding.anel/${project.version} + + diff --git a/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/AnelConfiguration.java b/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/AnelConfiguration.java new file mode 100644 index 000000000000..a30f77f3dc9b --- /dev/null +++ b/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/AnelConfiguration.java @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2010-2021 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.anel.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * The {@link AnelConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Patrick Koenemann - Initial contribution + */ +@NonNullByDefault +public class AnelConfiguration { + + public @Nullable String hostname; + public @Nullable String user; + public @Nullable String password; + /** Port to send data from openhab to device. */ + public int udpSendPort = IAnelConstants.DEFAULT_SEND_PORT; + /** Openhab receives messages via this port from device. */ + public int udpReceivePort = IAnelConstants.DEFAULT_RECEIVE_PORT; + + public AnelConfiguration() { + } + + public AnelConfiguration(@Nullable String hostname, @Nullable String user, @Nullable String password, int sendPort, + int receivePort) { + this.hostname = hostname; + this.user = user; + this.password = password; + this.udpSendPort = sendPort; + this.udpReceivePort = receivePort; + } + + @Override + public String toString() { + final StringBuilder builder = new StringBuilder(); + builder.append(getClass().getSimpleName()); + builder.append("[hostname="); + builder.append(hostname); + builder.append(",user="); + builder.append(user); + builder.append(",password="); + builder.append(mask(password)); + builder.append(",udpSendPort="); + builder.append(udpSendPort); + builder.append(",udpReceivePort="); + builder.append(udpReceivePort); + builder.append("]"); + return builder.toString(); + } + + private @Nullable String mask(@Nullable String string) { + return string == null ? null : string.replaceAll(".", "X"); + } +} diff --git a/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/AnelHandler.java b/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/AnelHandler.java new file mode 100644 index 000000000000..8abc5c580eb6 --- /dev/null +++ b/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/AnelHandler.java @@ -0,0 +1,356 @@ +/** + * Copyright (c) 2010-2021 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.anel.internal; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.anel.internal.auth.AnelAuthentication; +import org.openhab.binding.anel.internal.auth.AnelAuthentication.AuthMethod; +import org.openhab.binding.anel.internal.state.AnelCommandHandler; +import org.openhab.binding.anel.internal.state.AnelState; +import org.openhab.binding.anel.internal.state.AnelStateUpdater; +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 {@link AnelHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Patrick Koenemann - Initial contribution + */ +@NonNullByDefault +public class AnelHandler extends BaseThingHandler { + + private final Logger logger = LoggerFactory.getLogger(AnelHandler.class); + + private final AnelCommandHandler commandHandler = new AnelCommandHandler(); + private final AnelStateUpdater stateUpdater = new AnelStateUpdater(); + + private @Nullable AnelConfiguration config; + private @Nullable AnelUdpConnector udpConnector; + + /** The most recent state of the Anel device. */ + private @Nullable AnelState state; + /** Cached authentication information (encrypted, if possible). */ + private @Nullable String authentication; + + private @Nullable ScheduledFuture periodicRefreshTask; + + private int sendingFailures = 0; + private int updateStateFailures = 0; + private int refreshRequestWithoutResponse = 0; + private boolean refreshRequested = false; // avoid multiple simultaneous refresh requests + + public AnelHandler(Thing thing) { + super(thing); + } + + @Override + public void initialize() { + config = getConfigAs(AnelConfiguration.class); + + updateStatus(ThingStatus.UNKNOWN); + + // background initialization + scheduler.execute(this::initializeConnection); + } + + private void initializeConnection() { + final AnelConfiguration config2 = config; + final String host = config2 == null ? null : config2.hostname; + if (config2 == null || host == null) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "Cannot initialize thing without configuration: " + config2); + return; + } + try { + final AnelUdpConnector newUdpConnector = new AnelUdpConnector(host, config2.udpReceivePort, + config2.udpSendPort, scheduler); + udpConnector = newUdpConnector; + + // establish connection and register listener + newUdpConnector.connect(this::handleStatusUpdate, true); + + // request initial state, 3 attempts + for (int attempt = 1; attempt <= IAnelConstants.ATTEMPTS_WITH_COMMUNICATION_ERRORS + && state == null; attempt++) { + try { + newUdpConnector.send(IAnelConstants.BROADCAST_DISCOVERY_MSG); + } catch (IOException e) { + // network or socket failure, also wait 2 sec and try again + } + + // answer expected within 50-600ms on a regular network; wait up to 2sec just to make sure + for (int delay = 0; delay < 10 && state == null; delay++) { + Thread.sleep(200); // wait 10 x 200ms = 2sec + } + } + + // set thing status (and set unique property) + final AnelState state2 = state; + if (state2 != null) { + updateStatus(ThingStatus.ONLINE); + + final String mac = state2.mac; + if (mac != null && !mac.isEmpty()) { + updateProperty(IAnelConstants.UNIQUE_PROPERTY_NAME, mac); + } + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "Device does not respond (check IP, ports, and network connection): " + config); + } + + // schedule refresher task to continuously check for device state + periodicRefreshTask = scheduler.scheduleWithFixedDelay(this::periodicRefresh, // + 0, IAnelConstants.REFRESH_INTERVAL_SEC, TimeUnit.SECONDS); + } catch (InterruptedException e) { + // OH shutdown - don't log anything, Framework will call dispose() + } catch (Exception e) { + logger.debug("Connection to '{}' failed", config, e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR, "Connection to '" + config + + "' failed unexpectedly with " + e.getClass().getSimpleName() + ": " + e.getMessage()); + dispose(); + } + } + + private void periodicRefresh() { + /* + * it's sufficient to send "wer da?" to the configured ip address. + * the listener should be able to process the response like any other response. + */ + final AnelUdpConnector udpConnector2 = udpConnector; + if (udpConnector2 != null && udpConnector2.isConnected()) { + /* + * Check whether or not the device sends a response at all. If not, after some unanswered refresh requests, + * we should change the thing status to COMM_ERROR. The refresh task should remain active so that the device + * has a chance to get back online as soon as it responds again. + */ + if (refreshRequestWithoutResponse > IAnelConstants.UNANSWERED_REFRESH_REQUESTS_TO_SET_THING_OFFLINE + && getThing().getStatus() == ThingStatus.ONLINE) { + final String msg = "Setting thing offline because it did not respond to the last " + + IAnelConstants.UNANSWERED_REFRESH_REQUESTS_TO_SET_THING_OFFLINE + " status requests: " + + config; + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, msg); + } + + try { + refreshRequestWithoutResponse++; + + udpConnector2.send(IAnelConstants.BROADCAST_DISCOVERY_MSG); + sendingFailures = 0; + } catch (Exception e) { + handleSendException(e); + } + } + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + final AnelUdpConnector udpConnector2 = udpConnector; + if (udpConnector2 == null || !udpConnector2.isConnected() || getThing().getStatus() != ThingStatus.ONLINE) { + // don't log initial refresh commands because they may occur before thing is online + if (!(command instanceof RefreshType)) { + logger.debug("Cannot handle command '{}' for channel '{}' because thing ({}) is not connected: {}", // + command, channelUID.getId(), getThing().getStatus(), config); + } + return; + } + + String anelCommand = null; + if (command instanceof RefreshType) { + final State update = stateUpdater.getChannelUpdate(channelUID.getId(), state); + if (update != null) { + updateState(channelUID, update); + } else if (!refreshRequested) { + // send broadcast request for refreshing the state; remember it to avoid multiple simultaneous requests + refreshRequested = true; + anelCommand = IAnelConstants.BROADCAST_DISCOVERY_MSG; + } else { + logger.debug( + "Channel {} received command {} which is ignored because another channel already requested the same command", + channelUID, command); + } + } else if (command instanceof OnOffType) { + final State lockedState; + synchronized (this) { // lock needed to update the state if needed + lockedState = commandHandler.getLockedState(state, channelUID.getId()); + if (lockedState == null) { + // command only possible if state is not locked + anelCommand = commandHandler.toAnelCommandAndUnsetState(state, channelUID.getId(), command, + getAuthentication()); + } + } + + if (lockedState != null) { + logger.debug("Channel {} received command {} but it is locked, so the state is reset to {}.", + channelUID, command, lockedState); + + updateState(channelUID, lockedState); + } else if (anelCommand == null) { + logger.warn( + "Channel {} received command {} which is (currently) not supported; please check channel configuration.", + channelUID, command); + } + } else { + logger.warn("Channel {} received command {} which is not supported", channelUID, command); + } + + if (anelCommand != null) { + logger.debug("Channel {} received command {} which is converted to: {}", channelUID, command, anelCommand); + + try { + udpConnector2.send(anelCommand); + sendingFailures = 0; + } catch (Exception e) { + handleSendException(e); + } + } + } + + private void handleSendException(Exception e) { + if (getThing().getStatus() == ThingStatus.ONLINE) { + if (sendingFailures++ == IAnelConstants.ATTEMPTS_WITH_COMMUNICATION_ERRORS) { + final String msg = "Setting thing offline because binding failed to send " + sendingFailures + + " messages to it: " + config; + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, msg); + } else if (sendingFailures < IAnelConstants.ATTEMPTS_WITH_COMMUNICATION_ERRORS) { + logger.warn("Failed to send message to: {}", config, e); + } + } // else: ignore exception for offline things + } + + private void handleStatusUpdate(@Nullable String newStatus) { + refreshRequestWithoutResponse = 0; + try { + if (newStatus != null && newStatus.contains(IAnelConstants.ERROR_CREDENTIALS)) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "Invalid username or password for " + config); + return; + } + if (newStatus != null && newStatus.contains(IAnelConstants.ERROR_INSUFFICIENT_RIGHTS)) { + final AnelConfiguration config2 = config; + if (config2 != null) { + logger.warn( + "User '{}' on device {} has insufficient rights to change the state of a relay or IO port; you can fix that in the Web-UI, 'Einstellungen / Settings' -> 'User'.", + config2.user, config2.hostname); + } + return; + } + + final AnelState recentState, newState; + synchronized (this) { // to make sure state is fully processed before replacing it + recentState = state; + if (newStatus != null && recentState != null && newStatus.equals(recentState.status) + && !hasUnsetState(recentState)) { + return; // no changes + } + newState = AnelState.of(newStatus); + + state = newState; // update most recent state + } + final Map updates = stateUpdater.getChannelUpdates(recentState, newState); + + if (getThing().getStatus() == ThingStatus.OFFLINE) { + updateStatus(ThingStatus.ONLINE); // we got a response! set thing online if it wasn't! + } + updateStateFailures = 0; // reset error counter, if necessary + + // report all state updates + if (!updates.isEmpty()) { + logger.debug("updating channel states: {}", updates); + + updates.forEach(this::updateState); + } + } catch (Exception e) { + if (getThing().getStatus() == ThingStatus.ONLINE) { + if (updateStateFailures++ == IAnelConstants.ATTEMPTS_WITH_COMMUNICATION_ERRORS) { + final String msg = "Setting thing offline because status updated failed " + updateStateFailures + + " times in a row for: " + config; + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, msg); + } else if (updateStateFailures < IAnelConstants.ATTEMPTS_WITH_COMMUNICATION_ERRORS) { + logger.warn("Status update failed for: {}", config, e); + } + } // else: ignore exception for offline things + } + } + + private boolean hasUnsetState(AnelState state) { + for (int i = 0; i < state.relayState.length; i++) { + if (state.relayState[i] == null) { + return true; + } + } + for (int i = 0; i < state.ioState.length; i++) { + if (state.ioName[i] != null && state.ioState[i] == null) { + return true; + } + } + return false; + } + + private String getAuthentication() { + // create and remember authentication string + final String currentAuthentication = authentication; + if (currentAuthentication != null) { + return currentAuthentication; + } + + final AnelState currentState = state; + if (currentState == null) { + // should never happen because initialization ensures that initial state is received + throw new IllegalStateException("Cannot send any command to device b/c it did not send any answer yet"); + } + + final AnelConfiguration currentConfig = config; + if (currentConfig == null) { + throw new IllegalStateException("Config must not be null!"); + } + + final String newAuthentication = AnelAuthentication.getUserPasswordString(currentConfig.user, + currentConfig.password, AuthMethod.of(currentState.status)); + authentication = newAuthentication; + return newAuthentication; + } + + @Override + public void dispose() { + final ScheduledFuture periodicRefreshTask2 = periodicRefreshTask; + if (periodicRefreshTask2 != null) { + periodicRefreshTask2.cancel(false); + periodicRefreshTask = null; + } + final AnelUdpConnector connector = udpConnector; + if (connector != null) { + udpConnector = null; + try { + connector.disconnect(); + } catch (Exception e) { + logger.debug("Failed to close socket connection for: {}", config, e); + } + } + } +} diff --git a/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/AnelHandlerFactory.java b/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/AnelHandlerFactory.java new file mode 100644 index 000000000000..96a5d9e5e979 --- /dev/null +++ b/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/AnelHandlerFactory.java @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2010-2021 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.anel.internal; + +import static org.openhab.binding.anel.internal.IAnelConstants.SUPPORTED_THING_TYPES_UIDS; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +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.Component; + +/** + * The {@link AnelHandlerFactory} is responsible for creating things and thing + * handlers. + * + * @author Patrick Koenemann - Initial contribution + */ +@NonNullByDefault +@Component(configurationPid = "binding.anel", service = ThingHandlerFactory.class) +public class AnelHandlerFactory extends BaseThingHandlerFactory { + + @Override + public boolean supportsThingType(ThingTypeUID thingTypeUID) { + return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID); + } + + @Override + protected @Nullable ThingHandler createHandler(Thing thing) { + if (supportsThingType(thing.getThingTypeUID())) { + return new AnelHandler(thing); + } + return null; + } +} diff --git a/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/AnelUdpConnector.java b/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/AnelUdpConnector.java new file mode 100644 index 000000000000..c1fe5ecd126d --- /dev/null +++ b/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/AnelUdpConnector.java @@ -0,0 +1,263 @@ +/** + * Copyright (c) 2010-2021 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.anel.internal; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.SocketException; +import java.util.Arrays; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.function.Consumer; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.common.NamedThreadFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This class handles the actual communication to ANEL devices. + * + * @author Patrick Koenemann - Initial contribution + */ +@NonNullByDefault +public class AnelUdpConnector { + + /** Buffer for incoming UDP packages. */ + private static final int MAX_PACKET_SIZE = 512; + + private final Logger logger = LoggerFactory.getLogger(AnelUdpConnector.class); + + /** The device IP this connector is listening to / sends to. */ + private final String host; + + /** The port this connector is listening to. */ + private final int receivePort; + + /** The port this connector is sending to. */ + private final int sendPort; + + /** Service to spawn new threads for handling status updates. */ + private final ExecutorService executorService; + + /** Thread factory for UDP listening thread. */ + private final NamedThreadFactory listeningThreadFactory = new NamedThreadFactory(IAnelConstants.BINDING_ID, true); + + /** Socket for receiving UDP packages. */ + private @Nullable DatagramSocket receivingSocket = null; + /** Socket for sending UDP packages. */ + private @Nullable DatagramSocket sendingSocket = null; + + /** The listener that gets notified upon newly received messages. */ + private @Nullable Consumer listener; + + private int receiveFailures = 0; + private boolean listenerActive = false; + + /** + * Create a new connector to an Anel device via the given host and UDP + * ports. + * + * @param host + * The IP address / network name of the device. + * @param udpReceivePort + * The UDP port to listen for packages. + * @param udpSendPort + * The UDP port to send packages. + */ + public AnelUdpConnector(String host, int udpReceivePort, int udpSendPort, ExecutorService executorService) { + if (udpReceivePort <= 0) { + throw new IllegalArgumentException("Invalid udpReceivePort: " + udpReceivePort); + } + if (udpSendPort <= 0) { + throw new IllegalArgumentException("Invalid udpSendPort: " + udpSendPort); + } + if (host.trim().isEmpty()) { + throw new IllegalArgumentException("Missing host."); + } + this.host = host; + this.receivePort = udpReceivePort; + this.sendPort = udpSendPort; + this.executorService = executorService; + } + + /** + * Initialize socket connection to the UDP receive port for the given listener. + * + * @throws SocketException Is only thrown if logNotTHrowException = false. + * @throws InterruptedException Typically happens during shutdown. + */ + public void connect(Consumer listener, boolean logNotThrowExcpetion) + throws SocketException, InterruptedException { + if (receivingSocket == null) { + try { + receivingSocket = new DatagramSocket(receivePort); + sendingSocket = new DatagramSocket(); + this.listener = listener; + + /*- + * Due to the issue with 4 concurrently listening threads [1], we should follow Kais suggestion [2] + * to create our own listening daemonized thread. + * + * [1] https://community.openhab.org/t/anel-net-pwrctrl-binding-for-oh3/123378 + * [2] https://www.eclipse.org/forums/index.php/m/1775932/?#msg_1775429 + */ + listeningThreadFactory.newThread(this::listen).start(); + + // wait for the listening thread to be active + for (int i = 0; i < 20 && !listenerActive; i++) { + Thread.sleep(100); // wait at most 20 * 100ms = 2sec for the listener to be active + } + if (!listenerActive) { + logger.warn( + "Listener thread started but listener is not yet active after 2sec; something seems to be wrong with the JVM thread handling?!"); + } + } catch (SocketException e) { + if (logNotThrowExcpetion) { + logger.warn( + "Failed to open socket connection on port {} (maybe there is already another socket listener on that port?)", + receivePort, e); + } + + disconnect(); + + if (!logNotThrowExcpetion) { + throw e; + } + } + } else if (!Objects.equals(this.listener, listener)) { + throw new IllegalStateException("A listening thread is already running"); + } + } + + private void listen() { + try { + listenUnhandledInterruption(); + } catch (InterruptedException e) { + // OH shutdown - don't log anything, just quit + } + } + + private void listenUnhandledInterruption() throws InterruptedException { + logger.info("Anel NET-PwrCtrl listener started for: '{}:{}'", host, receivePort); + + final Consumer listener2 = listener; + final DatagramSocket socket2 = receivingSocket; + while (listener2 != null && socket2 != null && receivingSocket != null) { + try { + final DatagramPacket packet = new DatagramPacket(new byte[MAX_PACKET_SIZE], MAX_PACKET_SIZE); + + listenerActive = true; + socket2.receive(packet); // receive packet (blocking call) + listenerActive = false; + + final byte[] data = Arrays.copyOfRange(packet.getData(), 0, packet.getLength() - 1); + + if (data == null || data.length == 0) { + if (isConnected()) { + logger.debug("Nothing received, this may happen during shutdown or some unknown error"); + } + continue; + } + receiveFailures = 0; // message successfully received, unset failure counter + + /* useful for debugging without logger (e.g. in AnelUdpConnectorTest): */ + // System.out.println(String.format("%s [%s] received: %s", getClass().getSimpleName(), + // new SimpleDateFormat("HH:mm:ss.SSS").format(new Date()), new String(data).trim())); + + // log & notify listener in new thread (so that listener loop continues immediately) + executorService.execute(() -> { + final String message = new String(data); + + logger.debug("Received data on port {}: {}", receivePort, message); + + listener2.accept(message); + }); + } catch (Exception e) { + listenerActive = false; + + if (receivingSocket == null) { + logger.debug("Socket closed; stopping listener on port {}.", receivePort); + } else { + // if we get 3 errors in a row, we should better add a delay to stop spamming the log! + if (receiveFailures++ > IAnelConstants.ATTEMPTS_WITH_COMMUNICATION_ERRORS) { + logger.debug( + "Unexpected error while listening on port {}; waiting 10sec before the next attempt to listen on that port.", + receivePort, e); + for (int i = 0; i < 50 && receivingSocket != null; i++) { + Thread.sleep(200); // 50 * 200ms = 10sec + } + } else { + logger.warn("Unexpected error while listening on port {}", receivePort, e); + } + } + } + } + } + + /** Close the socket connection. */ + public void disconnect() { + logger.debug("Anel NET-PwrCtrl listener stopped for: '{}:{}'", host, receivePort); + listener = null; + final DatagramSocket receivingSocket2 = receivingSocket; + if (receivingSocket2 != null) { + receivingSocket = null; + if (!receivingSocket2.isClosed()) { + receivingSocket2.close(); // this interrupts and terminates the listening thread + } + } + final DatagramSocket sendingSocket2 = sendingSocket; + if (sendingSocket2 != null) { + synchronized (this) { + if (Objects.equals(sendingSocket, sendingSocket2)) { + sendingSocket = null; + if (!sendingSocket2.isClosed()) { + sendingSocket2.close(); + } + } + } + } + } + + public void send(String msg) throws IOException { + logger.debug("Sending message '{}' to {}:{}", msg, host, sendPort); + if (msg.isEmpty()) { + throw new IllegalArgumentException("Message must not be empty"); + } + + final InetAddress ipAddress = InetAddress.getByName(host); + final byte[] bytes = msg.getBytes(); + final DatagramPacket packet = new DatagramPacket(bytes, bytes.length, ipAddress, sendPort); + + // make sure we are not interrupted by a disconnect while sending this message + synchronized (this) { + final DatagramSocket sendingSocket2 = sendingSocket; + if (sendingSocket2 != null) { + sendingSocket2.send(packet); + + /* useful for debugging without logger (e.g. in AnelUdpConnectorTest): */ + // System.out.println(String.format("%s [%s] sent: %s", getClass().getSimpleName(), + // new SimpleDateFormat("HH:mm:ss.SSS").format(new Date()), msg)); + + logger.debug("Sending successful."); + } + } + } + + public boolean isConnected() { + return receivingSocket != null; + } +} diff --git a/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/IAnelConstants.java b/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/IAnelConstants.java new file mode 100644 index 000000000000..c8aacb6d0a42 --- /dev/null +++ b/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/IAnelConstants.java @@ -0,0 +1,123 @@ +/** + * Copyright (c) 2010-2021 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.anel.internal; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.ThingTypeUID; + +/** + * The {@link IAnelConstants} class defines common constants, which are + * used across the whole binding. + * + * @author Patrick Koenemann - Initial contribution + */ +@NonNullByDefault +public interface IAnelConstants { + + String BINDING_ID = "anel"; + + /** Message sent to Anel devices to detect new dfevices and to request the current state. */ + String BROADCAST_DISCOVERY_MSG = "wer da?"; + /** Expected prefix for all received Anel status messages. */ + String STATUS_RESPONSE_PREFIX = "NET-PwrCtrl"; + /** Separator of the received Anel status messages. */ + String STATUS_SEPARATOR = ":"; + + /** Status message String if the current user / password does not match. */ + String ERROR_CREDENTIALS = ":NoPass:Err"; + /** Status message String if the current user does not have enough rights. */ + String ERROR_INSUFFICIENT_RIGHTS = ":NoAccess:Err"; + + /** Property name to uniquely identify (discovered) things. */ + String UNIQUE_PROPERTY_NAME = "mac"; + + /** Default port used to send message to Anel devices. */ + int DEFAULT_SEND_PORT = 75; + /** Default port used to receive message from Anel devices. */ + int DEFAULT_RECEIVE_PORT = 77; + + /** Static refresh interval for heartbeat for Thing status. */ + int REFRESH_INTERVAL_SEC = 60; + + /** Thing is set OFFLINE after so many communication errors. */ + int ATTEMPTS_WITH_COMMUNICATION_ERRORS = 3; + + /** Thing is set OFFLINE if it did not respond to so many refresh requests. */ + int UNANSWERED_REFRESH_REQUESTS_TO_SET_THING_OFFLINE = 5; + + /** Thing Type UID for Anel Net-PwrCtrl HOME. */ + ThingTypeUID THING_TYPE_ANEL_HOME = new ThingTypeUID(BINDING_ID, "home"); + /** Thing Type UID for Anel Net-PwrCtrl PRO / POWER. */ + ThingTypeUID THING_TYPE_ANEL_SIMPLE = new ThingTypeUID(BINDING_ID, "simple-firmware"); + /** Thing Type UID for Anel Net-PwrCtrl ADV / IO / HUT. */ + ThingTypeUID THING_TYPE_ANEL_ADVANCED = new ThingTypeUID(BINDING_ID, "advanced-firmware"); + /** All supported Thing Type UIDs. */ + Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_ANEL_HOME, THING_TYPE_ANEL_SIMPLE, + THING_TYPE_ANEL_ADVANCED); + + /** The device type is part of the status response and is mapped to the thing types. */ + Map DEVICE_TYPE_TO_THING_TYPE = Map.of( // + 'H', THING_TYPE_ANEL_HOME, // HOME + 'P', THING_TYPE_ANEL_SIMPLE, // PRO / POWER + 'h', THING_TYPE_ANEL_ADVANCED, // HUT (and variants, e.g. h3 for HUT3) + 'a', THING_TYPE_ANEL_ADVANCED, // ADV + 'i', THING_TYPE_ANEL_ADVANCED); // IO + + // All remaining constants are Channel ids + + String CHANNEL_NAME = "prop#name"; + String CHANNEL_TEMPERATURE = "prop#temperature"; + + List CHANNEL_RELAY_NAME = List.of("r1#name", "r2#name", "r3#name", "r4#name", "r5#name", "r6#name", + "r7#name", "r8#name"); + + // second character must be the index b/c it is parsed in AnelCommandHandler! + List CHANNEL_RELAY_STATE = List.of("r1#state", "r2#state", "r3#state", "r4#state", "r5#state", "r6#state", + "r7#state", "r8#state"); + + List CHANNEL_RELAY_LOCKED = List.of("r1#locked", "r2#locked", "r3#locked", "r4#locked", "r5#locked", + "r6#locked", "r7#locked", "r8#locked"); + + List CHANNEL_IO_NAME = List.of("io1#name", "io2#name", "io3#name", "io4#name", "io5#name", "io6#name", + "io7#name", "io8#name"); + + List CHANNEL_IO_MODE = List.of("io1#mode", "io2#mode", "io3#mode", "io4#mode", "io5#mode", "io6#mode", + "io7#mode", "io8#mode"); + + // third character must be the index b/c it is parsed in AnelCommandHandler! + List CHANNEL_IO_STATE = List.of("io1#state", "io2#state", "io3#state", "io4#state", "io5#state", + "io6#state", "io7#state", "io8#state"); + + String CHANNEL_SENSOR_TEMPERATURE = "sensor#temperature"; + String CHANNEL_SENSOR_HUMIDITY = "sensor#humidity"; + String CHANNEL_SENSOR_BRIGHTNESS = "sensor#brightness"; + + /** + * @param channelId A channel ID. + * @return The zero-based index of the relay or IO channel (0-7); -1 if it's not a relay + * or IO channel. + */ + static int getIndexFromChannel(String channelId) { + if (channelId.startsWith("r") && channelId.length() > 2) { + return Character.getNumericValue(channelId.charAt(1)) - 1; + } + if (channelId.startsWith("io") && channelId.length() > 2) { + return Character.getNumericValue(channelId.charAt(2)) - 1; + } + return -1; // not a relay or io channel + } +} diff --git a/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/auth/AnelAuthentication.java b/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/auth/AnelAuthentication.java new file mode 100644 index 000000000000..ea897e50c88a --- /dev/null +++ b/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/auth/AnelAuthentication.java @@ -0,0 +1,98 @@ +/** + * Copyright (c) 2010-2021 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.anel.internal.auth; + +import java.util.Base64; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * This class determines the authentication method from a status response of an ANEL device. + * + * @author Patrick Koenemann - Initial contribution + */ +@NonNullByDefault +public class AnelAuthentication { + + public enum AuthMethod { + PLAIN, + BASE64, + XORBASE64; + + private static final Pattern NAME_AND_FIRMWARE_PATTERN = Pattern.compile(":NET-PWRCTRL_0?(\\d+\\.\\d)"); + private static final Pattern LAST_SEGMENT_FIRMWARE_PATTERN = Pattern.compile(":(\\d+\\.\\d)$"); + + private static final String MIN_FIRMWARE_BASE64 = "6.0"; + private static final String MIN_FIRMWARE_XOR_BASE64 = "6.1"; + + public static AuthMethod of(String status) { + if (status.isEmpty()) { + return PLAIN; // fallback + } + if (status.trim().endsWith(":xor") || status.contains(":xor:")) { + return XORBASE64; + } + final String firmwareVersion = getFirmwareVersion(status); + if (firmwareVersion == null) { + return PLAIN; + } + if (firmwareVersion.compareTo(MIN_FIRMWARE_XOR_BASE64) >= 0) { + return XORBASE64; // >= 6.1 + } + if (firmwareVersion.compareTo(MIN_FIRMWARE_BASE64) >= 0) { + return BASE64; // exactly 6.0 + } + return PLAIN; // fallback + } + + private static @Nullable String getFirmwareVersion(String fullStatusStringOrFirmwareVersion) { + final Matcher matcher1 = NAME_AND_FIRMWARE_PATTERN.matcher(fullStatusStringOrFirmwareVersion); + if (matcher1.find()) { + return matcher1.group(1); + } + final Matcher matcher2 = LAST_SEGMENT_FIRMWARE_PATTERN.matcher(fullStatusStringOrFirmwareVersion.trim()); + if (matcher2.find()) { + return matcher2.group(1); + } + return null; + } + } + + public static String getUserPasswordString(@Nullable String user, @Nullable String password, + @Nullable AuthMethod authMethod) { + final String userPassword = (user == null ? "" : user) + (password == null ? "" : password); + if (authMethod == null || authMethod == AuthMethod.PLAIN) { + return userPassword; + } + + if (authMethod == AuthMethod.BASE64 || password == null || password.isEmpty()) { + return Base64.getEncoder().encodeToString(userPassword.getBytes()); + } + + if (authMethod == AuthMethod.XORBASE64) { + final StringBuilder result = new StringBuilder(); + + // XOR + for (int c = 0; c < userPassword.length(); c++) { + result.append((char) (userPassword.charAt(c) ^ password.charAt(c % password.length()))); + } + + return Base64.getEncoder().encodeToString(result.toString().getBytes()); + } + + throw new UnsupportedOperationException("Unknown auth method: " + authMethod); + } +} diff --git a/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/discovery/AnelDiscoveryService.java b/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/discovery/AnelDiscoveryService.java new file mode 100644 index 000000000000..cead6e0286c4 --- /dev/null +++ b/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/discovery/AnelDiscoveryService.java @@ -0,0 +1,210 @@ +/** + * Copyright (c) 2010-2021 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.anel.internal.discovery; + +import java.io.IOException; +import java.net.BindException; +import java.nio.channels.ClosedByInterruptException; +import java.util.Set; +import java.util.TreeSet; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.anel.internal.AnelUdpConnector; +import org.openhab.binding.anel.internal.IAnelConstants; +import org.openhab.core.common.AbstractUID; +import org.openhab.core.common.NamedThreadFactory; +import org.openhab.core.config.discovery.AbstractDiscoveryService; +import org.openhab.core.config.discovery.DiscoveryResult; +import org.openhab.core.config.discovery.DiscoveryResultBuilder; +import org.openhab.core.config.discovery.DiscoveryService; +import org.openhab.core.net.NetUtil; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.ThingUID; +import org.osgi.service.component.annotations.Component; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Discovery service for ANEL devices. + * + * @author Patrick Koenemann - Initial contribution + */ +@NonNullByDefault +@Component(service = DiscoveryService.class, immediate = true, configurationPid = "discovery.anel") +public class AnelDiscoveryService extends AbstractDiscoveryService { + + private static final String PASSWORD = "anel"; + private static final String USER = "user7"; + private static final int[][] DISCOVERY_PORTS = { { 750, 770 }, { 7500, 7700 }, { 7750, 7770 } }; + private static final Set BROADCAST_ADDRESSES = new TreeSet<>(NetUtil.getAllBroadcastAddresses()); + + private static final int DISCOVER_DEVICE_TIMEOUT_SECONDS = 2; + + /** #BroadcastAddresses * DiscoverDeviceTimeout * (3 * #DiscoveryPorts) */ + private static final int DISCOVER_TIMEOUT_SECONDS = BROADCAST_ADDRESSES.size() * DISCOVER_DEVICE_TIMEOUT_SECONDS + * (3 * DISCOVERY_PORTS.length); + + private final Logger logger = LoggerFactory.getLogger(AnelDiscoveryService.class); + + private @Nullable Thread scanningThread = null; + + public AnelDiscoveryService() throws IllegalArgumentException { + super(IAnelConstants.SUPPORTED_THING_TYPES_UIDS, DISCOVER_TIMEOUT_SECONDS); + logger.debug( + "Anel NET-PwrCtrl discovery service instantiated for broadcast addresses {} with a timeout of {} seconds.", + BROADCAST_ADDRESSES, DISCOVER_TIMEOUT_SECONDS); + } + + @Override + protected void startScan() { + /* + * Start scan in background thread, otherwise progress is not shown in the web UI. + * Do not use the scheduler, otherwise further threads (for handling discovered things) are not started + * immediately but only after the scan is complete. + */ + final Thread thread = new NamedThreadFactory(IAnelConstants.BINDING_ID, true).newThread(this::doScan); + thread.start(); + scanningThread = thread; + } + + private void doScan() { + logger.debug("Starting scan of Anel devices via UDP broadcast messages..."); + + try { + for (final String broadcastAddress : BROADCAST_ADDRESSES) { + + // for each available broadcast network address try factory default ports first + scan(broadcastAddress, IAnelConstants.DEFAULT_SEND_PORT, IAnelConstants.DEFAULT_RECEIVE_PORT); + + // try reasonable ports... + for (int[] ports : DISCOVERY_PORTS) { + int sendPort = ports[0]; + int receivePort = ports[1]; + + // ...and continue if a device was found, maybe there is yet another device on the next port + while (scan(broadcastAddress, sendPort, receivePort) || sendPort == ports[0]) { + sendPort++; + receivePort++; + } + } + } + } catch (InterruptedException | ClosedByInterruptException e) { + return; // OH shutdown or scan was aborted + } catch (Exception e) { + logger.warn("Unexpected exception during anel device scan", e); + } finally { + scanningThread = null; + } + logger.debug("Scan finished."); + } + + /* @return Whether or not a device was found for the given broadcast address and port. */ + private boolean scan(String broadcastAddress, int sendPort, int receivePort) + throws IOException, InterruptedException { + logger.debug("Scanning {}:{}...", broadcastAddress, sendPort); + final AnelUdpConnector udpConnector = new AnelUdpConnector(broadcastAddress, receivePort, sendPort, scheduler); + + try { + final boolean[] deviceDiscovered = new boolean[] { false }; + udpConnector.connect(status -> { + // avoid the same device to be discovered multiple times for multiple responses + if (!deviceDiscovered[0]) { + boolean discoverDevice = true; + synchronized (this) { + if (deviceDiscovered[0]) { + discoverDevice = false; // already discovered by another thread + } else { + deviceDiscovered[0] = true; // we discover the device! + } + } + if (discoverDevice) { + // discover device outside synchronized-block + deviceDiscovered(status, sendPort, receivePort); + } + } + }, false); + + udpConnector.send(IAnelConstants.BROADCAST_DISCOVERY_MSG); + + // answer expected within 50-600ms on a regular network; wait up to 2sec just to make sure + for (int delay = 0; delay < 10 && !deviceDiscovered[0]; delay++) { + Thread.sleep(100 * DISCOVER_DEVICE_TIMEOUT_SECONDS); // wait 10 x 200ms = 2sec + } + + return deviceDiscovered[0]; + } catch (BindException e) { + // most likely socket is already in use, ignore this exception. + logger.debug( + "Invalid address {} or one of the ports {} or {} is already in use. Skipping scan of these ports.", + broadcastAddress, sendPort, receivePort); + } finally { + udpConnector.disconnect(); + } + return false; + } + + @Override + protected synchronized void stopScan() { + final Thread thread = scanningThread; + if (thread != null) { + thread.interrupt(); + } + super.stopScan(); + } + + private void deviceDiscovered(String status, int sendPort, int receivePort) { + final String[] segments = status.split(":"); + if (segments.length >= 16) { + final String name = segments[1].trim(); + final String ip = segments[2]; + final String macAddress = segments[5]; + final String deviceType = segments.length > 17 ? segments[17] : null; + final ThingTypeUID thingTypeUid = getThingTypeUid(deviceType, segments); + final ThingUID thingUid = new ThingUID(thingTypeUid + AbstractUID.SEPARATOR + macAddress.replace(".", "")); + + final DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUid) // + .withThingType(thingTypeUid) // + .withProperty("hostname", ip) // AnelConfiguration.hostname + .withProperty("user", USER) // AnelConfiguration.user + .withProperty("password", PASSWORD) // AnelConfiguration.password + .withProperty("udpSendPort", sendPort) // AnelConfiguration.udpSendPort + .withProperty("udpReceivePort", receivePort) // AnelConfiguration.udbReceivePort + .withProperty(IAnelConstants.UNIQUE_PROPERTY_NAME, macAddress) // + .withLabel(name) // + .withRepresentationProperty(IAnelConstants.UNIQUE_PROPERTY_NAME) // + .build(); + + thingDiscovered(discoveryResult); + } + } + + private ThingTypeUID getThingTypeUid(@Nullable String deviceType, String[] segments) { + // device type is contained since firmware 6.0 + if (deviceType != null && !deviceType.isEmpty()) { + final char deviceTypeChar = deviceType.charAt(0); + final ThingTypeUID thingTypeUID = IAnelConstants.DEVICE_TYPE_TO_THING_TYPE.get(deviceTypeChar); + if (thingTypeUID != null) { + return thingTypeUID; + } + } + + if (segments.length < 20) { + // no information given, we should be save with return the simple firmware thing type + return IAnelConstants.THING_TYPE_ANEL_SIMPLE; + } else { + // more than 20 segments must include IO ports, hence it's an advanced firmware + return IAnelConstants.THING_TYPE_ANEL_ADVANCED; + } + } +} diff --git a/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/state/AnelCommandHandler.java b/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/state/AnelCommandHandler.java new file mode 100644 index 000000000000..c2cc504b8e4c --- /dev/null +++ b/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/state/AnelCommandHandler.java @@ -0,0 +1,116 @@ +/** + * Copyright (c) 2010-2021 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.anel.internal.state; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.anel.internal.IAnelConstants; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Convert an openhab command to an ANEL UDP command message. + * + * @author Patrick Koenemann - Initial contribution + */ +@NonNullByDefault +public class AnelCommandHandler { + + private final Logger logger = LoggerFactory.getLogger(AnelCommandHandler.class); + + public @Nullable State getLockedState(@Nullable AnelState state, String channelId) { + if (IAnelConstants.CHANNEL_RELAY_STATE.contains(channelId)) { + if (state == null) { + return null; // assume unlocked + } + + final int index = IAnelConstants.getIndexFromChannel(channelId); + + final @Nullable Boolean locked = state.relayLocked[index]; + if (locked == null || !locked.booleanValue()) { + return null; // no lock information or unlocked + } + + final @Nullable Boolean lockedState = state.relayState[index]; + if (lockedState == null) { + return null; // no state information available + } + + return OnOffType.from(lockedState.booleanValue()); + } + + if (IAnelConstants.CHANNEL_IO_STATE.contains(channelId)) { + if (state == null) { + return null; // assume unlocked + } + + final int index = IAnelConstants.getIndexFromChannel(channelId); + + final @Nullable Boolean isInput = state.ioIsInput[index]; + if (isInput == null || !isInput.booleanValue()) { + return null; // no direction infmoration or output port + } + + final @Nullable Boolean ioState = state.ioState[index]; + if (ioState == null) { + return null; // no state information available + } + return OnOffType.from(ioState.booleanValue()); + } + return null; // all other channels are read-only! + } + + public @Nullable String toAnelCommandAndUnsetState(@Nullable AnelState state, String channelId, Command command, + String authentication) { + if (!(command instanceof OnOffType)) { + // only relay states and io states can be changed, all other channels are read-only + logger.warn("Anel binding only support ON/OFF and Refresh commands, not {}: {}", + command.getClass().getSimpleName(), command); + } else if (IAnelConstants.CHANNEL_RELAY_STATE.contains(channelId)) { + final int index = IAnelConstants.getIndexFromChannel(channelId); + + // unset anel state which enforces a channel state update + if (state != null) { + state.relayState[index] = null; + } + + @Nullable + final Boolean locked = state == null ? null : state.relayLocked[index]; + if (locked == null || !locked.booleanValue()) { + return String.format("Sw_%s%d%s", command.toString().toLowerCase(), index + 1, authentication); + } else { + logger.warn("Relay {} is locked; skipping command {}.", index + 1, command); + } + } else if (IAnelConstants.CHANNEL_IO_STATE.contains(channelId)) { + final int index = IAnelConstants.getIndexFromChannel(channelId); + + // unset anel state which enforces a channel state update + if (state != null) { + state.ioState[index] = null; + } + + @Nullable + final Boolean isInput = state == null ? null : state.ioIsInput[index]; + if (isInput == null || !isInput.booleanValue()) { + return String.format("IO_%s%d%s", command.toString().toLowerCase(), index + 1, authentication); + } else { + logger.warn("IO {} has direction input, not output; skipping command {}.", index + 1, command); + } + } + + return null; // all other channels are read-only + } +} diff --git a/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/state/AnelState.java b/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/state/AnelState.java new file mode 100644 index 000000000000..defc0975bb43 --- /dev/null +++ b/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/state/AnelState.java @@ -0,0 +1,308 @@ +/** + * Copyright (c) 2010-2021 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.anel.internal.state; + +import java.util.Arrays; +import java.util.IllegalFormatException; +import java.util.LinkedList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.anel.internal.IAnelConstants; + +/** + * Parser and data structure for the state of an Anel device. + *

+ * Documentation in Anel forum (German). + * + * @author Patrick Koenemann - Initial contribution + */ +@NonNullByDefault +public class AnelState { + + /** Pattern for temp, e.g. 26.4°C or -1°F */ + private static final Pattern PATTERN_TEMPERATURE = Pattern.compile("(\\-?\\d+(?:\\.\\d)?).[CF]"); + /** Pattern for switch state: [name],[state: 1=on,0=off] */ + private static final Pattern PATTERN_SWITCH_STATE = Pattern.compile("(.+),(0|1)"); + /** Pattern for IO state: [name],[1=input,0=output],[state: 1=on,0=off] */ + private static final Pattern PATTERN_IO_STATE = Pattern.compile("(.+),(0|1),(0|1)"); + + /** The raw status this state was created from. */ + public final String status; + + /** Device IP address; read-only. */ + public final @Nullable String ip; + /** Device name; read-only. */ + public final @Nullable String name; + /** Device mac address; read-only. */ + public final @Nullable String mac; + + /** Device relay names; read-only. */ + public final String[] relayName = new String[8]; + /** Device relay states; changeable. */ + public final Boolean[] relayState = new Boolean[8]; + /** Device relay locked status; read-only. */ + public final Boolean[] relayLocked = new Boolean[8]; + + /** Device IO names; read-only. */ + public final String[] ioName = new String[8]; + /** Device IO states; changeable if they are configured as input. */ + public final Boolean[] ioState = new Boolean[8]; + /** Device IO input states (true means changeable); read-only. */ + public final Boolean[] ioIsInput = new Boolean[8]; + + /** Device temperature (optional); read-only. */ + public final @Nullable String temperature; + + /** Sensor temperature, e.g. "20.61" (optional); read-only. */ + public final @Nullable String sensorTemperature; + /** Sensor Humidity, e.g. "40.7" (optional); read-only. */ + public final @Nullable String sensorHumidity; + /** Sensor Brightness, e.g. "7.0" (optional); read-only. */ + public final @Nullable String sensorBrightness; + + private static final AnelState INVALID_STATE = new AnelState(); + + public static AnelState of(@Nullable String status) { + if (status == null || status.isEmpty()) { + return INVALID_STATE; + } + return new AnelState(status); + } + + private AnelState() { + status = ""; + ip = null; + name = null; + mac = null; + temperature = null; + sensorTemperature = null; + sensorHumidity = null; + sensorBrightness = null; + } + + private AnelState(@Nullable String status) throws IllegalFormatException { + if (status == null || status.isEmpty()) { + throw new IllegalArgumentException("status must not be null or empty"); + } + this.status = status; + final String[] segments = status.split(IAnelConstants.STATUS_SEPARATOR); + if (!segments[0].equals(IAnelConstants.STATUS_RESPONSE_PREFIX)) { + throw new IllegalArgumentException( + "Data must start with '" + IAnelConstants.STATUS_RESPONSE_PREFIX + "' but it didn't: " + status); + } + if (segments.length < 16) { + throw new IllegalArgumentException("Data must have at least 16 segments but it didn't: " + status); + } + final List issues = new LinkedList<>(); + + // name, host, mac + name = segments[1].trim(); + ip = segments[2]; + mac = segments[5]; + + // 8 switches / relays + Integer lockedSwitches; + try { + lockedSwitches = Integer.parseInt(segments[14]); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + "Segment 15 (" + segments[14] + ") is expected to be a number but it's not: " + status); + } + for (int i = 0; i < 8; i++) { + final Matcher matcher = PATTERN_SWITCH_STATE.matcher(segments[6 + i]); + if (matcher.matches()) { + relayName[i] = matcher.group(1); + relayState[i] = "1".equals(matcher.group(2)); + } else { + issues.add("Unexpected format for switch " + i + ": '" + segments[6 + i]); + relayName[i] = ""; + relayState[i] = false; + } + relayLocked[i] = (lockedSwitches & (1 << i)) > 0; + } + + // 8 IO ports (devices with IO ports have >=24 segments) + if (segments.length >= 24) { + for (int i = 0; i < 8; i++) { + final Matcher matcher = PATTERN_IO_STATE.matcher(segments[16 + i]); + if (matcher.matches()) { + ioName[i] = matcher.group(1); + ioIsInput[i] = "1".equals(matcher.group(2)); + ioState[i] = "1".equals(matcher.group(3)); + } else { + issues.add("Unexpected format for IO " + i + ": '" + segments[16 + i]); + ioName[i] = ""; + } + } + } + + // temperature + temperature = segments.length > 24 ? parseTemperature(segments[24], issues) : null; + + if (segments.length > 34 && "p".equals(segments[27])) { + // optional sensor (if device supports it and firmware >= 6.1) after power management + if (segments.length > 38 && "s".equals(segments[35])) { + sensorTemperature = segments[36]; + sensorHumidity = segments[37]; + sensorBrightness = segments[38]; + } else { + sensorTemperature = null; + sensorHumidity = null; + sensorBrightness = null; + } + } else if (segments.length > 31 && "n".equals(segments[27]) && "s".equals(segments[28])) { + // but sensor! (if device supports it and firmware >= 6.1) + sensorTemperature = segments[29]; + sensorHumidity = segments[30]; + sensorBrightness = segments[31]; + } else { + // firmware <= 6.0 or unknown format; skip rest + sensorTemperature = null; + sensorBrightness = null; + sensorHumidity = null; + } + + if (!issues.isEmpty()) { + throw new IllegalArgumentException(String.format("Anel status string contains %d issue%s: %s\n%s", // + issues.size(), issues.size() == 1 ? "" : "s", status, + issues.stream().collect(Collectors.joining("\n")))); + } + } + + private static @Nullable String parseTemperature(String temp, List issues) { + if (!temp.isEmpty()) { + final Matcher matcher = PATTERN_TEMPERATURE.matcher(temp); + if (matcher.matches()) { + return matcher.group(1); + } + issues.add("Unexpected format for temperature: " + temp); + } + return null; + } + + @Override + public String toString() { + return getClass().getSimpleName() + "[" + status + "]"; + } + + /* generated */ + @Override + @SuppressWarnings("null") + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((ip == null) ? 0 : ip.hashCode()); + result = prime * result + ((mac == null) ? 0 : mac.hashCode()); + result = prime * result + ((name == null) ? 0 : name.hashCode()); + result = prime * result + Arrays.hashCode(ioIsInput); + result = prime * result + Arrays.hashCode(ioName); + result = prime * result + Arrays.hashCode(ioState); + result = prime * result + Arrays.hashCode(relayLocked); + result = prime * result + Arrays.hashCode(relayName); + result = prime * result + Arrays.hashCode(relayState); + result = prime * result + ((temperature == null) ? 0 : temperature.hashCode()); + result = prime * result + ((sensorBrightness == null) ? 0 : sensorBrightness.hashCode()); + result = prime * result + ((sensorHumidity == null) ? 0 : sensorHumidity.hashCode()); + result = prime * result + ((sensorTemperature == null) ? 0 : sensorTemperature.hashCode()); + return result; + } + + /* generated */ + @Override + @SuppressWarnings("null") + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + AnelState other = (AnelState) obj; + if (ip == null) { + if (other.ip != null) { + return false; + } + } else if (!ip.equals(other.ip)) { + return false; + } + if (!Arrays.equals(ioIsInput, other.ioIsInput)) { + return false; + } + if (!Arrays.equals(ioName, other.ioName)) { + return false; + } + if (!Arrays.equals(ioState, other.ioState)) { + return false; + } + if (mac == null) { + if (other.mac != null) { + return false; + } + } else if (!mac.equals(other.mac)) { + return false; + } + if (name == null) { + if (other.name != null) { + return false; + } + } else if (!name.equals(other.name)) { + return false; + } + if (sensorBrightness == null) { + if (other.sensorBrightness != null) { + return false; + } + } else if (!sensorBrightness.equals(other.sensorBrightness)) { + return false; + } + if (sensorHumidity == null) { + if (other.sensorHumidity != null) { + return false; + } + } else if (!sensorHumidity.equals(other.sensorHumidity)) { + return false; + } + if (sensorTemperature == null) { + if (other.sensorTemperature != null) { + return false; + } + } else if (!sensorTemperature.equals(other.sensorTemperature)) { + return false; + } + if (!Arrays.equals(relayLocked, other.relayLocked)) { + return false; + } + if (!Arrays.equals(relayName, other.relayName)) { + return false; + } + if (!Arrays.equals(relayState, other.relayState)) { + return false; + } + if (temperature == null) { + if (other.temperature != null) { + return false; + } + } else if (!temperature.equals(other.temperature)) { + return false; + } + return true; + } +} diff --git a/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/state/AnelStateUpdater.java b/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/state/AnelStateUpdater.java new file mode 100644 index 000000000000..1f208712fb57 --- /dev/null +++ b/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/state/AnelStateUpdater.java @@ -0,0 +1,216 @@ +/** + * Copyright (c) 2010-2021 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.anel.internal.state; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.anel.internal.IAnelConstants; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.library.unit.SIUnits; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; + +/** + * Get updates for {@link AnelState}s. + * + * @author Patrick Koenemann - Initial contribution + */ +@NonNullByDefault +public class AnelStateUpdater { + + public @Nullable State getChannelUpdate(String channelId, @Nullable AnelState state) { + if (state == null) { + return null; + } + + final int index = IAnelConstants.getIndexFromChannel(channelId); + if (index >= 0) { + if (IAnelConstants.CHANNEL_RELAY_NAME.contains(channelId)) { + return getStringState(state.relayName[index]); + } + if (IAnelConstants.CHANNEL_RELAY_STATE.contains(channelId)) { + return getSwitchState(state.relayState[index]); + } + if (IAnelConstants.CHANNEL_RELAY_LOCKED.contains(channelId)) { + return getSwitchState(state.relayLocked[index]); + } + + if (IAnelConstants.CHANNEL_IO_NAME.contains(channelId)) { + return getStringState(state.ioName[index]); + } + if (IAnelConstants.CHANNEL_IO_STATE.contains(channelId)) { + return getSwitchState(state.ioState[index]); + } + if (IAnelConstants.CHANNEL_IO_MODE.contains(channelId)) { + return getSwitchState(state.ioState[index]); + } + } else { + if (IAnelConstants.CHANNEL_NAME.equals(channelId)) { + return getStringState(state.name); + } + if (IAnelConstants.CHANNEL_TEMPERATURE.equals(channelId)) { + return getTemperatureState(state.temperature); + } + + if (IAnelConstants.CHANNEL_SENSOR_TEMPERATURE.equals(channelId)) { + return getTemperatureState(state.sensorTemperature); + } + if (IAnelConstants.CHANNEL_SENSOR_HUMIDITY.equals(channelId)) { + return getDecimalState(state.sensorHumidity); + } + if (IAnelConstants.CHANNEL_SENSOR_BRIGHTNESS.equals(channelId)) { + return getDecimalState(state.sensorBrightness); + } + } + return null; + } + + public Map getChannelUpdates(@Nullable AnelState oldState, AnelState newState) { + if (oldState != null && newState.status.equals(oldState.status)) { + return Collections.emptyMap(); // definitely no change! + } + + final Map updates = new HashMap<>(); + + // name and device temperature + final State newName = getNewStringState(oldState == null ? null : oldState.name, newState.name); + if (newName != null) { + updates.put(IAnelConstants.CHANNEL_NAME, newName); + } + final State newTemperature = getNewTemperatureState(oldState == null ? null : oldState.temperature, + newState.temperature); + if (newTemperature != null) { + updates.put(IAnelConstants.CHANNEL_TEMPERATURE, newTemperature); + } + + // relay properties + for (int i = 0; i < 8; i++) { + final State newRelayName = getNewStringState(oldState == null ? null : oldState.relayName[i], + newState.relayName[i]); + if (newRelayName != null) { + updates.put(IAnelConstants.CHANNEL_RELAY_NAME.get(i), newRelayName); + } + + final State newRelayState = getNewSwitchState(oldState == null ? null : oldState.relayState[i], + newState.relayState[i]); + if (newRelayState != null) { + updates.put(IAnelConstants.CHANNEL_RELAY_STATE.get(i), newRelayState); + } + + final State newRelayLocked = getNewSwitchState(oldState == null ? null : oldState.relayLocked[i], + newState.relayLocked[i]); + if (newRelayLocked != null) { + updates.put(IAnelConstants.CHANNEL_RELAY_LOCKED.get(i), newRelayLocked); + } + } + + // IO properties + for (int i = 0; i < 8; i++) { + final State newIOName = getNewStringState(oldState == null ? null : oldState.ioName[i], newState.ioName[i]); + if (newIOName != null) { + updates.put(IAnelConstants.CHANNEL_IO_NAME.get(i), newIOName); + } + + final State newIOIsInput = getNewSwitchState(oldState == null ? null : oldState.ioIsInput[i], + newState.ioIsInput[i]); + if (newIOIsInput != null) { + updates.put(IAnelConstants.CHANNEL_IO_MODE.get(i), newIOIsInput); + } + + final State newIOState = getNewSwitchState(oldState == null ? null : oldState.ioState[i], + newState.ioState[i]); + if (newIOState != null) { + updates.put(IAnelConstants.CHANNEL_IO_STATE.get(i), newIOState); + } + } + + // sensor values + final State newSensorTemperature = getNewTemperatureState(oldState == null ? null : oldState.sensorTemperature, + newState.sensorTemperature); + if (newSensorTemperature != null) { + updates.put(IAnelConstants.CHANNEL_SENSOR_TEMPERATURE, newSensorTemperature); + } + final State newSensorHumidity = getNewDecimalState(oldState == null ? null : oldState.sensorHumidity, + newState.sensorHumidity); + if (newSensorHumidity != null) { + updates.put(IAnelConstants.CHANNEL_SENSOR_HUMIDITY, newSensorHumidity); + } + final State newSensorBrightness = getNewDecimalState(oldState == null ? null : oldState.sensorBrightness, + newState.sensorBrightness); + if (newSensorBrightness != null) { + updates.put(IAnelConstants.CHANNEL_SENSOR_BRIGHTNESS, newSensorBrightness); + } + + return updates; + } + + private @Nullable State getStringState(@Nullable String value) { + return value == null ? null : new StringType(value); + } + + private @Nullable State getDecimalState(@Nullable String value) { + return value == null ? null : new DecimalType(value); + } + + private @Nullable State getTemperatureState(@Nullable String value) { + if (value == null || value.trim().isEmpty()) { + return null; + } + final float floatValue = Float.parseFloat(value); + return QuantityType.valueOf(floatValue, SIUnits.CELSIUS); + } + + private @Nullable State getSwitchState(@Nullable Boolean value) { + return value == null ? null : OnOffType.from(value.booleanValue()); + } + + private @Nullable State getNewStringState(@Nullable String oldValue, @Nullable String newValue) { + return getNewState(oldValue, newValue, StringType::new); + } + + private @Nullable State getNewDecimalState(@Nullable String oldValue, @Nullable String newValue) { + return getNewState(oldValue, newValue, DecimalType::new); + } + + private @Nullable State getNewTemperatureState(@Nullable String oldValue, @Nullable String newValue) { + return getNewState(oldValue, newValue, value -> QuantityType.valueOf(Float.parseFloat(value), SIUnits.CELSIUS)); + } + + private @Nullable State getNewSwitchState(@Nullable Boolean oldValue, @Nullable Boolean newValue) { + return getNewState(oldValue, newValue, value -> OnOffType.from(value.booleanValue())); + } + + private @Nullable State getNewState(@Nullable T oldValue, @Nullable T newValue, + Function createState) { + if (oldValue == null) { + if (newValue == null) { + return null; // no change + } else { + return createState.apply(newValue); // from null to some value + } + } else if (newValue == null) { + return UnDefType.NULL; // from some value to null + } else if (oldValue.equals(newValue)) { + return null; // no change + } + return createState.apply(newValue); // from some value to another value + } +} diff --git a/bundles/org.openhab.binding.anel/src/main/resources/OH-INF/binding/binding.xml b/bundles/org.openhab.binding.anel/src/main/resources/OH-INF/binding/binding.xml new file mode 100644 index 000000000000..1635ce3daf4d --- /dev/null +++ b/bundles/org.openhab.binding.anel/src/main/resources/OH-INF/binding/binding.xml @@ -0,0 +1,9 @@ + + + + Anel NET-PwrCtrl Binding + This is the binding for Anel NET-PwrCtrl devices. + + diff --git a/bundles/org.openhab.binding.anel/src/main/resources/OH-INF/config/config.xml b/bundles/org.openhab.binding.anel/src/main/resources/OH-INF/config/config.xml new file mode 100644 index 000000000000..96dc873097bd --- /dev/null +++ b/bundles/org.openhab.binding.anel/src/main/resources/OH-INF/config/config.xml @@ -0,0 +1,39 @@ + + + + + + network-address + + net-control + Hostname or IP address of the device + + + port-send + + 75 + UDP port to send data to the device (in the anel web UI, it's the receive port!) + + + port-receive + + 77 + UDP port to receive data from the device (in the anel web UI, it's the send port!) + + + user + + user7 + User to access the device (make sure it has rights to change relay / IO states!) + + + password + + anel + Password to access the device + + + diff --git a/bundles/org.openhab.binding.anel/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.anel/src/main/resources/OH-INF/thing/thing-types.xml new file mode 100644 index 000000000000..d9e45864579b --- /dev/null +++ b/bundles/org.openhab.binding.anel/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,201 @@ + + + + + + Anel device with 3 controllable outlets without IO ports. + + + + + + + + + + + + ANEL Elektronik AG + NET-PwrCtrl HOME + + macAddress + + + + + + + Anel device with 8 controllable outlets without IO ports. + + + + + + + + + + + + + + + + + ANEL Elektronik AG + NET-PwrCtrl PRO / POWER + + macAddress + + + + + + + Anel device with 8 controllable outlets / relays and possibly 8 IO ports. + + + + + + + + + + + + + + + + + + + + + + + + + + + + ANEL Elektronik AG + NET-PwrCtrl ADV / IO / HUT + + macAddress + + + + + + + Device properties + + + + + + + + A relay / socket + + + + + + + + + An Input / Output Port + + + + + + + + + + Optional sensor values + + + + + + + + + String + + The name of the Anel device + + + + Number:Temperature + + The value of the built-in temperature sensor of the Anel device + + + + + String + + The name of the relay / socket + + + + Switch + + Whether or not the relay is locked + + + + Switch + + The state of the relay / socket (read-only if locked!) + veto + + + + String + + The name of the I/O port + + + + Switch + + Whether the port is configured as input (true) or output (false) + + + + Switch + + The state of the I/O port (read-only for input ports) + veto + + + + Number:Temperature + + The temperature value of the optional sensor + + + + Number + + The humidity value of the optional sensor + + + + Number + + The brightness value of the optional sensor + + + + diff --git a/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelAuthenticationTest.java b/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelAuthenticationTest.java new file mode 100644 index 000000000000..ca81fed646ff --- /dev/null +++ b/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelAuthenticationTest.java @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2010-2021 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.anel.internal; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.assertThat; + +import java.util.Base64; +import java.util.function.BiFunction; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.binding.anel.internal.auth.AnelAuthentication; +import org.openhab.binding.anel.internal.auth.AnelAuthentication.AuthMethod; + +/** + * This class tests {@link AnelAuthentication}. + * + * @author Patrick Koenemann - Initial contribution + */ +@NonNullByDefault +public class AnelAuthenticationTest { + + private static final String STATUS_HUT_V4 = "NET-PwrCtrl:NET-CONTROL :192.168.178.148:255.255.255.0:192.168.178.1:0.4.163.10.9.107:Nr. 1,1:Nr. 2,1:Nr. 3,1:Nr. 4,0:Nr. 5,0:Nr. 6,0:Nr. 7,1:Nr. 8,1:0:80:IO-1,0,0:IO-2,0,0:IO-3,0,0:IO-4,0,0:IO-5,0,0:IO-6,0,0:IO-7,0,0:IO-8,0,0:27.7°C:NET-PWRCTRL_04.0"; + private static final String STATUS_HUT_V5 = "NET-PwrCtrl:ANEL2 :192.168.0.244:255.255.255.0:192.168.0.1:0.4.163.10.9.107:Nr. 1,1:Nr. 2,1:Nr. 3,1:Nr. 4,0:Nr. 5,0:Nr. 6,0:Nr. 7,1:Nr. 8,1:0:80:IO-1,0,0:IO-2,0,0:IO-3,0,0:IO-4,0,0:IO-5,0,0:IO-6,0,0:IO-7,0,0:IO-8,0,0:27.9*C:NET-PWRCTRL_05.0"; + private static final String STATUS_HOME_V4_6 = "NET-PwrCtrl:NET-CONTROL :192.168.0.244:255.255.255.0:192.168.0.1:0.5.163.21.4.71:Nr. 1,0:Nr. 2,1:Nr. 3,0:Nr. 4,0:Nr. 5,0:Nr. 6,0:Nr. 7,0:Nr. 8,0:248:80:NET-PWRCTRL_04.6:H:xor:"; + private static final String STATUS_UDP_SPEC_EXAMPLE_V7 = "NET-PwrCtrl:NET-CONTROL :192.168.178.148:255.255.255.0:192.168.178.1:0.4.163.10.9.107:Nr. 1,1:Nr. 2,1:Nr. 3,1:Nr. 4,0:Nr. 5,0:Nr. 6,0:Nr. 7,1:Nr. 8,1:0:80:IO-1,0,0:IO-2,0,0:IO-3,0,0:IO-4,0,0:IO-5,0,0:IO-6,0,0:IO-7,0,0:IO-8,0,0:27.7°C:NET-PWRCTRL_06.1:h:p:225.9:0.0004:50.056:0.04:0.00:0.0:1.0000:s:20.61:40.7:7.0:xor"; + private static final String STATUS_PRO_EXAMPLE_V4_5 = "172.25.3.147776172NET-PwrCtrl:DT-BT14-IPL-1 :172.25.3.14:255.255.0.0:172.25.1.1:0.4.163.19.3.129:Nr. 1,0:Nr. 2,0:Nr. 3,0:Nr. 4,0:Nr. 5,0:Nr. 6,0:Nr. 7,0:Nr. 8,0:0:80:NET-PWRCTRL_04.5:xor:"; + private static final String STATUS_IO_EXAMPLE_V6_5 = "NET-PwrCtrl:NET-CONTROL :192.168.0.244:255.255.255.0:192.168.0.1:0.4.163.20.7.65:Nr.1,0:Nr.2,1:Nr.3,0:Nr.4,0:Nr.5,0:Nr.6,0:Nr.7,0:Nr.8,0:0:80:IO-1,0,1:IO-2,0,0:IO-3,0,0:IO-4,0,0:IO-5,0,0:IO-6,0,0:IO-7,0,0:IO-8,0,0:23.1°C:NET-PWRCTRL_06.5:i:n:xor:"; + private static final String STATUS_EXAMPLE_V6_0 = " NET-PwrCtrl:NET-CONTROL :192.168.178.148:255.255.255.0:192.168.178.1:0.4.163.10.9.107:Nr. 1,1:Nr. 2,1:Nr. 3,1:Nr. 4,0:Nr. 5,0:Nr. 6,0:Nr. 7,1:Nr. 8,1:0:80:IO-1,0,0:IO-2,0,0:IO-3,0,0:IO-4,0,0:IO-5,0,0:IO-6,0,0:IO-7,0,0:IO-8,0,0:27.7°C:NET-PWRCTRL_06.0:o:p:225.9:0.0004:50.056:0.04:0.00:0.0:1.0000"; + + @Test + public void authenticationMethod() { + assertThat(AuthMethod.of(""), is(AuthMethod.PLAIN)); + assertThat(AuthMethod.of(" \n"), is(AuthMethod.PLAIN)); + assertThat(AuthMethod.of(STATUS_HUT_V4), is(AuthMethod.PLAIN)); + assertThat(AuthMethod.of(STATUS_HUT_V5), is(AuthMethod.PLAIN)); + assertThat(AuthMethod.of(STATUS_HOME_V4_6), is(AuthMethod.XORBASE64)); + assertThat(AuthMethod.of(STATUS_UDP_SPEC_EXAMPLE_V7), is(AuthMethod.XORBASE64)); + assertThat(AuthMethod.of(STATUS_PRO_EXAMPLE_V4_5), is(AuthMethod.XORBASE64)); + assertThat(AuthMethod.of(STATUS_IO_EXAMPLE_V6_5), is(AuthMethod.XORBASE64)); + assertThat(AuthMethod.of(STATUS_EXAMPLE_V6_0), is(AuthMethod.BASE64)); + } + + @Test + public void encodeUserPasswordPlain() { + encodeUserPassword(AuthMethod.PLAIN, (u, p) -> u + p); + } + + @Test + public void encodeUserPasswordBase64() { + encodeUserPassword(AuthMethod.BASE64, (u, p) -> base64(u + p)); + } + + @Test + public void encodeUserPasswordXorBase64() { + encodeUserPassword(AuthMethod.XORBASE64, (u, p) -> base64(xor(u + p, p))); + } + + private void encodeUserPassword(AuthMethod authMethod, BiFunction expectedEncoding) { + assertThat(AnelAuthentication.getUserPasswordString("admin", "anel", authMethod), + is(equalTo(expectedEncoding.apply("admin", "anel")))); + assertThat(AnelAuthentication.getUserPasswordString("", "", authMethod), + is(equalTo(expectedEncoding.apply("", "")))); + assertThat(AnelAuthentication.getUserPasswordString(null, "", authMethod), + is(equalTo(expectedEncoding.apply("", "")))); + assertThat(AnelAuthentication.getUserPasswordString("", null, authMethod), + is(equalTo(expectedEncoding.apply("", "")))); + assertThat(AnelAuthentication.getUserPasswordString(null, null, authMethod), + is(equalTo(expectedEncoding.apply("", "")))); + } + + private static String base64(String string) { + return Base64.getEncoder().encodeToString(string.getBytes()); + } + + private String xor(String text, String key) { + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < text.length(); i++) { + sb.append((char) (text.charAt(i) ^ key.charAt(i % key.length()))); + } + return sb.toString(); + } +} diff --git a/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelCommandHandlerTest.java b/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelCommandHandlerTest.java new file mode 100644 index 000000000000..ea7466de4666 --- /dev/null +++ b/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelCommandHandlerTest.java @@ -0,0 +1,179 @@ +/** + * Copyright (c) 2010-2021 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.anel.internal; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.binding.anel.internal.state.AnelCommandHandler; +import org.openhab.binding.anel.internal.state.AnelState; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.IncreaseDecreaseType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.library.types.UpDownType; +import org.openhab.core.types.RefreshType; + +/** + * This class tests {@link AnelCommandHandler}. + * + * @author Patrick Koenemann - Initial contribution + */ +@NonNullByDefault +public class AnelCommandHandlerTest { + + private static final String CHANNEL_R1 = IAnelConstants.CHANNEL_RELAY_STATE.get(0); + private static final String CHANNEL_R3 = IAnelConstants.CHANNEL_RELAY_STATE.get(2); + private static final String CHANNEL_R4 = IAnelConstants.CHANNEL_RELAY_STATE.get(3); + private static final String CHANNEL_IO1 = IAnelConstants.CHANNEL_IO_STATE.get(0); + private static final String CHANNEL_IO6 = IAnelConstants.CHANNEL_IO_STATE.get(5); + + private static final AnelState STATE_INVALID = AnelState.of(null); + private static final AnelState STATE_HOME = AnelState.of(IAnelTestStatus.STATUS_HOME_V46); + private static final AnelState STATE_HUT = AnelState.of(IAnelTestStatus.STATUS_HUT_V65); + + private final AnelCommandHandler commandHandler = new AnelCommandHandler(); + + @Test + public void refreshCommand() { + // given & when + final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_INVALID, CHANNEL_R1, RefreshType.REFRESH, + "a"); + // then + assertNull(cmd); + } + + @Test + public void decimalCommandReturnsNull() { + // given & when + final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R1, new DecimalType("1"), "a"); + // then + assertNull(cmd); + } + + @Test + public void stringCommandReturnsNull() { + // given & when + final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R1, new StringType("ON"), "a"); + // then + assertNull(cmd); + } + + @Test + public void increaseDecreaseCommandReturnsNull() { + // given & when + final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R1, + IncreaseDecreaseType.INCREASE, "a"); + // then + assertNull(cmd); + } + + @Test + public void upDownCommandReturnsNull() { + // given & when + final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R1, UpDownType.UP, "a"); + // then + assertNull(cmd); + } + + @Test + public void unlockedSwitchReturnsCommand() { + // given & when + final String cmdOn1 = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R1, OnOffType.ON, "a"); + final String cmdOff1 = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R1, OnOffType.OFF, "a"); + final String cmdOn3 = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R3, OnOffType.ON, "a"); + final String cmdOff3 = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R3, OnOffType.OFF, "a"); + // then + assertThat(cmdOn1, equalTo("Sw_on1a")); + assertThat(cmdOff1, equalTo("Sw_off1a")); + assertThat(cmdOn3, equalTo("Sw_on3a")); + assertThat(cmdOff3, equalTo("Sw_off3a")); + } + + @Test + public void lockedSwitchReturnsNull() { + // given & when + final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R4, OnOffType.ON, "a"); + // then + assertNull(cmd); + } + + @Test + public void nullIOSwitchReturnsCommand() { + // given & when + final String cmdOn = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_IO1, OnOffType.ON, "a"); + final String cmdOff = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_IO1, OnOffType.OFF, "a"); + // then + assertThat(cmdOn, equalTo("IO_on1a")); + assertThat(cmdOff, equalTo("IO_off1a")); + } + + @Test + public void inputIOSwitchReturnsNull() { + // given & when + final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_HUT, CHANNEL_IO6, OnOffType.ON, "a"); + // then + assertNull(cmd); + } + + @Test + public void outputIOSwitchReturnsCommand() { + // given & when + final String cmdOn = commandHandler.toAnelCommandAndUnsetState(STATE_HUT, CHANNEL_IO1, OnOffType.ON, "a"); + final String cmdOff = commandHandler.toAnelCommandAndUnsetState(STATE_HUT, CHANNEL_IO1, OnOffType.OFF, "a"); + // then + assertThat(cmdOn, equalTo("IO_on1a")); + assertThat(cmdOff, equalTo("IO_off1a")); + } + + @Test + public void ioDirectionSwitchReturnsNull() { + // given & when + final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_HUT, IAnelConstants.CHANNEL_IO_MODE.get(0), + OnOffType.ON, "a"); + // then + assertNull(cmd); + } + + @Test + public void sensorTemperatureCommandReturnsNull() { + // given & when + final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_HUT, + IAnelConstants.CHANNEL_SENSOR_TEMPERATURE, new DecimalType("1.0"), "a"); + // then + assertNull(cmd); + } + + @Test + public void relayChannelIdIndex() { + for (int i = 0; i < IAnelConstants.CHANNEL_RELAY_STATE.size(); i++) { + final String relayStateChannelId = IAnelConstants.CHANNEL_RELAY_STATE.get(i); + final String relayIndex = relayStateChannelId.substring(1, 2); + final String expectedIndex = String.valueOf(i + 1); + assertThat(relayIndex, equalTo(expectedIndex)); + } + } + + @Test + public void ioChannelIdIndex() { + for (int i = 0; i < IAnelConstants.CHANNEL_IO_STATE.size(); i++) { + final String ioStateChannelId = IAnelConstants.CHANNEL_IO_STATE.get(i); + final String ioIndex = ioStateChannelId.substring(2, 3); + final String expectedIndex = String.valueOf(i + 1); + assertThat(ioIndex, equalTo(expectedIndex)); + } + } +} diff --git a/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelStateTest.java b/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelStateTest.java new file mode 100644 index 000000000000..a8a1a3fc9759 --- /dev/null +++ b/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelStateTest.java @@ -0,0 +1,185 @@ +/** + * Copyright (c) 2010-2021 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.anel.internal; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.binding.anel.internal.state.AnelState; + +/** + * This class tests {@link AnelState}. + * + * @author Patrick Koenemann - Initial contribution + */ +@NonNullByDefault +public class AnelStateTest implements IAnelTestStatus { + + @Test + public void parseHomeV46Status() { + final AnelState state = AnelState.of(STATUS_HOME_V46); + assertThat(state.name, equalTo("NET-CONTROL")); + assertThat(state.ip, equalTo("192.168.0.63")); + assertThat(state.mac, equalTo("0.5.163.21.4.71")); + assertNull(state.temperature); + for (int i = 1; i <= 8; i++) { + assertThat(state.relayName[i - 1], equalTo("Nr. " + i)); + assertThat(state.relayState[i - 1], is(i % 2 == 1)); + assertThat(state.relayLocked[i - 1], is(i > 3)); // 248 is binary for: 11111000, so first 3 are not locked + } + for (int i = 1; i <= 8; i++) { + assertNull(state.ioName[i - 1]); + assertNull(state.ioState[i - 1]); + assertNull(state.ioIsInput[i - 1]); + } + assertNull(state.sensorTemperature); + assertNull(state.sensorBrightness); + assertNull(state.sensorHumidity); + } + + @Test + public void parseLockedStates() { + final AnelState state = AnelState.of(STATUS_HOME_V46.replaceAll(":\\d+:80:", ":236:80:")); + assertThat(state.relayLocked[0], is(false)); + assertThat(state.relayLocked[1], is(false)); + assertThat(state.relayLocked[2], is(true)); + assertThat(state.relayLocked[3], is(true)); + assertThat(state.relayLocked[4], is(false)); + assertThat(state.relayLocked[5], is(true)); + assertThat(state.relayLocked[6], is(true)); + assertThat(state.relayLocked[7], is(true)); + } + + @Test + public void parseHutV65Status() { + final AnelState state = AnelState.of(STATUS_HUT_V65); + assertThat(state.name, equalTo("NET-CONTROL")); + assertThat(state.ip, equalTo("192.168.0.64")); + assertThat(state.mac, equalTo("0.5.163.17.9.116")); + assertThat(state.temperature, equalTo("27.0")); + for (int i = 1; i <= 8; i++) { + assertThat(state.relayName[i - 1], equalTo("Nr." + i)); + assertThat(state.relayState[i - 1], is(i % 2 == 0)); + assertThat(state.relayLocked[i - 1], is(i > 3)); // 248 is binary for: 11111000, so first 3 are not locked + } + for (int i = 1; i <= 8; i++) { + assertThat(state.ioName[i - 1], equalTo("IO-" + i)); + assertThat(state.ioState[i - 1], is(false)); + assertThat(state.ioIsInput[i - 1], is(i >= 5)); + } + assertNull(state.sensorTemperature); + assertNull(state.sensorBrightness); + assertNull(state.sensorHumidity); + } + + @Test + public void parseHutV5Status() { + final AnelState state = AnelState.of(STATUS_HUT_V5); + assertThat(state.name, equalTo("ANEL1")); + assertThat(state.ip, equalTo("192.168.0.244")); + assertThat(state.mac, equalTo("0.5.163.14.7.91")); + assertThat(state.temperature, equalTo("27.3")); + for (int i = 1; i <= 8; i++) { + assertThat(state.relayName[i - 1], matchesPattern(".+")); + assertThat(state.relayState[i - 1], is(false)); + assertThat(state.relayLocked[i - 1], is(false)); + } + for (int i = 1; i <= 8; i++) { + assertThat(state.ioName[i - 1], matchesPattern(".+")); + assertThat(state.ioState[i - 1], is(true)); + assertThat(state.ioIsInput[i - 1], is(true)); + } + assertNull(state.sensorTemperature); + assertNull(state.sensorBrightness); + assertNull(state.sensorHumidity); + } + + @Test + public void parseHutV61StatusAndSensor() { + final AnelState state = AnelState.of(STATUS_HUT_V61_POW_SENSOR); + assertThat(state.name, equalTo("NET-CONTROL")); + assertThat(state.ip, equalTo("192.168.178.148")); + assertThat(state.mac, equalTo("0.4.163.10.9.107")); + assertThat(state.temperature, equalTo("27.7")); + for (int i = 1; i <= 8; i++) { + assertThat(state.relayName[i - 1], equalTo("Nr. " + i)); + assertThat(state.relayState[i - 1], is(i <= 3 || i >= 7)); + assertThat(state.relayLocked[i - 1], is(false)); + } + for (int i = 1; i <= 8; i++) { + assertThat(state.ioName[i - 1], equalTo("IO-" + i)); + assertThat(state.ioState[i - 1], is(false)); + assertThat(state.ioIsInput[i - 1], is(false)); + } + assertThat(state.sensorTemperature, equalTo("20.61")); + assertThat(state.sensorHumidity, equalTo("40.7")); + assertThat(state.sensorBrightness, equalTo("7.0")); + } + + @Test + public void parseHutV61StatusWithSensor() { + final AnelState state = AnelState.of(STATUS_HUT_V61_SENSOR); + assertThat(state.name, equalTo("NET-CONTROL")); + assertThat(state.ip, equalTo("192.168.178.148")); + assertThat(state.mac, equalTo("0.4.163.10.9.107")); + assertThat(state.temperature, equalTo("27.7")); + for (int i = 1; i <= 8; i++) { + assertThat(state.relayName[i - 1], equalTo("Nr. " + i)); + assertThat(state.relayState[i - 1], is(i <= 3 || i >= 7)); + assertThat(state.relayLocked[i - 1], is(false)); + } + for (int i = 1; i <= 8; i++) { + assertThat(state.ioName[i - 1], equalTo("IO-" + i)); + assertThat(state.ioState[i - 1], is(false)); + assertThat(state.ioIsInput[i - 1], is(false)); + } + assertThat(state.sensorTemperature, equalTo("20.61")); + assertThat(state.sensorHumidity, equalTo("40.7")); + assertThat(state.sensorBrightness, equalTo("7.0")); + } + + @Test + public void parseHutV61StatusWithoutSensor() { + final AnelState state = AnelState.of(STATUS_HUT_V61_POW); + assertThat(state.name, equalTo("NET-CONTROL")); + assertThat(state.ip, equalTo("192.168.178.148")); + assertThat(state.mac, equalTo("0.4.163.10.9.107")); + assertThat(state.temperature, equalTo("27.7")); + for (int i = 1; i <= 8; i++) { + assertThat(state.relayName[i - 1], equalTo("Nr. " + i)); + assertThat(state.relayState[i - 1], is(i <= 3 || i >= 7)); + assertThat(state.relayLocked[i - 1], is(false)); + } + for (int i = 1; i <= 8; i++) { + assertThat(state.ioName[i - 1], equalTo("IO-" + i)); + assertThat(state.ioState[i - 1], is(false)); + assertThat(state.ioIsInput[i - 1], is(false)); + } + assertNull(state.sensorTemperature); + assertNull(state.sensorBrightness); + assertNull(state.sensorHumidity); + } + + @Test + public void colonSeparatorInSwitchNameThrowsException() { + try { + AnelState.of(STATUS_INVALID_NAME); + fail("Status format exception expected because of colon separator in name 'Nr: 3'"); + } catch (IllegalArgumentException e) { + assertThat(e.getMessage(), containsString("is expected to be a number but it's not")); + } + } +} diff --git a/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelStateUpdaterTest.java b/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelStateUpdaterTest.java new file mode 100644 index 000000000000..3703b4c33d43 --- /dev/null +++ b/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelStateUpdaterTest.java @@ -0,0 +1,142 @@ +/** + * Copyright (c) 2010-2021 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.anel.internal; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.junit.jupiter.api.Test; +import org.openhab.binding.anel.internal.state.AnelState; +import org.openhab.binding.anel.internal.state.AnelStateUpdater; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.types.State; + +/** + * This class tests {@link AnelStateUpdater}. + * + * @author Patrick Koenemann - Initial contribution + */ +@NonNullByDefault +public class AnelStateUpdaterTest implements IAnelTestStatus, IAnelConstants { + + private final AnelStateUpdater stateUpdater = new AnelStateUpdater(); + + @Test + public void noStateChange() { + // given + final AnelState oldState = AnelState.of(STATUS_HUT_V5); + final AnelState newState = AnelState.of(STATUS_HUT_V5.replace(":80:", ":81:")); // port is irrelevant + // when + Map updates = stateUpdater.getChannelUpdates(oldState, newState); + // then + assertThat(updates.entrySet(), is(empty())); + } + + @Test + public void fromNullStateUpdatesHome() { + // given + final AnelState newState = AnelState.of(STATUS_HOME_V46); + // when + Map updates = stateUpdater.getChannelUpdates(null, newState); + // then + final Map expected = new HashMap<>(); + expected.put(CHANNEL_NAME, new StringType("NET-CONTROL")); + for (int i = 1; i <= 8; i++) { + expected.put(CHANNEL_RELAY_NAME.get(i - 1), new StringType("Nr. " + i)); + expected.put(CHANNEL_RELAY_STATE.get(i - 1), OnOffType.from(i % 2 == 1)); + expected.put(CHANNEL_RELAY_LOCKED.get(i - 1), OnOffType.from(i > 3)); + } + assertThat(updates, equalTo(expected)); + } + + @Test + public void fromNullStateUpdatesHutPowerSensor() { + // given + final AnelState newState = AnelState.of(STATUS_HUT_V61_POW_SENSOR); + // when + Map updates = stateUpdater.getChannelUpdates(null, newState); + // then + assertThat(updates.size(), is(5 + 8 * 6)); + assertThat(updates.get(CHANNEL_NAME), equalTo(new StringType("NET-CONTROL"))); + assertTemperature(updates.get(CHANNEL_TEMPERATURE), 27.7); + + assertThat(updates.get(CHANNEL_SENSOR_BRIGHTNESS), equalTo(new DecimalType("7"))); + assertThat(updates.get(CHANNEL_SENSOR_HUMIDITY), equalTo(new DecimalType("40.7"))); + assertTemperature(updates.get(CHANNEL_SENSOR_TEMPERATURE), 20.61); + + for (int i = 1; i <= 8; i++) { + assertThat(updates.get(CHANNEL_RELAY_NAME.get(i - 1)), equalTo(new StringType("Nr. " + i))); + assertThat(updates.get(CHANNEL_RELAY_STATE.get(i - 1)), equalTo(OnOffType.from(i <= 3 || i >= 7))); + assertThat(updates.get(CHANNEL_RELAY_LOCKED.get(i - 1)), equalTo(OnOffType.OFF)); + } + for (int i = 1; i <= 8; i++) { + assertThat(updates.get(CHANNEL_IO_NAME.get(i - 1)), equalTo(new StringType("IO-" + i))); + assertThat(updates.get(CHANNEL_IO_STATE.get(i - 1)), equalTo(OnOffType.OFF)); + assertThat(updates.get(CHANNEL_IO_MODE.get(i - 1)), equalTo(OnOffType.OFF)); + } + } + + @Test + public void singleRelayStateChange() { + // given + final AnelState oldState = AnelState.of(STATUS_HUT_V61_POW_SENSOR); + final AnelState newState = AnelState.of(STATUS_HUT_V61_POW_SENSOR.replace("Nr. 4,0", "Nr. 4,1")); + // when + Map updates = stateUpdater.getChannelUpdates(oldState, newState); + // then + final Map expected = new HashMap<>(); + expected.put(CHANNEL_RELAY_STATE.get(3), OnOffType.ON); + assertThat(updates, equalTo(expected)); + } + + @Test + public void temperatureChange() { + // given + final AnelState oldState = AnelState.of(STATUS_HUT_V65); + final AnelState newState = AnelState.of(STATUS_HUT_V65.replaceFirst(":27\\.0(.)C:", ":27.1°C:")); + // when + Map updates = stateUpdater.getChannelUpdates(oldState, newState); + // then + assertThat(updates.size(), is(1)); + assertTemperature(updates.get(CHANNEL_TEMPERATURE), 27.1); + } + + @Test + public void singleSensorStatesChange() { + // given + final AnelState oldState = AnelState.of(STATUS_HUT_V61_SENSOR); + final AnelState newState = AnelState.of(STATUS_HUT_V61_SENSOR.replace(":s:20.61:40.7:7.0:", ":s:20.6:40:7.1:")); + // when + Map updates = stateUpdater.getChannelUpdates(oldState, newState); + // then + assertThat(updates.size(), is(3)); + assertThat(updates.get(CHANNEL_SENSOR_BRIGHTNESS), equalTo(new DecimalType("7.1"))); + assertThat(updates.get(CHANNEL_SENSOR_HUMIDITY), equalTo(new DecimalType("40"))); + assertTemperature(updates.get(CHANNEL_SENSOR_TEMPERATURE), 20.6); + } + + private void assertTemperature(@Nullable State state, double value) { + assertThat(state, isA(QuantityType.class)); + if (state instanceof QuantityType) { + assertThat(((QuantityType) state).doubleValue(), closeTo(value, 0.0001d)); + } + } +} diff --git a/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelUdpConnectorTest.java b/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelUdpConnectorTest.java new file mode 100644 index 000000000000..60f34e4ee5c1 --- /dev/null +++ b/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelUdpConnectorTest.java @@ -0,0 +1,185 @@ +/** + * Copyright (c) 2010-2021 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.anel.internal; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +import java.util.LinkedHashSet; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.openhab.binding.anel.internal.auth.AnelAuthentication; +import org.openhab.binding.anel.internal.auth.AnelAuthentication.AuthMethod; + +/** + * This test requires a physical Anel device! + * + * @author Patrick Koenemann - Initial contribution + */ +@NonNullByDefault +@Disabled // requires a physically available device in the local network +public class AnelUdpConnectorTest { + + /* + * The IP and ports for the Anel device under test. + */ + private static final String HOST = "192.168.6.63"; // 63 / 64 + private static final int PORT_SEND = 7500; // 7500 / 75001 + private static final int PORT_RECEIVE = 7700; // 7700 / 7701 + private static final String USER = "user7"; + private static final String PASSWORD = "anel"; + + /* The device may have an internal delay of 200ms, plus network latency! Should not be <1sec. */ + private static final int WAIT_FOR_DEVICE_RESPONSE_MS = 1000; + + private static final ExecutorService EXECUTOR_SERVICE = Executors.newCachedThreadPool(); + + private final Queue receivedMessages = new ConcurrentLinkedQueue<>(); + + @Nullable + private static AnelUdpConnector connector; + + @BeforeAll + public static void prepareConnector() { + connector = new AnelUdpConnector(HOST, PORT_RECEIVE, PORT_SEND, EXECUTOR_SERVICE); + } + + @AfterAll + @SuppressWarnings("null") + public static void closeConnection() { + connector.disconnect(); + } + + @BeforeEach + @SuppressWarnings("null") + public void connectIfNotYetConnected() throws Exception { + Thread.sleep(100); + receivedMessages.clear(); // clear all previously received messages + + if (!connector.isConnected()) { + connector.connect(receivedMessages::offer, false); + } + } + + @Test + public void connectionTest() throws Exception { + final String response = sendAndReceiveSingle(IAnelConstants.BROADCAST_DISCOVERY_MSG); + /* + * Expected example response: + * "NET-PwrCtrl:NET-CONTROL :192.168.0.244:255.255.255.0:192.168.0.1:0.5.163.21.4.71:Nr. 1,0:Nr. 2,1:Nr. 3,0:Nr. 4,0:Nr. 5,0:Nr. 6,0:Nr. 7,0:Nr. 8,0:248:80:NET-PWRCTRL_04.6:H:xor:" + */ + assertThat(response, startsWith(IAnelConstants.STATUS_RESPONSE_PREFIX + IAnelConstants.STATUS_SEPARATOR)); + } + + @Test + public void toggleSwitch1() throws Exception { + toggleSwitch(1); + } + + @Test + public void toggleSwitch2() throws Exception { + toggleSwitch(2); + } + + @Test + public void toggleSwitch3() throws Exception { + toggleSwitch(3); + } + + @Test + public void toggleSwitch4() throws Exception { + toggleSwitch(4); + } + + @Test + public void toggleSwitch5() throws Exception { + toggleSwitch(5); + } + + @Test + public void toggleSwitch6() throws Exception { + toggleSwitch(6); + } + + @Test + public void toggleSwitch7() throws Exception { + toggleSwitch(7); + } + + @Test + public void toggleSwitch8() throws Exception { + toggleSwitch(8); + } + + private void toggleSwitch(int switchNr) throws Exception { + assertThat(switchNr, allOf(greaterThan(0), lessThan(9))); + final int index = 5 + switchNr; + + // get state of switch 1 + final String status = sendAndReceiveSingle(IAnelConstants.BROADCAST_DISCOVERY_MSG); + final String[] segments = status.split(IAnelConstants.STATUS_SEPARATOR); + assertThat(segments[5 + switchNr], anyOf(endsWith(",1"), endsWith(",0"))); + final boolean switch1state = segments[index].endsWith(",1"); + + // toggle state of switch 1 + final String auth = AnelAuthentication.getUserPasswordString(USER, PASSWORD, AuthMethod.of(status)); + final String command = "Sw_" + (switch1state ? "off" : "on") + String.valueOf(switchNr) + auth; + final String status2 = sendAndReceiveSingle(command); + + // assert new state of switch 1 + assertThat(status2.trim(), not(endsWith(":Err"))); + final String[] segments2 = status2.split(IAnelConstants.STATUS_SEPARATOR); + final String expectedState = segments2[index].substring(0, segments2[index].length() - 1) + + (switch1state ? "0" : "1"); + assertThat(segments2[index], equalTo(expectedState)); + } + + @Test + public void withoutCredentials() throws Exception { + final String status2 = sendAndReceiveSingle("Sw_on1"); + assertThat(status2.trim(), endsWith(":NoPass:Err")); + Thread.sleep(3100); // locked for 3 seconds + } + + private String sendAndReceiveSingle(final String msg) throws Exception { + final Set response = sendAndReceive(msg); + assertThat(response, hasSize(1)); + return response.iterator().next(); + } + + @SuppressWarnings("null") + private Set sendAndReceive(final String msg) throws Exception { + assertThat(receivedMessages, is(empty())); + connector.send(msg); + Thread.sleep(WAIT_FOR_DEVICE_RESPONSE_MS); + final Set response = new LinkedHashSet<>(); + while (!receivedMessages.isEmpty()) { + final String receivedMessage = receivedMessages.poll(); + if (receivedMessage != null) { + response.add(receivedMessage); + } + } + return response; + } +} diff --git a/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/IAnelTestStatus.java b/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/IAnelTestStatus.java new file mode 100644 index 000000000000..61505b03696b --- /dev/null +++ b/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/IAnelTestStatus.java @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2010-2021 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.anel.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Some constants used in the unit tests. + * + * @author Patrick Koenemann - Initial contribution + */ +@NonNullByDefault +public interface IAnelTestStatus { + + String STATUS_INVALID_NAME = "NET-PwrCtrl:NET-CONTROL :192.168.6.63:255.255.255.0:192.168.6.1:0.4.163.21.4.71:" + + "Nr. 1,0:Nr. 2,1:Nr: 3,1:Nr. 4,0:Nr. 5,0:Nr. 6,0:Nr. 7,0:Nr. 8,0:248:80:NET-PWRCTRL_04.6:H:xor:"; + String STATUS_HUT_V61_POW = "NET-PwrCtrl:NET-CONTROL :192.168.178.148:255.255.255.0:192.168.178.1:0.4.163.10.9.107:" + + "Nr. 1,1:Nr. 2,1:Nr. 3,1:Nr. 4,0:Nr. 5,0:Nr. 6,0:Nr. 7,1:Nr. 8,1:0:80:" + + "IO-1,0,0:IO-2,0,0:IO-3,0,0:IO-4,0,0:IO-5,0,0:IO-6,0,0:IO-7,0,0:IO-8,0,0:27.7°C:NET-PWRCTRL_06.1:h:" + + "p:225.9:0.0004:50.056:0.04:0.00:0.0:1.0000:xor:"; + String STATUS_HUT_V61_SENSOR = "NET-PwrCtrl:NET-CONTROL :192.168.178.148:255.255.255.0:192.168.178.1:0.4.163.10.9.107:" + + "Nr. 1,1:Nr. 2,1:Nr. 3,1:Nr. 4,0:Nr. 5,0:Nr. 6,0:Nr. 7,1:Nr. 8,1:0:80:" + + "IO-1,0,0:IO-2,0,0:IO-3,0,0:IO-4,0,0:IO-5,0,0:IO-6,0,0:IO-7,0,0:IO-8,0,0:27.7°C:NET-PWRCTRL_06.1:h:" + + "n:s:20.61:40.7:7.0:xor:"; + String STATUS_HUT_V61_POW_SENSOR = "NET-PwrCtrl:NET-CONTROL :192.168.178.148:255.255.255.0:192.168.178.1:0.4.163.10.9.107:" + + "Nr. 1,1:Nr. 2,1:Nr. 3,1:Nr. 4,0:Nr. 5,0:Nr. 6,0:Nr. 7,1:Nr. 8,1:0:80:" + + "IO-1,0,0:IO-2,0,0:IO-3,0,0:IO-4,0,0:IO-5,0,0:IO-6,0,0:IO-7,0,0:IO-8,0,0:27.7°C:NET-PWRCTRL_06.1:h:" + + "p:225.9:0.0004:50.056:0.04:0.00:0.0:1.0000:s:20.61:40.7:7.0:xor"; + String STATUS_HUT_V5 = "NET-PwrCtrl:ANEL1 :192.168.0.244:255.255.255.0:192.168.0.1:0.5.163.14.7.91:" + + "hoch,0:links hoch,0:runter,0:rechts run,0:runter,0:hoch,0:links runt,0:rechts hoc,0:0:80:" + + "WHN_UP,1,1:LI_DOWN,1,1:RE_DOWN,1,1:LI_UP,1,1:RE_UP,1,1:DOWN,1,1:DOWN,1,1:UP,1,1:27.3°C:NET-PWRCTRL_05.0"; + String STATUS_HUT_V65 = "NET-PwrCtrl:NET-CONTROL :192.168.0.64:255.255.255.0:192.168.6.1:0.5.163.17.9.116:" + + "Nr.1,0:Nr.2,1:Nr.3,0:Nr.4,1:Nr.5,0:Nr.6,1:Nr.7,0:Nr.8,1:248:80:" + + "IO-1,0,0:IO-2,0,0:IO-3,0,0:IO-4,0,0:IO-5,1,0:IO-6,1,0:IO-7,1,0:IO-8,1,0:27.0�C:NET-PWRCTRL_06.5:h:n:xor:"; + String STATUS_HOME_V46 = "NET-PwrCtrl:NET-CONTROL :192.168.0.63:255.255.255.0:192.168.6.1:0.5.163.21.4.71:" + + "Nr. 1,1:Nr. 2,0:Nr. 3,1:Nr. 4,0:Nr. 5,1:Nr. 6,0:Nr. 7,1:Nr. 8,0:248:80:NET-PWRCTRL_04.6:H:xor:"; +} diff --git a/bundles/pom.xml b/bundles/pom.xml index 6ab04faa208b..e2dcde088fb9 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -55,6 +55,7 @@ org.openhab.binding.ambientweather org.openhab.binding.amplipi org.openhab.binding.androiddebugbridge + org.openhab.binding.anel org.openhab.binding.astro org.openhab.binding.atlona org.openhab.binding.autelis