* 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>
/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
<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>
--- /dev/null
+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
--- /dev/null
+# 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) |
+| --- | --- | --- |
+| [](https://de.anel.eu/?src=produkte/hut_2/hut_2.htm) | [](https://de.anel.eu/?src=produkte/io/io.htm) | [](https://de.anel.eu/?src=produkte/home/home.htm) |
+
+Thing type IDs:
+
+* *home*: The smallest device, the _HOME_, is the only one with only three power sockets and only available in Germany.
+* *simple-firmware*: The _PRO_ and _REDUNDANT_ have eight power sockets and a similar (simplified) firmware as the _HOME_.
+* *advanced-firmware*: All others (_ADV_, _IO_, and the different _HUT_ variants) have eight power sockets / relays, eight IO ports, and an advanced firmware.
+
+An [additional sensor](https://en.anel.eu/?src=/produkte/sensor_1/sensor_1.htm) may be used for monitoring temperature, humidity, and brightness.
+The sensor can be attached to a _HUT_ device via an Ethernet cable (max length is 50m).
+
+
+## Discovery
+
+Devices can be discovered automatically if their UDP ports are configured as follows:
+
+* 75 / 77 (default)
+* 750 / 770
+* 7500 / 7700
+* 7750 / 7770
+
+If a device is found for a specific port (excluding the default port), the subsequent port is also scanned, e.g. 7500/7700 → 7501/7701 → 7502/7702 → etc.
+
+Depending on the network switch and router devices, discovery may or may not work on wireless networks.
+It should work reliably though on local wired networks.
+
+
+## Thing Configuration
+
+Each Thing requires the following configuration parameters.
+
+| Parameter | Type | Default | Required | Description |
+|-----------------------|---------|-------------|----------|-------------|
+| Hostname / IP address | String | net-control | yes | Hostname or IP address of the device |
+| Send Port | Integer | 75 | yes | UDP port to send data to the device (in the anel web UI, it's the receive port!) |
+| Receive Port | Integer | 77 | yes | UDP port to receive data from the device (in the anel web UI, it's the send port!) |
+| User | String | user7 | yes | User to access the device (make sure it has rights to change relay / IO states!) |
+| Password | String | anel | yes | Password of the given user |
+
+For multiple devices, please use exclusive UDP ports for each device.
+Ports above 1024 are recommended because they are outside the range of system ports.
+
+Possible entries in your thing file could be (thing types _home_, _simple-firmware_, and _advanced-firmware_ are explained above in _Supported Things_):
+
+```
+anel:home:mydevice1 [hostname="192.168.0.101", udpSendPort=7500, udpReceivePort=7700, user="user7", password="anel"]
+anel:simple-firmware:mydevice2 [hostname="192.168.0.102", udpSendPort=7501, udpReceivePort=7701, user="user7", password="anel"]
+anel:advanced-firmware:mydevice3 [hostname="192.168.0.103", udpSendPort=7502, udpReceivePort=7702, user="user7", password="anel"]
+anel:advanced-firmware:mydevice4 [hostname="192.168.0.104", udpSendPort=7503, udpReceivePort=7703, user="user7", password="anel"]
+```
+
+
+## Channels
+
+Depending on the thing type, the following channels are available.
+
+| Channel ID | Item Type | Supported Things | Read Only | Description |
+|--------------------|--------------------|-------------------|-----------|-------------|
+| prop#name | String | all | yes | Name of the device |
+| prop#temperature | Number:Temperature | simple / advanced | yes | Temperature of the integrated sensor |
+| sensor#temperature | Number:Temperature | advanced | yes | Temperature of the optional external sensor |
+| sensor#humidity | Number | advanced | yes | Humidity of the optional external sensor |
+| sensor#brightness | Number | advanced | yes | Brightness of the optional external sensor |
+| r1#name | String | all | yes | Name of relay / socket 1 |
+| r2#name | String | all | yes | Name of relay / socket 2 |
+| r3#name | String | all | yes | Name of relay / socket 3 |
+| r4#name | String | simple / advanced | yes | Name of relay / socket 4 |
+| r5#name | String | simple / advanced | yes | Name of relay / socket 5 |
+| r6#name | String | simple / advanced | yes | Name of relay / socket 6 |
+| r7#name | String | simple / advanced | yes | Name of relay / socket 7 |
+| r8#name | String | simple / advanced | yes | Name of relay / socket 8 |
+| r1#state | Switch | all | no * | State of relay / socket 1 |
+| r2#state | Switch | all | no * | State of relay / socket 2 |
+| r3#state | Switch | all | no * | State of relay / socket 3 |
+| r4#state | Switch | simple / advanced | no * | State of relay / socket 4 |
+| r5#state | Switch | simple / advanced | no * | State of relay / socket 5 |
+| r6#state | Switch | simple / advanced | no * | State of relay / socket 6 |
+| r7#state | Switch | simple / advanced | no * | State of relay / socket 7 |
+| r8#state | Switch | simple / advanced | no * | State of relay / socket 8 |
+| r1#locked | Switch | all | yes | Whether or not relay / socket 1 is locked |
+| r2#locked | Switch | all | yes | Whether or not relay / socket 2 is locked |
+| r3#locked | Switch | all | yes | Whether or not relay / socket 3 is locked |
+| r4#locked | Switch | simple / advanced | yes | Whether or not relay / socket 4 is locked |
+| r5#locked | Switch | simple / advanced | yes | Whether or not relay / socket 5 is locked |
+| r6#locked | Switch | simple / advanced | yes | Whether or not relay / socket 6 is locked |
+| r7#locked | Switch | simple / advanced | yes | Whether or not relay / socket 7 is locked |
+| r8#locked | Switch | simple / advanced | yes | Whether or not relay / socket 8 is locked |
+| io1#name | String | advanced | yes | Name of IO port 1 |
+| io2#name | String | advanced | yes | Name of IO port 2 |
+| io3#name | String | advanced | yes | Name of IO port 3 |
+| io4#name | String | advanced | yes | Name of IO port 4 |
+| io5#name | String | advanced | yes | Name of IO port 5 |
+| io6#name | String | advanced | yes | Name of IO port 6 |
+| io7#name | String | advanced | yes | Name of IO port 7 |
+| io8#name | String | advanced | yes | Name of IO port 8 |
+| io1#state | Switch | advanced | no ** | State of IO port 1 |
+| io2#state | Switch | advanced | no ** | State of IO port 2 |
+| io3#state | Switch | advanced | no ** | State of IO port 3 |
+| io4#state | Switch | advanced | no ** | State of IO port 4 |
+| io5#state | Switch | advanced | no ** | State of IO port 5 |
+| io6#state | Switch | advanced | no ** | State of IO port 6 |
+| io7#state | Switch | advanced | no ** | State of IO port 7 |
+| io8#state | Switch | advanced | no ** | State of IO port 8 |
+| io1#mode | Switch | advanced | yes | Mode of port 1: _ON_ = input, _OFF_ = output |
+| io2#mode | Switch | advanced | yes | Mode of port 2: _ON_ = input, _OFF_ = output |
+| io3#mode | Switch | advanced | yes | Mode of port 3: _ON_ = input, _OFF_ = output |
+| io4#mode | Switch | advanced | yes | Mode of port 4: _ON_ = input, _OFF_ = output |
+| io5#mode | Switch | advanced | yes | Mode of port 5: _ON_ = input, _OFF_ = output |
+| io6#mode | Switch | advanced | yes | Mode of port 6: _ON_ = input, _OFF_ = output |
+| io7#mode | Switch | advanced | yes | Mode of port 7: _ON_ = input, _OFF_ = output |
+| io8#mode | Switch | advanced | yes | Mode of port 8: _ON_ = input, _OFF_ = output |
+
+\* Relay / socket state is read-only if it is locked; otherwise it is changeable.<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).
+
--- /dev/null
+<?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>
--- /dev/null
+<?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>
--- /dev/null
+/**
+ * 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");
+ }
+}
--- /dev/null
+/**
+ * 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);
+ }
+ }
+ }
+}
--- /dev/null
+/**
+ * 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;
+ }
+}
--- /dev/null
+/**
+ * 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;
+ }
+}
--- /dev/null
+/**
+ * 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
+ }
+}
--- /dev/null
+/**
+ * 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);
+ }
+}
--- /dev/null
+/**
+ * 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;
+ }
+ }
+}
--- /dev/null
+/**
+ * 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
+ }
+}
--- /dev/null
+/**
+ * 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;
+ }
+}
--- /dev/null
+/**
+ * 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
+ }
+}
--- /dev/null
+<?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>
--- /dev/null
+<?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>
--- /dev/null
+<?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>
--- /dev/null
+/**
+ * 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();
+ }
+}
--- /dev/null
+/**
+ * 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));
+ }
+ }
+}
--- /dev/null
+/**
+ * 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"));
+ }
+ }
+}
--- /dev/null
+/**
+ * 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));
+ }
+ }
+}
--- /dev/null
+/**
+ * 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;
+ }
+}
--- /dev/null
+/**
+ * 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:";
+}
<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>