BROADCAST_ADDRESSES = new TreeSet<>(NetUtil.getAllBroadcastAddresses());
+
+ private static final int DISCOVER_DEVICE_TIMEOUT_SECONDS = 2;
+
+ /** #BroadcastAddresses * DiscoverDeviceTimeout * (3 * #DiscoveryPorts) */
+ private static final int DISCOVER_TIMEOUT_SECONDS = BROADCAST_ADDRESSES.size() * DISCOVER_DEVICE_TIMEOUT_SECONDS
+ * (3 * DISCOVERY_PORTS.length);
+
+ private final Logger logger = LoggerFactory.getLogger(AnelDiscoveryService.class);
+
+ private @Nullable Thread scanningThread = null;
+
+ public AnelDiscoveryService() throws IllegalArgumentException {
+ super(IAnelConstants.SUPPORTED_THING_TYPES_UIDS, DISCOVER_TIMEOUT_SECONDS);
+ logger.debug(
+ "Anel NET-PwrCtrl discovery service instantiated for broadcast addresses {} with a timeout of {} seconds.",
+ BROADCAST_ADDRESSES, DISCOVER_TIMEOUT_SECONDS);
+ }
+
+ @Override
+ protected void startScan() {
+ /*
+ * Start scan in background thread, otherwise progress is not shown in the web UI.
+ * Do not use the scheduler, otherwise further threads (for handling discovered things) are not started
+ * immediately but only after the scan is complete.
+ */
+ final Thread thread = new NamedThreadFactory(IAnelConstants.BINDING_ID, true).newThread(this::doScan);
+ thread.start();
+ scanningThread = thread;
+ }
+
+ private void doScan() {
+ logger.debug("Starting scan of Anel devices via UDP broadcast messages...");
+
+ try {
+ for (final String broadcastAddress : BROADCAST_ADDRESSES) {
+
+ // for each available broadcast network address try factory default ports first
+ scan(broadcastAddress, IAnelConstants.DEFAULT_SEND_PORT, IAnelConstants.DEFAULT_RECEIVE_PORT);
+
+ // try reasonable ports...
+ for (int[] ports : DISCOVERY_PORTS) {
+ int sendPort = ports[0];
+ int receivePort = ports[1];
+
+ // ...and continue if a device was found, maybe there is yet another device on the next port
+ while (scan(broadcastAddress, sendPort, receivePort) || sendPort == ports[0]) {
+ sendPort++;
+ receivePort++;
+ }
+ }
+ }
+ } catch (InterruptedException | ClosedByInterruptException e) {
+ return; // OH shutdown or scan was aborted
+ } catch (Exception e) {
+ logger.warn("Unexpected exception during anel device scan", e);
+ } finally {
+ scanningThread = null;
+ }
+ logger.debug("Scan finished.");
+ }
+
+ /* @return Whether or not a device was found for the given broadcast address and port. */
+ private boolean scan(String broadcastAddress, int sendPort, int receivePort)
+ throws IOException, InterruptedException {
+ logger.debug("Scanning {}:{}...", broadcastAddress, sendPort);
+ final AnelUdpConnector udpConnector = new AnelUdpConnector(broadcastAddress, receivePort, sendPort, scheduler);
+
+ try {
+ final boolean[] deviceDiscovered = new boolean[] { false };
+ udpConnector.connect(status -> {
+ // avoid the same device to be discovered multiple times for multiple responses
+ if (!deviceDiscovered[0]) {
+ boolean discoverDevice = true;
+ synchronized (this) {
+ if (deviceDiscovered[0]) {
+ discoverDevice = false; // already discovered by another thread
+ } else {
+ deviceDiscovered[0] = true; // we discover the device!
+ }
+ }
+ if (discoverDevice) {
+ // discover device outside synchronized-block
+ deviceDiscovered(status, sendPort, receivePort);
+ }
+ }
+ }, false);
+
+ udpConnector.send(IAnelConstants.BROADCAST_DISCOVERY_MSG);
+
+ // answer expected within 50-600ms on a regular network; wait up to 2sec just to make sure
+ for (int delay = 0; delay < 10 && !deviceDiscovered[0]; delay++) {
+ Thread.sleep(100 * DISCOVER_DEVICE_TIMEOUT_SECONDS); // wait 10 x 200ms = 2sec
+ }
+
+ return deviceDiscovered[0];
+ } catch (BindException e) {
+ // most likely socket is already in use, ignore this exception.
+ logger.debug(
+ "Invalid address {} or one of the ports {} or {} is already in use. Skipping scan of these ports.",
+ broadcastAddress, sendPort, receivePort);
+ } finally {
+ udpConnector.disconnect();
+ }
+ return false;
+ }
+
+ @Override
+ protected synchronized void stopScan() {
+ final Thread thread = scanningThread;
+ if (thread != null) {
+ thread.interrupt();
+ }
+ super.stopScan();
+ }
+
+ private void deviceDiscovered(String status, int sendPort, int receivePort) {
+ final String[] segments = status.split(":");
+ if (segments.length >= 16) {
+ final String name = segments[1].trim();
+ final String ip = segments[2];
+ final String macAddress = segments[5];
+ final String deviceType = segments.length > 17 ? segments[17] : null;
+ final ThingTypeUID thingTypeUid = getThingTypeUid(deviceType, segments);
+ final ThingUID thingUid = new ThingUID(thingTypeUid + AbstractUID.SEPARATOR + macAddress.replace(".", ""));
+
+ final DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUid) //
+ .withThingType(thingTypeUid) //
+ .withProperty("hostname", ip) // AnelConfiguration.hostname
+ .withProperty("user", USER) // AnelConfiguration.user
+ .withProperty("password", PASSWORD) // AnelConfiguration.password
+ .withProperty("udpSendPort", sendPort) // AnelConfiguration.udpSendPort
+ .withProperty("udpReceivePort", receivePort) // AnelConfiguration.udbReceivePort
+ .withProperty(IAnelConstants.UNIQUE_PROPERTY_NAME, macAddress) //
+ .withLabel(name) //
+ .withRepresentationProperty(IAnelConstants.UNIQUE_PROPERTY_NAME) //
+ .build();
+
+ thingDiscovered(discoveryResult);
+ }
+ }
+
+ private ThingTypeUID getThingTypeUid(@Nullable String deviceType, String[] segments) {
+ // device type is contained since firmware 6.0
+ if (deviceType != null && !deviceType.isEmpty()) {
+ final char deviceTypeChar = deviceType.charAt(0);
+ final ThingTypeUID thingTypeUID = IAnelConstants.DEVICE_TYPE_TO_THING_TYPE.get(deviceTypeChar);
+ if (thingTypeUID != null) {
+ return thingTypeUID;
+ }
+ }
+
+ if (segments.length < 20) {
+ // no information given, we should be save with return the simple firmware thing type
+ return IAnelConstants.THING_TYPE_ANEL_SIMPLE;
+ } else {
+ // more than 20 segments must include IO ports, hence it's an advanced firmware
+ return IAnelConstants.THING_TYPE_ANEL_ADVANCED;
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/state/AnelCommandHandler.java b/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/state/AnelCommandHandler.java
new file mode 100644
index 0000000000000..c2cc504b8e4cf
--- /dev/null
+++ b/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/state/AnelCommandHandler.java
@@ -0,0 +1,116 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.anel.internal.state;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.anel.internal.IAnelConstants;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Convert an openhab command to an ANEL UDP command message.
+ *
+ * @author Patrick Koenemann - Initial contribution
+ */
+@NonNullByDefault
+public class AnelCommandHandler {
+
+ private final Logger logger = LoggerFactory.getLogger(AnelCommandHandler.class);
+
+ public @Nullable State getLockedState(@Nullable AnelState state, String channelId) {
+ if (IAnelConstants.CHANNEL_RELAY_STATE.contains(channelId)) {
+ if (state == null) {
+ return null; // assume unlocked
+ }
+
+ final int index = IAnelConstants.getIndexFromChannel(channelId);
+
+ final @Nullable Boolean locked = state.relayLocked[index];
+ if (locked == null || !locked.booleanValue()) {
+ return null; // no lock information or unlocked
+ }
+
+ final @Nullable Boolean lockedState = state.relayState[index];
+ if (lockedState == null) {
+ return null; // no state information available
+ }
+
+ return OnOffType.from(lockedState.booleanValue());
+ }
+
+ if (IAnelConstants.CHANNEL_IO_STATE.contains(channelId)) {
+ if (state == null) {
+ return null; // assume unlocked
+ }
+
+ final int index = IAnelConstants.getIndexFromChannel(channelId);
+
+ final @Nullable Boolean isInput = state.ioIsInput[index];
+ if (isInput == null || !isInput.booleanValue()) {
+ return null; // no direction infmoration or output port
+ }
+
+ final @Nullable Boolean ioState = state.ioState[index];
+ if (ioState == null) {
+ return null; // no state information available
+ }
+ return OnOffType.from(ioState.booleanValue());
+ }
+ return null; // all other channels are read-only!
+ }
+
+ public @Nullable String toAnelCommandAndUnsetState(@Nullable AnelState state, String channelId, Command command,
+ String authentication) {
+ if (!(command instanceof OnOffType)) {
+ // only relay states and io states can be changed, all other channels are read-only
+ logger.warn("Anel binding only support ON/OFF and Refresh commands, not {}: {}",
+ command.getClass().getSimpleName(), command);
+ } else if (IAnelConstants.CHANNEL_RELAY_STATE.contains(channelId)) {
+ final int index = IAnelConstants.getIndexFromChannel(channelId);
+
+ // unset anel state which enforces a channel state update
+ if (state != null) {
+ state.relayState[index] = null;
+ }
+
+ @Nullable
+ final Boolean locked = state == null ? null : state.relayLocked[index];
+ if (locked == null || !locked.booleanValue()) {
+ return String.format("Sw_%s%d%s", command.toString().toLowerCase(), index + 1, authentication);
+ } else {
+ logger.warn("Relay {} is locked; skipping command {}.", index + 1, command);
+ }
+ } else if (IAnelConstants.CHANNEL_IO_STATE.contains(channelId)) {
+ final int index = IAnelConstants.getIndexFromChannel(channelId);
+
+ // unset anel state which enforces a channel state update
+ if (state != null) {
+ state.ioState[index] = null;
+ }
+
+ @Nullable
+ final Boolean isInput = state == null ? null : state.ioIsInput[index];
+ if (isInput == null || !isInput.booleanValue()) {
+ return String.format("IO_%s%d%s", command.toString().toLowerCase(), index + 1, authentication);
+ } else {
+ logger.warn("IO {} has direction input, not output; skipping command {}.", index + 1, command);
+ }
+ }
+
+ return null; // all other channels are read-only
+ }
+}
diff --git a/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/state/AnelState.java b/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/state/AnelState.java
new file mode 100644
index 0000000000000..defc0975bb43d
--- /dev/null
+++ b/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/state/AnelState.java
@@ -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.
+ *
+ * Documentation in Anel forum (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 (true
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 = "";
+ 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 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 issues) {
+ if (!temp.isEmpty()) {
+ final Matcher matcher = PATTERN_TEMPERATURE.matcher(temp);
+ if (matcher.matches()) {
+ return matcher.group(1);
+ }
+ issues.add("Unexpected format for temperature: " + temp);
+ }
+ return null;
+ }
+
+ @Override
+ public String toString() {
+ return getClass().getSimpleName() + "[" + status + "]";
+ }
+
+ /* generated */
+ @Override
+ @SuppressWarnings("null")
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((ip == null) ? 0 : ip.hashCode());
+ result = prime * result + ((mac == null) ? 0 : mac.hashCode());
+ result = prime * result + ((name == null) ? 0 : name.hashCode());
+ result = prime * result + Arrays.hashCode(ioIsInput);
+ result = prime * result + Arrays.hashCode(ioName);
+ result = prime * result + Arrays.hashCode(ioState);
+ result = prime * result + Arrays.hashCode(relayLocked);
+ result = prime * result + Arrays.hashCode(relayName);
+ result = prime * result + Arrays.hashCode(relayState);
+ result = prime * result + ((temperature == null) ? 0 : temperature.hashCode());
+ result = prime * result + ((sensorBrightness == null) ? 0 : sensorBrightness.hashCode());
+ result = prime * result + ((sensorHumidity == null) ? 0 : sensorHumidity.hashCode());
+ result = prime * result + ((sensorTemperature == null) ? 0 : sensorTemperature.hashCode());
+ return result;
+ }
+
+ /* generated */
+ @Override
+ @SuppressWarnings("null")
+ public boolean equals(@Nullable Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ AnelState other = (AnelState) obj;
+ if (ip == null) {
+ if (other.ip != null) {
+ return false;
+ }
+ } else if (!ip.equals(other.ip)) {
+ return false;
+ }
+ if (!Arrays.equals(ioIsInput, other.ioIsInput)) {
+ return false;
+ }
+ if (!Arrays.equals(ioName, other.ioName)) {
+ return false;
+ }
+ if (!Arrays.equals(ioState, other.ioState)) {
+ return false;
+ }
+ if (mac == null) {
+ if (other.mac != null) {
+ return false;
+ }
+ } else if (!mac.equals(other.mac)) {
+ return false;
+ }
+ if (name == null) {
+ if (other.name != null) {
+ return false;
+ }
+ } else if (!name.equals(other.name)) {
+ return false;
+ }
+ if (sensorBrightness == null) {
+ if (other.sensorBrightness != null) {
+ return false;
+ }
+ } else if (!sensorBrightness.equals(other.sensorBrightness)) {
+ return false;
+ }
+ if (sensorHumidity == null) {
+ if (other.sensorHumidity != null) {
+ return false;
+ }
+ } else if (!sensorHumidity.equals(other.sensorHumidity)) {
+ return false;
+ }
+ if (sensorTemperature == null) {
+ if (other.sensorTemperature != null) {
+ return false;
+ }
+ } else if (!sensorTemperature.equals(other.sensorTemperature)) {
+ return false;
+ }
+ if (!Arrays.equals(relayLocked, other.relayLocked)) {
+ return false;
+ }
+ if (!Arrays.equals(relayName, other.relayName)) {
+ return false;
+ }
+ if (!Arrays.equals(relayState, other.relayState)) {
+ return false;
+ }
+ if (temperature == null) {
+ if (other.temperature != null) {
+ return false;
+ }
+ } else if (!temperature.equals(other.temperature)) {
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/state/AnelStateUpdater.java b/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/state/AnelStateUpdater.java
new file mode 100644
index 0000000000000..1f208712fb578
--- /dev/null
+++ b/bundles/org.openhab.binding.anel/src/main/java/org/openhab/binding/anel/internal/state/AnelStateUpdater.java
@@ -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 getChannelUpdates(@Nullable AnelState oldState, AnelState newState) {
+ if (oldState != null && newState.status.equals(oldState.status)) {
+ return Collections.emptyMap(); // definitely no change!
+ }
+
+ final Map 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 @Nullable State getNewState(@Nullable T oldValue, @Nullable T newValue,
+ Function createState) {
+ if (oldValue == null) {
+ if (newValue == null) {
+ return null; // no change
+ } else {
+ return createState.apply(newValue); // from null to some value
+ }
+ } else if (newValue == null) {
+ return UnDefType.NULL; // from some value to null
+ } else if (oldValue.equals(newValue)) {
+ return null; // no change
+ }
+ return createState.apply(newValue); // from some value to another value
+ }
+}
diff --git a/bundles/org.openhab.binding.anel/src/main/resources/OH-INF/binding/binding.xml b/bundles/org.openhab.binding.anel/src/main/resources/OH-INF/binding/binding.xml
new file mode 100644
index 0000000000000..1635ce3daf4df
--- /dev/null
+++ b/bundles/org.openhab.binding.anel/src/main/resources/OH-INF/binding/binding.xml
@@ -0,0 +1,9 @@
+
+
+
+ Anel NET-PwrCtrl Binding
+ This is the binding for Anel NET-PwrCtrl devices.
+
+
diff --git a/bundles/org.openhab.binding.anel/src/main/resources/OH-INF/config/config.xml b/bundles/org.openhab.binding.anel/src/main/resources/OH-INF/config/config.xml
new file mode 100644
index 0000000000000..96dc873097bd2
--- /dev/null
+++ b/bundles/org.openhab.binding.anel/src/main/resources/OH-INF/config/config.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+ network-address
+
+ net-control
+ Hostname or IP address of the device
+
+
+ port-send
+
+ 75
+ UDP port to send data to the device (in the anel web UI, it's the receive port!)
+
+
+ port-receive
+
+ 77
+ UDP port to receive data from the device (in the anel web UI, it's the send port!)
+
+
+ user
+
+ user7
+ User to access the device (make sure it has rights to change relay / IO states!)
+
+
+ password
+
+ anel
+ Password to access the device
+
+
+
diff --git a/bundles/org.openhab.binding.anel/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.anel/src/main/resources/OH-INF/thing/thing-types.xml
new file mode 100644
index 0000000000000..d9e45864579bb
--- /dev/null
+++ b/bundles/org.openhab.binding.anel/src/main/resources/OH-INF/thing/thing-types.xml
@@ -0,0 +1,201 @@
+
+
+
+
+
+ Anel device with 3 controllable outlets without IO ports.
+
+
+
+
+
+
+
+
+
+
+
+ ANEL Elektronik AG
+ NET-PwrCtrl HOME
+
+ macAddress
+
+
+
+
+
+
+ Anel device with 8 controllable outlets without IO ports.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ANEL Elektronik AG
+ NET-PwrCtrl PRO / POWER
+
+ macAddress
+
+
+
+
+
+
+ Anel device with 8 controllable outlets / relays and possibly 8 IO ports.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ANEL Elektronik AG
+ NET-PwrCtrl ADV / IO / HUT
+
+ macAddress
+
+
+
+
+
+
+ Device properties
+
+
+
+
+
+
+
+ A relay / socket
+
+
+
+
+
+
+
+
+ An Input / Output Port
+
+
+
+
+
+
+
+
+
+ Optional sensor values
+
+
+
+
+
+
+
+
+ String
+
+ The name of the Anel device
+
+
+
+ Number:Temperature
+
+ The value of the built-in temperature sensor of the Anel device
+
+
+
+
+ String
+
+ The name of the relay / socket
+
+
+
+ Switch
+
+ Whether or not the relay is locked
+
+
+
+ Switch
+
+ The state of the relay / socket (read-only if locked!)
+ veto
+
+
+
+ String
+
+ The name of the I/O port
+
+
+
+ Switch
+
+ Whether the port is configured as input (true) or output (false)
+
+
+
+ Switch
+
+ The state of the I/O port (read-only for input ports)
+ veto
+
+
+
+ Number:Temperature
+
+ The temperature value of the optional sensor
+
+
+
+ Number
+
+ The humidity value of the optional sensor
+
+
+
+ Number
+
+ The brightness value of the optional sensor
+
+
+
+
diff --git a/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelAuthenticationTest.java b/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelAuthenticationTest.java
new file mode 100644
index 0000000000000..ca81fed646ffe
--- /dev/null
+++ b/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelAuthenticationTest.java
@@ -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 expectedEncoding) {
+ assertThat(AnelAuthentication.getUserPasswordString("admin", "anel", authMethod),
+ is(equalTo(expectedEncoding.apply("admin", "anel"))));
+ assertThat(AnelAuthentication.getUserPasswordString("", "", authMethod),
+ is(equalTo(expectedEncoding.apply("", ""))));
+ assertThat(AnelAuthentication.getUserPasswordString(null, "", authMethod),
+ is(equalTo(expectedEncoding.apply("", ""))));
+ assertThat(AnelAuthentication.getUserPasswordString("", null, authMethod),
+ is(equalTo(expectedEncoding.apply("", ""))));
+ assertThat(AnelAuthentication.getUserPasswordString(null, null, authMethod),
+ is(equalTo(expectedEncoding.apply("", ""))));
+ }
+
+ private static String base64(String string) {
+ return Base64.getEncoder().encodeToString(string.getBytes());
+ }
+
+ private String xor(String text, String key) {
+ final StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < text.length(); i++) {
+ sb.append((char) (text.charAt(i) ^ key.charAt(i % key.length())));
+ }
+ return sb.toString();
+ }
+}
diff --git a/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelCommandHandlerTest.java b/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelCommandHandlerTest.java
new file mode 100644
index 0000000000000..ea7466de4666e
--- /dev/null
+++ b/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelCommandHandlerTest.java
@@ -0,0 +1,179 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.anel.internal;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.anel.internal.state.AnelCommandHandler;
+import org.openhab.binding.anel.internal.state.AnelState;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.IncreaseDecreaseType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.types.UpDownType;
+import org.openhab.core.types.RefreshType;
+
+/**
+ * This class tests {@link AnelCommandHandler}.
+ *
+ * @author Patrick Koenemann - Initial contribution
+ */
+@NonNullByDefault
+public class AnelCommandHandlerTest {
+
+ private static final String CHANNEL_R1 = IAnelConstants.CHANNEL_RELAY_STATE.get(0);
+ private static final String CHANNEL_R3 = IAnelConstants.CHANNEL_RELAY_STATE.get(2);
+ private static final String CHANNEL_R4 = IAnelConstants.CHANNEL_RELAY_STATE.get(3);
+ private static final String CHANNEL_IO1 = IAnelConstants.CHANNEL_IO_STATE.get(0);
+ private static final String CHANNEL_IO6 = IAnelConstants.CHANNEL_IO_STATE.get(5);
+
+ private static final AnelState STATE_INVALID = AnelState.of(null);
+ private static final AnelState STATE_HOME = AnelState.of(IAnelTestStatus.STATUS_HOME_V46);
+ private static final AnelState STATE_HUT = AnelState.of(IAnelTestStatus.STATUS_HUT_V65);
+
+ private final AnelCommandHandler commandHandler = new AnelCommandHandler();
+
+ @Test
+ public void refreshCommand() {
+ // given & when
+ final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_INVALID, CHANNEL_R1, RefreshType.REFRESH,
+ "a");
+ // then
+ assertNull(cmd);
+ }
+
+ @Test
+ public void decimalCommandReturnsNull() {
+ // given & when
+ final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R1, new DecimalType("1"), "a");
+ // then
+ assertNull(cmd);
+ }
+
+ @Test
+ public void stringCommandReturnsNull() {
+ // given & when
+ final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R1, new StringType("ON"), "a");
+ // then
+ assertNull(cmd);
+ }
+
+ @Test
+ public void increaseDecreaseCommandReturnsNull() {
+ // given & when
+ final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R1,
+ IncreaseDecreaseType.INCREASE, "a");
+ // then
+ assertNull(cmd);
+ }
+
+ @Test
+ public void upDownCommandReturnsNull() {
+ // given & when
+ final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R1, UpDownType.UP, "a");
+ // then
+ assertNull(cmd);
+ }
+
+ @Test
+ public void unlockedSwitchReturnsCommand() {
+ // given & when
+ final String cmdOn1 = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R1, OnOffType.ON, "a");
+ final String cmdOff1 = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R1, OnOffType.OFF, "a");
+ final String cmdOn3 = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R3, OnOffType.ON, "a");
+ final String cmdOff3 = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R3, OnOffType.OFF, "a");
+ // then
+ assertThat(cmdOn1, equalTo("Sw_on1a"));
+ assertThat(cmdOff1, equalTo("Sw_off1a"));
+ assertThat(cmdOn3, equalTo("Sw_on3a"));
+ assertThat(cmdOff3, equalTo("Sw_off3a"));
+ }
+
+ @Test
+ public void lockedSwitchReturnsNull() {
+ // given & when
+ final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_R4, OnOffType.ON, "a");
+ // then
+ assertNull(cmd);
+ }
+
+ @Test
+ public void nullIOSwitchReturnsCommand() {
+ // given & when
+ final String cmdOn = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_IO1, OnOffType.ON, "a");
+ final String cmdOff = commandHandler.toAnelCommandAndUnsetState(STATE_HOME, CHANNEL_IO1, OnOffType.OFF, "a");
+ // then
+ assertThat(cmdOn, equalTo("IO_on1a"));
+ assertThat(cmdOff, equalTo("IO_off1a"));
+ }
+
+ @Test
+ public void inputIOSwitchReturnsNull() {
+ // given & when
+ final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_HUT, CHANNEL_IO6, OnOffType.ON, "a");
+ // then
+ assertNull(cmd);
+ }
+
+ @Test
+ public void outputIOSwitchReturnsCommand() {
+ // given & when
+ final String cmdOn = commandHandler.toAnelCommandAndUnsetState(STATE_HUT, CHANNEL_IO1, OnOffType.ON, "a");
+ final String cmdOff = commandHandler.toAnelCommandAndUnsetState(STATE_HUT, CHANNEL_IO1, OnOffType.OFF, "a");
+ // then
+ assertThat(cmdOn, equalTo("IO_on1a"));
+ assertThat(cmdOff, equalTo("IO_off1a"));
+ }
+
+ @Test
+ public void ioDirectionSwitchReturnsNull() {
+ // given & when
+ final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_HUT, IAnelConstants.CHANNEL_IO_MODE.get(0),
+ OnOffType.ON, "a");
+ // then
+ assertNull(cmd);
+ }
+
+ @Test
+ public void sensorTemperatureCommandReturnsNull() {
+ // given & when
+ final String cmd = commandHandler.toAnelCommandAndUnsetState(STATE_HUT,
+ IAnelConstants.CHANNEL_SENSOR_TEMPERATURE, new DecimalType("1.0"), "a");
+ // then
+ assertNull(cmd);
+ }
+
+ @Test
+ public void relayChannelIdIndex() {
+ for (int i = 0; i < IAnelConstants.CHANNEL_RELAY_STATE.size(); i++) {
+ final String relayStateChannelId = IAnelConstants.CHANNEL_RELAY_STATE.get(i);
+ final String relayIndex = relayStateChannelId.substring(1, 2);
+ final String expectedIndex = String.valueOf(i + 1);
+ assertThat(relayIndex, equalTo(expectedIndex));
+ }
+ }
+
+ @Test
+ public void ioChannelIdIndex() {
+ for (int i = 0; i < IAnelConstants.CHANNEL_IO_STATE.size(); i++) {
+ final String ioStateChannelId = IAnelConstants.CHANNEL_IO_STATE.get(i);
+ final String ioIndex = ioStateChannelId.substring(2, 3);
+ final String expectedIndex = String.valueOf(i + 1);
+ assertThat(ioIndex, equalTo(expectedIndex));
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelStateTest.java b/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelStateTest.java
new file mode 100644
index 0000000000000..a8a1a3fc97592
--- /dev/null
+++ b/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelStateTest.java
@@ -0,0 +1,185 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.anel.internal;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.anel.internal.state.AnelState;
+
+/**
+ * This class tests {@link AnelState}.
+ *
+ * @author Patrick Koenemann - Initial contribution
+ */
+@NonNullByDefault
+public class AnelStateTest implements IAnelTestStatus {
+
+ @Test
+ public void parseHomeV46Status() {
+ final AnelState state = AnelState.of(STATUS_HOME_V46);
+ assertThat(state.name, equalTo("NET-CONTROL"));
+ assertThat(state.ip, equalTo("192.168.0.63"));
+ assertThat(state.mac, equalTo("0.5.163.21.4.71"));
+ assertNull(state.temperature);
+ for (int i = 1; i <= 8; i++) {
+ assertThat(state.relayName[i - 1], equalTo("Nr. " + i));
+ assertThat(state.relayState[i - 1], is(i % 2 == 1));
+ assertThat(state.relayLocked[i - 1], is(i > 3)); // 248 is binary for: 11111000, so first 3 are not locked
+ }
+ for (int i = 1; i <= 8; i++) {
+ assertNull(state.ioName[i - 1]);
+ assertNull(state.ioState[i - 1]);
+ assertNull(state.ioIsInput[i - 1]);
+ }
+ assertNull(state.sensorTemperature);
+ assertNull(state.sensorBrightness);
+ assertNull(state.sensorHumidity);
+ }
+
+ @Test
+ public void parseLockedStates() {
+ final AnelState state = AnelState.of(STATUS_HOME_V46.replaceAll(":\\d+:80:", ":236:80:"));
+ assertThat(state.relayLocked[0], is(false));
+ assertThat(state.relayLocked[1], is(false));
+ assertThat(state.relayLocked[2], is(true));
+ assertThat(state.relayLocked[3], is(true));
+ assertThat(state.relayLocked[4], is(false));
+ assertThat(state.relayLocked[5], is(true));
+ assertThat(state.relayLocked[6], is(true));
+ assertThat(state.relayLocked[7], is(true));
+ }
+
+ @Test
+ public void parseHutV65Status() {
+ final AnelState state = AnelState.of(STATUS_HUT_V65);
+ assertThat(state.name, equalTo("NET-CONTROL"));
+ assertThat(state.ip, equalTo("192.168.0.64"));
+ assertThat(state.mac, equalTo("0.5.163.17.9.116"));
+ assertThat(state.temperature, equalTo("27.0"));
+ for (int i = 1; i <= 8; i++) {
+ assertThat(state.relayName[i - 1], equalTo("Nr." + i));
+ assertThat(state.relayState[i - 1], is(i % 2 == 0));
+ assertThat(state.relayLocked[i - 1], is(i > 3)); // 248 is binary for: 11111000, so first 3 are not locked
+ }
+ for (int i = 1; i <= 8; i++) {
+ assertThat(state.ioName[i - 1], equalTo("IO-" + i));
+ assertThat(state.ioState[i - 1], is(false));
+ assertThat(state.ioIsInput[i - 1], is(i >= 5));
+ }
+ assertNull(state.sensorTemperature);
+ assertNull(state.sensorBrightness);
+ assertNull(state.sensorHumidity);
+ }
+
+ @Test
+ public void parseHutV5Status() {
+ final AnelState state = AnelState.of(STATUS_HUT_V5);
+ assertThat(state.name, equalTo("ANEL1"));
+ assertThat(state.ip, equalTo("192.168.0.244"));
+ assertThat(state.mac, equalTo("0.5.163.14.7.91"));
+ assertThat(state.temperature, equalTo("27.3"));
+ for (int i = 1; i <= 8; i++) {
+ assertThat(state.relayName[i - 1], matchesPattern(".+"));
+ assertThat(state.relayState[i - 1], is(false));
+ assertThat(state.relayLocked[i - 1], is(false));
+ }
+ for (int i = 1; i <= 8; i++) {
+ assertThat(state.ioName[i - 1], matchesPattern(".+"));
+ assertThat(state.ioState[i - 1], is(true));
+ assertThat(state.ioIsInput[i - 1], is(true));
+ }
+ assertNull(state.sensorTemperature);
+ assertNull(state.sensorBrightness);
+ assertNull(state.sensorHumidity);
+ }
+
+ @Test
+ public void parseHutV61StatusAndSensor() {
+ final AnelState state = AnelState.of(STATUS_HUT_V61_POW_SENSOR);
+ assertThat(state.name, equalTo("NET-CONTROL"));
+ assertThat(state.ip, equalTo("192.168.178.148"));
+ assertThat(state.mac, equalTo("0.4.163.10.9.107"));
+ assertThat(state.temperature, equalTo("27.7"));
+ for (int i = 1; i <= 8; i++) {
+ assertThat(state.relayName[i - 1], equalTo("Nr. " + i));
+ assertThat(state.relayState[i - 1], is(i <= 3 || i >= 7));
+ assertThat(state.relayLocked[i - 1], is(false));
+ }
+ for (int i = 1; i <= 8; i++) {
+ assertThat(state.ioName[i - 1], equalTo("IO-" + i));
+ assertThat(state.ioState[i - 1], is(false));
+ assertThat(state.ioIsInput[i - 1], is(false));
+ }
+ assertThat(state.sensorTemperature, equalTo("20.61"));
+ assertThat(state.sensorHumidity, equalTo("40.7"));
+ assertThat(state.sensorBrightness, equalTo("7.0"));
+ }
+
+ @Test
+ public void parseHutV61StatusWithSensor() {
+ final AnelState state = AnelState.of(STATUS_HUT_V61_SENSOR);
+ assertThat(state.name, equalTo("NET-CONTROL"));
+ assertThat(state.ip, equalTo("192.168.178.148"));
+ assertThat(state.mac, equalTo("0.4.163.10.9.107"));
+ assertThat(state.temperature, equalTo("27.7"));
+ for (int i = 1; i <= 8; i++) {
+ assertThat(state.relayName[i - 1], equalTo("Nr. " + i));
+ assertThat(state.relayState[i - 1], is(i <= 3 || i >= 7));
+ assertThat(state.relayLocked[i - 1], is(false));
+ }
+ for (int i = 1; i <= 8; i++) {
+ assertThat(state.ioName[i - 1], equalTo("IO-" + i));
+ assertThat(state.ioState[i - 1], is(false));
+ assertThat(state.ioIsInput[i - 1], is(false));
+ }
+ assertThat(state.sensorTemperature, equalTo("20.61"));
+ assertThat(state.sensorHumidity, equalTo("40.7"));
+ assertThat(state.sensorBrightness, equalTo("7.0"));
+ }
+
+ @Test
+ public void parseHutV61StatusWithoutSensor() {
+ final AnelState state = AnelState.of(STATUS_HUT_V61_POW);
+ assertThat(state.name, equalTo("NET-CONTROL"));
+ assertThat(state.ip, equalTo("192.168.178.148"));
+ assertThat(state.mac, equalTo("0.4.163.10.9.107"));
+ assertThat(state.temperature, equalTo("27.7"));
+ for (int i = 1; i <= 8; i++) {
+ assertThat(state.relayName[i - 1], equalTo("Nr. " + i));
+ assertThat(state.relayState[i - 1], is(i <= 3 || i >= 7));
+ assertThat(state.relayLocked[i - 1], is(false));
+ }
+ for (int i = 1; i <= 8; i++) {
+ assertThat(state.ioName[i - 1], equalTo("IO-" + i));
+ assertThat(state.ioState[i - 1], is(false));
+ assertThat(state.ioIsInput[i - 1], is(false));
+ }
+ assertNull(state.sensorTemperature);
+ assertNull(state.sensorBrightness);
+ assertNull(state.sensorHumidity);
+ }
+
+ @Test
+ public void colonSeparatorInSwitchNameThrowsException() {
+ try {
+ AnelState.of(STATUS_INVALID_NAME);
+ fail("Status format exception expected because of colon separator in name 'Nr: 3'");
+ } catch (IllegalArgumentException e) {
+ assertThat(e.getMessage(), containsString("is expected to be a number but it's not"));
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelStateUpdaterTest.java b/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelStateUpdaterTest.java
new file mode 100644
index 0000000000000..3703b4c33d434
--- /dev/null
+++ b/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelStateUpdaterTest.java
@@ -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 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 updates = stateUpdater.getChannelUpdates(null, newState);
+ // then
+ final Map 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 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 updates = stateUpdater.getChannelUpdates(oldState, newState);
+ // then
+ final Map 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 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 updates = stateUpdater.getChannelUpdates(oldState, newState);
+ // then
+ assertThat(updates.size(), is(3));
+ assertThat(updates.get(CHANNEL_SENSOR_BRIGHTNESS), equalTo(new DecimalType("7.1")));
+ assertThat(updates.get(CHANNEL_SENSOR_HUMIDITY), equalTo(new DecimalType("40")));
+ assertTemperature(updates.get(CHANNEL_SENSOR_TEMPERATURE), 20.6);
+ }
+
+ private void assertTemperature(@Nullable State state, double value) {
+ assertThat(state, isA(QuantityType.class));
+ if (state instanceof QuantityType>) {
+ assertThat(((QuantityType>) state).doubleValue(), closeTo(value, 0.0001d));
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelUdpConnectorTest.java b/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelUdpConnectorTest.java
new file mode 100644
index 0000000000000..60f34e4ee5c1a
--- /dev/null
+++ b/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/AnelUdpConnectorTest.java
@@ -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 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 response = sendAndReceive(msg);
+ assertThat(response, hasSize(1));
+ return response.iterator().next();
+ }
+
+ @SuppressWarnings("null")
+ private Set sendAndReceive(final String msg) throws Exception {
+ assertThat(receivedMessages, is(empty()));
+ connector.send(msg);
+ Thread.sleep(WAIT_FOR_DEVICE_RESPONSE_MS);
+ final Set response = new LinkedHashSet<>();
+ while (!receivedMessages.isEmpty()) {
+ final String receivedMessage = receivedMessages.poll();
+ if (receivedMessage != null) {
+ response.add(receivedMessage);
+ }
+ }
+ return response;
+ }
+}
diff --git a/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/IAnelTestStatus.java b/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/IAnelTestStatus.java
new file mode 100644
index 0000000000000..61505b03696b0
--- /dev/null
+++ b/bundles/org.openhab.binding.anel/src/test/java/org/openhab/binding/anel/internal/IAnelTestStatus.java
@@ -0,0 +1,47 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.anel.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Some constants used in the unit tests.
+ *
+ * @author Patrick Koenemann - Initial contribution
+ */
+@NonNullByDefault
+public interface IAnelTestStatus {
+
+ String STATUS_INVALID_NAME = "NET-PwrCtrl:NET-CONTROL :192.168.6.63:255.255.255.0:192.168.6.1:0.4.163.21.4.71:"
+ + "Nr. 1,0:Nr. 2,1:Nr: 3,1:Nr. 4,0:Nr. 5,0:Nr. 6,0:Nr. 7,0:Nr. 8,0:248:80:NET-PWRCTRL_04.6:H:xor:";
+ String STATUS_HUT_V61_POW = "NET-PwrCtrl:NET-CONTROL :192.168.178.148:255.255.255.0:192.168.178.1:0.4.163.10.9.107:"
+ + "Nr. 1,1:Nr. 2,1:Nr. 3,1:Nr. 4,0:Nr. 5,0:Nr. 6,0:Nr. 7,1:Nr. 8,1:0:80:"
+ + "IO-1,0,0:IO-2,0,0:IO-3,0,0:IO-4,0,0:IO-5,0,0:IO-6,0,0:IO-7,0,0:IO-8,0,0:27.7°C:NET-PWRCTRL_06.1:h:"
+ + "p:225.9:0.0004:50.056:0.04:0.00:0.0:1.0000:xor:";
+ String STATUS_HUT_V61_SENSOR = "NET-PwrCtrl:NET-CONTROL :192.168.178.148:255.255.255.0:192.168.178.1:0.4.163.10.9.107:"
+ + "Nr. 1,1:Nr. 2,1:Nr. 3,1:Nr. 4,0:Nr. 5,0:Nr. 6,0:Nr. 7,1:Nr. 8,1:0:80:"
+ + "IO-1,0,0:IO-2,0,0:IO-3,0,0:IO-4,0,0:IO-5,0,0:IO-6,0,0:IO-7,0,0:IO-8,0,0:27.7°C:NET-PWRCTRL_06.1:h:"
+ + "n:s:20.61:40.7:7.0:xor:";
+ String STATUS_HUT_V61_POW_SENSOR = "NET-PwrCtrl:NET-CONTROL :192.168.178.148:255.255.255.0:192.168.178.1:0.4.163.10.9.107:"
+ + "Nr. 1,1:Nr. 2,1:Nr. 3,1:Nr. 4,0:Nr. 5,0:Nr. 6,0:Nr. 7,1:Nr. 8,1:0:80:"
+ + "IO-1,0,0:IO-2,0,0:IO-3,0,0:IO-4,0,0:IO-5,0,0:IO-6,0,0:IO-7,0,0:IO-8,0,0:27.7°C:NET-PWRCTRL_06.1:h:"
+ + "p:225.9:0.0004:50.056:0.04:0.00:0.0:1.0000:s:20.61:40.7:7.0:xor";
+ String STATUS_HUT_V5 = "NET-PwrCtrl:ANEL1 :192.168.0.244:255.255.255.0:192.168.0.1:0.5.163.14.7.91:"
+ + "hoch,0:links hoch,0:runter,0:rechts run,0:runter,0:hoch,0:links runt,0:rechts hoc,0:0:80:"
+ + "WHN_UP,1,1:LI_DOWN,1,1:RE_DOWN,1,1:LI_UP,1,1:RE_UP,1,1:DOWN,1,1:DOWN,1,1:UP,1,1:27.3°C:NET-PWRCTRL_05.0";
+ String STATUS_HUT_V65 = "NET-PwrCtrl:NET-CONTROL :192.168.0.64:255.255.255.0:192.168.6.1:0.5.163.17.9.116:"
+ + "Nr.1,0:Nr.2,1:Nr.3,0:Nr.4,1:Nr.5,0:Nr.6,1:Nr.7,0:Nr.8,1:248:80:"
+ + "IO-1,0,0:IO-2,0,0:IO-3,0,0:IO-4,0,0:IO-5,1,0:IO-6,1,0:IO-7,1,0:IO-8,1,0:27.0�C:NET-PWRCTRL_06.5:h:n:xor:";
+ String STATUS_HOME_V46 = "NET-PwrCtrl:NET-CONTROL :192.168.0.63:255.255.255.0:192.168.6.1:0.5.163.21.4.71:"
+ + "Nr. 1,1:Nr. 2,0:Nr. 3,1:Nr. 4,0:Nr. 5,1:Nr. 6,0:Nr. 7,1:Nr. 8,0:248:80:NET-PWRCTRL_04.6:H:xor:";
+}
diff --git a/bundles/pom.xml b/bundles/pom.xml
index 6ab04faa208be..e2dcde088fb95 100644
--- a/bundles/pom.xml
+++ b/bundles/pom.xml
@@ -55,6 +55,7 @@
org.openhab.binding.ambientweather
org.openhab.binding.amplipi
org.openhab.binding.androiddebugbridge
+ org.openhab.binding.anel
org.openhab.binding.astro
org.openhab.binding.atlona
org.openhab.binding.autelis