[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
26 changed files with 3163 additions and 0 deletions

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