From 2c5f0d82a63289684baf0da15906b24e775a4064 Mon Sep 17 00:00:00 2001 From: GiviMAD Date: Sat, 24 Oct 2020 19:00:25 +0200 Subject: [PATCH] [unifiedremote] Initial contribution (#8546) Signed-off-by: GiviMAD --- CODEOWNERS | 1 + bom/openhab-addons/pom.xml | 5 + .../org.openhab.binding.unifiedremote/NOTICE | 13 + .../README.md | 49 ++++ .../org.openhab.binding.unifiedremote/pom.xml | 17 ++ .../src/main/feature/feature.xml | 23 ++ .../UnifiedRemoteBindingConstants.java | 46 +++ .../internal/UnifiedRemoteConfiguration.java | 27 ++ .../internal/UnifiedRemoteConnection.java | 266 ++++++++++++++++++ .../UnifiedRemoteDiscoveryService.java | 183 ++++++++++++ .../internal/UnifiedRemoteHandler.java | 155 ++++++++++ .../internal/UnifiedRemoteHandlerFactory.java | 57 ++++ .../main/resources/OH-INF/binding/binding.xml | 10 + .../resources/OH-INF/thing/thing-types.xml | 83 ++++++ bundles/pom.xml | 1 + 15 files changed, 936 insertions(+) create mode 100644 bundles/org.openhab.binding.unifiedremote/NOTICE create mode 100644 bundles/org.openhab.binding.unifiedremote/README.md create mode 100644 bundles/org.openhab.binding.unifiedremote/pom.xml create mode 100644 bundles/org.openhab.binding.unifiedremote/src/main/feature/feature.xml create mode 100644 bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteBindingConstants.java create mode 100644 bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteConfiguration.java create mode 100644 bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteConnection.java create mode 100644 bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteDiscoveryService.java create mode 100644 bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteHandler.java create mode 100644 bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteHandlerFactory.java create mode 100644 bundles/org.openhab.binding.unifiedremote/src/main/resources/OH-INF/binding/binding.xml create mode 100644 bundles/org.openhab.binding.unifiedremote/src/main/resources/OH-INF/thing/thing-types.xml diff --git a/CODEOWNERS b/CODEOWNERS index 45b6eaaceeda..cf2250b775d0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -246,6 +246,7 @@ /bundles/org.openhab.binding.tplinksmarthome/ @Hilbrand /bundles/org.openhab.binding.tradfri/ @cweitkamp @kaikreuzer /bundles/org.openhab.binding.unifi/ @mgbowman +/bundles/org.openhab.binding.unifiedremote/ @GiviMAD /bundles/org.openhab.binding.upb/ @marcusb /bundles/org.openhab.binding.upnpcontrol/ @mherwege /bundles/org.openhab.binding.urtsi/ @OLibutzki diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index b10448e27b69..a6321b9c68c9 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -1216,6 +1216,11 @@ org.openhab.binding.unifi ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.unifiedremote + ${project.version} + org.openhab.addons.bundles org.openhab.binding.upb diff --git a/bundles/org.openhab.binding.unifiedremote/NOTICE b/bundles/org.openhab.binding.unifiedremote/NOTICE new file mode 100644 index 000000000000..38d625e34923 --- /dev/null +++ b/bundles/org.openhab.binding.unifiedremote/NOTICE @@ -0,0 +1,13 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons diff --git a/bundles/org.openhab.binding.unifiedremote/README.md b/bundles/org.openhab.binding.unifiedremote/README.md new file mode 100644 index 000000000000..407b23540c36 --- /dev/null +++ b/bundles/org.openhab.binding.unifiedremote/README.md @@ -0,0 +1,49 @@ +# UnifiedRemote Binding + +This binding integrates the [Unified Remote Server](https://www.unifiedremote.com/). + +Known Limitations: It needs the web interface to be enabled on the server settings to work. + +## Discovery + +Discovery works on the default discovery UDP port 9511. + +## Thing Configuration + +Only supported thing is 'Unified Remote Server Thing' which requires the Hostname to be correctly configured in order to work. + +| ThinTypeID | description | +|----------|------------------------------| +| server | Unified Remote Server Thing | + + +| Config | Type | description | +|----------|----------|------------------------------| +| host | String | Unified Remote Server IP | + + + +## Channels + + +| channel | type | description | +|----------|--------|------------------------------| +| mouse-move | String | Relative mouse move in pixels. Expect number JSON array [x,y] ("[10,10]"). | +| send-key | String | Use server key. Supported keys are: LEFT_CLICK, RIGHT_CLICK, LOCK, UNLOCK, SLEEP, SHUTDOWN, RESTART, LOGOFF, PLAY, PLAY, PAUSE, NEXT, PREVIOUS, STOP, VOLUME_MUTE, VOLUME_UP, VOLUME_DOWN, BRIGHTNESS_UP, BRIGHTNESS_DOWN, MONITOR_OFF, MONITOR_ON, ESCAPE, SPACE, BACK, LWIN, CONTROL, TAB, MENU, RETURN, UP, DOWN, LEFT, RIGHT | + + +## Full Example + +### Sample Thing + +``` +Thing unifiedremote:server:xx-xx-xx-xx-xx-xx [ host="192.168.1.10" ] +``` + +### Sample Items + +``` +Group pcRemote "Living room PC" +String PC_SendKey "Send Key" (pcRemote) { channel="unifiedremote:server:xx-xx-xx-xx-xx-xx:send-key" } +String PC_MouseMove "Mouse Move" (pcRemote) { channel="samsungtv:tv:livingroom:mouse-move" } +``` diff --git a/bundles/org.openhab.binding.unifiedremote/pom.xml b/bundles/org.openhab.binding.unifiedremote/pom.xml new file mode 100644 index 000000000000..475b0af81336 --- /dev/null +++ b/bundles/org.openhab.binding.unifiedremote/pom.xml @@ -0,0 +1,17 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 3.0.0-SNAPSHOT + + + org.openhab.binding.unifiedremote + + openHAB Add-ons :: Bundles :: UnifiedRemote Binding + + diff --git a/bundles/org.openhab.binding.unifiedremote/src/main/feature/feature.xml b/bundles/org.openhab.binding.unifiedremote/src/main/feature/feature.xml new file mode 100644 index 000000000000..94409d2a88c9 --- /dev/null +++ b/bundles/org.openhab.binding.unifiedremote/src/main/feature/feature.xml @@ -0,0 +1,23 @@ + + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.binding.unifiedremote/${project.version} + + diff --git a/bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteBindingConstants.java b/bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteBindingConstants.java new file mode 100644 index 000000000000..e98f9a99579b --- /dev/null +++ b/bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteBindingConstants.java @@ -0,0 +1,46 @@ +/** + * 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.unifiedremote.internal; + +import java.util.Collections; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.ThingTypeUID; + +/** + * The {@link UnifiedRemoteBindingConstants} class defines common constants, which are + * used across the whole binding. + * + * @author Miguel Álvarez - Initial contribution + */ +@NonNullByDefault +public class UnifiedRemoteBindingConstants { + + private static final String BINDING_ID = "unifiedremote"; + + // List of all Thing Type UIDs + public static final ThingTypeUID THING_TYPE_UNIFIED_REMOTE_SERVER = new ThingTypeUID(BINDING_ID, "server"); + public static final Set SUPPORTED_THING_TYPES = Collections + .singleton(THING_TYPE_UNIFIED_REMOTE_SERVER); + + // List of all Channel ids + public static final String MOUSE_CHANNEL = "mouse-move"; + public static final String SEND_KEY_CHANNEL = "send-key"; + + // List of all Parameters + public static final String PARAMETER_MAC_ADDRESS = "macAddress"; + public static final String PARAMETER_HOSTNAME = "host"; + public static final String PARAMETER_TCP_PORT = "udpPort"; + public static final String PARAMETER_UDP_PORT = "tcpPort"; +} diff --git a/bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteConfiguration.java b/bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteConfiguration.java new file mode 100644 index 000000000000..1e13710db320 --- /dev/null +++ b/bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteConfiguration.java @@ -0,0 +1,27 @@ +/** + * 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.unifiedremote.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link UnifiedRemoteConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Miguel Álvarez - Initial contribution + */ +@NonNullByDefault +public class UnifiedRemoteConfiguration { + public String host = ""; + public int tcpPort; + public int udpPort; +} diff --git a/bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteConnection.java b/bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteConnection.java new file mode 100644 index 000000000000..603f766d7a01 --- /dev/null +++ b/bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteConnection.java @@ -0,0 +1,266 @@ +/** + * 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.unifiedremote.internal; + +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.client.util.StringContentProvider; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpMethod; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +/** + * The {@link UnifiedRemoteConnection} Handles Remote Server Communications + * + * @author Miguel Alvarez - Initial contribution + */ +@NonNullByDefault +public class UnifiedRemoteConnection { + + private static final int WEB_CLIENT_PORT = 9510; + private static final int TIMEOUT_SEC = 10; + private static final String CONNECTION_ID_HEADER = "UR-Connection-ID"; + private static final String MOUSE_REMOTE = "Relmtech.Basic Input"; + private static final String NAVIGATION_REMOTE = "Unified.Navigation"; + private static final String POWER_REMOTE = "Unified.Power"; + private static final String MEDIA_REMOTE = "Unified.Media"; + private static final String MONITOR_REMOTE = "Unified.Monitor"; + + private Logger logger = LoggerFactory.getLogger(UnifiedRemoteConnection.class); + private final String url; + private final JsonParser jsonParser = new JsonParser(); + private HttpClient httpClient; + private @Nullable String connectionID; + private @Nullable String connectionGUID; + + public UnifiedRemoteConnection(HttpClient httpClient, String host) { + this.httpClient = httpClient; + url = "http://" + host + ":" + WEB_CLIENT_PORT + "/client/"; + } + + public void authenticate() throws InterruptedException, ExecutionException, TimeoutException { + ContentResponse response = null; + connectionGUID = "web-" + UUID.randomUUID().toString(); + response = httpClient.newRequest(getPath("connect")).method(HttpMethod.GET) + .timeout(TIMEOUT_SEC, TimeUnit.SECONDS).send(); + JsonObject responseBody = jsonParser.parse(response.getContentAsString()).getAsJsonObject(); + connectionID = responseBody.get("id").getAsString(); + + String password = UUID.randomUUID().toString(); + JsonObject authPayload = new JsonObject(); + authPayload.addProperty("Action", 0); + authPayload.addProperty("Request", 0); + authPayload.addProperty("Version", 10); + authPayload.addProperty("Password", password); + authPayload.addProperty("Platform", "web"); + authPayload.addProperty("Source", connectionGUID); + request(authPayload); + + JsonObject capabilitiesPayload = new JsonObject(); + JsonObject capabilitiesInnerPayload = new JsonObject(); + capabilitiesInnerPayload.addProperty("Actions", true); + capabilitiesInnerPayload.addProperty("Sync", true); + capabilitiesInnerPayload.addProperty("Grid", true); + capabilitiesInnerPayload.addProperty("Fast", false); + capabilitiesInnerPayload.addProperty("Loading", true); + capabilitiesInnerPayload.addProperty("Encryption2", true); + capabilitiesPayload.add("Capabilities", capabilitiesInnerPayload); + capabilitiesPayload.addProperty("Action", 1); + capabilitiesPayload.addProperty("Request", 1); + capabilitiesPayload.addProperty("Source", connectionGUID); + request(capabilitiesPayload); + } + + public ContentResponse mouseMove(String jsonIntArray) + throws InterruptedException, ExecutionException, TimeoutException { + JsonArray cordinates = jsonParser.parse(jsonIntArray).getAsJsonArray(); + int x = cordinates.get(0).getAsInt(); + int y = cordinates.get(1).getAsInt(); + return this.execRemoteAction("Relmtech.Basic Input", "delta", + wrapValues(new String[] { "0", Integer.toString(x), Integer.toString(y) })); + } + + public ContentResponse sendKey(String key) throws InterruptedException, ExecutionException, TimeoutException { + String remoteID = ""; + String actionName = ""; + String value = null; + switch (key) { + case "LEFT_CLICK": + remoteID = MOUSE_REMOTE; + actionName = "left"; + break; + case "RIGHT_CLICK": + remoteID = MOUSE_REMOTE; + actionName = "right"; + break; + case "LOCK": + remoteID = POWER_REMOTE; + actionName = "lock"; + break; + case "UNLOCK": + remoteID = POWER_REMOTE; + actionName = "unlock"; + break; + case "SLEEP": + remoteID = POWER_REMOTE; + actionName = "sleep"; + break; + case "SHUTDOWN": + remoteID = POWER_REMOTE; + actionName = "shutdown"; + break; + case "RESTART": + remoteID = POWER_REMOTE; + actionName = "restart"; + break; + case "LOGOFF": + remoteID = POWER_REMOTE; + actionName = "logoff"; + break; + case "PLAY/PAUSE": + case "PLAY": + case "PAUSE": + remoteID = MEDIA_REMOTE; + actionName = "play_pause"; + break; + case "NEXT": + remoteID = MEDIA_REMOTE; + actionName = "next"; + break; + case "PREVIOUS": + remoteID = MEDIA_REMOTE; + actionName = "previous"; + break; + case "STOP": + remoteID = MEDIA_REMOTE; + actionName = "stop"; + break; + case "VOLUME_MUTE": + remoteID = MEDIA_REMOTE; + actionName = "volume_mute"; + break; + case "VOLUME_UP": + remoteID = MEDIA_REMOTE; + actionName = "volume_up"; + break; + case "VOLUME_DOWN": + remoteID = MEDIA_REMOTE; + actionName = "volume_down"; + break; + case "BRIGHTNESS_UP": + remoteID = MONITOR_REMOTE; + actionName = "brightness_up"; + break; + case "BRIGHTNESS_DOWN": + remoteID = MONITOR_REMOTE; + actionName = "brightness_down"; + break; + case "MONITOR_OFF": + remoteID = MONITOR_REMOTE; + actionName = "turn_off"; + break; + case "MONITOR_ON": + remoteID = MONITOR_REMOTE; + actionName = "turn_on"; + break; + case "ESCAPE": + case "SPACE": + case "BACK": + case "LWIN": + case "CONTROL": + case "TAB": + case "MENU": + case "RETURN": + case "UP": + case "DOWN": + case "LEFT": + case "RIGHT": + remoteID = NAVIGATION_REMOTE; + actionName = "toggle"; + value = key; + break; + } + JsonArray wrappedValues = null; + if (value != null) { + wrappedValues = wrapValues(new String[] { value }); + } + return this.execRemoteAction(remoteID, actionName, wrappedValues); + } + + public ContentResponse keepAlive() throws InterruptedException, ExecutionException, TimeoutException { + JsonObject payload = new JsonObject(); + payload.addProperty("KeepAlive", true); + payload.addProperty("Source", connectionGUID); + return request(payload); + } + + private ContentResponse execRemoteAction(String remoteID, String name, @Nullable JsonElement values) + throws InterruptedException, ExecutionException, TimeoutException { + JsonObject payload = new JsonObject(); + + JsonObject runInnerPayload = new JsonObject(); + JsonObject extrasInnerPayload = new JsonObject(); + if (values != null) { + extrasInnerPayload.add("Values", values); + runInnerPayload.add("Extras", extrasInnerPayload); + } + runInnerPayload.addProperty("Name", name); + payload.addProperty("ID", remoteID); + payload.addProperty("Action", 7); + payload.addProperty("Request", 7); + payload.add("Run", runInnerPayload); + payload.addProperty("Source", connectionGUID); + return request(payload); + } + + private ContentResponse request(JsonObject content) + throws InterruptedException, ExecutionException, TimeoutException { + Request request = httpClient.newRequest(getPath("request")).method(HttpMethod.POST).timeout(TIMEOUT_SEC, + TimeUnit.SECONDS); + request.header(HttpHeader.CONTENT_TYPE, "application/json"); + if (connectionID != null) + request.header(CONNECTION_ID_HEADER, connectionID); + String stringContent = content.toString(); + logger.debug("[Request Payload {} ]", stringContent); + request.content(new StringContentProvider(stringContent, "utf-8")); + return request.send(); + } + + private JsonArray wrapValues(String[] commandValues) { + JsonArray values = new JsonArray(); + for (String value : commandValues) { + JsonObject valueWrapper = new JsonObject(); + valueWrapper.addProperty("Value", value); + values.add(valueWrapper); + } + return values; + } + + private String getPath(String path) { + return url + path; + } +} diff --git a/bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteDiscoveryService.java b/bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteDiscoveryService.java new file mode 100644 index 000000000000..6e230664c4a6 --- /dev/null +++ b/bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteDiscoveryService.java @@ -0,0 +1,183 @@ +/** + * 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.unifiedremote.internal; + +import static org.openhab.binding.unifiedremote.internal.UnifiedRemoteBindingConstants.*; + +import java.io.IOException; +import java.net.*; +import java.text.ParseException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.config.discovery.AbstractDiscoveryService; +import org.openhab.core.config.discovery.DiscoveryResultBuilder; +import org.openhab.core.config.discovery.DiscoveryService; +import org.openhab.core.thing.ThingUID; +import org.osgi.service.component.annotations.Component; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link UnifiedRemoteDiscoveryService} discover Unified Remote Server Instances in the network. + * + * @author Miguel Alvarez - Initial contribution + */ +@Component(service = DiscoveryService.class, configurationPid = "discovery.unifiedremote") +@NonNullByDefault +public class UnifiedRemoteDiscoveryService extends AbstractDiscoveryService { + + private Logger logger = LoggerFactory.getLogger(UnifiedRemoteDiscoveryService.class); + static final int TIMEOUT_MS = 20000; + private static final long DISCOVERY_RESULT_TTL_SEC = TimeUnit.MINUTES.toSeconds(5); + + /** + * Port used for broadcast and listening. + */ + public static final int DISCOVERY_PORT = 9511; + /** + * String the client sends, to disambiguate packets on this port. + */ + public static final String DISCOVERY_REQUEST = "6N T|-Ar-A6N T|-Ar-A6N T|-Ar-A"; + /** + * String the client sends, to disambiguate packets on this port. + */ + public static final String DISCOVERY_RESPONSE_PREFIX = ")-b@ h): :)i)-b@ h): :)i)-b@ h): :)"; + /** + * String used to replace non printable characters on service response + */ + public static final String NON_PRINTABLE_CHARTS_REPLACEMENT = ": :"; + + private static final int MAX_PACKET_SIZE = 2048; + /** + * maximum time to wait for a reply, in milliseconds. + */ + private static final int SOCKET_TIMEOUT_MS = 3000; + + public UnifiedRemoteDiscoveryService() { + super(SUPPORTED_THING_TYPES, TIMEOUT_MS, false); + } + + @Override + protected void startScan() { + sendBroadcast(this::addNewServer); + } + + private void addNewServer(ServerInfo serverInfo) { + Map properties = new HashMap<>(); + properties.put(PARAMETER_MAC_ADDRESS, serverInfo.macAddress); + properties.put(PARAMETER_HOSTNAME, serverInfo.host); + properties.put(PARAMETER_TCP_PORT, serverInfo.tcpPort); + properties.put(PARAMETER_UDP_PORT, serverInfo.udpPort); + thingDiscovered( + DiscoveryResultBuilder.create(new ThingUID(THING_TYPE_UNIFIED_REMOTE_SERVER, serverInfo.macAddress)) + .withTTL(DISCOVERY_RESULT_TTL_SEC).withRepresentationProperty(PARAMETER_MAC_ADDRESS) + .withProperties(properties).withLabel(serverInfo.name).build()); + } + + /** + * Create a UDP socket on the service discovery broadcast port. + * + * @return open DatagramSocket if successful + * @throws RuntimeException if cannot create the socket + */ + public DatagramSocket createSocket() throws SocketException { + DatagramSocket socket; + socket = new DatagramSocket(); + socket.setBroadcast(true); + socket.setSoTimeout(TIMEOUT_MS); + return socket; + } + + private ServerInfo tryParseServerDiscovery(DatagramPacket receivePacket) throws ParseException { + String host = receivePacket.getAddress().getHostAddress(); + String reply = new String(receivePacket.getData()).replaceAll("[\\p{C}]", NON_PRINTABLE_CHARTS_REPLACEMENT) + .replaceAll("[^\\x00-\\x7F]", NON_PRINTABLE_CHARTS_REPLACEMENT); + if (!reply.startsWith(DISCOVERY_RESPONSE_PREFIX)) + throw new ParseException("Bad discovery response prefix", 0); + String[] parts = Arrays + .stream(reply.replace(DISCOVERY_RESPONSE_PREFIX, "").split(NON_PRINTABLE_CHARTS_REPLACEMENT)) + .filter((String e) -> e.length() != 0).toArray(String[]::new); + String name = parts[0]; + int tcpPort = Integer.parseInt(parts[1]); + int udpPort = Integer.parseInt(parts[3]); + String macAddress = parts[2]; + return new ServerInfo(host, tcpPort, udpPort, name, macAddress); + } + + /** + * Send broadcast packets with service request string until a response + * is received. Return the response as String (even though it should + * contain an internet address). + * + * @return String received from server. Should be server IP address. + * Returns empty string if failed to get valid reply. + */ + public void sendBroadcast(Consumer listener) { + byte[] receiveBuffer = new byte[MAX_PACKET_SIZE]; + DatagramPacket receivePacket = new DatagramPacket(receiveBuffer, receiveBuffer.length); + + DatagramSocket socket = null; + try { + socket = createSocket(); + } catch (SocketException e) { + logger.debug("Error creating discovery socket: {}", e.getMessage()); + return; + } + byte[] packetData = DISCOVERY_REQUEST.getBytes(); + try { + InetAddress broadcastAddress = InetAddress.getByName("255.255.255.255"); + int servicePort = DISCOVERY_PORT; + DatagramPacket packet = new DatagramPacket(packetData, packetData.length, broadcastAddress, servicePort); + socket.send(packet); + logger.debug("Sent packet to {}:{}", broadcastAddress.getHostAddress(), servicePort); + for (int i = 0; i < 20; i++) { + socket.receive(receivePacket); + String host = receivePacket.getAddress().getHostAddress(); + logger.debug("Received reply from {}", host); + try { + ServerInfo serverInfo = tryParseServerDiscovery(receivePacket); + listener.accept(serverInfo); + } catch (ParseException ex) { + logger.debug("Unable to parse server discovery response from {}: {}", host, ex.getMessage()); + } + } + } catch (SocketTimeoutException ste) { + logger.debug("SocketTimeoutException during socket operation: {}", ste.getMessage()); + } catch (IOException ioe) { + logger.debug("IOException during socket operation: {}", ioe.getMessage()); + } finally { + socket.close(); + } + } + + public class ServerInfo { + String name; + int tcpPort; + int udpPort; + String host; + String macAddress; + + ServerInfo(String host, int tcpPort, int udpPort, String name, String macAddress) { + this.name = name; + this.tcpPort = tcpPort; + this.udpPort = udpPort; + this.host = host; + this.macAddress = macAddress; + } + } +} diff --git a/bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteHandler.java b/bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteHandler.java new file mode 100644 index 000000000000..78c4f9e509c8 --- /dev/null +++ b/bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteHandler.java @@ -0,0 +1,155 @@ +/** + * 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.unifiedremote.internal; + +import static org.openhab.binding.unifiedremote.internal.UnifiedRemoteBindingConstants.MOUSE_CHANNEL; +import static org.openhab.binding.unifiedremote.internal.UnifiedRemoteBindingConstants.SEND_KEY_CHANNEL; + +import java.net.ConnectException; +import java.net.NoRouteToHostException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.ContentResponse; +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; + +/** + * The {@link UnifiedRemoteHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Miguel Alvarez - Initial contribution + */ +@NonNullByDefault +public class UnifiedRemoteHandler extends BaseThingHandler { + + private @Nullable UnifiedRemoteConnection connection; + private @Nullable ScheduledFuture connectionCheckerSchedule; + private HttpClient httpClient; + + public UnifiedRemoteHandler(Thing thing, HttpClient httpClient) { + super(thing); + this.httpClient = httpClient; + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + String channelId = channelUID.getId(); + if (!isLinked(channelId)) + return; + String stringCommand = command.toFullString(); + UnifiedRemoteConnection urConnection = connection; + try { + if (urConnection != null) { + ContentResponse response; + switch (channelId) { + case MOUSE_CHANNEL: + response = urConnection.mouseMove(stringCommand); + break; + case SEND_KEY_CHANNEL: + response = urConnection.sendKey(stringCommand); + break; + default: + return; + } + if (isErrorResponse(response)) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Session expired"); + urConnection.authenticate(); + updateStatus(ThingStatus.ONLINE); + } + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Connection not initialized"); + } + } catch (InterruptedException | ExecutionException | TimeoutException e) { + if (isThingOfflineException(e)) { + // we assume thing is offline + updateStatus(ThingStatus.OFFLINE); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "Unexpected exception: " + e.getMessage()); + } + } + } + + @Override + public void initialize() { + updateStatus(ThingStatus.UNKNOWN); + connection = getNewConnection(); + initConnectionChecker(); + } + + private UnifiedRemoteConnection getNewConnection() { + UnifiedRemoteConfiguration currentConfiguration = getConfigAs(UnifiedRemoteConfiguration.class); + return new UnifiedRemoteConnection(this.httpClient, currentConfiguration.host); + } + + private void initConnectionChecker() { + stopConnectionChecker(); + connectionCheckerSchedule = scheduler.scheduleWithFixedDelay(() -> { + try { + UnifiedRemoteConnection urConnection = connection; + if (urConnection == null) + return; + ThingStatus status = thing.getStatus(); + if ((status == ThingStatus.OFFLINE || status == ThingStatus.UNKNOWN) && connection != null) { + urConnection.authenticate(); + updateStatus(ThingStatus.ONLINE); + } else if (status == ThingStatus.ONLINE) { + if (isErrorResponse(urConnection.keepAlive())) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Keep alive failed"); + } + } + } catch (InterruptedException | ExecutionException | TimeoutException e) { + if (isThingOfflineException(e)) { + // we assume thing is offline + updateStatus(ThingStatus.OFFLINE); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "Unexpected exception: " + e.getMessage()); + } + } + }, 0, 40, TimeUnit.SECONDS); + } + + private boolean isThingOfflineException(Exception e) { + return e instanceof TimeoutException || e.getCause() instanceof ConnectException + || e.getCause() instanceof NoRouteToHostException; + } + + private void stopConnectionChecker() { + var schedule = connectionCheckerSchedule; + if (schedule != null) { + schedule.cancel(true); + connectionCheckerSchedule = null; + } + } + + @Override + public void dispose() { + stopConnectionChecker(); + super.dispose(); + } + + private boolean isErrorResponse(ContentResponse response) { + return response.getStatus() != 200 || response.getContentAsString().contains("Not a valid connection"); + } +} diff --git a/bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteHandlerFactory.java b/bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteHandlerFactory.java new file mode 100644 index 000000000000..30cb21ea9e45 --- /dev/null +++ b/bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteHandlerFactory.java @@ -0,0 +1,57 @@ +/** + * 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.unifiedremote.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.core.io.net.http.HttpClientFactory; +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.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * The {@link UnifiedRemoteHandlerFactory} is responsible for creating things and thing + * handlers. + * + * @author Miguel Álvarez - Initial contribution + */ +@NonNullByDefault +@Component(configurationPid = "binding.unifiedremote", service = ThingHandlerFactory.class) +public class UnifiedRemoteHandlerFactory extends BaseThingHandlerFactory { + private final HttpClient httpClient; + + @Activate + public UnifiedRemoteHandlerFactory(@Reference HttpClientFactory httpClientFactory) { + this.httpClient = httpClientFactory.getCommonHttpClient(); + } + + @Override + public boolean supportsThingType(ThingTypeUID thingTypeUID) { + return UnifiedRemoteBindingConstants.SUPPORTED_THING_TYPES.contains(thingTypeUID); + } + + @Override + protected @Nullable ThingHandler createHandler(Thing thing) { + ThingTypeUID thingTypeUID = thing.getThingTypeUID(); + if (supportsThingType(thingTypeUID)) { + return new UnifiedRemoteHandler(thing, httpClient); + } + return null; + } +} diff --git a/bundles/org.openhab.binding.unifiedremote/src/main/resources/OH-INF/binding/binding.xml b/bundles/org.openhab.binding.unifiedremote/src/main/resources/OH-INF/binding/binding.xml new file mode 100644 index 000000000000..6c455fb126f3 --- /dev/null +++ b/bundles/org.openhab.binding.unifiedremote/src/main/resources/OH-INF/binding/binding.xml @@ -0,0 +1,10 @@ + + + + Unified Remote Binding + This is the binding for Unified Remote Server (https://www.unifiedremote.com/). + Miguel Álvarez + + diff --git a/bundles/org.openhab.binding.unifiedremote/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.unifiedremote/src/main/resources/OH-INF/thing/thing-types.xml new file mode 100644 index 000000000000..7409898c1512 --- /dev/null +++ b/bundles/org.openhab.binding.unifiedremote/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,83 @@ + + + + + + Unified Remote Server Thing for Unified Remote Binding + + + + + macAddress + + + + network-address + Unified Remote Server Hostname + + + + Unified Remote Server Port TCP + + + + Unified Remote Server Port UDP + + + + + + String + + Relative mouse control on the server host + + + + String + + Toggle Key + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bundles/pom.xml b/bundles/pom.xml index 7d6054735d90..7149fd487b10 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -278,6 +278,7 @@ org.openhab.binding.tplinksmarthome org.openhab.binding.tradfri org.openhab.binding.unifi + org.openhab.binding.unifiedremote org.openhab.binding.upnpcontrol org.openhab.binding.upb org.openhab.binding.urtsi