mirror of
https://github.com/openhab/openhab-addons.git
synced 2025-01-10 07:02:02 +01:00
[anel] Initial contribution of the Anel NET-PwrCtrl binding for OH3 (#10952)
* Initial contribution of the Anel NET-PwrCtrl binding for OH3. Signed-off-by: Patrick Koenemann <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>
This commit is contained in:
parent
9bde2df3b4
commit
0adacaf596
@ -23,6 +23,7 @@
|
||||
/bundles/org.openhab.binding.ambientweather/ @mhilbush
|
||||
/bundles/org.openhab.binding.amplipi/ @kaikreuzer
|
||||
/bundles/org.openhab.binding.androiddebugbridge/ @GiviMAD
|
||||
/bundles/org.openhab.binding.anel/ @paphko
|
||||
/bundles/org.openhab.binding.astro/ @gerrieg
|
||||
/bundles/org.openhab.binding.atlona/ @tmrobert8
|
||||
/bundles/org.openhab.binding.autelis/ @digitaldan
|
||||
|
@ -106,6 +106,11 @@
|
||||
<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>
|
||||
|
13
bundles/org.openhab.binding.anel/NOTICE
Normal file
13
bundles/org.openhab.binding.anel/NOTICE
Normal file
@ -0,0 +1,13 @@
|
||||
This content is produced and maintained by the openHAB project.
|
||||
|
||||
* Project home: https://www.openhab.org
|
||||
|
||||
== Declared Project Licenses
|
||||
|
||||
This program and the accompanying materials are made available under the terms
|
||||
of the Eclipse Public License 2.0 which is available at
|
||||
https://www.eclipse.org/legal/epl-2.0/.
|
||||
|
||||
== Source Code
|
||||
|
||||
https://github.com/openhab/openhab-addons
|
231
bundles/org.openhab.binding.anel/README.md
Normal file
231
bundles/org.openhab.binding.anel/README.md
Normal file
@ -0,0 +1,231 @@
|
||||
# Anel NET-PwrCtrl Binding
|
||||
|
||||
Monitor and control Anel NET-PwrCtrl devices.
|
||||
|
||||
NET-PwrCtrl devices are power sockets / relays that can be configured via browser but they can also be controlled over the network, e.g. with an Android or iPhone app - and also with openHAB via this binding.
|
||||
Some NET-PwrCtrl devices also have 8 I/O ports which can either be used to directly switch the sockets / relays, or they can be used as general input / output switches in openHAB.
|
||||
|
||||
|
||||
## Supported Things
|
||||
|
||||
There are three kinds of devices ([overview on manufacturer's homepage](https://en.anel.eu/?src=/produkte/produkte.htm)):
|
||||
|
||||
| [Anel NET-PwrCtrl HUT](https://en.anel.eu/?src=/produkte/hut_2/hut_2.htm) <br/> <sub>( _advanced-firmware_ )</sub> | [Anel NET-PwrCtrl IO](https://en.anel.eu/?src=/produkte/io/io.htm) <br/> <sub>( _advanced-firmware_ )</sub> | [Anel NET-PwrCtrl HOME](https://de.anel.eu/?src=produkte/home/home.htm) <br/> <sub>( _home_ )</sub> <br/> (only German version) |
|
||||
| --- | --- | --- |
|
||||
| [![Anel NET-PwrCtrl HUT 2](https://de.anel.eu/image/leisten/HUT2LV-P_500.jpg)](https://de.anel.eu/?src=produkte/hut_2/hut_2.htm) | [![Anel NET-PwrCtrl IO](https://de.anel.eu/image/leisten/IO-Stecker.png)](https://de.anel.eu/?src=produkte/io/io.htm) | [![Anel NET-PwrCtrl HOME](https://de.anel.eu/image/leisten/HOME-DE-500.gif)](https://de.anel.eu/?src=produkte/home/home.htm) |
|
||||
|
||||
Thing type IDs:
|
||||
|
||||
* *home*: The smallest device, the _HOME_, is the only one with only three power sockets and only available in Germany.
|
||||
* *simple-firmware*: The _PRO_ and _REDUNDANT_ have eight power sockets and a similar (simplified) firmware as the _HOME_.
|
||||
* *advanced-firmware*: All others (_ADV_, _IO_, and the different _HUT_ variants) have eight power sockets / relays, eight IO ports, and an advanced firmware.
|
||||
|
||||
An [additional sensor](https://en.anel.eu/?src=/produkte/sensor_1/sensor_1.htm) may be used for monitoring temperature, humidity, and brightness.
|
||||
The sensor can be attached to a _HUT_ device via an Ethernet cable (max length is 50m).
|
||||
|
||||
|
||||
## Discovery
|
||||
|
||||
Devices can be discovered automatically if their UDP ports are configured as follows:
|
||||
|
||||
* 75 / 77 (default)
|
||||
* 750 / 770
|
||||
* 7500 / 7700
|
||||
* 7750 / 7770
|
||||
|
||||
If a device is found for a specific port (excluding the default port), the subsequent port is also scanned, e.g. 7500/7700 → 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).
|
||||
|
17
bundles/org.openhab.binding.anel/pom.xml
Normal file
17
bundles/org.openhab.binding.anel/pom.xml
Normal file
@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
|
||||
<version>3.2.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>org.openhab.binding.anel</artifactId>
|
||||
|
||||
<name>openHAB Add-ons :: Bundles :: Anel Binding</name>
|
||||
|
||||
</project>
|
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<features name="org.openhab.binding.anel-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
|
||||
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
|
||||
|
||||
<feature name="openhab-binding-anel" description="Anel Binding" version="${project.version}">
|
||||
<feature>openhab-runtime-base</feature>
|
||||
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.anel/${project.version}</bundle>
|
||||
</feature>
|
||||
</features>
|
@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2021 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.anel.internal;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* The {@link AnelConfiguration} class contains fields mapping thing configuration parameters.
|
||||
*
|
||||
* @author Patrick Koenemann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class AnelConfiguration {
|
||||
|
||||
public @Nullable String hostname;
|
||||
public @Nullable String user;
|
||||
public @Nullable String password;
|
||||
/** Port to send data from openhab to device. */
|
||||
public int udpSendPort = IAnelConstants.DEFAULT_SEND_PORT;
|
||||
/** Openhab receives messages via this port from device. */
|
||||
public int udpReceivePort = IAnelConstants.DEFAULT_RECEIVE_PORT;
|
||||
|
||||
public AnelConfiguration() {
|
||||
}
|
||||
|
||||
public AnelConfiguration(@Nullable String hostname, @Nullable String user, @Nullable String password, int sendPort,
|
||||
int receivePort) {
|
||||
this.hostname = hostname;
|
||||
this.user = user;
|
||||
this.password = password;
|
||||
this.udpSendPort = sendPort;
|
||||
this.udpReceivePort = receivePort;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
final StringBuilder builder = new StringBuilder();
|
||||
builder.append(getClass().getSimpleName());
|
||||
builder.append("[hostname=");
|
||||
builder.append(hostname);
|
||||
builder.append(",user=");
|
||||
builder.append(user);
|
||||
builder.append(",password=");
|
||||
builder.append(mask(password));
|
||||
builder.append(",udpSendPort=");
|
||||
builder.append(udpSendPort);
|
||||
builder.append(",udpReceivePort=");
|
||||
builder.append(udpReceivePort);
|
||||
builder.append("]");
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private @Nullable String mask(@Nullable String string) {
|
||||
return string == null ? null : string.replaceAll(".", "X");
|
||||
}
|
||||
}
|
@ -0,0 +1,356 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2021 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.anel.internal;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.anel.internal.auth.AnelAuthentication;
|
||||
import org.openhab.binding.anel.internal.auth.AnelAuthentication.AuthMethod;
|
||||
import org.openhab.binding.anel.internal.state.AnelCommandHandler;
|
||||
import org.openhab.binding.anel.internal.state.AnelState;
|
||||
import org.openhab.binding.anel.internal.state.AnelStateUpdater;
|
||||
import org.openhab.core.library.types.OnOffType;
|
||||
import org.openhab.core.thing.ChannelUID;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingStatus;
|
||||
import org.openhab.core.thing.ThingStatusDetail;
|
||||
import org.openhab.core.thing.binding.BaseThingHandler;
|
||||
import org.openhab.core.types.Command;
|
||||
import org.openhab.core.types.RefreshType;
|
||||
import org.openhab.core.types.State;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link AnelHandler} is responsible for handling commands, which are
|
||||
* sent to one of the channels.
|
||||
*
|
||||
* @author Patrick Koenemann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class AnelHandler extends BaseThingHandler {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(AnelHandler.class);
|
||||
|
||||
private final AnelCommandHandler commandHandler = new AnelCommandHandler();
|
||||
private final AnelStateUpdater stateUpdater = new AnelStateUpdater();
|
||||
|
||||
private @Nullable AnelConfiguration config;
|
||||
private @Nullable AnelUdpConnector udpConnector;
|
||||
|
||||
/** The most recent state of the Anel device. */
|
||||
private @Nullable AnelState state;
|
||||
/** Cached authentication information (encrypted, if possible). */
|
||||
private @Nullable String authentication;
|
||||
|
||||
private @Nullable ScheduledFuture<?> periodicRefreshTask;
|
||||
|
||||
private int sendingFailures = 0;
|
||||
private int updateStateFailures = 0;
|
||||
private int refreshRequestWithoutResponse = 0;
|
||||
private boolean refreshRequested = false; // avoid multiple simultaneous refresh requests
|
||||
|
||||
public AnelHandler(Thing thing) {
|
||||
super(thing);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize() {
|
||||
config = getConfigAs(AnelConfiguration.class);
|
||||
|
||||
updateStatus(ThingStatus.UNKNOWN);
|
||||
|
||||
// background initialization
|
||||
scheduler.execute(this::initializeConnection);
|
||||
}
|
||||
|
||||
private void initializeConnection() {
|
||||
final AnelConfiguration config2 = config;
|
||||
final String host = config2 == null ? null : config2.hostname;
|
||||
if (config2 == null || host == null) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||
"Cannot initialize thing without configuration: " + config2);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final AnelUdpConnector newUdpConnector = new AnelUdpConnector(host, config2.udpReceivePort,
|
||||
config2.udpSendPort, scheduler);
|
||||
udpConnector = newUdpConnector;
|
||||
|
||||
// establish connection and register listener
|
||||
newUdpConnector.connect(this::handleStatusUpdate, true);
|
||||
|
||||
// request initial state, 3 attempts
|
||||
for (int attempt = 1; attempt <= IAnelConstants.ATTEMPTS_WITH_COMMUNICATION_ERRORS
|
||||
&& state == null; attempt++) {
|
||||
try {
|
||||
newUdpConnector.send(IAnelConstants.BROADCAST_DISCOVERY_MSG);
|
||||
} catch (IOException e) {
|
||||
// network or socket failure, also wait 2 sec and try again
|
||||
}
|
||||
|
||||
// answer expected within 50-600ms on a regular network; wait up to 2sec just to make sure
|
||||
for (int delay = 0; delay < 10 && state == null; delay++) {
|
||||
Thread.sleep(200); // wait 10 x 200ms = 2sec
|
||||
}
|
||||
}
|
||||
|
||||
// set thing status (and set unique property)
|
||||
final AnelState state2 = state;
|
||||
if (state2 != null) {
|
||||
updateStatus(ThingStatus.ONLINE);
|
||||
|
||||
final String mac = state2.mac;
|
||||
if (mac != null && !mac.isEmpty()) {
|
||||
updateProperty(IAnelConstants.UNIQUE_PROPERTY_NAME, mac);
|
||||
}
|
||||
} else {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||
"Device does not respond (check IP, ports, and network connection): " + config);
|
||||
}
|
||||
|
||||
// schedule refresher task to continuously check for device state
|
||||
periodicRefreshTask = scheduler.scheduleWithFixedDelay(this::periodicRefresh, //
|
||||
0, IAnelConstants.REFRESH_INTERVAL_SEC, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
// OH shutdown - don't log anything, Framework will call dispose()
|
||||
} catch (Exception e) {
|
||||
logger.debug("Connection to '{}' failed", config, e);
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR, "Connection to '" + config
|
||||
+ "' failed unexpectedly with " + e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||
dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void periodicRefresh() {
|
||||
/*
|
||||
* it's sufficient to send "wer da?" to the configured ip address.
|
||||
* the listener should be able to process the response like any other response.
|
||||
*/
|
||||
final AnelUdpConnector udpConnector2 = udpConnector;
|
||||
if (udpConnector2 != null && udpConnector2.isConnected()) {
|
||||
/*
|
||||
* Check whether or not the device sends a response at all. If not, after some unanswered refresh requests,
|
||||
* we should change the thing status to COMM_ERROR. The refresh task should remain active so that the device
|
||||
* has a chance to get back online as soon as it responds again.
|
||||
*/
|
||||
if (refreshRequestWithoutResponse > IAnelConstants.UNANSWERED_REFRESH_REQUESTS_TO_SET_THING_OFFLINE
|
||||
&& getThing().getStatus() == ThingStatus.ONLINE) {
|
||||
final String msg = "Setting thing offline because it did not respond to the last "
|
||||
+ IAnelConstants.UNANSWERED_REFRESH_REQUESTS_TO_SET_THING_OFFLINE + " status requests: "
|
||||
+ config;
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, msg);
|
||||
}
|
||||
|
||||
try {
|
||||
refreshRequestWithoutResponse++;
|
||||
|
||||
udpConnector2.send(IAnelConstants.BROADCAST_DISCOVERY_MSG);
|
||||
sendingFailures = 0;
|
||||
} catch (Exception e) {
|
||||
handleSendException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleCommand(ChannelUID channelUID, Command command) {
|
||||
final AnelUdpConnector udpConnector2 = udpConnector;
|
||||
if (udpConnector2 == null || !udpConnector2.isConnected() || getThing().getStatus() != ThingStatus.ONLINE) {
|
||||
// don't log initial refresh commands because they may occur before thing is online
|
||||
if (!(command instanceof RefreshType)) {
|
||||
logger.debug("Cannot handle command '{}' for channel '{}' because thing ({}) is not connected: {}", //
|
||||
command, channelUID.getId(), getThing().getStatus(), config);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
String anelCommand = null;
|
||||
if (command instanceof RefreshType) {
|
||||
final State update = stateUpdater.getChannelUpdate(channelUID.getId(), state);
|
||||
if (update != null) {
|
||||
updateState(channelUID, update);
|
||||
} else if (!refreshRequested) {
|
||||
// send broadcast request for refreshing the state; remember it to avoid multiple simultaneous requests
|
||||
refreshRequested = true;
|
||||
anelCommand = IAnelConstants.BROADCAST_DISCOVERY_MSG;
|
||||
} else {
|
||||
logger.debug(
|
||||
"Channel {} received command {} which is ignored because another channel already requested the same command",
|
||||
channelUID, command);
|
||||
}
|
||||
} else if (command instanceof OnOffType) {
|
||||
final State lockedState;
|
||||
synchronized (this) { // lock needed to update the state if needed
|
||||
lockedState = commandHandler.getLockedState(state, channelUID.getId());
|
||||
if (lockedState == null) {
|
||||
// command only possible if state is not locked
|
||||
anelCommand = commandHandler.toAnelCommandAndUnsetState(state, channelUID.getId(), command,
|
||||
getAuthentication());
|
||||
}
|
||||
}
|
||||
|
||||
if (lockedState != null) {
|
||||
logger.debug("Channel {} received command {} but it is locked, so the state is reset to {}.",
|
||||
channelUID, command, lockedState);
|
||||
|
||||
updateState(channelUID, lockedState);
|
||||
} else if (anelCommand == null) {
|
||||
logger.warn(
|
||||
"Channel {} received command {} which is (currently) not supported; please check channel configuration.",
|
||||
channelUID, command);
|
||||
}
|
||||
} else {
|
||||
logger.warn("Channel {} received command {} which is not supported", channelUID, command);
|
||||
}
|
||||
|
||||
if (anelCommand != null) {
|
||||
logger.debug("Channel {} received command {} which is converted to: {}", channelUID, command, anelCommand);
|
||||
|
||||
try {
|
||||
udpConnector2.send(anelCommand);
|
||||
sendingFailures = 0;
|
||||
} catch (Exception e) {
|
||||
handleSendException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleSendException(Exception e) {
|
||||
if (getThing().getStatus() == ThingStatus.ONLINE) {
|
||||
if (sendingFailures++ == IAnelConstants.ATTEMPTS_WITH_COMMUNICATION_ERRORS) {
|
||||
final String msg = "Setting thing offline because binding failed to send " + sendingFailures
|
||||
+ " messages to it: " + config;
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, msg);
|
||||
} else if (sendingFailures < IAnelConstants.ATTEMPTS_WITH_COMMUNICATION_ERRORS) {
|
||||
logger.warn("Failed to send message to: {}", config, e);
|
||||
}
|
||||
} // else: ignore exception for offline things
|
||||
}
|
||||
|
||||
private void handleStatusUpdate(@Nullable String newStatus) {
|
||||
refreshRequestWithoutResponse = 0;
|
||||
try {
|
||||
if (newStatus != null && newStatus.contains(IAnelConstants.ERROR_CREDENTIALS)) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||
"Invalid username or password for " + config);
|
||||
return;
|
||||
}
|
||||
if (newStatus != null && newStatus.contains(IAnelConstants.ERROR_INSUFFICIENT_RIGHTS)) {
|
||||
final AnelConfiguration config2 = config;
|
||||
if (config2 != null) {
|
||||
logger.warn(
|
||||
"User '{}' on device {} has insufficient rights to change the state of a relay or IO port; you can fix that in the Web-UI, 'Einstellungen / Settings' -> 'User'.",
|
||||
config2.user, config2.hostname);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final AnelState recentState, newState;
|
||||
synchronized (this) { // to make sure state is fully processed before replacing it
|
||||
recentState = state;
|
||||
if (newStatus != null && recentState != null && newStatus.equals(recentState.status)
|
||||
&& !hasUnsetState(recentState)) {
|
||||
return; // no changes
|
||||
}
|
||||
newState = AnelState.of(newStatus);
|
||||
|
||||
state = newState; // update most recent state
|
||||
}
|
||||
final Map<String, State> updates = stateUpdater.getChannelUpdates(recentState, newState);
|
||||
|
||||
if (getThing().getStatus() == ThingStatus.OFFLINE) {
|
||||
updateStatus(ThingStatus.ONLINE); // we got a response! set thing online if it wasn't!
|
||||
}
|
||||
updateStateFailures = 0; // reset error counter, if necessary
|
||||
|
||||
// report all state updates
|
||||
if (!updates.isEmpty()) {
|
||||
logger.debug("updating channel states: {}", updates);
|
||||
|
||||
updates.forEach(this::updateState);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (getThing().getStatus() == ThingStatus.ONLINE) {
|
||||
if (updateStateFailures++ == IAnelConstants.ATTEMPTS_WITH_COMMUNICATION_ERRORS) {
|
||||
final String msg = "Setting thing offline because status updated failed " + updateStateFailures
|
||||
+ " times in a row for: " + config;
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, msg);
|
||||
} else if (updateStateFailures < IAnelConstants.ATTEMPTS_WITH_COMMUNICATION_ERRORS) {
|
||||
logger.warn("Status update failed for: {}", config, e);
|
||||
}
|
||||
} // else: ignore exception for offline things
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasUnsetState(AnelState state) {
|
||||
for (int i = 0; i < state.relayState.length; i++) {
|
||||
if (state.relayState[i] == null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < state.ioState.length; i++) {
|
||||
if (state.ioName[i] != null && state.ioState[i] == null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String getAuthentication() {
|
||||
// create and remember authentication string
|
||||
final String currentAuthentication = authentication;
|
||||
if (currentAuthentication != null) {
|
||||
return currentAuthentication;
|
||||
}
|
||||
|
||||
final AnelState currentState = state;
|
||||
if (currentState == null) {
|
||||
// should never happen because initialization ensures that initial state is received
|
||||
throw new IllegalStateException("Cannot send any command to device b/c it did not send any answer yet");
|
||||
}
|
||||
|
||||
final AnelConfiguration currentConfig = config;
|
||||
if (currentConfig == null) {
|
||||
throw new IllegalStateException("Config must not be null!");
|
||||
}
|
||||
|
||||
final String newAuthentication = AnelAuthentication.getUserPasswordString(currentConfig.user,
|
||||
currentConfig.password, AuthMethod.of(currentState.status));
|
||||
authentication = newAuthentication;
|
||||
return newAuthentication;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
final ScheduledFuture<?> periodicRefreshTask2 = periodicRefreshTask;
|
||||
if (periodicRefreshTask2 != null) {
|
||||
periodicRefreshTask2.cancel(false);
|
||||
periodicRefreshTask = null;
|
||||
}
|
||||
final AnelUdpConnector connector = udpConnector;
|
||||
if (connector != null) {
|
||||
udpConnector = null;
|
||||
try {
|
||||
connector.disconnect();
|
||||
} catch (Exception e) {
|
||||
logger.debug("Failed to close socket connection for: {}", config, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2021 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.anel.internal;
|
||||
|
||||
import static org.openhab.binding.anel.internal.IAnelConstants.SUPPORTED_THING_TYPES_UIDS;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
|
||||
import org.openhab.core.thing.binding.ThingHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandlerFactory;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
|
||||
/**
|
||||
* The {@link AnelHandlerFactory} is responsible for creating things and thing
|
||||
* handlers.
|
||||
*
|
||||
* @author Patrick Koenemann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@Component(configurationPid = "binding.anel", service = ThingHandlerFactory.class)
|
||||
public class AnelHandlerFactory extends BaseThingHandlerFactory {
|
||||
|
||||
@Override
|
||||
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
|
||||
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @Nullable ThingHandler createHandler(Thing thing) {
|
||||
if (supportsThingType(thing.getThingTypeUID())) {
|
||||
return new AnelHandler(thing);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2021 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.anel.internal;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.DatagramPacket;
|
||||
import java.net.DatagramSocket;
|
||||
import java.net.InetAddress;
|
||||
import java.net.SocketException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.core.common.NamedThreadFactory;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* This class handles the actual communication to ANEL devices.
|
||||
*
|
||||
* @author Patrick Koenemann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class AnelUdpConnector {
|
||||
|
||||
/** Buffer for incoming UDP packages. */
|
||||
private static final int MAX_PACKET_SIZE = 512;
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(AnelUdpConnector.class);
|
||||
|
||||
/** The device IP this connector is listening to / sends to. */
|
||||
private final String host;
|
||||
|
||||
/** The port this connector is listening to. */
|
||||
private final int receivePort;
|
||||
|
||||
/** The port this connector is sending to. */
|
||||
private final int sendPort;
|
||||
|
||||
/** Service to spawn new threads for handling status updates. */
|
||||
private final ExecutorService executorService;
|
||||
|
||||
/** Thread factory for UDP listening thread. */
|
||||
private final NamedThreadFactory listeningThreadFactory = new NamedThreadFactory(IAnelConstants.BINDING_ID, true);
|
||||
|
||||
/** Socket for receiving UDP packages. */
|
||||
private @Nullable DatagramSocket receivingSocket = null;
|
||||
/** Socket for sending UDP packages. */
|
||||
private @Nullable DatagramSocket sendingSocket = null;
|
||||
|
||||
/** The listener that gets notified upon newly received messages. */
|
||||
private @Nullable Consumer<String> listener;
|
||||
|
||||
private int receiveFailures = 0;
|
||||
private boolean listenerActive = false;
|
||||
|
||||
/**
|
||||
* Create a new connector to an Anel device via the given host and UDP
|
||||
* ports.
|
||||
*
|
||||
* @param host
|
||||
* The IP address / network name of the device.
|
||||
* @param udpReceivePort
|
||||
* The UDP port to listen for packages.
|
||||
* @param udpSendPort
|
||||
* The UDP port to send packages.
|
||||
*/
|
||||
public AnelUdpConnector(String host, int udpReceivePort, int udpSendPort, ExecutorService executorService) {
|
||||
if (udpReceivePort <= 0) {
|
||||
throw new IllegalArgumentException("Invalid udpReceivePort: " + udpReceivePort);
|
||||
}
|
||||
if (udpSendPort <= 0) {
|
||||
throw new IllegalArgumentException("Invalid udpSendPort: " + udpSendPort);
|
||||
}
|
||||
if (host.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing host.");
|
||||
}
|
||||
this.host = host;
|
||||
this.receivePort = udpReceivePort;
|
||||
this.sendPort = udpSendPort;
|
||||
this.executorService = executorService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize socket connection to the UDP receive port for the given listener.
|
||||
*
|
||||
* @throws SocketException Is only thrown if <code>logNotTHrowException = false</code>.
|
||||
* @throws InterruptedException Typically happens during shutdown.
|
||||
*/
|
||||
public void connect(Consumer<String> listener, boolean logNotThrowExcpetion)
|
||||
throws SocketException, InterruptedException {
|
||||
if (receivingSocket == null) {
|
||||
try {
|
||||
receivingSocket = new DatagramSocket(receivePort);
|
||||
sendingSocket = new DatagramSocket();
|
||||
this.listener = listener;
|
||||
|
||||
/*-
|
||||
* Due to the issue with 4 concurrently listening threads [1], we should follow Kais suggestion [2]
|
||||
* to create our own listening daemonized thread.
|
||||
*
|
||||
* [1] https://community.openhab.org/t/anel-net-pwrctrl-binding-for-oh3/123378
|
||||
* [2] https://www.eclipse.org/forums/index.php/m/1775932/?#msg_1775429
|
||||
*/
|
||||
listeningThreadFactory.newThread(this::listen).start();
|
||||
|
||||
// wait for the listening thread to be active
|
||||
for (int i = 0; i < 20 && !listenerActive; i++) {
|
||||
Thread.sleep(100); // wait at most 20 * 100ms = 2sec for the listener to be active
|
||||
}
|
||||
if (!listenerActive) {
|
||||
logger.warn(
|
||||
"Listener thread started but listener is not yet active after 2sec; something seems to be wrong with the JVM thread handling?!");
|
||||
}
|
||||
} catch (SocketException e) {
|
||||
if (logNotThrowExcpetion) {
|
||||
logger.warn(
|
||||
"Failed to open socket connection on port {} (maybe there is already another socket listener on that port?)",
|
||||
receivePort, e);
|
||||
}
|
||||
|
||||
disconnect();
|
||||
|
||||
if (!logNotThrowExcpetion) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
} else if (!Objects.equals(this.listener, listener)) {
|
||||
throw new IllegalStateException("A listening thread is already running");
|
||||
}
|
||||
}
|
||||
|
||||
private void listen() {
|
||||
try {
|
||||
listenUnhandledInterruption();
|
||||
} catch (InterruptedException e) {
|
||||
// OH shutdown - don't log anything, just quit
|
||||
}
|
||||
}
|
||||
|
||||
private void listenUnhandledInterruption() throws InterruptedException {
|
||||
logger.info("Anel NET-PwrCtrl listener started for: '{}:{}'", host, receivePort);
|
||||
|
||||
final Consumer<String> listener2 = listener;
|
||||
final DatagramSocket socket2 = receivingSocket;
|
||||
while (listener2 != null && socket2 != null && receivingSocket != null) {
|
||||
try {
|
||||
final DatagramPacket packet = new DatagramPacket(new byte[MAX_PACKET_SIZE], MAX_PACKET_SIZE);
|
||||
|
||||
listenerActive = true;
|
||||
socket2.receive(packet); // receive packet (blocking call)
|
||||
listenerActive = false;
|
||||
|
||||
final byte[] data = Arrays.copyOfRange(packet.getData(), 0, packet.getLength() - 1);
|
||||
|
||||
if (data == null || data.length == 0) {
|
||||
if (isConnected()) {
|
||||
logger.debug("Nothing received, this may happen during shutdown or some unknown error");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
receiveFailures = 0; // message successfully received, unset failure counter
|
||||
|
||||
/* useful for debugging without logger (e.g. in AnelUdpConnectorTest): */
|
||||
// System.out.println(String.format("%s [%s] received: %s", getClass().getSimpleName(),
|
||||
// new SimpleDateFormat("HH:mm:ss.SSS").format(new Date()), new String(data).trim()));
|
||||
|
||||
// log & notify listener in new thread (so that listener loop continues immediately)
|
||||
executorService.execute(() -> {
|
||||
final String message = new String(data);
|
||||
|
||||
logger.debug("Received data on port {}: {}", receivePort, message);
|
||||
|
||||
listener2.accept(message);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
listenerActive = false;
|
||||
|
||||
if (receivingSocket == null) {
|
||||
logger.debug("Socket closed; stopping listener on port {}.", receivePort);
|
||||
} else {
|
||||
// if we get 3 errors in a row, we should better add a delay to stop spamming the log!
|
||||
if (receiveFailures++ > IAnelConstants.ATTEMPTS_WITH_COMMUNICATION_ERRORS) {
|
||||
logger.debug(
|
||||
"Unexpected error while listening on port {}; waiting 10sec before the next attempt to listen on that port.",
|
||||
receivePort, e);
|
||||
for (int i = 0; i < 50 && receivingSocket != null; i++) {
|
||||
Thread.sleep(200); // 50 * 200ms = 10sec
|
||||
}
|
||||
} else {
|
||||
logger.warn("Unexpected error while listening on port {}", receivePort, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the socket connection. */
|
||||
public void disconnect() {
|
||||
logger.debug("Anel NET-PwrCtrl listener stopped for: '{}:{}'", host, receivePort);
|
||||
listener = null;
|
||||
final DatagramSocket receivingSocket2 = receivingSocket;
|
||||
if (receivingSocket2 != null) {
|
||||
receivingSocket = null;
|
||||
if (!receivingSocket2.isClosed()) {
|
||||
receivingSocket2.close(); // this interrupts and terminates the listening thread
|
||||
}
|
||||
}
|
||||
final DatagramSocket sendingSocket2 = sendingSocket;
|
||||
if (sendingSocket2 != null) {
|
||||
synchronized (this) {
|
||||
if (Objects.equals(sendingSocket, sendingSocket2)) {
|
||||
sendingSocket = null;
|
||||
if (!sendingSocket2.isClosed()) {
|
||||
sendingSocket2.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void send(String msg) throws IOException {
|
||||
logger.debug("Sending message '{}' to {}:{}", msg, host, sendPort);
|
||||
if (msg.isEmpty()) {
|
||||
throw new IllegalArgumentException("Message must not be empty");
|
||||
}
|
||||
|
||||
final InetAddress ipAddress = InetAddress.getByName(host);
|
||||
final byte[] bytes = msg.getBytes();
|
||||
final DatagramPacket packet = new DatagramPacket(bytes, bytes.length, ipAddress, sendPort);
|
||||
|
||||
// make sure we are not interrupted by a disconnect while sending this message
|
||||
synchronized (this) {
|
||||
final DatagramSocket sendingSocket2 = sendingSocket;
|
||||
if (sendingSocket2 != null) {
|
||||
sendingSocket2.send(packet);
|
||||
|
||||
/* useful for debugging without logger (e.g. in AnelUdpConnectorTest): */
|
||||
// System.out.println(String.format("%s [%s] sent: %s", getClass().getSimpleName(),
|
||||
// new SimpleDateFormat("HH:mm:ss.SSS").format(new Date()), msg));
|
||||
|
||||
logger.debug("Sending successful.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
return receivingSocket != null;
|
||||
}
|
||||
}
|
@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2021 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.anel.internal;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
|
||||
/**
|
||||
* The {@link IAnelConstants} class defines common constants, which are
|
||||
* used across the whole binding.
|
||||
*
|
||||
* @author Patrick Koenemann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public interface IAnelConstants {
|
||||
|
||||
String BINDING_ID = "anel";
|
||||
|
||||
/** Message sent to Anel devices to detect new dfevices and to request the current state. */
|
||||
String BROADCAST_DISCOVERY_MSG = "wer da?";
|
||||
/** Expected prefix for all received Anel status messages. */
|
||||
String STATUS_RESPONSE_PREFIX = "NET-PwrCtrl";
|
||||
/** Separator of the received Anel status messages. */
|
||||
String STATUS_SEPARATOR = ":";
|
||||
|
||||
/** Status message String if the current user / password does not match. */
|
||||
String ERROR_CREDENTIALS = ":NoPass:Err";
|
||||
/** Status message String if the current user does not have enough rights. */
|
||||
String ERROR_INSUFFICIENT_RIGHTS = ":NoAccess:Err";
|
||||
|
||||
/** Property name to uniquely identify (discovered) things. */
|
||||
String UNIQUE_PROPERTY_NAME = "mac";
|
||||
|
||||
/** Default port used to send message to Anel devices. */
|
||||
int DEFAULT_SEND_PORT = 75;
|
||||
/** Default port used to receive message from Anel devices. */
|
||||
int DEFAULT_RECEIVE_PORT = 77;
|
||||
|
||||
/** Static refresh interval for heartbeat for Thing status. */
|
||||
int REFRESH_INTERVAL_SEC = 60;
|
||||
|
||||
/** Thing is set OFFLINE after so many communication errors. */
|
||||
int ATTEMPTS_WITH_COMMUNICATION_ERRORS = 3;
|
||||
|
||||
/** Thing is set OFFLINE if it did not respond to so many refresh requests. */
|
||||
int UNANSWERED_REFRESH_REQUESTS_TO_SET_THING_OFFLINE = 5;
|
||||
|
||||
/** Thing Type UID for Anel Net-PwrCtrl HOME. */
|
||||
ThingTypeUID THING_TYPE_ANEL_HOME = new ThingTypeUID(BINDING_ID, "home");
|
||||
/** Thing Type UID for Anel Net-PwrCtrl PRO / POWER. */
|
||||
ThingTypeUID THING_TYPE_ANEL_SIMPLE = new ThingTypeUID(BINDING_ID, "simple-firmware");
|
||||
/** Thing Type UID for Anel Net-PwrCtrl ADV / IO / HUT. */
|
||||
ThingTypeUID THING_TYPE_ANEL_ADVANCED = new ThingTypeUID(BINDING_ID, "advanced-firmware");
|
||||
/** All supported Thing Type UIDs. */
|
||||
Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_ANEL_HOME, THING_TYPE_ANEL_SIMPLE,
|
||||
THING_TYPE_ANEL_ADVANCED);
|
||||
|
||||
/** The device type is part of the status response and is mapped to the thing types. */
|
||||
Map<Character, ThingTypeUID> DEVICE_TYPE_TO_THING_TYPE = Map.of( //
|
||||
'H', THING_TYPE_ANEL_HOME, // HOME
|
||||
'P', THING_TYPE_ANEL_SIMPLE, // PRO / POWER
|
||||
'h', THING_TYPE_ANEL_ADVANCED, // HUT (and variants, e.g. h3 for HUT3)
|
||||
'a', THING_TYPE_ANEL_ADVANCED, // ADV
|
||||
'i', THING_TYPE_ANEL_ADVANCED); // IO
|
||||
|
||||
// All remaining constants are Channel ids
|
||||
|
||||
String CHANNEL_NAME = "prop#name";
|
||||
String CHANNEL_TEMPERATURE = "prop#temperature";
|
||||
|
||||
List<String> CHANNEL_RELAY_NAME = List.of("r1#name", "r2#name", "r3#name", "r4#name", "r5#name", "r6#name",
|
||||
"r7#name", "r8#name");
|
||||
|
||||
// second character must be the index b/c it is parsed in AnelCommandHandler!
|
||||
List<String> CHANNEL_RELAY_STATE = List.of("r1#state", "r2#state", "r3#state", "r4#state", "r5#state", "r6#state",
|
||||
"r7#state", "r8#state");
|
||||
|
||||
List<String> CHANNEL_RELAY_LOCKED = List.of("r1#locked", "r2#locked", "r3#locked", "r4#locked", "r5#locked",
|
||||
"r6#locked", "r7#locked", "r8#locked");
|
||||
|
||||
List<String> CHANNEL_IO_NAME = List.of("io1#name", "io2#name", "io3#name", "io4#name", "io5#name", "io6#name",
|
||||
"io7#name", "io8#name");
|
||||
|
||||
List<String> CHANNEL_IO_MODE = List.of("io1#mode", "io2#mode", "io3#mode", "io4#mode", "io5#mode", "io6#mode",
|
||||
"io7#mode", "io8#mode");
|
||||
|
||||
// third character must be the index b/c it is parsed in AnelCommandHandler!
|
||||
List<String> CHANNEL_IO_STATE = List.of("io1#state", "io2#state", "io3#state", "io4#state", "io5#state",
|
||||
"io6#state", "io7#state", "io8#state");
|
||||
|
||||
String CHANNEL_SENSOR_TEMPERATURE = "sensor#temperature";
|
||||
String CHANNEL_SENSOR_HUMIDITY = "sensor#humidity";
|
||||
String CHANNEL_SENSOR_BRIGHTNESS = "sensor#brightness";
|
||||
|
||||
/**
|
||||
* @param channelId A channel ID.
|
||||
* @return The zero-based index of the relay or IO channel (<code>0-7</code>); <code>-1</code> if it's not a relay
|
||||
* or IO channel.
|
||||
*/
|
||||
static int getIndexFromChannel(String channelId) {
|
||||
if (channelId.startsWith("r") && channelId.length() > 2) {
|
||||
return Character.getNumericValue(channelId.charAt(1)) - 1;
|
||||
}
|
||||
if (channelId.startsWith("io") && channelId.length() > 2) {
|
||||
return Character.getNumericValue(channelId.charAt(2)) - 1;
|
||||
}
|
||||
return -1; // not a relay or io channel
|
||||
}
|
||||
}
|
@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2021 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.anel.internal.auth;
|
||||
|
||||
import java.util.Base64;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* This class determines the authentication method from a status response of an ANEL device.
|
||||
*
|
||||
* @author Patrick Koenemann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class AnelAuthentication {
|
||||
|
||||
public enum AuthMethod {
|
||||
PLAIN,
|
||||
BASE64,
|
||||
XORBASE64;
|
||||
|
||||
private static final Pattern NAME_AND_FIRMWARE_PATTERN = Pattern.compile(":NET-PWRCTRL_0?(\\d+\\.\\d)");
|
||||
private static final Pattern LAST_SEGMENT_FIRMWARE_PATTERN = Pattern.compile(":(\\d+\\.\\d)$");
|
||||
|
||||
private static final String MIN_FIRMWARE_BASE64 = "6.0";
|
||||
private static final String MIN_FIRMWARE_XOR_BASE64 = "6.1";
|
||||
|
||||
public static AuthMethod of(String status) {
|
||||
if (status.isEmpty()) {
|
||||
return PLAIN; // fallback
|
||||
}
|
||||
if (status.trim().endsWith(":xor") || status.contains(":xor:")) {
|
||||
return XORBASE64;
|
||||
}
|
||||
final String firmwareVersion = getFirmwareVersion(status);
|
||||
if (firmwareVersion == null) {
|
||||
return PLAIN;
|
||||
}
|
||||
if (firmwareVersion.compareTo(MIN_FIRMWARE_XOR_BASE64) >= 0) {
|
||||
return XORBASE64; // >= 6.1
|
||||
}
|
||||
if (firmwareVersion.compareTo(MIN_FIRMWARE_BASE64) >= 0) {
|
||||
return BASE64; // exactly 6.0
|
||||
}
|
||||
return PLAIN; // fallback
|
||||
}
|
||||
|
||||
private static @Nullable String getFirmwareVersion(String fullStatusStringOrFirmwareVersion) {
|
||||
final Matcher matcher1 = NAME_AND_FIRMWARE_PATTERN.matcher(fullStatusStringOrFirmwareVersion);
|
||||
if (matcher1.find()) {
|
||||
return matcher1.group(1);
|
||||
}
|
||||
final Matcher matcher2 = LAST_SEGMENT_FIRMWARE_PATTERN.matcher(fullStatusStringOrFirmwareVersion.trim());
|
||||
if (matcher2.find()) {
|
||||
return matcher2.group(1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static String getUserPasswordString(@Nullable String user, @Nullable String password,
|
||||
@Nullable AuthMethod authMethod) {
|
||||
final String userPassword = (user == null ? "" : user) + (password == null ? "" : password);
|
||||
if (authMethod == null || authMethod == AuthMethod.PLAIN) {
|
||||
return userPassword;
|
||||
}
|
||||
|
||||
if (authMethod == AuthMethod.BASE64 || password == null || password.isEmpty()) {
|
||||
return Base64.getEncoder().encodeToString(userPassword.getBytes());
|
||||
}
|
||||
|
||||
if (authMethod == AuthMethod.XORBASE64) {
|
||||
final StringBuilder result = new StringBuilder();
|
||||
|
||||
// XOR
|
||||
for (int c = 0; c < userPassword.length(); c++) {
|
||||
result.append((char) (userPassword.charAt(c) ^ password.charAt(c % password.length())));
|
||||
}
|
||||
|
||||
return Base64.getEncoder().encodeToString(result.toString().getBytes());
|
||||
}
|
||||
|
||||
throw new UnsupportedOperationException("Unknown auth method: " + authMethod);
|
||||
}
|
||||
}
|
@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2021 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.anel.internal.discovery;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.BindException;
|
||||
import java.nio.channels.ClosedByInterruptException;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.anel.internal.AnelUdpConnector;
|
||||
import org.openhab.binding.anel.internal.IAnelConstants;
|
||||
import org.openhab.core.common.AbstractUID;
|
||||
import org.openhab.core.common.NamedThreadFactory;
|
||||
import org.openhab.core.config.discovery.AbstractDiscoveryService;
|
||||
import org.openhab.core.config.discovery.DiscoveryResult;
|
||||
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
|
||||
import org.openhab.core.config.discovery.DiscoveryService;
|
||||
import org.openhab.core.net.NetUtil;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
import org.openhab.core.thing.ThingUID;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Discovery service for ANEL devices.
|
||||
*
|
||||
* @author Patrick Koenemann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@Component(service = DiscoveryService.class, immediate = true, configurationPid = "discovery.anel")
|
||||
public class AnelDiscoveryService extends AbstractDiscoveryService {
|
||||
|
||||
private static final String PASSWORD = "anel";
|
||||
private static final String USER = "user7";
|
||||
private static final int[][] DISCOVERY_PORTS = { { 750, 770 }, { 7500, 7700 }, { 7750, 7770 } };
|
||||
private static final Set<String> BROADCAST_ADDRESSES = new TreeSet<>(NetUtil.getAllBroadcastAddresses());
|
||||
|
||||
private static final int DISCOVER_DEVICE_TIMEOUT_SECONDS = 2;
|
||||
|
||||
/** #BroadcastAddresses * DiscoverDeviceTimeout * (3 * #DiscoveryPorts) */
|
||||
private static final int DISCOVER_TIMEOUT_SECONDS = BROADCAST_ADDRESSES.size() * DISCOVER_DEVICE_TIMEOUT_SECONDS
|
||||
* (3 * DISCOVERY_PORTS.length);
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(AnelDiscoveryService.class);
|
||||
|
||||
private @Nullable Thread scanningThread = null;
|
||||
|
||||
public AnelDiscoveryService() throws IllegalArgumentException {
|
||||
super(IAnelConstants.SUPPORTED_THING_TYPES_UIDS, DISCOVER_TIMEOUT_SECONDS);
|
||||
logger.debug(
|
||||
"Anel NET-PwrCtrl discovery service instantiated for broadcast addresses {} with a timeout of {} seconds.",
|
||||
BROADCAST_ADDRESSES, DISCOVER_TIMEOUT_SECONDS);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void startScan() {
|
||||
/*
|
||||
* Start scan in background thread, otherwise progress is not shown in the web UI.
|
||||
* Do not use the scheduler, otherwise further threads (for handling discovered things) are not started
|
||||
* immediately but only after the scan is complete.
|
||||
*/
|
||||
final Thread thread = new NamedThreadFactory(IAnelConstants.BINDING_ID, true).newThread(this::doScan);
|
||||
thread.start();
|
||||
scanningThread = thread;
|
||||
}
|
||||
|
||||
private void doScan() {
|
||||
logger.debug("Starting scan of Anel devices via UDP broadcast messages...");
|
||||
|
||||
try {
|
||||
for (final String broadcastAddress : BROADCAST_ADDRESSES) {
|
||||
|
||||
// for each available broadcast network address try factory default ports first
|
||||
scan(broadcastAddress, IAnelConstants.DEFAULT_SEND_PORT, IAnelConstants.DEFAULT_RECEIVE_PORT);
|
||||
|
||||
// try reasonable ports...
|
||||
for (int[] ports : DISCOVERY_PORTS) {
|
||||
int sendPort = ports[0];
|
||||
int receivePort = ports[1];
|
||||
|
||||
// ...and continue if a device was found, maybe there is yet another device on the next port
|
||||
while (scan(broadcastAddress, sendPort, receivePort) || sendPort == ports[0]) {
|
||||
sendPort++;
|
||||
receivePort++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException | ClosedByInterruptException e) {
|
||||
return; // OH shutdown or scan was aborted
|
||||
} catch (Exception e) {
|
||||
logger.warn("Unexpected exception during anel device scan", e);
|
||||
} finally {
|
||||
scanningThread = null;
|
||||
}
|
||||
logger.debug("Scan finished.");
|
||||
}
|
||||
|
||||
/* @return Whether or not a device was found for the given broadcast address and port. */
|
||||
private boolean scan(String broadcastAddress, int sendPort, int receivePort)
|
||||
throws IOException, InterruptedException {
|
||||
logger.debug("Scanning {}:{}...", broadcastAddress, sendPort);
|
||||
final AnelUdpConnector udpConnector = new AnelUdpConnector(broadcastAddress, receivePort, sendPort, scheduler);
|
||||
|
||||
try {
|
||||
final boolean[] deviceDiscovered = new boolean[] { false };
|
||||
udpConnector.connect(status -> {
|
||||
// avoid the same device to be discovered multiple times for multiple responses
|
||||
if (!deviceDiscovered[0]) {
|
||||
boolean discoverDevice = true;
|
||||
synchronized (this) {
|
||||
if (deviceDiscovered[0]) {
|
||||
discoverDevice = false; // already discovered by another thread
|
||||
} else {
|
||||
deviceDiscovered[0] = true; // we discover the device!
|
||||
}
|
||||
}
|
||||
if (discoverDevice) {
|
||||
// discover device outside synchronized-block
|
||||
deviceDiscovered(status, sendPort, receivePort);
|
||||
}
|
||||
}
|
||||
}, false);
|
||||
|
||||
udpConnector.send(IAnelConstants.BROADCAST_DISCOVERY_MSG);
|
||||
|
||||
// answer expected within 50-600ms on a regular network; wait up to 2sec just to make sure
|
||||
for (int delay = 0; delay < 10 && !deviceDiscovered[0]; delay++) {
|
||||
Thread.sleep(100 * DISCOVER_DEVICE_TIMEOUT_SECONDS); // wait 10 x 200ms = 2sec
|
||||
}
|
||||
|
||||
return deviceDiscovered[0];
|
||||
} catch (BindException e) {
|
||||
// most likely socket is already in use, ignore this exception.
|
||||
logger.debug(
|
||||
"Invalid address {} or one of the ports {} or {} is already in use. Skipping scan of these ports.",
|
||||
broadcastAddress, sendPort, receivePort);
|
||||
} finally {
|
||||
udpConnector.disconnect();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected synchronized void stopScan() {
|
||||
final Thread thread = scanningThread;
|
||||
if (thread != null) {
|
||||
thread.interrupt();
|
||||
}
|
||||
super.stopScan();
|
||||
}
|
||||
|
||||
private void deviceDiscovered(String status, int sendPort, int receivePort) {
|
||||
final String[] segments = status.split(":");
|
||||
if (segments.length >= 16) {
|
||||
final String name = segments[1].trim();
|
||||
final String ip = segments[2];
|
||||
final String macAddress = segments[5];
|
||||
final String deviceType = segments.length > 17 ? segments[17] : null;
|
||||
final ThingTypeUID thingTypeUid = getThingTypeUid(deviceType, segments);
|
||||
final ThingUID thingUid = new ThingUID(thingTypeUid + AbstractUID.SEPARATOR + macAddress.replace(".", ""));
|
||||
|
||||
final DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUid) //
|
||||
.withThingType(thingTypeUid) //
|
||||
.withProperty("hostname", ip) // AnelConfiguration.hostname
|
||||
.withProperty("user", USER) // AnelConfiguration.user
|
||||
.withProperty("password", PASSWORD) // AnelConfiguration.password
|
||||
.withProperty("udpSendPort", sendPort) // AnelConfiguration.udpSendPort
|
||||
.withProperty("udpReceivePort", receivePort) // AnelConfiguration.udbReceivePort
|
||||
.withProperty(IAnelConstants.UNIQUE_PROPERTY_NAME, macAddress) //
|
||||
.withLabel(name) //
|
||||
.withRepresentationProperty(IAnelConstants.UNIQUE_PROPERTY_NAME) //
|
||||
.build();
|
||||
|
||||
thingDiscovered(discoveryResult);
|
||||
}
|
||||
}
|
||||
|
||||
private ThingTypeUID getThingTypeUid(@Nullable String deviceType, String[] segments) {
|
||||
// device type is contained since firmware 6.0
|
||||
if (deviceType != null && !deviceType.isEmpty()) {
|
||||
final char deviceTypeChar = deviceType.charAt(0);
|
||||
final ThingTypeUID thingTypeUID = IAnelConstants.DEVICE_TYPE_TO_THING_TYPE.get(deviceTypeChar);
|
||||
if (thingTypeUID != null) {
|
||||
return thingTypeUID;
|
||||
}
|
||||
}
|
||||
|
||||
if (segments.length < 20) {
|
||||
// no information given, we should be save with return the simple firmware thing type
|
||||
return IAnelConstants.THING_TYPE_ANEL_SIMPLE;
|
||||
} else {
|
||||
// more than 20 segments must include IO ports, hence it's an advanced firmware
|
||||
return IAnelConstants.THING_TYPE_ANEL_ADVANCED;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2021 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.anel.internal.state;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.anel.internal.IAnelConstants;
|
||||
import org.openhab.core.library.types.OnOffType;
|
||||
import org.openhab.core.types.Command;
|
||||
import org.openhab.core.types.State;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Convert an openhab command to an ANEL UDP command message.
|
||||
*
|
||||
* @author Patrick Koenemann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class AnelCommandHandler {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(AnelCommandHandler.class);
|
||||
|
||||
public @Nullable State getLockedState(@Nullable AnelState state, String channelId) {
|
||||
if (IAnelConstants.CHANNEL_RELAY_STATE.contains(channelId)) {
|
||||
if (state == null) {
|
||||
return null; // assume unlocked
|
||||
}
|
||||
|
||||
final int index = IAnelConstants.getIndexFromChannel(channelId);
|
||||
|
||||
final @Nullable Boolean locked = state.relayLocked[index];
|
||||
if (locked == null || !locked.booleanValue()) {
|
||||
return null; // no lock information or unlocked
|
||||
}
|
||||
|
||||
final @Nullable Boolean lockedState = state.relayState[index];
|
||||
if (lockedState == null) {
|
||||
return null; // no state information available
|
||||
}
|
||||
|
||||
return OnOffType.from(lockedState.booleanValue());
|
||||
}
|
||||
|
||||
if (IAnelConstants.CHANNEL_IO_STATE.contains(channelId)) {
|
||||
if (state == null) {
|
||||
return null; // assume unlocked
|
||||
}
|
||||
|
||||
final int index = IAnelConstants.getIndexFromChannel(channelId);
|
||||
|
||||
final @Nullable Boolean isInput = state.ioIsInput[index];
|
||||
if (isInput == null || !isInput.booleanValue()) {
|
||||
return null; // no direction infmoration or output port
|
||||
}
|
||||
|
||||
final @Nullable Boolean ioState = state.ioState[index];
|
||||
if (ioState == null) {
|
||||
return null; // no state information available
|
||||
}
|
||||
return OnOffType.from(ioState.booleanValue());
|
||||
}
|
||||
return null; // all other channels are read-only!
|
||||
}
|
||||
|
||||
public @Nullable String toAnelCommandAndUnsetState(@Nullable AnelState state, String channelId, Command command,
|
||||
String authentication) {
|
||||
if (!(command instanceof OnOffType)) {
|
||||
// only relay states and io states can be changed, all other channels are read-only
|
||||
logger.warn("Anel binding only support ON/OFF and Refresh commands, not {}: {}",
|
||||
command.getClass().getSimpleName(), command);
|
||||
} else if (IAnelConstants.CHANNEL_RELAY_STATE.contains(channelId)) {
|
||||
final int index = IAnelConstants.getIndexFromChannel(channelId);
|
||||
|
||||
// unset anel state which enforces a channel state update
|
||||
if (state != null) {
|
||||
state.relayState[index] = null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
final Boolean locked = state == null ? null : state.relayLocked[index];
|
||||
if (locked == null || !locked.booleanValue()) {
|
||||
return String.format("Sw_%s%d%s", command.toString().toLowerCase(), index + 1, authentication);
|
||||
} else {
|
||||
logger.warn("Relay {} is locked; skipping command {}.", index + 1, command);
|
||||
}
|
||||
} else if (IAnelConstants.CHANNEL_IO_STATE.contains(channelId)) {
|
||||
final int index = IAnelConstants.getIndexFromChannel(channelId);
|
||||
|
||||
// unset anel state which enforces a channel state update
|
||||
if (state != null) {
|
||||
state.ioState[index] = null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
final Boolean isInput = state == null ? null : state.ioIsInput[index];
|
||||
if (isInput == null || !isInput.booleanValue()) {
|
||||
return String.format("IO_%s%d%s", command.toString().toLowerCase(), index + 1, authentication);
|
||||
} else {
|
||||
logger.warn("IO {} has direction input, not output; skipping command {}.", index + 1, command);
|
||||
}
|
||||
}
|
||||
|
||||
return null; // all other channels are read-only
|
||||
}
|
||||
}
|
@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2021 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.anel.internal.state;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.IllegalFormatException;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.anel.internal.IAnelConstants;
|
||||
|
||||
/**
|
||||
* Parser and data structure for the state of an Anel device.
|
||||
* <p>
|
||||
* Documentation in <a href="https://forum.anel.eu/viewtopic.php?f=16&t=207">Anel forum</a> (German).
|
||||
*
|
||||
* @author Patrick Koenemann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class AnelState {
|
||||
|
||||
/** Pattern for temp, e.g. 26.4°C or -1°F */
|
||||
private static final Pattern PATTERN_TEMPERATURE = Pattern.compile("(\\-?\\d+(?:\\.\\d)?).[CF]");
|
||||
/** Pattern for switch state: [name],[state: 1=on,0=off] */
|
||||
private static final Pattern PATTERN_SWITCH_STATE = Pattern.compile("(.+),(0|1)");
|
||||
/** Pattern for IO state: [name],[1=input,0=output],[state: 1=on,0=off] */
|
||||
private static final Pattern PATTERN_IO_STATE = Pattern.compile("(.+),(0|1),(0|1)");
|
||||
|
||||
/** The raw status this state was created from. */
|
||||
public final String status;
|
||||
|
||||
/** Device IP address; read-only. */
|
||||
public final @Nullable String ip;
|
||||
/** Device name; read-only. */
|
||||
public final @Nullable String name;
|
||||
/** Device mac address; read-only. */
|
||||
public final @Nullable String mac;
|
||||
|
||||
/** Device relay names; read-only. */
|
||||
public final String[] relayName = new String[8];
|
||||
/** Device relay states; changeable. */
|
||||
public final Boolean[] relayState = new Boolean[8];
|
||||
/** Device relay locked status; read-only. */
|
||||
public final Boolean[] relayLocked = new Boolean[8];
|
||||
|
||||
/** Device IO names; read-only. */
|
||||
public final String[] ioName = new String[8];
|
||||
/** Device IO states; changeable if they are configured as input. */
|
||||
public final Boolean[] ioState = new Boolean[8];
|
||||
/** Device IO input states (<code>true</code> means changeable); read-only. */
|
||||
public final Boolean[] ioIsInput = new Boolean[8];
|
||||
|
||||
/** Device temperature (optional); read-only. */
|
||||
public final @Nullable String temperature;
|
||||
|
||||
/** Sensor temperature, e.g. "20.61" (optional); read-only. */
|
||||
public final @Nullable String sensorTemperature;
|
||||
/** Sensor Humidity, e.g. "40.7" (optional); read-only. */
|
||||
public final @Nullable String sensorHumidity;
|
||||
/** Sensor Brightness, e.g. "7.0" (optional); read-only. */
|
||||
public final @Nullable String sensorBrightness;
|
||||
|
||||
private static final AnelState INVALID_STATE = new AnelState();
|
||||
|
||||
public static AnelState of(@Nullable String status) {
|
||||
if (status == null || status.isEmpty()) {
|
||||
return INVALID_STATE;
|
||||
}
|
||||
return new AnelState(status);
|
||||
}
|
||||
|
||||
private AnelState() {
|
||||
status = "<invalid>";
|
||||
ip = null;
|
||||
name = null;
|
||||
mac = null;
|
||||
temperature = null;
|
||||
sensorTemperature = null;
|
||||
sensorHumidity = null;
|
||||
sensorBrightness = null;
|
||||
}
|
||||
|
||||
private AnelState(@Nullable String status) throws IllegalFormatException {
|
||||
if (status == null || status.isEmpty()) {
|
||||
throw new IllegalArgumentException("status must not be null or empty");
|
||||
}
|
||||
this.status = status;
|
||||
final String[] segments = status.split(IAnelConstants.STATUS_SEPARATOR);
|
||||
if (!segments[0].equals(IAnelConstants.STATUS_RESPONSE_PREFIX)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Data must start with '" + IAnelConstants.STATUS_RESPONSE_PREFIX + "' but it didn't: " + status);
|
||||
}
|
||||
if (segments.length < 16) {
|
||||
throw new IllegalArgumentException("Data must have at least 16 segments but it didn't: " + status);
|
||||
}
|
||||
final List<String> issues = new LinkedList<>();
|
||||
|
||||
// name, host, mac
|
||||
name = segments[1].trim();
|
||||
ip = segments[2];
|
||||
mac = segments[5];
|
||||
|
||||
// 8 switches / relays
|
||||
Integer lockedSwitches;
|
||||
try {
|
||||
lockedSwitches = Integer.parseInt(segments[14]);
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException(
|
||||
"Segment 15 (" + segments[14] + ") is expected to be a number but it's not: " + status);
|
||||
}
|
||||
for (int i = 0; i < 8; i++) {
|
||||
final Matcher matcher = PATTERN_SWITCH_STATE.matcher(segments[6 + i]);
|
||||
if (matcher.matches()) {
|
||||
relayName[i] = matcher.group(1);
|
||||
relayState[i] = "1".equals(matcher.group(2));
|
||||
} else {
|
||||
issues.add("Unexpected format for switch " + i + ": '" + segments[6 + i]);
|
||||
relayName[i] = "";
|
||||
relayState[i] = false;
|
||||
}
|
||||
relayLocked[i] = (lockedSwitches & (1 << i)) > 0;
|
||||
}
|
||||
|
||||
// 8 IO ports (devices with IO ports have >=24 segments)
|
||||
if (segments.length >= 24) {
|
||||
for (int i = 0; i < 8; i++) {
|
||||
final Matcher matcher = PATTERN_IO_STATE.matcher(segments[16 + i]);
|
||||
if (matcher.matches()) {
|
||||
ioName[i] = matcher.group(1);
|
||||
ioIsInput[i] = "1".equals(matcher.group(2));
|
||||
ioState[i] = "1".equals(matcher.group(3));
|
||||
} else {
|
||||
issues.add("Unexpected format for IO " + i + ": '" + segments[16 + i]);
|
||||
ioName[i] = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// temperature
|
||||
temperature = segments.length > 24 ? parseTemperature(segments[24], issues) : null;
|
||||
|
||||
if (segments.length > 34 && "p".equals(segments[27])) {
|
||||
// optional sensor (if device supports it and firmware >= 6.1) after power management
|
||||
if (segments.length > 38 && "s".equals(segments[35])) {
|
||||
sensorTemperature = segments[36];
|
||||
sensorHumidity = segments[37];
|
||||
sensorBrightness = segments[38];
|
||||
} else {
|
||||
sensorTemperature = null;
|
||||
sensorHumidity = null;
|
||||
sensorBrightness = null;
|
||||
}
|
||||
} else if (segments.length > 31 && "n".equals(segments[27]) && "s".equals(segments[28])) {
|
||||
// but sensor! (if device supports it and firmware >= 6.1)
|
||||
sensorTemperature = segments[29];
|
||||
sensorHumidity = segments[30];
|
||||
sensorBrightness = segments[31];
|
||||
} else {
|
||||
// firmware <= 6.0 or unknown format; skip rest
|
||||
sensorTemperature = null;
|
||||
sensorBrightness = null;
|
||||
sensorHumidity = null;
|
||||
}
|
||||
|
||||
if (!issues.isEmpty()) {
|
||||
throw new IllegalArgumentException(String.format("Anel status string contains %d issue%s: %s\n%s", //
|
||||
issues.size(), issues.size() == 1 ? "" : "s", status,
|
||||
issues.stream().collect(Collectors.joining("\n"))));
|
||||
}
|
||||
}
|
||||
|
||||
private static @Nullable String parseTemperature(String temp, List<String> issues) {
|
||||
if (!temp.isEmpty()) {
|
||||
final Matcher matcher = PATTERN_TEMPERATURE.matcher(temp);
|
||||
if (matcher.matches()) {
|
||||
return matcher.group(1);
|
||||
}
|
||||
issues.add("Unexpected format for temperature: " + temp);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return getClass().getSimpleName() + "[" + status + "]";
|
||||
}
|
||||
|
||||
/* generated */
|
||||
@Override
|
||||
@SuppressWarnings("null")
|
||||
public int hashCode() {
|
||||
final int prime = 31;
|
||||
int result = 1;
|
||||
result = prime * result + ((ip == null) ? 0 : ip.hashCode());
|
||||
result = prime * result + ((mac == null) ? 0 : mac.hashCode());
|
||||
result = prime * result + ((name == null) ? 0 : name.hashCode());
|
||||
result = prime * result + Arrays.hashCode(ioIsInput);
|
||||
result = prime * result + Arrays.hashCode(ioName);
|
||||
result = prime * result + Arrays.hashCode(ioState);
|
||||
result = prime * result + Arrays.hashCode(relayLocked);
|
||||
result = prime * result + Arrays.hashCode(relayName);
|
||||
result = prime * result + Arrays.hashCode(relayState);
|
||||
result = prime * result + ((temperature == null) ? 0 : temperature.hashCode());
|
||||
result = prime * result + ((sensorBrightness == null) ? 0 : sensorBrightness.hashCode());
|
||||
result = prime * result + ((sensorHumidity == null) ? 0 : sensorHumidity.hashCode());
|
||||
result = prime * result + ((sensorTemperature == null) ? 0 : sensorTemperature.hashCode());
|
||||
return result;
|
||||
}
|
||||
|
||||
/* generated */
|
||||
@Override
|
||||
@SuppressWarnings("null")
|
||||
public boolean equals(@Nullable Object obj) {
|
||||
if (this == obj) {
|
||||
return true;
|
||||
}
|
||||
if (obj == null) {
|
||||
return false;
|
||||
}
|
||||
if (getClass() != obj.getClass()) {
|
||||
return false;
|
||||
}
|
||||
AnelState other = (AnelState) obj;
|
||||
if (ip == null) {
|
||||
if (other.ip != null) {
|
||||
return false;
|
||||
}
|
||||
} else if (!ip.equals(other.ip)) {
|
||||
return false;
|
||||
}
|
||||
if (!Arrays.equals(ioIsInput, other.ioIsInput)) {
|
||||
return false;
|
||||
}
|
||||
if (!Arrays.equals(ioName, other.ioName)) {
|
||||
return false;
|
||||
}
|
||||
if (!Arrays.equals(ioState, other.ioState)) {
|
||||
return false;
|
||||
}
|
||||
if (mac == null) {
|
||||
if (other.mac != null) {
|
||||
return false;
|
||||
}
|
||||
} else if (!mac.equals(other.mac)) {
|
||||
return false;
|
||||
}
|
||||
if (name == null) {
|
||||
if (other.name != null) {
|
||||
return false;
|
||||
}
|
||||
} else if (!name.equals(other.name)) {
|
||||
return false;
|
||||
}
|
||||
if (sensorBrightness == null) {
|
||||
if (other.sensorBrightness != null) {
|
||||
return false;
|
||||
}
|
||||
} else if (!sensorBrightness.equals(other.sensorBrightness)) {
|
||||
return false;
|
||||
}
|
||||
if (sensorHumidity == null) {
|
||||
if (other.sensorHumidity != null) {
|
||||
return false;
|
||||
}
|
||||
} else if (!sensorHumidity.equals(other.sensorHumidity)) {
|
||||
return false;
|
||||
}
|
||||
if (sensorTemperature == null) {
|
||||
if (other.sensorTemperature != null) {
|
||||
return false;
|
||||
}
|
||||
} else if (!sensorTemperature.equals(other.sensorTemperature)) {
|
||||
return false;
|
||||
}
|
||||
if (!Arrays.equals(relayLocked, other.relayLocked)) {
|
||||
return false;
|
||||
}
|
||||
if (!Arrays.equals(relayName, other.relayName)) {
|
||||
return false;
|
||||
}
|
||||
if (!Arrays.equals(relayState, other.relayState)) {
|
||||
return false;
|
||||
}
|
||||
if (temperature == null) {
|
||||
if (other.temperature != null) {
|
||||
return false;
|
||||
}
|
||||
} else if (!temperature.equals(other.temperature)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2021 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.anel.internal.state;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.anel.internal.IAnelConstants;
|
||||
import org.openhab.core.library.types.DecimalType;
|
||||
import org.openhab.core.library.types.OnOffType;
|
||||
import org.openhab.core.library.types.QuantityType;
|
||||
import org.openhab.core.library.types.StringType;
|
||||
import org.openhab.core.library.unit.SIUnits;
|
||||
import org.openhab.core.types.State;
|
||||
import org.openhab.core.types.UnDefType;
|
||||
|
||||
/**
|
||||
* Get updates for {@link AnelState}s.
|
||||
*
|
||||
* @author Patrick Koenemann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class AnelStateUpdater {
|
||||
|
||||
public @Nullable State getChannelUpdate(String channelId, @Nullable AnelState state) {
|
||||
if (state == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final int index = IAnelConstants.getIndexFromChannel(channelId);
|
||||
if (index >= 0) {
|
||||
if (IAnelConstants.CHANNEL_RELAY_NAME.contains(channelId)) {
|
||||
return getStringState(state.relayName[index]);
|
||||
}
|
||||
if (IAnelConstants.CHANNEL_RELAY_STATE.contains(channelId)) {
|
||||
return getSwitchState(state.relayState[index]);
|
||||
}
|
||||
if (IAnelConstants.CHANNEL_RELAY_LOCKED.contains(channelId)) {
|
||||
return getSwitchState(state.relayLocked[index]);
|
||||
}
|
||||
|
||||
if (IAnelConstants.CHANNEL_IO_NAME.contains(channelId)) {
|
||||
return getStringState(state.ioName[index]);
|
||||
}
|
||||
if (IAnelConstants.CHANNEL_IO_STATE.contains(channelId)) {
|
||||
return getSwitchState(state.ioState[index]);
|
||||
}
|
||||
if (IAnelConstants.CHANNEL_IO_MODE.contains(channelId)) {
|
||||
return getSwitchState(state.ioState[index]);
|
||||
}
|
||||
} else {
|
||||
if (IAnelConstants.CHANNEL_NAME.equals(channelId)) {
|
||||
return getStringState(state.name);
|
||||
}
|
||||
if (IAnelConstants.CHANNEL_TEMPERATURE.equals(channelId)) {
|
||||
return getTemperatureState(state.temperature);
|
||||
}
|
||||
|
||||
if (IAnelConstants.CHANNEL_SENSOR_TEMPERATURE.equals(channelId)) {
|
||||
return getTemperatureState(state.sensorTemperature);
|
||||
}
|
||||
if (IAnelConstants.CHANNEL_SENSOR_HUMIDITY.equals(channelId)) {
|
||||
return getDecimalState(state.sensorHumidity);
|
||||
}
|
||||
if (IAnelConstants.CHANNEL_SENSOR_BRIGHTNESS.equals(channelId)) {
|
||||
return getDecimalState(state.sensorBrightness);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public Map<String, State> getChannelUpdates(@Nullable AnelState oldState, AnelState newState) {
|
||||
if (oldState != null && newState.status.equals(oldState.status)) {
|
||||
return Collections.emptyMap(); // definitely no change!
|
||||
}
|
||||
|
||||
final Map<String, State> updates = new HashMap<>();
|
||||
|
||||
// name and device temperature
|
||||
final State newName = getNewStringState(oldState == null ? null : oldState.name, newState.name);
|
||||
if (newName != null) {
|
||||
updates.put(IAnelConstants.CHANNEL_NAME, newName);
|
||||
}
|
||||
final State newTemperature = getNewTemperatureState(oldState == null ? null : oldState.temperature,
|
||||
newState.temperature);
|
||||
if (newTemperature != null) {
|
||||
updates.put(IAnelConstants.CHANNEL_TEMPERATURE, newTemperature);
|
||||
}
|
||||
|
||||
// relay properties
|
||||
for (int i = 0; i < 8; i++) {
|
||||
final State newRelayName = getNewStringState(oldState == null ? null : oldState.relayName[i],
|
||||
newState.relayName[i]);
|
||||
if (newRelayName != null) {
|
||||
updates.put(IAnelConstants.CHANNEL_RELAY_NAME.get(i), newRelayName);
|
||||
}
|
||||
|
||||
final State newRelayState = getNewSwitchState(oldState == null ? null : oldState.relayState[i],
|
||||
newState.relayState[i]);
|
||||
if (newRelayState != null) {
|
||||
updates.put(IAnelConstants.CHANNEL_RELAY_STATE.get(i), newRelayState);
|
||||
}
|
||||
|
||||
final State newRelayLocked = getNewSwitchState(oldState == null ? null : oldState.relayLocked[i],
|
||||
newState.relayLocked[i]);
|
||||
if (newRelayLocked != null) {
|
||||
updates.put(IAnelConstants.CHANNEL_RELAY_LOCKED.get(i), newRelayLocked);
|
||||
}
|
||||
}
|
||||
|
||||
// IO properties
|
||||
for (int i = 0; i < 8; i++) {
|
||||
final State newIOName = getNewStringState(oldState == null ? null : oldState.ioName[i], newState.ioName[i]);
|
||||
if (newIOName != null) {
|
||||
updates.put(IAnelConstants.CHANNEL_IO_NAME.get(i), newIOName);
|
||||
}
|
||||
|
||||
final State newIOIsInput = getNewSwitchState(oldState == null ? null : oldState.ioIsInput[i],
|
||||
newState.ioIsInput[i]);
|
||||
if (newIOIsInput != null) {
|
||||
updates.put(IAnelConstants.CHANNEL_IO_MODE.get(i), newIOIsInput);
|
||||
}
|
||||
|
||||
final State newIOState = getNewSwitchState(oldState == null ? null : oldState.ioState[i],
|
||||
newState.ioState[i]);
|
||||
if (newIOState != null) {
|
||||
updates.put(IAnelConstants.CHANNEL_IO_STATE.get(i), newIOState);
|
||||
}
|
||||
}
|
||||
|
||||
// sensor values
|
||||
final State newSensorTemperature = getNewTemperatureState(oldState == null ? null : oldState.sensorTemperature,
|
||||
newState.sensorTemperature);
|
||||
if (newSensorTemperature != null) {
|
||||
updates.put(IAnelConstants.CHANNEL_SENSOR_TEMPERATURE, newSensorTemperature);
|
||||
}
|
||||
final State newSensorHumidity = getNewDecimalState(oldState == null ? null : oldState.sensorHumidity,
|
||||
newState.sensorHumidity);
|
||||
if (newSensorHumidity != null) {
|
||||
updates.put(IAnelConstants.CHANNEL_SENSOR_HUMIDITY, newSensorHumidity);
|
||||
}
|
||||
final State newSensorBrightness = getNewDecimalState(oldState == null ? null : oldState.sensorBrightness,
|
||||
newState.sensorBrightness);
|
||||
if (newSensorBrightness != null) {
|
||||
updates.put(IAnelConstants.CHANNEL_SENSOR_BRIGHTNESS, newSensorBrightness);
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
private @Nullable State getStringState(@Nullable String value) {
|
||||
return value == null ? null : new StringType(value);
|
||||
}
|
||||
|
||||
private @Nullable State getDecimalState(@Nullable String value) {
|
||||
return value == null ? null : new DecimalType(value);
|
||||
}
|
||||
|
||||
private @Nullable State getTemperatureState(@Nullable String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
final float floatValue = Float.parseFloat(value);
|
||||
return QuantityType.valueOf(floatValue, SIUnits.CELSIUS);
|
||||
}
|
||||
|
||||
private @Nullable State getSwitchState(@Nullable Boolean value) {
|
||||
return value == null ? null : OnOffType.from(value.booleanValue());
|
||||
}
|
||||
|
||||
private @Nullable State getNewStringState(@Nullable String oldValue, @Nullable String newValue) {
|
||||
return getNewState(oldValue, newValue, StringType::new);
|
||||
}
|
||||
|
||||
private @Nullable State getNewDecimalState(@Nullable String oldValue, @Nullable String newValue) {
|
||||
return getNewState(oldValue, newValue, DecimalType::new);
|
||||
}
|
||||
|
||||
private @Nullable State getNewTemperatureState(@Nullable String oldValue, @Nullable String newValue) {
|
||||
return getNewState(oldValue, newValue, value -> QuantityType.valueOf(Float.parseFloat(value), SIUnits.CELSIUS));
|
||||
}
|
||||
|
||||
private @Nullable State getNewSwitchState(@Nullable Boolean oldValue, @Nullable Boolean newValue) {
|
||||
return getNewState(oldValue, newValue, value -> OnOffType.from(value.booleanValue()));
|
||||
}
|
||||
|
||||
private <T> @Nullable State getNewState(@Nullable T oldValue, @Nullable T newValue,
|
||||
Function<T, State> createState) {
|
||||
if (oldValue == null) {
|
||||
if (newValue == null) {
|
||||
return null; // no change
|
||||
} else {
|
||||
return createState.apply(newValue); // from null to some value
|
||||
}
|
||||
} else if (newValue == null) {
|
||||
return UnDefType.NULL; // from some value to null
|
||||
} else if (oldValue.equals(newValue)) {
|
||||
return null; // no change
|
||||
}
|
||||
return createState.apply(newValue); // from some value to another value
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<binding:binding id="anel" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:binding="https://openhab.org/schemas/binding/v1.0.0"
|
||||
xsi:schemaLocation="https://openhab.org/schemas/binding/v1.0.0 https://openhab.org/schemas/binding-1.0.0.xsd">
|
||||
|
||||
<name>Anel NET-PwrCtrl Binding</name>
|
||||
<description>This is the binding for Anel NET-PwrCtrl devices.</description>
|
||||
|
||||
</binding:binding>
|
@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config-description:config-descriptions
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
|
||||
xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
|
||||
|
||||
<config-description uri="thing-type:anel:config">
|
||||
<parameter name="hostname" type="text" required="true">
|
||||
<context>network-address</context>
|
||||
<label>Hostname / IP address</label>
|
||||
<default>net-control</default>
|
||||
<description>Hostname or IP address of the device</description>
|
||||
</parameter>
|
||||
<parameter name="udpSendPort" type="integer" required="true">
|
||||
<context>port-send</context>
|
||||
<label>Send Port</label>
|
||||
<default>75</default>
|
||||
<description>UDP port to send data to the device (in the anel web UI, it's the receive port!)</description>
|
||||
</parameter>
|
||||
<parameter name="udpReceivePort" type="integer" required="true">
|
||||
<context>port-receive</context>
|
||||
<label>Receive Port</label>
|
||||
<default>77</default>
|
||||
<description>UDP port to receive data from the device (in the anel web UI, it's the send port!)</description>
|
||||
</parameter>
|
||||
<parameter name="user" type="text" required="true">
|
||||
<context>user</context>
|
||||
<label>User</label>
|
||||
<default>user7</default>
|
||||
<description>User to access the device (make sure it has rights to change relay / IO states!)</description>
|
||||
</parameter>
|
||||
<parameter name="password" type="text" required="true">
|
||||
<context>password</context>
|
||||
<label>Password</label>
|
||||
<default>anel</default>
|
||||
<description>Password to access the device</description>
|
||||
</parameter>
|
||||
</config-description>
|
||||
</config-description:config-descriptions>
|
@ -0,0 +1,201 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<thing:thing-descriptions bindingId="anel"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
|
||||
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
|
||||
|
||||
<thing-type id="home">
|
||||
<label>HOME</label>
|
||||
<description>Anel device with 3 controllable outlets without IO ports.</description>
|
||||
|
||||
<!-- Example channel ID: anel:home:mydevice:prop#temperature -->
|
||||
<channel-groups>
|
||||
<channel-group id="prop" typeId="propertiesGroup"/>
|
||||
|
||||
<channel-group id="r1" typeId="relayGroup"/>
|
||||
<channel-group id="r2" typeId="relayGroup"/>
|
||||
<channel-group id="r3" typeId="relayGroup"/>
|
||||
</channel-groups>
|
||||
|
||||
<properties>
|
||||
<property name="vendor">ANEL Elektronik AG</property>
|
||||
<property name="modelId">NET-PwrCtrl HOME</property>
|
||||
</properties>
|
||||
<representation-property>macAddress</representation-property>
|
||||
|
||||
<config-description-ref uri="thing-type:anel:config"/>
|
||||
</thing-type>
|
||||
|
||||
<thing-type id="simple-firmware">
|
||||
<label>PRO / POWER</label>
|
||||
<description>Anel device with 8 controllable outlets without IO ports.</description>
|
||||
|
||||
<channel-groups>
|
||||
<channel-group id="prop" typeId="propertiesGroup"/>
|
||||
|
||||
<!-- Example channel ID: anel:simple-firmware:mydevice:r1#state -->
|
||||
<channel-group id="r1" typeId="relayGroup"/>
|
||||
<channel-group id="r2" typeId="relayGroup"/>
|
||||
<channel-group id="r3" typeId="relayGroup"/>
|
||||
<channel-group id="r4" typeId="relayGroup"/>
|
||||
<channel-group id="r5" typeId="relayGroup"/>
|
||||
<channel-group id="r6" typeId="relayGroup"/>
|
||||
<channel-group id="r7" typeId="relayGroup"/>
|
||||
<channel-group id="r8" typeId="relayGroup"/>
|
||||
</channel-groups>
|
||||
|
||||
<properties>
|
||||
<property name="vendor">ANEL Elektronik AG</property>
|
||||
<property name="modelId">NET-PwrCtrl PRO / POWER</property>
|
||||
</properties>
|
||||
<representation-property>macAddress</representation-property>
|
||||
|
||||
<config-description-ref uri="thing-type:anel:config"/>
|
||||
</thing-type>
|
||||
|
||||
<thing-type id="advanced-firmware">
|
||||
<label>ADV / IO / HUT</label>
|
||||
<description>Anel device with 8 controllable outlets / relays and possibly 8 IO ports.</description>
|
||||
|
||||
<channel-groups>
|
||||
<channel-group id="prop" typeId="propertiesGroup"/>
|
||||
|
||||
<channel-group id="r1" typeId="relayGroup"/>
|
||||
<channel-group id="r2" typeId="relayGroup"/>
|
||||
<channel-group id="r3" typeId="relayGroup"/>
|
||||
<channel-group id="r4" typeId="relayGroup"/>
|
||||
<channel-group id="r5" typeId="relayGroup"/>
|
||||
<channel-group id="r6" typeId="relayGroup"/>
|
||||
<channel-group id="r7" typeId="relayGroup"/>
|
||||
<channel-group id="r8" typeId="relayGroup"/>
|
||||
|
||||
<channel-group id="io1" typeId="ioGroup"/>
|
||||
<channel-group id="io2" typeId="ioGroup"/>
|
||||
<channel-group id="io3" typeId="ioGroup"/>
|
||||
<channel-group id="io4" typeId="ioGroup"/>
|
||||
<channel-group id="io5" typeId="ioGroup"/>
|
||||
<channel-group id="io6" typeId="ioGroup"/>
|
||||
<channel-group id="io7" typeId="ioGroup"/>
|
||||
<channel-group id="io8" typeId="ioGroup"/>
|
||||
|
||||
<!-- Example channel ID: anel:advanced-firmware:mydevice:sensor#humidity -->
|
||||
<channel-group id="sensor" typeId="sensorGroup"/>
|
||||
</channel-groups>
|
||||
|
||||
<properties>
|
||||
<property name="vendor">ANEL Elektronik AG</property>
|
||||
<property name="modelId">NET-PwrCtrl ADV / IO / HUT</property>
|
||||
</properties>
|
||||
<representation-property>macAddress</representation-property>
|
||||
|
||||
<config-description-ref uri="thing-type:anel:config"/>
|
||||
</thing-type>
|
||||
|
||||
<channel-group-type id="propertiesGroup">
|
||||
<label>Device Properties</label>
|
||||
<description>Device properties</description>
|
||||
<channels>
|
||||
<channel id="name" typeId="name-channel"/>
|
||||
<channel id="temperature" typeId="temperature-channel"/>
|
||||
</channels>
|
||||
</channel-group-type>
|
||||
<channel-group-type id="relayGroup">
|
||||
<label>Relay / Socket</label>
|
||||
<description>A relay / socket</description>
|
||||
<channels>
|
||||
<channel id="name" typeId="relayName-channel"/>
|
||||
<channel id="locked" typeId="relayLocked-channel"/>
|
||||
<channel id="state" typeId="relayState-channel"/>
|
||||
</channels>
|
||||
</channel-group-type>
|
||||
<channel-group-type id="ioGroup">
|
||||
<label>I/O Port</label>
|
||||
<description>An Input / Output Port</description>
|
||||
<channels>
|
||||
<channel id="name" typeId="ioName-channel"/>
|
||||
<channel id="mode" typeId="ioMode-channel"/>
|
||||
<channel id="state" typeId="ioState-channel"/>
|
||||
<channel id="event" typeId="system.rawbutton"/>
|
||||
</channels>
|
||||
</channel-group-type>
|
||||
<channel-group-type id="sensorGroup">
|
||||
<label>Sensor</label>
|
||||
<description>Optional sensor values</description>
|
||||
<channels>
|
||||
<channel id="temperature" typeId="sensorTemperature-channel"/>
|
||||
<channel id="humidity" typeId="sensorHumidity-channel"/>
|
||||
<channel id="brightness" typeId="sensorBrightness-channel"/>
|
||||
</channels>
|
||||
</channel-group-type>
|
||||
|
||||
<channel-type id="name-channel">
|
||||
<item-type>String</item-type>
|
||||
<label>Device Name</label>
|
||||
<description>The name of the Anel device</description>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
<channel-type id="temperature-channel">
|
||||
<item-type>Number:Temperature</item-type>
|
||||
<label>Anel Device Temperature</label>
|
||||
<description>The value of the built-in temperature sensor of the Anel device</description>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="relayName-channel">
|
||||
<item-type>String</item-type>
|
||||
<label>Relay Name</label>
|
||||
<description>The name of the relay / socket</description>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
<channel-type id="relayLocked-channel" advanced="true">
|
||||
<item-type>Switch</item-type>
|
||||
<label>Relay Locked</label>
|
||||
<description>Whether or not the relay is locked</description>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
<channel-type id="relayState-channel">
|
||||
<item-type>Switch</item-type>
|
||||
<label>Relay State</label>
|
||||
<description>The state of the relay / socket (read-only if locked!)</description>
|
||||
<autoUpdatePolicy>veto</autoUpdatePolicy><!-- updates are only sent in non-locked mode -->
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="ioName-channel">
|
||||
<item-type>String</item-type>
|
||||
<label>IO Name</label>
|
||||
<description>The name of the I/O port</description>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
<channel-type id="ioMode-channel" advanced="true">
|
||||
<item-type>Switch</item-type>
|
||||
<label>IO is Input</label>
|
||||
<description>Whether the port is configured as input (true) or output (false)</description>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
<channel-type id="ioState-channel">
|
||||
<item-type>Switch</item-type>
|
||||
<label>IO State</label>
|
||||
<description>The state of the I/O port (read-only for input ports)</description>
|
||||
<autoUpdatePolicy>veto</autoUpdatePolicy><!-- updates are only sent in output mode -->
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="sensorTemperature-channel">
|
||||
<item-type>Number:Temperature</item-type>
|
||||
<label>Sensor Temperature</label>
|
||||
<description>The temperature value of the optional sensor</description>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
<channel-type id="sensorHumidity-channel">
|
||||
<item-type>Number</item-type>
|
||||
<label>Sensor Humidity</label>
|
||||
<description>The humidity value of the optional sensor</description>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
<channel-type id="sensorBrightness-channel">
|
||||
<item-type>Number</item-type>
|
||||
<label>Sensor Brightness</label>
|
||||
<description>The brightness value of the optional sensor</description>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
|
||||
</thing:thing-descriptions>
|
@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2021 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.anel.internal;
|
||||
|
||||
import static org.hamcrest.CoreMatchers.*;
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
|
||||
import java.util.Base64;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openhab.binding.anel.internal.auth.AnelAuthentication;
|
||||
import org.openhab.binding.anel.internal.auth.AnelAuthentication.AuthMethod;
|
||||
|
||||
/**
|
||||
* This class tests {@link AnelAuthentication}.
|
||||
*
|
||||
* @author Patrick Koenemann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class AnelAuthenticationTest {
|
||||
|
||||
private static final String STATUS_HUT_V4 = "NET-PwrCtrl:NET-CONTROL :192.168.178.148:255.255.255.0:192.168.178.1:0.4.163.10.9.107:Nr. 1,1:Nr. 2,1:Nr. 3,1:Nr. 4,0:Nr. 5,0:Nr. 6,0:Nr. 7,1:Nr. 8,1:0:80:IO-1,0,0:IO-2,0,0:IO-3,0,0:IO-4,0,0:IO-5,0,0:IO-6,0,0:IO-7,0,0:IO-8,0,0:27.7°C:NET-PWRCTRL_04.0";
|
||||
private static final String STATUS_HUT_V5 = "NET-PwrCtrl:ANEL2 :192.168.0.244:255.255.255.0:192.168.0.1:0.4.163.10.9.107:Nr. 1,1:Nr. 2,1:Nr. 3,1:Nr. 4,0:Nr. 5,0:Nr. 6,0:Nr. 7,1:Nr. 8,1:0:80:IO-1,0,0:IO-2,0,0:IO-3,0,0:IO-4,0,0:IO-5,0,0:IO-6,0,0:IO-7,0,0:IO-8,0,0:27.9*C:NET-PWRCTRL_05.0";
|
||||
private static final String STATUS_HOME_V4_6 = "NET-PwrCtrl:NET-CONTROL :192.168.0.244:255.255.255.0:192.168.0.1:0.5.163.21.4.71:Nr. 1,0:Nr. 2,1:Nr. 3,0:Nr. 4,0:Nr. 5,0:Nr. 6,0:Nr. 7,0:Nr. 8,0:248:80:NET-PWRCTRL_04.6:H:xor:";
|
||||
private static final String STATUS_UDP_SPEC_EXAMPLE_V7 = "NET-PwrCtrl:NET-CONTROL :192.168.178.148:255.255.255.0:192.168.178.1:0.4.163.10.9.107:Nr. 1,1:Nr. 2,1:Nr. 3,1:Nr. 4,0:Nr. 5,0:Nr. 6,0:Nr. 7,1:Nr. 8,1:0:80:IO-1,0,0:IO-2,0,0:IO-3,0,0:IO-4,0,0:IO-5,0,0:IO-6,0,0:IO-7,0,0:IO-8,0,0:27.7°C:NET-PWRCTRL_06.1:h:p:225.9:0.0004:50.056:0.04:0.00:0.0:1.0000:s:20.61:40.7:7.0:xor";
|
||||
private static final String STATUS_PRO_EXAMPLE_V4_5 = "172.25.3.147776172NET-PwrCtrl:DT-BT14-IPL-1 :172.25.3.14:255.255.0.0:172.25.1.1:0.4.163.19.3.129:Nr. 1,0:Nr. 2,0:Nr. 3,0:Nr. 4,0:Nr. 5,0:Nr. 6,0:Nr. 7,0:Nr. 8,0:0:80:NET-PWRCTRL_04.5:xor:";
|
||||
private static final String STATUS_IO_EXAMPLE_V6_5 = "NET-PwrCtrl:NET-CONTROL :192.168.0.244:255.255.255.0:192.168.0.1:0.4.163.20.7.65:Nr.1,0:Nr.2,1:Nr.3,0:Nr.4,0:Nr.5,0:Nr.6,0:Nr.7,0:Nr.8,0:0:80:IO-1,0,1:IO-2,0,0:IO-3,0,0:IO-4,0,0:IO-5,0,0:IO-6,0,0:IO-7,0,0:IO-8,0,0:23.1°C:NET-PWRCTRL_06.5:i:n:xor:";
|
||||
private static final String STATUS_EXAMPLE_V6_0 = " NET-PwrCtrl:NET-CONTROL :192.168.178.148:255.255.255.0:192.168.178.1:0.4.163.10.9.107:Nr. 1,1:Nr. 2,1:Nr. 3,1:Nr. 4,0:Nr. 5,0:Nr. 6,0:Nr. 7,1:Nr. 8,1:0:80:IO-1,0,0:IO-2,0,0:IO-3,0,0:IO-4,0,0:IO-5,0,0:IO-6,0,0:IO-7,0,0:IO-8,0,0:27.7°C:NET-PWRCTRL_06.0:o:p:225.9:0.0004:50.056:0.04:0.00:0.0:1.0000";
|
||||
|
||||
@Test
|
||||
public void authenticationMethod() {
|
||||
assertThat(AuthMethod.of(""), is(AuthMethod.PLAIN));
|
||||
assertThat(AuthMethod.of(" \n"), is(AuthMethod.PLAIN));
|
||||
assertThat(AuthMethod.of(STATUS_HUT_V4), is(AuthMethod.PLAIN));
|
||||
assertThat(AuthMethod.of(STATUS_HUT_V5), is(AuthMethod.PLAIN));
|
||||
assertThat(AuthMethod.of(STATUS_HOME_V4_6), is(AuthMethod.XORBASE64));
|
||||
assertThat(AuthMethod.of(STATUS_UDP_SPEC_EXAMPLE_V7), is(AuthMethod.XORBASE64));
|
||||
assertThat(AuthMethod.of(STATUS_PRO_EXAMPLE_V4_5), is(AuthMethod.XORBASE64));
|
||||
assertThat(AuthMethod.of(STATUS_IO_EXAMPLE_V6_5), is(AuthMethod.XORBASE64));
|
||||
assertThat(AuthMethod.of(STATUS_EXAMPLE_V6_0), is(AuthMethod.BASE64));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void encodeUserPasswordPlain() {
|
||||
encodeUserPassword(AuthMethod.PLAIN, (u, p) -> u + p);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void encodeUserPasswordBase64() {
|
||||
encodeUserPassword(AuthMethod.BASE64, (u, p) -> base64(u + p));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void encodeUserPasswordXorBase64() {
|
||||
encodeUserPassword(AuthMethod.XORBASE64, (u, p) -> base64(xor(u + p, p)));
|
||||
}
|
||||
|
||||
private void encodeUserPassword(AuthMethod authMethod, BiFunction<String, String, String> expectedEncoding) {
|
||||
assertThat(AnelAuthentication.getUserPasswordString("admin", "anel", authMethod),
|
||||
is(equalTo(expectedEncoding.apply("admin", "anel"))));
|
||||
assertThat(AnelAuthentication.getUserPasswordString("", "", authMethod),
|
||||
is(equalTo(expectedEncoding.apply("", ""))));
|
||||
assertThat(AnelAuthentication.getUserPasswordString(null, "", authMethod),
|
||||
is(equalTo(expectedEncoding.apply("", ""))));
|
||||
assertThat(AnelAuthentication.getUserPasswordString("", null, authMethod),
|
||||
is(equalTo(expectedEncoding.apply("", ""))));
|
||||
assertThat(AnelAuthentication.getUserPasswordString(null, null, authMethod),
|
||||
is(equalTo(expectedEncoding.apply("", ""))));
|
||||
}
|
||||
|
||||
private static String base64(String string) {
|
||||
return Base64.getEncoder().encodeToString(string.getBytes());
|
||||
}
|
||||
|
||||
private String xor(String text, String key) {
|
||||
final StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
sb.append((char) (text.charAt(i) ^ key.charAt(i % key.length())));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2021 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.anel.internal;
|
||||
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openhab.binding.anel.internal.state.AnelCommandHandler;
|
||||
import org.openhab.binding.anel.internal.state.AnelState;
|
||||
import org.openhab.core.library.types.DecimalType;
|
||||
import org.openhab.core.library.types.IncreaseDecreaseType;
|
||||
import org.openhab.core.library.types.OnOffType;
|
||||
import org.openhab.core.library.types.StringType;
|
||||
import org.openhab.core.library.types.UpDownType;
|
||||
import org.openhab.core.types.RefreshType;
|
||||
|
||||
/**
|
||||
* This class tests {@link AnelCommandHandler}.
|
||||
*
|
||||
* @author Patrick Koenemann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class AnelCommandHandlerTest {
|
||||
|
||||
private static final String CHANNEL_R1 = IAnelConstants.CHANNEL_RELAY_STATE.get(0);
|
||||
private static final String CHANNEL_R3 = IAnelConstants.CHANNEL_RELAY_STATE.get(2);
|
||||
private static final String CHANNEL_R4 = IAnelConstants.CHANNEL_RELAY_STATE.get(3);
|
||||
private static final String CHANNEL_IO1 = IAnelConstants.CHANNEL_IO_STATE.get(0);
|
||||
private static final String CHANNEL_IO6 = IAnelConstants.CHANNEL_IO_STATE.get(5);
|
||||
|
||||
private static final AnelState STATE_INVALID = AnelState.of(null);
|
||||
private static final AnelState STATE_HOME = AnelState.of(IAnelTestStatus.STATUS_HOME_V46);
|
||||
private static final AnelState STATE_HUT = AnelState.of(IAnelTestStatus.STATUS_HUT_V65);
|
||||
|
||||
private final AnelCommandHandler commandHandler = new AnelCommandHandler();
|
||||
|
||||
@Test
|
||||
public void refreshCommand() {
|
||||
// given & when
|
||||
final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_INVALID, CHANNEL_R1, RefreshType.REFRESH,
|
||||
"a");
|
||||
// then
|
||||
assertNull(cmd);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void decimalCommandReturnsNull() {
|
||||
// given & when
|
||||
final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R1, new DecimalType("1"), "a");
|
||||
// then
|
||||
assertNull(cmd);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void stringCommandReturnsNull() {
|
||||
// given & when
|
||||
final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R1, new StringType("ON"), "a");
|
||||
// then
|
||||
assertNull(cmd);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void increaseDecreaseCommandReturnsNull() {
|
||||
// given & when
|
||||
final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R1,
|
||||
IncreaseDecreaseType.INCREASE, "a");
|
||||
// then
|
||||
assertNull(cmd);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void upDownCommandReturnsNull() {
|
||||
// given & when
|
||||
final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R1, UpDownType.UP, "a");
|
||||
// then
|
||||
assertNull(cmd);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unlockedSwitchReturnsCommand() {
|
||||
// given & when
|
||||
final String cmdOn1 = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R1, OnOffType.ON, "a");
|
||||
final String cmdOff1 = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R1, OnOffType.OFF, "a");
|
||||
final String cmdOn3 = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R3, OnOffType.ON, "a");
|
||||
final String cmdOff3 = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R3, OnOffType.OFF, "a");
|
||||
// then
|
||||
assertThat(cmdOn1, equalTo("Sw_on1a"));
|
||||
assertThat(cmdOff1, equalTo("Sw_off1a"));
|
||||
assertThat(cmdOn3, equalTo("Sw_on3a"));
|
||||
assertThat(cmdOff3, equalTo("Sw_off3a"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lockedSwitchReturnsNull() {
|
||||
// given & when
|
||||
final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R4, OnOffType.ON, "a");
|
||||
// then
|
||||
assertNull(cmd);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nullIOSwitchReturnsCommand() {
|
||||
// given & when
|
||||
final String cmdOn = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_IO1, OnOffType.ON, "a");
|
||||
final String cmdOff = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_IO1, OnOffType.OFF, "a");
|
||||
// then
|
||||
assertThat(cmdOn, equalTo("IO_on1a"));
|
||||
assertThat(cmdOff, equalTo("IO_off1a"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void inputIOSwitchReturnsNull() {
|
||||
// given & when
|
||||
final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_HUT, CHANNEL_IO6, OnOffType.ON, "a");
|
||||
// then
|
||||
assertNull(cmd);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void outputIOSwitchReturnsCommand() {
|
||||
// given & when
|
||||
final String cmdOn = commandHandler.toAnelCommandAndUnsetState(STATE_HUT, CHANNEL_IO1, OnOffType.ON, "a");
|
||||
final String cmdOff = commandHandler.toAnelCommandAndUnsetState(STATE_HUT, CHANNEL_IO1, OnOffType.OFF, "a");
|
||||
// then
|
||||
assertThat(cmdOn, equalTo("IO_on1a"));
|
||||
assertThat(cmdOff, equalTo("IO_off1a"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ioDirectionSwitchReturnsNull() {
|
||||
// given & when
|
||||
final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_HUT, IAnelConstants.CHANNEL_IO_MODE.get(0),
|
||||
OnOffType.ON, "a");
|
||||
// then
|
||||
assertNull(cmd);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sensorTemperatureCommandReturnsNull() {
|
||||
// given & when
|
||||
final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_HUT,
|
||||
IAnelConstants.CHANNEL_SENSOR_TEMPERATURE, new DecimalType("1.0"), "a");
|
||||
// then
|
||||
assertNull(cmd);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void relayChannelIdIndex() {
|
||||
for (int i = 0; i < IAnelConstants.CHANNEL_RELAY_STATE.size(); i++) {
|
||||
final String relayStateChannelId = IAnelConstants.CHANNEL_RELAY_STATE.get(i);
|
||||
final String relayIndex = relayStateChannelId.substring(1, 2);
|
||||
final String expectedIndex = String.valueOf(i + 1);
|
||||
assertThat(relayIndex, equalTo(expectedIndex));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ioChannelIdIndex() {
|
||||
for (int i = 0; i < IAnelConstants.CHANNEL_IO_STATE.size(); i++) {
|
||||
final String ioStateChannelId = IAnelConstants.CHANNEL_IO_STATE.get(i);
|
||||
final String ioIndex = ioStateChannelId.substring(2, 3);
|
||||
final String expectedIndex = String.valueOf(i + 1);
|
||||
assertThat(ioIndex, equalTo(expectedIndex));
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2021 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.anel.internal;
|
||||
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openhab.binding.anel.internal.state.AnelState;
|
||||
|
||||
/**
|
||||
* This class tests {@link AnelState}.
|
||||
*
|
||||
* @author Patrick Koenemann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class AnelStateTest implements IAnelTestStatus {
|
||||
|
||||
@Test
|
||||
public void parseHomeV46Status() {
|
||||
final AnelState state = AnelState.of(STATUS_HOME_V46);
|
||||
assertThat(state.name, equalTo("NET-CONTROL"));
|
||||
assertThat(state.ip, equalTo("192.168.0.63"));
|
||||
assertThat(state.mac, equalTo("0.5.163.21.4.71"));
|
||||
assertNull(state.temperature);
|
||||
for (int i = 1; i <= 8; i++) {
|
||||
assertThat(state.relayName[i - 1], equalTo("Nr. " + i));
|
||||
assertThat(state.relayState[i - 1], is(i % 2 == 1));
|
||||
assertThat(state.relayLocked[i - 1], is(i > 3)); // 248 is binary for: 11111000, so first 3 are not locked
|
||||
}
|
||||
for (int i = 1; i <= 8; i++) {
|
||||
assertNull(state.ioName[i - 1]);
|
||||
assertNull(state.ioState[i - 1]);
|
||||
assertNull(state.ioIsInput[i - 1]);
|
||||
}
|
||||
assertNull(state.sensorTemperature);
|
||||
assertNull(state.sensorBrightness);
|
||||
assertNull(state.sensorHumidity);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseLockedStates() {
|
||||
final AnelState state = AnelState.of(STATUS_HOME_V46.replaceAll(":\\d+:80:", ":236:80:"));
|
||||
assertThat(state.relayLocked[0], is(false));
|
||||
assertThat(state.relayLocked[1], is(false));
|
||||
assertThat(state.relayLocked[2], is(true));
|
||||
assertThat(state.relayLocked[3], is(true));
|
||||
assertThat(state.relayLocked[4], is(false));
|
||||
assertThat(state.relayLocked[5], is(true));
|
||||
assertThat(state.relayLocked[6], is(true));
|
||||
assertThat(state.relayLocked[7], is(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseHutV65Status() {
|
||||
final AnelState state = AnelState.of(STATUS_HUT_V65);
|
||||
assertThat(state.name, equalTo("NET-CONTROL"));
|
||||
assertThat(state.ip, equalTo("192.168.0.64"));
|
||||
assertThat(state.mac, equalTo("0.5.163.17.9.116"));
|
||||
assertThat(state.temperature, equalTo("27.0"));
|
||||
for (int i = 1; i <= 8; i++) {
|
||||
assertThat(state.relayName[i - 1], equalTo("Nr." + i));
|
||||
assertThat(state.relayState[i - 1], is(i % 2 == 0));
|
||||
assertThat(state.relayLocked[i - 1], is(i > 3)); // 248 is binary for: 11111000, so first 3 are not locked
|
||||
}
|
||||
for (int i = 1; i <= 8; i++) {
|
||||
assertThat(state.ioName[i - 1], equalTo("IO-" + i));
|
||||
assertThat(state.ioState[i - 1], is(false));
|
||||
assertThat(state.ioIsInput[i - 1], is(i >= 5));
|
||||
}
|
||||
assertNull(state.sensorTemperature);
|
||||
assertNull(state.sensorBrightness);
|
||||
assertNull(state.sensorHumidity);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseHutV5Status() {
|
||||
final AnelState state = AnelState.of(STATUS_HUT_V5);
|
||||
assertThat(state.name, equalTo("ANEL1"));
|
||||
assertThat(state.ip, equalTo("192.168.0.244"));
|
||||
assertThat(state.mac, equalTo("0.5.163.14.7.91"));
|
||||
assertThat(state.temperature, equalTo("27.3"));
|
||||
for (int i = 1; i <= 8; i++) {
|
||||
assertThat(state.relayName[i - 1], matchesPattern(".+"));
|
||||
assertThat(state.relayState[i - 1], is(false));
|
||||
assertThat(state.relayLocked[i - 1], is(false));
|
||||
}
|
||||
for (int i = 1; i <= 8; i++) {
|
||||
assertThat(state.ioName[i - 1], matchesPattern(".+"));
|
||||
assertThat(state.ioState[i - 1], is(true));
|
||||
assertThat(state.ioIsInput[i - 1], is(true));
|
||||
}
|
||||
assertNull(state.sensorTemperature);
|
||||
assertNull(state.sensorBrightness);
|
||||
assertNull(state.sensorHumidity);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseHutV61StatusAndSensor() {
|
||||
final AnelState state = AnelState.of(STATUS_HUT_V61_POW_SENSOR);
|
||||
assertThat(state.name, equalTo("NET-CONTROL"));
|
||||
assertThat(state.ip, equalTo("192.168.178.148"));
|
||||
assertThat(state.mac, equalTo("0.4.163.10.9.107"));
|
||||
assertThat(state.temperature, equalTo("27.7"));
|
||||
for (int i = 1; i <= 8; i++) {
|
||||
assertThat(state.relayName[i - 1], equalTo("Nr. " + i));
|
||||
assertThat(state.relayState[i - 1], is(i <= 3 || i >= 7));
|
||||
assertThat(state.relayLocked[i - 1], is(false));
|
||||
}
|
||||
for (int i = 1; i <= 8; i++) {
|
||||
assertThat(state.ioName[i - 1], equalTo("IO-" + i));
|
||||
assertThat(state.ioState[i - 1], is(false));
|
||||
assertThat(state.ioIsInput[i - 1], is(false));
|
||||
}
|
||||
assertThat(state.sensorTemperature, equalTo("20.61"));
|
||||
assertThat(state.sensorHumidity, equalTo("40.7"));
|
||||
assertThat(state.sensorBrightness, equalTo("7.0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseHutV61StatusWithSensor() {
|
||||
final AnelState state = AnelState.of(STATUS_HUT_V61_SENSOR);
|
||||
assertThat(state.name, equalTo("NET-CONTROL"));
|
||||
assertThat(state.ip, equalTo("192.168.178.148"));
|
||||
assertThat(state.mac, equalTo("0.4.163.10.9.107"));
|
||||
assertThat(state.temperature, equalTo("27.7"));
|
||||
for (int i = 1; i <= 8; i++) {
|
||||
assertThat(state.relayName[i - 1], equalTo("Nr. " + i));
|
||||
assertThat(state.relayState[i - 1], is(i <= 3 || i >= 7));
|
||||
assertThat(state.relayLocked[i - 1], is(false));
|
||||
}
|
||||
for (int i = 1; i <= 8; i++) {
|
||||
assertThat(state.ioName[i - 1], equalTo("IO-" + i));
|
||||
assertThat(state.ioState[i - 1], is(false));
|
||||
assertThat(state.ioIsInput[i - 1], is(false));
|
||||
}
|
||||
assertThat(state.sensorTemperature, equalTo("20.61"));
|
||||
assertThat(state.sensorHumidity, equalTo("40.7"));
|
||||
assertThat(state.sensorBrightness, equalTo("7.0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseHutV61StatusWithoutSensor() {
|
||||
final AnelState state = AnelState.of(STATUS_HUT_V61_POW);
|
||||
assertThat(state.name, equalTo("NET-CONTROL"));
|
||||
assertThat(state.ip, equalTo("192.168.178.148"));
|
||||
assertThat(state.mac, equalTo("0.4.163.10.9.107"));
|
||||
assertThat(state.temperature, equalTo("27.7"));
|
||||
for (int i = 1; i <= 8; i++) {
|
||||
assertThat(state.relayName[i - 1], equalTo("Nr. " + i));
|
||||
assertThat(state.relayState[i - 1], is(i <= 3 || i >= 7));
|
||||
assertThat(state.relayLocked[i - 1], is(false));
|
||||
}
|
||||
for (int i = 1; i <= 8; i++) {
|
||||
assertThat(state.ioName[i - 1], equalTo("IO-" + i));
|
||||
assertThat(state.ioState[i - 1], is(false));
|
||||
assertThat(state.ioIsInput[i - 1], is(false));
|
||||
}
|
||||
assertNull(state.sensorTemperature);
|
||||
assertNull(state.sensorBrightness);
|
||||
assertNull(state.sensorHumidity);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void colonSeparatorInSwitchNameThrowsException() {
|
||||
try {
|
||||
AnelState.of(STATUS_INVALID_NAME);
|
||||
fail("Status format exception expected because of colon separator in name 'Nr: 3'");
|
||||
} catch (IllegalArgumentException e) {
|
||||
assertThat(e.getMessage(), containsString("is expected to be a number but it's not"));
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2021 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.anel.internal;
|
||||
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openhab.binding.anel.internal.state.AnelState;
|
||||
import org.openhab.binding.anel.internal.state.AnelStateUpdater;
|
||||
import org.openhab.core.library.types.DecimalType;
|
||||
import org.openhab.core.library.types.OnOffType;
|
||||
import org.openhab.core.library.types.QuantityType;
|
||||
import org.openhab.core.library.types.StringType;
|
||||
import org.openhab.core.types.State;
|
||||
|
||||
/**
|
||||
* This class tests {@link AnelStateUpdater}.
|
||||
*
|
||||
* @author Patrick Koenemann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class AnelStateUpdaterTest implements IAnelTestStatus, IAnelConstants {
|
||||
|
||||
private final AnelStateUpdater stateUpdater = new AnelStateUpdater();
|
||||
|
||||
@Test
|
||||
public void noStateChange() {
|
||||
// given
|
||||
final AnelState oldState = AnelState.of(STATUS_HUT_V5);
|
||||
final AnelState newState = AnelState.of(STATUS_HUT_V5.replace(":80:", ":81:")); // port is irrelevant
|
||||
// when
|
||||
Map<String, State> updates = stateUpdater.getChannelUpdates(oldState, newState);
|
||||
// then
|
||||
assertThat(updates.entrySet(), is(empty()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fromNullStateUpdatesHome() {
|
||||
// given
|
||||
final AnelState newState = AnelState.of(STATUS_HOME_V46);
|
||||
// when
|
||||
Map<String, State> updates = stateUpdater.getChannelUpdates(null, newState);
|
||||
// then
|
||||
final Map<String, State> expected = new HashMap<>();
|
||||
expected.put(CHANNEL_NAME, new StringType("NET-CONTROL"));
|
||||
for (int i = 1; i <= 8; i++) {
|
||||
expected.put(CHANNEL_RELAY_NAME.get(i - 1), new StringType("Nr. " + i));
|
||||
expected.put(CHANNEL_RELAY_STATE.get(i - 1), OnOffType.from(i % 2 == 1));
|
||||
expected.put(CHANNEL_RELAY_LOCKED.get(i - 1), OnOffType.from(i > 3));
|
||||
}
|
||||
assertThat(updates, equalTo(expected));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fromNullStateUpdatesHutPowerSensor() {
|
||||
// given
|
||||
final AnelState newState = AnelState.of(STATUS_HUT_V61_POW_SENSOR);
|
||||
// when
|
||||
Map<String, State> updates = stateUpdater.getChannelUpdates(null, newState);
|
||||
// then
|
||||
assertThat(updates.size(), is(5 + 8 * 6));
|
||||
assertThat(updates.get(CHANNEL_NAME), equalTo(new StringType("NET-CONTROL")));
|
||||
assertTemperature(updates.get(CHANNEL_TEMPERATURE), 27.7);
|
||||
|
||||
assertThat(updates.get(CHANNEL_SENSOR_BRIGHTNESS), equalTo(new DecimalType("7")));
|
||||
assertThat(updates.get(CHANNEL_SENSOR_HUMIDITY), equalTo(new DecimalType("40.7")));
|
||||
assertTemperature(updates.get(CHANNEL_SENSOR_TEMPERATURE), 20.61);
|
||||
|
||||
for (int i = 1; i <= 8; i++) {
|
||||
assertThat(updates.get(CHANNEL_RELAY_NAME.get(i - 1)), equalTo(new StringType("Nr. " + i)));
|
||||
assertThat(updates.get(CHANNEL_RELAY_STATE.get(i - 1)), equalTo(OnOffType.from(i <= 3 || i >= 7)));
|
||||
assertThat(updates.get(CHANNEL_RELAY_LOCKED.get(i - 1)), equalTo(OnOffType.OFF));
|
||||
}
|
||||
for (int i = 1; i <= 8; i++) {
|
||||
assertThat(updates.get(CHANNEL_IO_NAME.get(i - 1)), equalTo(new StringType("IO-" + i)));
|
||||
assertThat(updates.get(CHANNEL_IO_STATE.get(i - 1)), equalTo(OnOffType.OFF));
|
||||
assertThat(updates.get(CHANNEL_IO_MODE.get(i - 1)), equalTo(OnOffType.OFF));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void singleRelayStateChange() {
|
||||
// given
|
||||
final AnelState oldState = AnelState.of(STATUS_HUT_V61_POW_SENSOR);
|
||||
final AnelState newState = AnelState.of(STATUS_HUT_V61_POW_SENSOR.replace("Nr. 4,0", "Nr. 4,1"));
|
||||
// when
|
||||
Map<String, State> updates = stateUpdater.getChannelUpdates(oldState, newState);
|
||||
// then
|
||||
final Map<String, State> expected = new HashMap<>();
|
||||
expected.put(CHANNEL_RELAY_STATE.get(3), OnOffType.ON);
|
||||
assertThat(updates, equalTo(expected));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void temperatureChange() {
|
||||
// given
|
||||
final AnelState oldState = AnelState.of(STATUS_HUT_V65);
|
||||
final AnelState newState = AnelState.of(STATUS_HUT_V65.replaceFirst(":27\\.0(.)C:", ":27.1°C:"));
|
||||
// when
|
||||
Map<String, State> updates = stateUpdater.getChannelUpdates(oldState, newState);
|
||||
// then
|
||||
assertThat(updates.size(), is(1));
|
||||
assertTemperature(updates.get(CHANNEL_TEMPERATURE), 27.1);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void singleSensorStatesChange() {
|
||||
// given
|
||||
final AnelState oldState = AnelState.of(STATUS_HUT_V61_SENSOR);
|
||||
final AnelState newState = AnelState.of(STATUS_HUT_V61_SENSOR.replace(":s:20.61:40.7:7.0:", ":s:20.6:40:7.1:"));
|
||||
// when
|
||||
Map<String, State> updates = stateUpdater.getChannelUpdates(oldState, newState);
|
||||
// then
|
||||
assertThat(updates.size(), is(3));
|
||||
assertThat(updates.get(CHANNEL_SENSOR_BRIGHTNESS), equalTo(new DecimalType("7.1")));
|
||||
assertThat(updates.get(CHANNEL_SENSOR_HUMIDITY), equalTo(new DecimalType("40")));
|
||||
assertTemperature(updates.get(CHANNEL_SENSOR_TEMPERATURE), 20.6);
|
||||
}
|
||||
|
||||
private void assertTemperature(@Nullable State state, double value) {
|
||||
assertThat(state, isA(QuantityType.class));
|
||||
if (state instanceof QuantityType<?>) {
|
||||
assertThat(((QuantityType<?>) state).doubleValue(), closeTo(value, 0.0001d));
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2021 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.anel.internal;
|
||||
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Queue;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openhab.binding.anel.internal.auth.AnelAuthentication;
|
||||
import org.openhab.binding.anel.internal.auth.AnelAuthentication.AuthMethod;
|
||||
|
||||
/**
|
||||
* This test requires a physical Anel device!
|
||||
*
|
||||
* @author Patrick Koenemann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@Disabled // requires a physically available device in the local network
|
||||
public class AnelUdpConnectorTest {
|
||||
|
||||
/*
|
||||
* The IP and ports for the Anel device under test.
|
||||
*/
|
||||
private static final String HOST = "192.168.6.63"; // 63 / 64
|
||||
private static final int PORT_SEND = 7500; // 7500 / 75001
|
||||
private static final int PORT_RECEIVE = 7700; // 7700 / 7701
|
||||
private static final String USER = "user7";
|
||||
private static final String PASSWORD = "anel";
|
||||
|
||||
/* The device may have an internal delay of 200ms, plus network latency! Should not be <1sec. */
|
||||
private static final int WAIT_FOR_DEVICE_RESPONSE_MS = 1000;
|
||||
|
||||
private static final ExecutorService EXECUTOR_SERVICE = Executors.newCachedThreadPool();
|
||||
|
||||
private final Queue<String> receivedMessages = new ConcurrentLinkedQueue<>();
|
||||
|
||||
@Nullable
|
||||
private static AnelUdpConnector connector;
|
||||
|
||||
@BeforeAll
|
||||
public static void prepareConnector() {
|
||||
connector = new AnelUdpConnector(HOST, PORT_RECEIVE, PORT_SEND, EXECUTOR_SERVICE);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
@SuppressWarnings("null")
|
||||
public static void closeConnection() {
|
||||
connector.disconnect();
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
@SuppressWarnings("null")
|
||||
public void connectIfNotYetConnected() throws Exception {
|
||||
Thread.sleep(100);
|
||||
receivedMessages.clear(); // clear all previously received messages
|
||||
|
||||
if (!connector.isConnected()) {
|
||||
connector.connect(receivedMessages::offer, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void connectionTest() throws Exception {
|
||||
final String response = sendAndReceiveSingle(IAnelConstants.BROADCAST_DISCOVERY_MSG);
|
||||
/*
|
||||
* Expected example response:
|
||||
* "NET-PwrCtrl:NET-CONTROL :192.168.0.244:255.255.255.0:192.168.0.1:0.5.163.21.4.71:Nr. 1,0:Nr. 2,1:Nr. 3,0:Nr. 4,0:Nr. 5,0:Nr. 6,0:Nr. 7,0:Nr. 8,0:248:80:NET-PWRCTRL_04.6:H:xor:"
|
||||
*/
|
||||
assertThat(response, startsWith(IAnelConstants.STATUS_RESPONSE_PREFIX + IAnelConstants.STATUS_SEPARATOR));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toggleSwitch1() throws Exception {
|
||||
toggleSwitch(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toggleSwitch2() throws Exception {
|
||||
toggleSwitch(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toggleSwitch3() throws Exception {
|
||||
toggleSwitch(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toggleSwitch4() throws Exception {
|
||||
toggleSwitch(4);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toggleSwitch5() throws Exception {
|
||||
toggleSwitch(5);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toggleSwitch6() throws Exception {
|
||||
toggleSwitch(6);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toggleSwitch7() throws Exception {
|
||||
toggleSwitch(7);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toggleSwitch8() throws Exception {
|
||||
toggleSwitch(8);
|
||||
}
|
||||
|
||||
private void toggleSwitch(int switchNr) throws Exception {
|
||||
assertThat(switchNr, allOf(greaterThan(0), lessThan(9)));
|
||||
final int index = 5 + switchNr;
|
||||
|
||||
// get state of switch 1
|
||||
final String status = sendAndReceiveSingle(IAnelConstants.BROADCAST_DISCOVERY_MSG);
|
||||
final String[] segments = status.split(IAnelConstants.STATUS_SEPARATOR);
|
||||
assertThat(segments[5 + switchNr], anyOf(endsWith(",1"), endsWith(",0")));
|
||||
final boolean switch1state = segments[index].endsWith(",1");
|
||||
|
||||
// toggle state of switch 1
|
||||
final String auth = AnelAuthentication.getUserPasswordString(USER, PASSWORD, AuthMethod.of(status));
|
||||
final String command = "Sw_" + (switch1state ? "off" : "on") + String.valueOf(switchNr) + auth;
|
||||
final String status2 = sendAndReceiveSingle(command);
|
||||
|
||||
// assert new state of switch 1
|
||||
assertThat(status2.trim(), not(endsWith(":Err")));
|
||||
final String[] segments2 = status2.split(IAnelConstants.STATUS_SEPARATOR);
|
||||
final String expectedState = segments2[index].substring(0, segments2[index].length() - 1)
|
||||
+ (switch1state ? "0" : "1");
|
||||
assertThat(segments2[index], equalTo(expectedState));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void withoutCredentials() throws Exception {
|
||||
final String status2 = sendAndReceiveSingle("Sw_on1");
|
||||
assertThat(status2.trim(), endsWith(":NoPass:Err"));
|
||||
Thread.sleep(3100); // locked for 3 seconds
|
||||
}
|
||||
|
||||
private String sendAndReceiveSingle(final String msg) throws Exception {
|
||||
final Set<String> response = sendAndReceive(msg);
|
||||
assertThat(response, hasSize(1));
|
||||
return response.iterator().next();
|
||||
}
|
||||
|
||||
@SuppressWarnings("null")
|
||||
private Set<String> sendAndReceive(final String msg) throws Exception {
|
||||
assertThat(receivedMessages, is(empty()));
|
||||
connector.send(msg);
|
||||
Thread.sleep(WAIT_FOR_DEVICE_RESPONSE_MS);
|
||||
final Set<String> response = new LinkedHashSet<>();
|
||||
while (!receivedMessages.isEmpty()) {
|
||||
final String receivedMessage = receivedMessages.poll();
|
||||
if (receivedMessage != null) {
|
||||
response.add(receivedMessage);
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2021 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.anel.internal;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* Some constants used in the unit tests.
|
||||
*
|
||||
* @author Patrick Koenemann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public interface IAnelTestStatus {
|
||||
|
||||
String STATUS_INVALID_NAME = "NET-PwrCtrl:NET-CONTROL :192.168.6.63:255.255.255.0:192.168.6.1:0.4.163.21.4.71:"
|
||||
+ "Nr. 1,0:Nr. 2,1:Nr: 3,1:Nr. 4,0:Nr. 5,0:Nr. 6,0:Nr. 7,0:Nr. 8,0:248:80:NET-PWRCTRL_04.6:H:xor:";
|
||||
String STATUS_HUT_V61_POW = "NET-PwrCtrl:NET-CONTROL :192.168.178.148:255.255.255.0:192.168.178.1:0.4.163.10.9.107:"
|
||||
+ "Nr. 1,1:Nr. 2,1:Nr. 3,1:Nr. 4,0:Nr. 5,0:Nr. 6,0:Nr. 7,1:Nr. 8,1:0:80:"
|
||||
+ "IO-1,0,0:IO-2,0,0:IO-3,0,0:IO-4,0,0:IO-5,0,0:IO-6,0,0:IO-7,0,0:IO-8,0,0:27.7°C:NET-PWRCTRL_06.1:h:"
|
||||
+ "p:225.9:0.0004:50.056:0.04:0.00:0.0:1.0000:xor:";
|
||||
String STATUS_HUT_V61_SENSOR = "NET-PwrCtrl:NET-CONTROL :192.168.178.148:255.255.255.0:192.168.178.1:0.4.163.10.9.107:"
|
||||
+ "Nr. 1,1:Nr. 2,1:Nr. 3,1:Nr. 4,0:Nr. 5,0:Nr. 6,0:Nr. 7,1:Nr. 8,1:0:80:"
|
||||
+ "IO-1,0,0:IO-2,0,0:IO-3,0,0:IO-4,0,0:IO-5,0,0:IO-6,0,0:IO-7,0,0:IO-8,0,0:27.7°C:NET-PWRCTRL_06.1:h:"
|
||||
+ "n:s:20.61:40.7:7.0:xor:";
|
||||
String STATUS_HUT_V61_POW_SENSOR = "NET-PwrCtrl:NET-CONTROL :192.168.178.148:255.255.255.0:192.168.178.1:0.4.163.10.9.107:"
|
||||
+ "Nr. 1,1:Nr. 2,1:Nr. 3,1:Nr. 4,0:Nr. 5,0:Nr. 6,0:Nr. 7,1:Nr. 8,1:0:80:"
|
||||
+ "IO-1,0,0:IO-2,0,0:IO-3,0,0:IO-4,0,0:IO-5,0,0:IO-6,0,0:IO-7,0,0:IO-8,0,0:27.7°C:NET-PWRCTRL_06.1:h:"
|
||||
+ "p:225.9:0.0004:50.056:0.04:0.00:0.0:1.0000:s:20.61:40.7:7.0:xor";
|
||||
String STATUS_HUT_V5 = "NET-PwrCtrl:ANEL1 :192.168.0.244:255.255.255.0:192.168.0.1:0.5.163.14.7.91:"
|
||||
+ "hoch,0:links hoch,0:runter,0:rechts run,0:runter,0:hoch,0:links runt,0:rechts hoc,0:0:80:"
|
||||
+ "WHN_UP,1,1:LI_DOWN,1,1:RE_DOWN,1,1:LI_UP,1,1:RE_UP,1,1:DOWN,1,1:DOWN,1,1:UP,1,1:27.3°C:NET-PWRCTRL_05.0";
|
||||
String STATUS_HUT_V65 = "NET-PwrCtrl:NET-CONTROL :192.168.0.64:255.255.255.0:192.168.6.1:0.5.163.17.9.116:"
|
||||
+ "Nr.1,0:Nr.2,1:Nr.3,0:Nr.4,1:Nr.5,0:Nr.6,1:Nr.7,0:Nr.8,1:248:80:"
|
||||
+ "IO-1,0,0:IO-2,0,0:IO-3,0,0:IO-4,0,0:IO-5,1,0:IO-6,1,0:IO-7,1,0:IO-8,1,0:27.0<EFBFBD>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:";
|
||||
}
|
@ -55,6 +55,7 @@
|
||||
<module>org.openhab.binding.ambientweather</module>
|
||||
<module>org.openhab.binding.amplipi</module>
|
||||
<module>org.openhab.binding.androiddebugbridge</module>
|
||||
<module>org.openhab.binding.anel</module>
|
||||
<module>org.openhab.binding.astro</module>
|
||||
<module>org.openhab.binding.atlona</module>
|
||||
<module>org.openhab.binding.autelis</module>
|
||||
|
Loading…
Reference in New Issue
Block a user