]> git.basschouten.com Git - openhab-addons.git/commitdiff
[anel] Initial contribution of the Anel NET-PwrCtrl binding for OH3 (#10952)
authorpaphko <git@paphko.de>
Mon, 29 Nov 2021 08:45:29 +0000 (09:45 +0100)
committerGitHub <noreply@github.com>
Mon, 29 Nov 2021 08:45:29 +0000 (09:45 +0100)
* Initial contribution of the Anel NET-PwrCtrl binding for OH3.

Signed-off-by: Patrick Koenemann <git@paphko.de>
* Adjustments based on code review.

Signed-off-by: Patrick Koenemann <git@paphko.de>
* Further adjustments according to second review.

Signed-off-by: Patrick Koenemann <git@paphko.de>
* Checkstyle warnings revmoed.

Signed-off-by: Patrick Koenemann <git@paphko.de>
26 files changed:
CODEOWNERS
bom/openhab-addons/pom.xml
bundles/org.openhab.binding.anel/NOTICE [new file with mode: 0644]
bundles/org.openhab.binding.anel/README.md [new file with mode: 0644]
bundles/org.openhab.binding.anel/pom.xml [new file with mode: 0644]
bundles/org.openhab.binding.anel/src/main/feature/feature.xml [new file with mode: 0644]
bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/AnelConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/AnelHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/AnelHandlerFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/AnelUdpConnector.java [new file with mode: 0644]
bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/IAnelConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/auth/AnelAuthentication.java [new file with mode: 0644]
bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/discovery/AnelDiscoveryService.java [new file with mode: 0644]
bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/state/AnelCommandHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/state/AnelState.java [new file with mode: 0644]
bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/state/AnelStateUpdater.java [new file with mode: 0644]
bundles/org.openhab.binding.anel/src/main/resources/OH-INF/binding/binding.xml [new file with mode: 0644]
bundles/org.openhab.binding.anel/src/main/resources/OH-INF/config/config.xml [new file with mode: 0644]
bundles/org.openhab.binding.anel/src/main/resources/OH-INF/thing/thing-types.xml [new file with mode: 0644]
bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelAuthenticationTest.java [new file with mode: 0644]
bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelCommandHandlerTest.java [new file with mode: 0644]
bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelStateTest.java [new file with mode: 0644]
bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelStateUpdaterTest.java [new file with mode: 0644]
bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelUdpConnectorTest.java [new file with mode: 0644]
bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/IAnelTestStatus.java [new file with mode: 0644]
bundles/pom.xml

index f8022d6d83321a43aa7321fb2dfa14ec8c133177..3b49605401ad7bdf649dbf53a5b8e8f6f3be760d 100644 (file)
@@ -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
index 9a6dd839a540bce564107d1a7cb99edc79adf897..4fe676379221e39ff08ed7c0dd456827f65e455d 100644 (file)
       <artifactId>org.openhab.binding.androiddebugbridge</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>org.openhab.addons.bundles</groupId>
+      <artifactId>org.openhab.binding.anel</artifactId>
+      <version>${project.version}</version>
+    </dependency>
     <dependency>
       <groupId>org.openhab.addons.bundles</groupId>
       <artifactId>org.openhab.binding.astro</artifactId>
diff --git a/bundles/org.openhab.binding.anel/NOTICE b/bundles/org.openhab.binding.anel/NOTICE
new file mode 100644 (file)
index 0000000..38d625e
--- /dev/null
@@ -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 (file)
index 0000000..e2189cb
--- /dev/null
@@ -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) <br/> <sub>( _advanced-firmware_ )</sub>  | [Anel NET-PwrCtrl IO](https://en.anel.eu/?src=/produkte/io/io.htm) <br/> <sub>( _advanced-firmware_ )</sub> | [Anel NET-PwrCtrl HOME](https://de.anel.eu/?src=produkte/home/home.htm) <br/> <sub>( _home_ )</sub> <br/> (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 &rarr; 7501/7701 &rarr; 7502/7702 &rarr; 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.<br/>
+\** 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.<br/>
+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 (file)
index 0000000..325a69c
--- /dev/null
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>org.openhab.addons.bundles</groupId>
+    <artifactId>org.openhab.addons.reactor.bundles</artifactId>
+    <version>3.2.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>org.openhab.binding.anel</artifactId>
+
+  <name>openHAB Add-ons :: Bundles :: Anel Binding</name>
+
+</project>
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 (file)
index 0000000..a4b8497
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<features name="org.openhab.binding.anel-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
+       <repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
+
+       <feature name="openhab-binding-anel" description="Anel Binding" version="${project.version}">
+               <feature>openhab-runtime-base</feature>
+               <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.anel/${project.version}</bundle>
+       </feature>
+</features>
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 (file)
index 0000000..a30f77f
--- /dev/null
@@ -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 (file)
index 0000000..8abc5c5
--- /dev/null
@@ -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<String, State> 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 (file)
index 0000000..96a5d9e
--- /dev/null
@@ -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 (file)
index 0000000..c1fe5ec
--- /dev/null
@@ -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<String> 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 <code>logNotTHrowException = false</code>.
+     * @throws InterruptedException Typically happens during shutdown.
+     */
+    public void connect(Consumer<String> 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<String> 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 (file)
index 0000000..c8aacb6
--- /dev/null
@@ -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<ThingTypeUID> 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<Character, ThingTypeUID> 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<String> 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<String> CHANNEL_RELAY_STATE = List.of("r1#state", "r2#state", "r3#state", "r4#state", "r5#state", "r6#state",
+            "r7#state", "r8#state");
+
+    List<String> CHANNEL_RELAY_LOCKED = List.of("r1#locked", "r2#locked", "r3#locked", "r4#locked", "r5#locked",
+            "r6#locked", "r7#locked", "r8#locked");
+
+    List<String> CHANNEL_IO_NAME = List.of("io1#name", "io2#name", "io3#name", "io4#name", "io5#name", "io6#name",
+            "io7#name", "io8#name");
+
+    List<String> 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<String> 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 (<code>0-7</code>); <code>-1</code> 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 (file)
index 0000000..ea897e5
--- /dev/null
@@ -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 (file)
index 0000000..cead6e0
--- /dev/null
@@ -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<String> 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 (file)
index 0000000..c2cc504
--- /dev/null
@@ -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 (file)
index 0000000..defc097
--- /dev/null
@@ -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.
+ * <p>
+ * Documentation in <a href="https://forum.anel.eu/viewtopic.php?f=16&t=207">Anel forum</a> (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 (<code>true</code> 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 = "<invalid>";
+        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<String> 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<String> 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 (file)
index 0000000..1f20871
--- /dev/null
@@ -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<String, State> getChannelUpdates(@Nullable AnelState oldState, AnelState newState) {
+        if (oldState != null && newState.status.equals(oldState.status)) {
+            return Collections.emptyMap(); // definitely no change!
+        }
+
+        final Map<String, State> 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 <T> @Nullable State getNewState(@Nullable T oldValue, @Nullable T newValue,
+            Function<T, State> 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 (file)
index 0000000..1635ce3
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<binding:binding id="anel" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:binding="https://openhab.org/schemas/binding/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/binding/v1.0.0 https://openhab.org/schemas/binding-1.0.0.xsd">
+
+       <name>Anel NET-PwrCtrl Binding</name>
+       <description>This is the binding for Anel NET-PwrCtrl devices.</description>
+
+</binding:binding>
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 (file)
index 0000000..96dc873
--- /dev/null
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<config-description:config-descriptions
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
+
+       <config-description uri="thing-type:anel:config">
+               <parameter name="hostname" type="text" required="true">
+                       <context>network-address</context>
+                       <label>Hostname / IP address</label>
+                       <default>net-control</default>
+                       <description>Hostname or IP address of the device</description>
+               </parameter>
+               <parameter name="udpSendPort" type="integer" required="true">
+                       <context>port-send</context>
+                       <label>Send Port</label>
+                       <default>75</default>
+                       <description>UDP port to send data to the device (in the anel web UI, it's the receive port!)</description>
+               </parameter>
+               <parameter name="udpReceivePort" type="integer" required="true">
+                       <context>port-receive</context>
+                       <label>Receive Port</label>
+                       <default>77</default>
+                       <description>UDP port to receive data from the device (in the anel web UI, it's the send port!)</description>
+               </parameter>
+               <parameter name="user" type="text" required="true">
+                       <context>user</context>
+                       <label>User</label>
+                       <default>user7</default>
+                       <description>User to access the device (make sure it has rights to change relay / IO states!)</description>
+               </parameter>
+               <parameter name="password" type="text" required="true">
+                       <context>password</context>
+                       <label>Password</label>
+                       <default>anel</default>
+                       <description>Password to access the device</description>
+               </parameter>
+       </config-description>
+</config-description:config-descriptions>
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 (file)
index 0000000..d9e4586
--- /dev/null
@@ -0,0 +1,201 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="anel"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <thing-type id="home">
+               <label>HOME</label>
+               <description>Anel device with 3 controllable outlets without IO ports.</description>
+
+               <!-- Example channel ID: anel:home:mydevice:prop#temperature -->
+               <channel-groups>
+                       <channel-group id="prop" typeId="propertiesGroup"/>
+
+                       <channel-group id="r1" typeId="relayGroup"/>
+                       <channel-group id="r2" typeId="relayGroup"/>
+                       <channel-group id="r3" typeId="relayGroup"/>
+               </channel-groups>
+
+               <properties>
+                       <property name="vendor">ANEL Elektronik AG</property>
+                       <property name="modelId">NET-PwrCtrl HOME</property>
+               </properties>
+               <representation-property>macAddress</representation-property>
+
+               <config-description-ref uri="thing-type:anel:config"/>
+       </thing-type>
+
+       <thing-type id="simple-firmware">
+               <label>PRO / POWER</label>
+               <description>Anel device with 8 controllable outlets without IO ports.</description>
+
+               <channel-groups>
+                       <channel-group id="prop" typeId="propertiesGroup"/>
+
+                       <!-- Example channel ID: anel:simple-firmware:mydevice:r1#state -->
+                       <channel-group id="r1" typeId="relayGroup"/>
+                       <channel-group id="r2" typeId="relayGroup"/>
+                       <channel-group id="r3" typeId="relayGroup"/>
+                       <channel-group id="r4" typeId="relayGroup"/>
+                       <channel-group id="r5" typeId="relayGroup"/>
+                       <channel-group id="r6" typeId="relayGroup"/>
+                       <channel-group id="r7" typeId="relayGroup"/>
+                       <channel-group id="r8" typeId="relayGroup"/>
+               </channel-groups>
+
+               <properties>
+                       <property name="vendor">ANEL Elektronik AG</property>
+                       <property name="modelId">NET-PwrCtrl PRO / POWER</property>
+               </properties>
+               <representation-property>macAddress</representation-property>
+
+               <config-description-ref uri="thing-type:anel:config"/>
+       </thing-type>
+
+       <thing-type id="advanced-firmware">
+               <label>ADV / IO / HUT</label>
+               <description>Anel device with 8 controllable outlets / relays and possibly 8 IO ports.</description>
+
+               <channel-groups>
+                       <channel-group id="prop" typeId="propertiesGroup"/>
+
+                       <channel-group id="r1" typeId="relayGroup"/>
+                       <channel-group id="r2" typeId="relayGroup"/>
+                       <channel-group id="r3" typeId="relayGroup"/>
+                       <channel-group id="r4" typeId="relayGroup"/>
+                       <channel-group id="r5" typeId="relayGroup"/>
+                       <channel-group id="r6" typeId="relayGroup"/>
+                       <channel-group id="r7" typeId="relayGroup"/>
+                       <channel-group id="r8" typeId="relayGroup"/>
+
+                       <channel-group id="io1" typeId="ioGroup"/>
+                       <channel-group id="io2" typeId="ioGroup"/>
+                       <channel-group id="io3" typeId="ioGroup"/>
+                       <channel-group id="io4" typeId="ioGroup"/>
+                       <channel-group id="io5" typeId="ioGroup"/>
+                       <channel-group id="io6" typeId="ioGroup"/>
+                       <channel-group id="io7" typeId="ioGroup"/>
+                       <channel-group id="io8" typeId="ioGroup"/>
+
+                       <!-- Example channel ID: anel:advanced-firmware:mydevice:sensor#humidity -->
+                       <channel-group id="sensor" typeId="sensorGroup"/>
+               </channel-groups>
+
+               <properties>
+                       <property name="vendor">ANEL Elektronik AG</property>
+                       <property name="modelId">NET-PwrCtrl ADV / IO / HUT</property>
+               </properties>
+               <representation-property>macAddress</representation-property>
+
+               <config-description-ref uri="thing-type:anel:config"/>
+       </thing-type>
+
+       <channel-group-type id="propertiesGroup">
+               <label>Device Properties</label>
+               <description>Device properties</description>
+               <channels>
+                       <channel id="name" typeId="name-channel"/>
+                       <channel id="temperature" typeId="temperature-channel"/>
+               </channels>
+       </channel-group-type>
+       <channel-group-type id="relayGroup">
+               <label>Relay / Socket</label>
+               <description>A relay / socket</description>
+               <channels>
+                       <channel id="name" typeId="relayName-channel"/>
+                       <channel id="locked" typeId="relayLocked-channel"/>
+                       <channel id="state" typeId="relayState-channel"/>
+               </channels>
+       </channel-group-type>
+       <channel-group-type id="ioGroup">
+               <label>I/O Port</label>
+               <description>An Input / Output Port</description>
+               <channels>
+                       <channel id="name" typeId="ioName-channel"/>
+                       <channel id="mode" typeId="ioMode-channel"/>
+                       <channel id="state" typeId="ioState-channel"/>
+                       <channel id="event" typeId="system.rawbutton"/>
+               </channels>
+       </channel-group-type>
+       <channel-group-type id="sensorGroup">
+               <label>Sensor</label>
+               <description>Optional sensor values</description>
+               <channels>
+                       <channel id="temperature" typeId="sensorTemperature-channel"/>
+                       <channel id="humidity" typeId="sensorHumidity-channel"/>
+                       <channel id="brightness" typeId="sensorBrightness-channel"/>
+               </channels>
+       </channel-group-type>
+
+       <channel-type id="name-channel">
+               <item-type>String</item-type>
+               <label>Device Name</label>
+               <description>The name of the Anel device</description>
+               <state readOnly="true"/>
+       </channel-type>
+       <channel-type id="temperature-channel">
+               <item-type>Number:Temperature</item-type>
+               <label>Anel Device Temperature</label>
+               <description>The value of the built-in temperature sensor of the Anel device</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="relayName-channel">
+               <item-type>String</item-type>
+               <label>Relay Name</label>
+               <description>The name of the relay / socket</description>
+               <state readOnly="true"/>
+       </channel-type>
+       <channel-type id="relayLocked-channel" advanced="true">
+               <item-type>Switch</item-type>
+               <label>Relay Locked</label>
+               <description>Whether or not the relay is locked</description>
+               <state readOnly="true"/>
+       </channel-type>
+       <channel-type id="relayState-channel">
+               <item-type>Switch</item-type>
+               <label>Relay State</label>
+               <description>The state of the relay / socket (read-only if locked!)</description>
+               <autoUpdatePolicy>veto</autoUpdatePolicy><!-- updates are only sent in non-locked mode -->
+       </channel-type>
+
+       <channel-type id="ioName-channel">
+               <item-type>String</item-type>
+               <label>IO Name</label>
+               <description>The name of the I/O port</description>
+               <state readOnly="true"/>
+       </channel-type>
+       <channel-type id="ioMode-channel" advanced="true">
+               <item-type>Switch</item-type>
+               <label>IO is Input</label>
+               <description>Whether the port is configured as input (true) or output (false)</description>
+               <state readOnly="true"/>
+       </channel-type>
+       <channel-type id="ioState-channel">
+               <item-type>Switch</item-type>
+               <label>IO State</label>
+               <description>The state of the I/O port (read-only for input ports)</description>
+               <autoUpdatePolicy>veto</autoUpdatePolicy><!-- updates are only sent in output mode -->
+       </channel-type>
+
+       <channel-type id="sensorTemperature-channel">
+               <item-type>Number:Temperature</item-type>
+               <label>Sensor Temperature</label>
+               <description>The temperature value of the optional sensor</description>
+               <state readOnly="true"/>
+       </channel-type>
+       <channel-type id="sensorHumidity-channel">
+               <item-type>Number</item-type>
+               <label>Sensor Humidity</label>
+               <description>The humidity value of the optional sensor</description>
+               <state readOnly="true"/>
+       </channel-type>
+       <channel-type id="sensorBrightness-channel">
+               <item-type>Number</item-type>
+               <label>Sensor Brightness</label>
+               <description>The brightness value of the optional sensor</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+</thing:thing-descriptions>
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 (file)
index 0000000..ca81fed
--- /dev/null
@@ -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<String, String, String> 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 (file)
index 0000000..ea7466d
--- /dev/null
@@ -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 (file)
index 0000000..a8a1a3f
--- /dev/null
@@ -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 (file)
index 0000000..3703b4c
--- /dev/null
@@ -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<String, State> 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<String, State> updates = stateUpdater.getChannelUpdates(null, newState);
+        // then
+        final Map<String, State> 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<String, State> 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<String, State> updates = stateUpdater.getChannelUpdates(oldState, newState);
+        // then
+        final Map<String, State> 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<String, State> 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<String, State> 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 (file)
index 0000000..60f34e4
--- /dev/null
@@ -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<String> 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<String> response = sendAndReceive(msg);
+        assertThat(response, hasSize(1));
+        return response.iterator().next();
+    }
+
+    @SuppressWarnings("null")
+    private Set<String> sendAndReceive(final String msg) throws Exception {
+        assertThat(receivedMessages, is(empty()));
+        connector.send(msg);
+        Thread.sleep(WAIT_FOR_DEVICE_RESPONSE_MS);
+        final Set<String> 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 (file)
index 0000000..61505b0
--- /dev/null
@@ -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:";
+}
index 6ab04faa208be6c31ba65f8edc4f5bd7cec62f58..e2dcde088fb957de9077a980f0318968b8d15dc5 100644 (file)
@@ -55,6 +55,7 @@
     <module>org.openhab.binding.ambientweather</module>
     <module>org.openhab.binding.amplipi</module>
     <module>org.openhab.binding.androiddebugbridge</module>
+    <module>org.openhab.binding.anel</module>
     <module>org.openhab.binding.astro</module>
     <module>org.openhab.binding.atlona</module>
     <module>org.openhab.binding.autelis</module>