[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:
paphko 2021-11-29 09:45:29 +01:00 committed by GitHub
parent 9bde2df3b4
commit 0adacaf596
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 3163 additions and 0 deletions

View File

@ -23,6 +23,7 @@
/bundles/org.openhab.binding.ambientweather/ @mhilbush
/bundles/org.openhab.binding.amplipi/ @kaikreuzer
/bundles/org.openhab.binding.androiddebugbridge/ @GiviMAD
/bundles/org.openhab.binding.anel/ @paphko
/bundles/org.openhab.binding.astro/ @gerrieg
/bundles/org.openhab.binding.atlona/ @tmrobert8
/bundles/org.openhab.binding.autelis/ @digitaldan

View File

@ -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>

View File

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

View File

@ -0,0 +1,231 @@
# Anel NET-PwrCtrl Binding
Monitor and control Anel NET-PwrCtrl devices.
NET-PwrCtrl devices are power sockets / relays that can be configured via browser but they can also be controlled over the network, e.g. with an Android or iPhone app - and also with openHAB via this binding.
Some NET-PwrCtrl devices also have 8 I/O ports which can either be used to directly switch the sockets / relays, or they can be used as general input / output switches in openHAB.
## Supported Things
There are three kinds of devices ([overview on manufacturer's homepage](https://en.anel.eu/?src=/produkte/produkte.htm)):
| [Anel NET-PwrCtrl HUT](https://en.anel.eu/?src=/produkte/hut_2/hut_2.htm) <br/> <sub>( _advanced-firmware_ )</sub> | [Anel NET-PwrCtrl IO](https://en.anel.eu/?src=/produkte/io/io.htm) <br/> <sub>( _advanced-firmware_ )</sub> | [Anel NET-PwrCtrl HOME](https://de.anel.eu/?src=produkte/home/home.htm) <br/> <sub>( _home_ )</sub> <br/> (only German version) |
| --- | --- | --- |
| [![Anel NET-PwrCtrl HUT 2](https://de.anel.eu/image/leisten/HUT2LV-P_500.jpg)](https://de.anel.eu/?src=produkte/hut_2/hut_2.htm) | [![Anel NET-PwrCtrl IO](https://de.anel.eu/image/leisten/IO-Stecker.png)](https://de.anel.eu/?src=produkte/io/io.htm) | [![Anel NET-PwrCtrl HOME](https://de.anel.eu/image/leisten/HOME-DE-500.gif)](https://de.anel.eu/?src=produkte/home/home.htm) |
Thing type IDs:
* *home*: The smallest device, the _HOME_, is the only one with only three power sockets and only available in Germany.
* *simple-firmware*: The _PRO_ and _REDUNDANT_ have eight power sockets and a similar (simplified) firmware as the _HOME_.
* *advanced-firmware*: All others (_ADV_, _IO_, and the different _HUT_ variants) have eight power sockets / relays, eight IO ports, and an advanced firmware.
An [additional sensor](https://en.anel.eu/?src=/produkte/sensor_1/sensor_1.htm) may be used for monitoring temperature, humidity, and brightness.
The sensor can be attached to a _HUT_ device via an Ethernet cable (max length is 50m).
## Discovery
Devices can be discovered automatically if their UDP ports are configured as follows:
* 75 / 77 (default)
* 750 / 770
* 7500 / 7700
* 7750 / 7770
If a device is found for a specific port (excluding the default port), the subsequent port is also scanned, e.g. 7500/7700 &rarr; 7501/7701 &rarr; 7502/7702 &rarr; etc.
Depending on the network switch and router devices, discovery may or may not work on wireless networks.
It should work reliably though on local wired networks.
## Thing Configuration
Each Thing requires the following configuration parameters.
| Parameter | Type | Default | Required | Description |
|-----------------------|---------|-------------|----------|-------------|
| Hostname / IP address | String | net-control | yes | Hostname or IP address of the device |
| Send Port | Integer | 75 | yes | UDP port to send data to the device (in the anel web UI, it's the receive port!) |
| Receive Port | Integer | 77 | yes | UDP port to receive data from the device (in the anel web UI, it's the send port!) |
| User | String | user7 | yes | User to access the device (make sure it has rights to change relay / IO states!) |
| Password | String | anel | yes | Password of the given user |
For multiple devices, please use exclusive UDP ports for each device.
Ports above 1024 are recommended because they are outside the range of system ports.
Possible entries in your thing file could be (thing types _home_, _simple-firmware_, and _advanced-firmware_ are explained above in _Supported Things_):
```
anel:home:mydevice1 [hostname="192.168.0.101", udpSendPort=7500, udpReceivePort=7700, user="user7", password="anel"]
anel:simple-firmware:mydevice2 [hostname="192.168.0.102", udpSendPort=7501, udpReceivePort=7701, user="user7", password="anel"]
anel:advanced-firmware:mydevice3 [hostname="192.168.0.103", udpSendPort=7502, udpReceivePort=7702, user="user7", password="anel"]
anel:advanced-firmware:mydevice4 [hostname="192.168.0.104", udpSendPort=7503, udpReceivePort=7703, user="user7", password="anel"]
```
## Channels
Depending on the thing type, the following channels are available.
| Channel ID | Item Type | Supported Things | Read Only | Description |
|--------------------|--------------------|-------------------|-----------|-------------|
| prop#name | String | all | yes | Name of the device |
| prop#temperature | Number:Temperature | simple / advanced | yes | Temperature of the integrated sensor |
| sensor#temperature | Number:Temperature | advanced | yes | Temperature of the optional external sensor |
| sensor#humidity | Number | advanced | yes | Humidity of the optional external sensor |
| sensor#brightness | Number | advanced | yes | Brightness of the optional external sensor |
| r1#name | String | all | yes | Name of relay / socket 1 |
| r2#name | String | all | yes | Name of relay / socket 2 |
| r3#name | String | all | yes | Name of relay / socket 3 |
| r4#name | String | simple / advanced | yes | Name of relay / socket 4 |
| r5#name | String | simple / advanced | yes | Name of relay / socket 5 |
| r6#name | String | simple / advanced | yes | Name of relay / socket 6 |
| r7#name | String | simple / advanced | yes | Name of relay / socket 7 |
| r8#name | String | simple / advanced | yes | Name of relay / socket 8 |
| r1#state | Switch | all | no * | State of relay / socket 1 |
| r2#state | Switch | all | no * | State of relay / socket 2 |
| r3#state | Switch | all | no * | State of relay / socket 3 |
| r4#state | Switch | simple / advanced | no * | State of relay / socket 4 |
| r5#state | Switch | simple / advanced | no * | State of relay / socket 5 |
| r6#state | Switch | simple / advanced | no * | State of relay / socket 6 |
| r7#state | Switch | simple / advanced | no * | State of relay / socket 7 |
| r8#state | Switch | simple / advanced | no * | State of relay / socket 8 |
| r1#locked | Switch | all | yes | Whether or not relay / socket 1 is locked |
| r2#locked | Switch | all | yes | Whether or not relay / socket 2 is locked |
| r3#locked | Switch | all | yes | Whether or not relay / socket 3 is locked |
| r4#locked | Switch | simple / advanced | yes | Whether or not relay / socket 4 is locked |
| r5#locked | Switch | simple / advanced | yes | Whether or not relay / socket 5 is locked |
| r6#locked | Switch | simple / advanced | yes | Whether or not relay / socket 6 is locked |
| r7#locked | Switch | simple / advanced | yes | Whether or not relay / socket 7 is locked |
| r8#locked | Switch | simple / advanced | yes | Whether or not relay / socket 8 is locked |
| io1#name | String | advanced | yes | Name of IO port 1 |
| io2#name | String | advanced | yes | Name of IO port 2 |
| io3#name | String | advanced | yes | Name of IO port 3 |
| io4#name | String | advanced | yes | Name of IO port 4 |
| io5#name | String | advanced | yes | Name of IO port 5 |
| io6#name | String | advanced | yes | Name of IO port 6 |
| io7#name | String | advanced | yes | Name of IO port 7 |
| io8#name | String | advanced | yes | Name of IO port 8 |
| io1#state | Switch | advanced | no ** | State of IO port 1 |
| io2#state | Switch | advanced | no ** | State of IO port 2 |
| io3#state | Switch | advanced | no ** | State of IO port 3 |
| io4#state | Switch | advanced | no ** | State of IO port 4 |
| io5#state | Switch | advanced | no ** | State of IO port 5 |
| io6#state | Switch | advanced | no ** | State of IO port 6 |
| io7#state | Switch | advanced | no ** | State of IO port 7 |
| io8#state | Switch | advanced | no ** | State of IO port 8 |
| io1#mode | Switch | advanced | yes | Mode of port 1: _ON_ = input, _OFF_ = output |
| io2#mode | Switch | advanced | yes | Mode of port 2: _ON_ = input, _OFF_ = output |
| io3#mode | Switch | advanced | yes | Mode of port 3: _ON_ = input, _OFF_ = output |
| io4#mode | Switch | advanced | yes | Mode of port 4: _ON_ = input, _OFF_ = output |
| io5#mode | Switch | advanced | yes | Mode of port 5: _ON_ = input, _OFF_ = output |
| io6#mode | Switch | advanced | yes | Mode of port 6: _ON_ = input, _OFF_ = output |
| io7#mode | Switch | advanced | yes | Mode of port 7: _ON_ = input, _OFF_ = output |
| io8#mode | Switch | advanced | yes | Mode of port 8: _ON_ = input, _OFF_ = output |
\* Relay / socket state is read-only if it is locked; otherwise it is changeable.<br/>
\** IO port state is read-only if its mode is _input_, it is changeable if its mode is _output_.
## Full Example
`.things` file:
```
Thing anel:advanced-firmware:anel1 "Anel1" [hostname="192.168.0.100", udpSendPort=7500, udpReceivePort=7700, user="user7", password="anel"]
```
`.items` file:
```
// device properties
String anel1name "Anel1 Name" {channel="anel:advanced-firmware:anel1:prop#name"}
Number:Temperature anel1temperature "Anel1 Temperature" {channel="anel:advanced-firmware:anel1:prop#temperature"}
// external sensor properties
Number:Temperature anel1sensorTemperature "Anel1 Sensor Temperature" {channel="anel:advanced-firmware:anel1:sensor#temperature"}
Number anel1sensorHumidity "Anel1 Sensor Humidity" {channel="anel:advanced-firmware:anel1:sensor#humidity"}
Number anel1sensorBrightness "Anel1 Sensor Brightness" {channel="anel:advanced-firmware:anel1:sensor#brightness"}
// relay names and states
String anel1relay1name "Anel1 Relay1 name" {channel="anel:advanced-firmware:anel1:r1#name"}
Switch anel1relay1locked "Anel1 Relay1 locked" {channel="anel:advanced-firmware:anel1:r1#locked"}
Switch anel1relay1state "Anel1 Relay1" {channel="anel:advanced-firmware:anel1:r1#state"}
Switch anel1relay2state "Anel1 Relay2" {channel="anel:advanced-firmware:anel1:r2#state"}
Switch anel1relay3state "Anel1 Relay3" {channel="anel:advanced-firmware:anel1:r3#state"}
Switch anel1relay4state "Anel1 Relay4" {channel="anel:advanced-firmware:anel1:r4#state"}
Switch anel1relay5state "Light Bedroom" {channel="anel:advanced-firmware:anel1:r5#state"}
Switch anel1relay6state "Doorbell" {channel="anel:advanced-firmware:anel1:r6#state"}
Switch anel1relay7state "Socket TV" {channel="anel:advanced-firmware:anel1:r7#state"}
Switch anel1relay8state "Socket Terrace" {channel="anel:advanced-firmware:anel1:r8#state"}
// IO port names and states
String anel1io1name "Anel1 IO1 name" {channel="anel:advanced-firmware:anel1:io1#name"}
Switch anel1io1mode "Anel1 IO1 mode" {channel="anel:advanced-firmware:anel1:io1#mode"}
Switch anel1io1state "Anel1 IO1" {channel="anel:advanced-firmware:anel1:io1#state"}
Switch anel1io2state "Anel1 IO2" {channel="anel:advanced-firmware:anel1:io2#state"}
Switch anel1io3state "Anel1 IO3" {channel="anel:advanced-firmware:anel1:io3#state"}
Switch anel1io4state "Anel1 IO4" {channel="anel:advanced-firmware:anel1:io4#state"}
Switch anel1io5state "Switch Bedroom" {channel="anel:advanced-firmware:anel1:io5#state"}
Switch anel1io6state "Doorbell" {channel="anel:advanced-firmware:anel1:io6#state"}
Switch anel1io7state "Switch Office" {channel="anel:advanced-firmware:anel1:io7#state"}
Switch anel1io8state "Reed Contact Door" {channel="anel:advanced-firmware:anel1:io8#state"}
```
`.sitemap` file:
```
sitemap anel label="Anel NET-PwrCtrl" {
Frame label="Device and Sensor" {
Text item=anel1name label="Anel1 Name"
Text item=anel1temperature label="Anel1 Temperature [%.1f °C]"
Text item=anel1sensorTemperature label="Anel1 Sensor Temperature [%.1f °C]"
Text item=anel1sensorHumidity label="Anel1 Sensor Humidity [%.1f]"
Text item=anel1sensorBrightness label="Anel1 Sensor Brightness [%.1f]"
}
Frame label="Relays" {
Text item=anel1relay1name label="Relay 1 name" labelcolor=[anel1relay1locked==ON="green",anel1relay1locked==OFF="maroon"]
Switch item=anel1relay1state
Switch item=anel1relay2state
Switch item=anel1relay3state
Switch item=anel1relay4state
Switch item=anel1relay5state
Switch item=anel1relay6state
Switch item=anel1relay7state
Switch item=anel1relay8state
}
Frame label="IO Ports" {
Text item=anel1io1name label="IO 1 name" labelcolor=[anel1io1mode==OFF="green",anel1io1mode==ON="maroon"]
Switch item=anel1io1state
Switch item=anel1io2state
Switch item=anel1io3state
Switch item=anel1io4state
Switch item=anel1io5state
Switch item=anel1io6state
Switch item=anel1io7state
Switch item=anel1io8state
}
}
```
The relay / IO port names are rarely useful because you probably set similar (static) labels for the state items.<br/>
The locked state / IO mode is also rarely relevant in practice, because it typically doesn't change.
`.rules` file:
```
rule "doorbell only at daytime"
when Item anel1io6state changed then
if (now.getHoursOfDay >= 6 && now.getHoursOfDay <= 22) {
anel1relay6state.sendCommand(if (anel1io6state.state != ON) ON else OFF)
}
someNotificationItem.sendCommand("Someone just rang the doorbell")
end
```
## Reference Documentation
The UDP protocol of Anel devices is explained [here](https://forum.anel.eu/viewtopic.php?f=16&t=207).

View 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>

View File

@ -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>

View File

@ -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");
}
}

View File

@ -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);
}
}
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}
}

View File

@ -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
}
}

View File

@ -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;
}
}

View File

@ -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
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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();
}
}

View File

@ -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));
}
}
}

View File

@ -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"));
}
}
}

View File

@ -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));
}
}
}

View File

@ -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;
}
}

View File

@ -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:";
}

View File

@ -55,6 +55,7 @@
<module>org.openhab.binding.ambientweather</module>
<module>org.openhab.binding.amplipi</module>
<module>org.openhab.binding.androiddebugbridge</module>
<module>org.openhab.binding.anel</module>
<module>org.openhab.binding.astro</module>
<module>org.openhab.binding.atlona</module>
<module>org.openhab.binding.autelis</module>