From 2b9f805bc745f0a8c2101462f21691247160ca69 Mon Sep 17 00:00:00 2001 From: Wouter Born Date: Wed, 26 Aug 2020 16:10:41 +0200 Subject: [PATCH] [network] Add support for Wake-on-LAN thing action (#8336) Closes #3799 Signed-off-by: Wouter Born --- bundles/org.openhab.binding.network/README.md | 22 ++- .../internal/NetworkHandlerConfiguration.java | 1 + .../internal/WakeOnLanPacketSender.java | 130 ++++++++++++++++++ .../internal/action/INetworkActions.java | 26 ++++ .../internal/action/NetworkActions.java | 92 +++++++++++++ .../internal/handler/NetworkHandler.java | 33 ++++- .../resources/ESH-INF/thing/thing-types.xml | 10 ++ .../internal/WakeOnLanPacketSenderTest.java | 98 +++++++++++++ 8 files changed, 406 insertions(+), 6 deletions(-) create mode 100644 bundles/org.openhab.binding.network/src/main/java/org/openhab/binding/network/internal/WakeOnLanPacketSender.java create mode 100644 bundles/org.openhab.binding.network/src/main/java/org/openhab/binding/network/internal/action/INetworkActions.java create mode 100644 bundles/org.openhab.binding.network/src/main/java/org/openhab/binding/network/internal/action/NetworkActions.java create mode 100644 bundles/org.openhab.binding.network/src/test/java/org/openhab/binding/network/internal/WakeOnLanPacketSenderTest.java diff --git a/bundles/org.openhab.binding.network/README.md b/bundles/org.openhab.binding.network/README.md index 5eea888461b84..4ebe399de095e 100644 --- a/bundles/org.openhab.binding.network/README.md +++ b/bundles/org.openhab.binding.network/README.md @@ -41,14 +41,15 @@ Please note: things discovered by the network binding will be provided with a ti ``` network:pingdevice:one_device [ hostname="192.168.0.64" ] -network:pingdevice:second_device [ hostname="192.168.0.65", retry=1, timeout=5000, refreshInterval=60000 ] +network:pingdevice:second_device [ hostname="192.168.0.65", macAddress="6f:70:65:6e:48:41", retry=1, timeout=5000, refreshInterval=60000 ] network:servicedevice:important_server [ hostname="192.168.0.62", port=1234 ] network:speedtest:local "SpeedTest 50Mo" @ "Internet" [refreshInterval=20, uploadSize=1000000, url="https://bouygues.testdebit.info/", fileName="50M.iso"] ``` Use the following options for a **network:pingdevice**: -- **hostname:** IP address or hostname of the device +- **hostname:** IP address or hostname of the device. +- **macAddress:** MAC address used for waking the device by the Wake-on-LAN action. - **retry:** After how many refresh interval cycles the device will be assumed to be offline. Default: `1`. - **timeout:** How long the ping will wait for an answer, in milliseconds. Default: `5000` (5 seconds). - **refreshInterval:** How often the device will be checked, in milliseconds. Default: `60000` (one minute). @@ -185,7 +186,7 @@ Things support the following channels: demo.things: ```xtend -Thing network:pingdevice:devicename [ hostname="192.168.0.42" ] +Thing network:pingdevice:devicename [ hostname="192.168.0.42", macAddress="6f:70:65:6e:48:41" ] Thing network:speedtest:local "SpeedTest 50Mo" @ "Internet" [url="https://bouygues.testdebit.info/", fileName="50M.iso"] ``` @@ -237,3 +238,18 @@ sitemap demo label="Main Menu" } } ``` + +## Rule Actions + +A Wake-on-LAN action is supported by this binding for the `pingdevice` and `servicedevice` thing types. +In classic rules this action is accessible as shown in the example below: + +``` +val actions = getActions("network", "network:pingdevice:devicename") +if (actions === null) { + logInfo("actions", "Actions not found, check thing ID") + return +} else { + actions.sendWakeOnLanPacket() +} +``` diff --git a/bundles/org.openhab.binding.network/src/main/java/org/openhab/binding/network/internal/NetworkHandlerConfiguration.java b/bundles/org.openhab.binding.network/src/main/java/org/openhab/binding/network/internal/NetworkHandlerConfiguration.java index 92427455c7cc6..259ebc7939990 100644 --- a/bundles/org.openhab.binding.network/src/main/java/org/openhab/binding/network/internal/NetworkHandlerConfiguration.java +++ b/bundles/org.openhab.binding.network/src/main/java/org/openhab/binding/network/internal/NetworkHandlerConfiguration.java @@ -24,6 +24,7 @@ @NonNullByDefault public class NetworkHandlerConfiguration { public String hostname = ""; + public String macAddress = ""; public @Nullable Integer port; public Integer retry = 1; public Integer refreshInterval = 60000; diff --git a/bundles/org.openhab.binding.network/src/main/java/org/openhab/binding/network/internal/WakeOnLanPacketSender.java b/bundles/org.openhab.binding.network/src/main/java/org/openhab/binding/network/internal/WakeOnLanPacketSender.java new file mode 100644 index 0000000000000..6c8499728472c --- /dev/null +++ b/bundles/org.openhab.binding.network/src/main/java/org/openhab/binding/network/internal/WakeOnLanPacketSender.java @@ -0,0 +1,130 @@ +/** + * Copyright (c) 2010-2020 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.network.internal; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.SocketException; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.smarthome.core.net.NetUtil; +import org.eclipse.smarthome.core.util.HexUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link WakeOnLanPacketSender} broadcasts a magic packet to wake a device. + * + * @author Wouter Born - Initial contribution + */ +@NonNullByDefault +public class WakeOnLanPacketSender { + + private static final int WOL_UDP_PORT = 9; + + // Wake-on-LAN magic packet constants + static final int PREFIX_BYTE_SIZE = 6; + static final int MAC_REPETITIONS = 16; + static final int MAC_BYTE_SIZE = 6; + static final int MAGIC_PACKET_BYTE_SIZE = PREFIX_BYTE_SIZE + MAC_REPETITIONS * MAC_BYTE_SIZE; + static final String[] MAC_SEPARATORS = new String[] { ":", "-" }; + + private final Logger logger = LoggerFactory.getLogger(WakeOnLanPacketSender.class); + + private final String macAddress; + private byte @Nullable [] magicPacket; + private final Consumer magicPacketSender; + + public WakeOnLanPacketSender(String macAddress) { + this.macAddress = macAddress; + this.magicPacketSender = this::broadcastMagicPacket; + } + + /** + * Used for testing only. + */ + WakeOnLanPacketSender(String macAddress, Consumer magicPacketSender) { + this.macAddress = macAddress; + this.magicPacketSender = magicPacketSender; + } + + public void sendPacket() { + byte[] localMagicPacket = magicPacket; + if (localMagicPacket == null) { + localMagicPacket = createMagicPacket(createMacBytes(macAddress)); + magicPacket = localMagicPacket; + } + + magicPacketSender.accept(localMagicPacket); + } + + private byte[] createMacBytes(String macAddress) { + String hexString = macAddress; + for (String macSeparator : MAC_SEPARATORS) { + hexString = hexString.replaceAll(macSeparator, ""); + } + if (hexString.length() != 2 * MAC_BYTE_SIZE) { + throw new IllegalStateException("Invalid MAC address: " + macAddress); + } + return HexUtils.hexToBytes(hexString); + } + + private byte[] createMagicPacket(byte[] macBytes) { + byte[] bytes = new byte[MAGIC_PACKET_BYTE_SIZE]; + Arrays.fill(bytes, 0, PREFIX_BYTE_SIZE, (byte) 0xff); + for (int i = PREFIX_BYTE_SIZE; i < MAGIC_PACKET_BYTE_SIZE; i += MAC_BYTE_SIZE) { + System.arraycopy(macBytes, 0, bytes, i, macBytes.length); + } + return bytes; + } + + private void broadcastMagicPacket(byte[] magicPacket) { + try (DatagramSocket socket = new DatagramSocket()) { + broadcastAddressStream().forEach(broadcastAddress -> { + try { + DatagramPacket packet = new DatagramPacket(magicPacket, MAGIC_PACKET_BYTE_SIZE, broadcastAddress, + WOL_UDP_PORT); + socket.send(packet); + logger.debug("Wake-on-LAN packet sent (MAC address: {}, broadcast address: {})", macAddress, + broadcastAddress.getHostAddress()); + } catch (IOException e) { + logger.debug("Failed to send Wake-on-LAN packet (MAC address: {}, broadcast address: {})", + macAddress, broadcastAddress.getHostAddress(), e); + } + }); + logger.info("Wake-on-LAN packets sent (MAC address: {})", macAddress); + } catch (SocketException e) { + logger.error("Failed to open Wake-on-LAN datagram socket", e); + } + } + + private Stream broadcastAddressStream() { + return NetUtil.getAllBroadcastAddresses().stream().map(address -> { + try { + return InetAddress.getByName(address); + } catch (UnknownHostException e) { + logger.debug("Failed to get broadcast address '{}' by name", address, e); + return null; + } + }).filter(Objects::nonNull); + } + +} diff --git a/bundles/org.openhab.binding.network/src/main/java/org/openhab/binding/network/internal/action/INetworkActions.java b/bundles/org.openhab.binding.network/src/main/java/org/openhab/binding/network/internal/action/INetworkActions.java new file mode 100644 index 0000000000000..cc068ae2de8e6 --- /dev/null +++ b/bundles/org.openhab.binding.network/src/main/java/org/openhab/binding/network/internal/action/INetworkActions.java @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2010-2020 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.network.internal.action; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link INetworkActions} defines the interface for all thing actions supported by the binding. + * + * @author Wouter Born - Initial contribution + */ +@NonNullByDefault +public interface INetworkActions { + + void sendWakeOnLanPacket(); +} diff --git a/bundles/org.openhab.binding.network/src/main/java/org/openhab/binding/network/internal/action/NetworkActions.java b/bundles/org.openhab.binding.network/src/main/java/org/openhab/binding/network/internal/action/NetworkActions.java new file mode 100644 index 0000000000000..ed1868c42a857 --- /dev/null +++ b/bundles/org.openhab.binding.network/src/main/java/org/openhab/binding/network/internal/action/NetworkActions.java @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2010-2020 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.network.internal.action; + +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.smarthome.core.thing.binding.ThingActions; +import org.eclipse.smarthome.core.thing.binding.ThingActionsScope; +import org.eclipse.smarthome.core.thing.binding.ThingHandler; +import org.openhab.binding.network.internal.handler.NetworkHandler; +import org.openhab.core.automation.annotation.RuleAction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The class is responsible to call corresponding actions on {@link NetworkHandler}. + *

+ * Note:The static method invokeMethodOf handles the case where + * the test actions instanceof NetworkActions fails. This test can fail + * due to an issue in openHAB core v2.5.0 where the {@link NetworkActions} class + * can be loaded by a different classloader than the actions instance. + * + * @author Wouter Born - Initial contribution + */ +@ThingActionsScope(name = "network") +@NonNullByDefault +public class NetworkActions implements ThingActions, INetworkActions { + + private final Logger logger = LoggerFactory.getLogger(NetworkActions.class); + + private @Nullable NetworkHandler handler; + + @Override + public void setThingHandler(@Nullable ThingHandler handler) { + if (handler instanceof NetworkHandler) { + this.handler = (NetworkHandler) handler; + } + } + + @Override + public @Nullable ThingHandler getThingHandler() { + return handler; + } + + @Override + @RuleAction(label = "Send WoL Packet", description = "Send a Wake-on-LAN packet to wake the device") + public void sendWakeOnLanPacket() { + NetworkHandler localHandler = handler; + if (localHandler != null) { + localHandler.sendWakeOnLanPacket(); + } else { + logger.warn("Failed to send Wake-on-LAN packet (handler null)"); + } + } + + public static void sendWakeOnLanPacket(@Nullable ThingActions actions) { + invokeMethodOf(actions).sendWakeOnLanPacket(); + } + + private static INetworkActions invokeMethodOf(@Nullable ThingActions actions) { + if (actions == null) { + throw new IllegalArgumentException("actions cannot be null"); + } + if (actions.getClass().getName().equals(NetworkActions.class.getName())) { + if (actions instanceof INetworkActions) { + return (INetworkActions) actions; + } else { + return (INetworkActions) Proxy.newProxyInstance(INetworkActions.class.getClassLoader(), + new Class[] { INetworkActions.class }, (Object proxy, Method method, Object[] args) -> { + Method m = actions.getClass().getDeclaredMethod(method.getName(), + method.getParameterTypes()); + return m.invoke(actions, args); + }); + } + } + throw new IllegalArgumentException("Actions is not an instance of " + NetworkActions.class.getName()); + } + +} diff --git a/bundles/org.openhab.binding.network/src/main/java/org/openhab/binding/network/internal/handler/NetworkHandler.java b/bundles/org.openhab.binding.network/src/main/java/org/openhab/binding/network/internal/handler/NetworkHandler.java index 66469c86d3e97..4d8fa668b84b8 100644 --- a/bundles/org.openhab.binding.network/src/main/java/org/openhab/binding/network/internal/handler/NetworkHandler.java +++ b/bundles/org.openhab.binding.network/src/main/java/org/openhab/binding/network/internal/handler/NetworkHandler.java @@ -16,6 +16,7 @@ import java.time.Instant; import java.time.ZonedDateTime; +import java.util.Collection; import java.util.Collections; import java.util.Map; import java.util.TimeZone; @@ -32,10 +33,19 @@ import org.eclipse.smarthome.core.thing.ThingStatus; import org.eclipse.smarthome.core.thing.ThingStatusDetail; import org.eclipse.smarthome.core.thing.binding.BaseThingHandler; +import org.eclipse.smarthome.core.thing.binding.ThingHandlerService; import org.eclipse.smarthome.core.types.Command; import org.eclipse.smarthome.core.types.RefreshType; import org.eclipse.smarthome.core.types.UnDefType; -import org.openhab.binding.network.internal.*; +import org.openhab.binding.network.internal.NetworkBindingConfiguration; +import org.openhab.binding.network.internal.NetworkBindingConfigurationListener; +import org.openhab.binding.network.internal.NetworkBindingConstants; +import org.openhab.binding.network.internal.NetworkHandlerConfiguration; +import org.openhab.binding.network.internal.PresenceDetection; +import org.openhab.binding.network.internal.PresenceDetectionListener; +import org.openhab.binding.network.internal.PresenceDetectionValue; +import org.openhab.binding.network.internal.WakeOnLanPacketSender; +import org.openhab.binding.network.internal.action.NetworkActions; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -45,12 +55,14 @@ * * @author Marc Mettke - Initial contribution * @author David Graeff - Rewritten + * @author Wouter Born - Add Wake-on-LAN thing action support */ @NonNullByDefault public class NetworkHandler extends BaseThingHandler implements PresenceDetectionListener, NetworkBindingConfigurationListener { private final Logger logger = LoggerFactory.getLogger(NetworkHandler.class); private @NonNullByDefault({}) PresenceDetection presenceDetection; + private @NonNullByDefault({}) WakeOnLanPacketSender wakeOnLanPacketSender; private boolean isTCPServiceDevice; private NetworkBindingConfiguration configuration; @@ -72,8 +84,8 @@ public NetworkHandler(Thing thing, boolean isTCPServiceDevice, NetworkBindingCon } private void refreshValue(ChannelUID channelUID) { - // We are not yet even initialised, don't do anything - if (!presenceDetection.isAutomaticRefreshing()) { + // We are not yet even initialized, don't do anything + if (presenceDetection == null || !presenceDetection.isAutomaticRefreshing()) { return; } @@ -187,6 +199,8 @@ void initialize(PresenceDetection presenceDetection) { presenceDetection.setRefreshInterval(handlerConfiguration.refreshInterval.longValue()); presenceDetection.setTimeout(handlerConfiguration.timeout.intValue()); + wakeOnLanPacketSender = new WakeOnLanPacketSender(handlerConfiguration.macAddress); + updateStatus(ThingStatus.ONLINE); presenceDetection.startAutomaticRefresh(scheduler); @@ -222,4 +236,17 @@ public void bindingConfigurationChanged() { // Make sure that changed binding configuration is reflected presenceDetection.setPreferResponseTimeAsLatency(configuration.preferResponseTimeAsLatency); } + + @Override + public Collection> getServices() { + return Collections.singletonList(NetworkActions.class); + } + + public void sendWakeOnLanPacket() { + if (handlerConfiguration.macAddress.isEmpty()) { + throw new IllegalStateException( + "Cannot send WoL packet because the 'macAddress' is not configured for " + thing.getUID()); + } + wakeOnLanPacketSender.sendPacket(); + } } diff --git a/bundles/org.openhab.binding.network/src/main/resources/ESH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.network/src/main/resources/ESH-INF/thing/thing-types.xml index b0a388320aaf5..ba3d4b1d4a2ca 100644 --- a/bundles/org.openhab.binding.network/src/main/resources/ESH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.network/src/main/resources/ESH-INF/thing/thing-types.xml @@ -30,6 +30,11 @@ Hostname or IP of the device + + + MAC address used for waking the device by the Wake-on-LAN action + + States how long to wait after a device state update before the next refresh shall occur (in ms) @@ -85,6 +90,11 @@ 80 + + + MAC address used for waking the device by the Wake-on-LAN action + + Defines how many times a connection attempt shall occur, before the device is stated as offline diff --git a/bundles/org.openhab.binding.network/src/test/java/org/openhab/binding/network/internal/WakeOnLanPacketSenderTest.java b/bundles/org.openhab.binding.network/src/test/java/org/openhab/binding/network/internal/WakeOnLanPacketSenderTest.java new file mode 100644 index 0000000000000..4812d3c448266 --- /dev/null +++ b/bundles/org.openhab.binding.network/src/test/java/org/openhab/binding/network/internal/WakeOnLanPacketSenderTest.java @@ -0,0 +1,98 @@ +/** + * Copyright (c) 2010-2020 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.network.internal; + +import static org.hamcrest.CoreMatchers.is; +import static org.junit.Assert.assertThat; +import static org.openhab.binding.network.internal.WakeOnLanPacketSender.*; + +import java.util.Arrays; + +import org.eclipse.smarthome.core.util.HexUtils; +import org.junit.Test; + +/** + * Tests cases for {@link WakeOnLanPacketSender}. + * + * @author Wouter Born - Initial contribution + */ +public class WakeOnLanPacketSenderTest { + + private void assertValidMagicPacket(byte[] macBytes, byte[] packet) { + byte[] prefix = new byte[PREFIX_BYTE_SIZE]; + Arrays.fill(prefix, (byte) 0xff); + + assertThat(Arrays.copyOfRange(packet, 0, PREFIX_BYTE_SIZE), is(prefix)); + + for (int i = PREFIX_BYTE_SIZE; i < MAGIC_PACKET_BYTE_SIZE; i += MAC_BYTE_SIZE) { + assertThat(Arrays.copyOfRange(packet, i, i + MAC_BYTE_SIZE), is(macBytes)); + } + } + + @Test + public void sendWithColonSeparatedMacAddress() { + byte[] actualPacket = new byte[MAGIC_PACKET_BYTE_SIZE]; + + WakeOnLanPacketSender sender = new WakeOnLanPacketSender("6f:70:65:6e:48:41", + bytes -> System.arraycopy(bytes, 0, actualPacket, 0, bytes.length)); + + sender.sendPacket(); + + assertValidMagicPacket(HexUtils.hexToBytes("6f:70:65:6e:48:41", ":"), actualPacket); + } + + @Test + public void sendWithHyphenSeparatedMacAddress() { + byte[] actualPacket = new byte[MAGIC_PACKET_BYTE_SIZE]; + + WakeOnLanPacketSender sender = new WakeOnLanPacketSender("6F-70-65-6E-48-41", + bytes -> System.arraycopy(bytes, 0, actualPacket, 0, bytes.length)); + + sender.sendPacket(); + + assertValidMagicPacket(HexUtils.hexToBytes("6F-70-65-6E-48-41", "-"), actualPacket); + } + + @Test + public void sendWithNoSeparatedMacAddress() { + byte[] actualPacket = new byte[MAGIC_PACKET_BYTE_SIZE]; + + WakeOnLanPacketSender sender = new WakeOnLanPacketSender("6f70656e4841", + bytes -> System.arraycopy(bytes, 0, actualPacket, 0, bytes.length)); + + sender.sendPacket(); + + assertValidMagicPacket(HexUtils.hexToBytes("6f70656e4841"), actualPacket); + } + + @Test(expected = IllegalStateException.class, timeout = 10000) + public void sendWithEmptyMacAddressThrowsException() { + new WakeOnLanPacketSender("").sendPacket(); + } + + @Test(expected = IllegalStateException.class, timeout = 10000) + public void sendWithTooShortMacAddressThrowsException() { + new WakeOnLanPacketSender("6f:70:65:6e:48").sendPacket(); + } + + @Test(expected = IllegalStateException.class, timeout = 10000) + public void sendWithTooLongMacAddressThrowsException() { + new WakeOnLanPacketSender("6f:70:65:6e:48:41:42").sendPacket(); + } + + @Test(expected = IllegalStateException.class, timeout = 10000) + public void sendWithUnsupportedSeparatorInMacAddressThrowsException() { + new WakeOnLanPacketSender("6f=70=65=6e=48=41").sendPacket(); + } + +}