]> git.basschouten.com Git - openhab-addons.git/commitdiff
[unifiedremote] Initial contribution (#8546)
authorGiviMAD <GiviMAD@users.noreply.github.com>
Sat, 24 Oct 2020 17:00:25 +0000 (19:00 +0200)
committerGitHub <noreply@github.com>
Sat, 24 Oct 2020 17:00:25 +0000 (10:00 -0700)
Signed-off-by: GiviMAD <miguelwork92@gmail.com>
15 files changed:
CODEOWNERS
bom/openhab-addons/pom.xml
bundles/org.openhab.binding.unifiedremote/NOTICE [new file with mode: 0644]
bundles/org.openhab.binding.unifiedremote/README.md [new file with mode: 0644]
bundles/org.openhab.binding.unifiedremote/pom.xml [new file with mode: 0644]
bundles/org.openhab.binding.unifiedremote/src/main/feature/feature.xml [new file with mode: 0644]
bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteBindingConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteConnection.java [new file with mode: 0644]
bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteDiscoveryService.java [new file with mode: 0644]
bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.unifiedremote/src/main/java/org/openhab/binding/unifiedremote/internal/UnifiedRemoteHandlerFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.unifiedremote/src/main/resources/OH-INF/binding/binding.xml [new file with mode: 0644]
bundles/org.openhab.binding.unifiedremote/src/main/resources/OH-INF/thing/thing-types.xml [new file with mode: 0644]
bundles/pom.xml

index 45b6eaaceeda9841e825715eb631c0d150d49d46..cf2250b775d0b64c9ecb2769d830d2b2683bfdea 100644 (file)
 /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
index b10448e27b69f83310d5d0b806e5c09493926a83..a6321b9c68c972aa82344c8997c05b5a9f77b567 100644 (file)
       <artifactId>org.openhab.binding.unifi</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>org.openhab.addons.bundles</groupId>
+      <artifactId>org.openhab.binding.unifiedremote</artifactId>
+      <version>${project.version}</version>
+    </dependency>
     <dependency>
       <groupId>org.openhab.addons.bundles</groupId>
       <artifactId>org.openhab.binding.upb</artifactId>
diff --git a/bundles/org.openhab.binding.unifiedremote/NOTICE b/bundles/org.openhab.binding.unifiedremote/NOTICE
new file mode 100644 (file)
index 0000000..38d625e
--- /dev/null
@@ -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 (file)
index 0000000..407b235
--- /dev/null
@@ -0,0 +1,49 @@
+# UnifiedRemote Binding
+
+This binding integrates the [Unified Remote Server](https://www.unifiedremote.com/).
+
+<b>Known Limitations: It needs the web interface to be enabled on the server settings to work.</b>
+
+## 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 (file)
index 0000000..475b0af
--- /dev/null
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>org.openhab.addons.bundles</groupId>
+    <artifactId>org.openhab.addons.reactor.bundles</artifactId>
+    <version>3.0.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>org.openhab.binding.unifiedremote</artifactId>
+
+  <name>openHAB Add-ons :: Bundles :: UnifiedRemote Binding</name>
+
+</project>
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 (file)
index 0000000..94409d2
--- /dev/null
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+       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
+
+-->
+<features name="org.openhab.binding.unifiedremote-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
+       <repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
+
+       <feature name="openhab-binding-unifiedremote" description="UnifiedRemote Binding" version="${project.version}">
+               <feature>openhab-runtime-base</feature>
+               <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.unifiedremote/${project.version}</bundle>
+       </feature>
+</features>
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 (file)
index 0000000..e98f9a9
--- /dev/null
@@ -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<ThingTypeUID> 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 (file)
index 0000000..1e13710
--- /dev/null
@@ -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 (file)
index 0000000..603f766
--- /dev/null
@@ -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 (file)
index 0000000..6e23066
--- /dev/null
@@ -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<String, Object> 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<ServerInfo> 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 (file)
index 0000000..78c4f9e
--- /dev/null
@@ -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 (file)
index 0000000..30cb21e
--- /dev/null
@@ -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 (file)
index 0000000..6c455fb
--- /dev/null
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<binding:binding id="unifiedremote" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:binding="https://openhab.org/schemas/binding/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/binding/v1.0.0 https://openhab.org/schemas/binding-1.0.0.xsd">
+
+       <name>Unified Remote Binding</name>
+       <description>This is the binding for Unified Remote Server (https://www.unifiedremote.com/).</description>
+       <author>Miguel Álvarez</author>
+
+</binding:binding>
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 (file)
index 0000000..7409898
--- /dev/null
@@ -0,0 +1,83 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="unifiedremote"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <thing-type id="server">
+               <label>Unified Remote Server</label>
+               <description>Unified Remote Server Thing for Unified Remote Binding</description>
+               <channels>
+                       <channel id="mouse-move" typeId="mouse-move-channel"/>
+                       <channel id="send-key" typeId="send-key-channel"/>
+               </channels>
+               <representation-property>macAddress</representation-property>
+               <config-description>
+                       <parameter name="host" type="text" required="true">
+                               <label>Hostname</label>
+                               <context>network-address</context>
+                               <description>Unified Remote Server Hostname</description>
+                       </parameter>
+                       <parameter name="tcpPort" type="integer">
+                               <label>TCP Port</label>
+                               <description>Unified Remote Server Port TCP</description>
+                       </parameter>
+                       <parameter name="udpPort" type="integer">
+                               <label>UDP Port</label>
+                               <description>Unified Remote Server Port UDP</description>
+                       </parameter>
+               </config-description>
+       </thing-type>
+
+       <channel-type id="mouse-move-channel">
+               <item-type>String</item-type>
+               <label>Mouse Move Channel</label>
+               <description>Relative mouse control on the server host</description>
+       </channel-type>
+
+       <channel-type id="send-key-channel">
+               <item-type>String</item-type>
+               <label>Toggle Key Channel</label>
+               <description>Toggle Key</description>
+               <state>
+                       <options>
+                               <!-- MOUSE -->
+                               <option value="LEFT_CLICK">LEFT_CLICK</option>
+                               <option value="RIGHT_CLICK">RIGHT_CLICK</option>
+                               <!-- SYSTEM -->
+                               <option value="LOCK">LOCK</option>
+                               <option value="UNLOCK">UNLOCK</option>
+                               <option value="SLEEP">SLEEP</option>
+                               <option value="SHUTDOWN">SHUTDOWN</option>
+                               <option value="RESTART">RESTART</option>
+                               <option value="LOGOFF">LOGOFF</option>
+                               <!-- Media -->
+                               <option value="PLAY/PAUSE">PLAY/PAUSE</option>
+                               <option value="NEXT">NEXT</option>
+                               <option value="PREVIOUS">PREVIOUS</option>
+                               <option value="STOP">STOP</option>
+                               <option value="VOLUME_MUTE">VOLUME_MUTE</option>
+                               <option value="VOLUME_UP">VOLUME_UP</option>
+                               <option value="VOLUME_DOWN">VOLUME_DOWN</option>
+                               <option value="BRIGHTNESS_UP">BRIGHTNESS_UP</option>
+                               <option value="BRIGHTNESS_DOWN">BRIGHTNESS_DOWN</option>
+                               <option value="MONITOR_OFF">MONITOR_OFF</option>
+                               <option value="MONITOR_ON">MONITOR_ON</option>
+                               <!-- Navigation -->
+                               <option value="ESCAPE">ESCAPE</option>
+                               <option value="SPACE">SPACE</option>
+                               <option value="BACK">BACK</option>
+                               <option value="LWIN">LWIN</option>
+                               <option value="CONTROL">CONTROL</option>
+                               <option value="TAB">TAB</option>
+                               <option value="MENU">MENU</option>
+                               <option value="RETURN">RETURN</option>
+                               <option value="UP">UP</option>
+                               <option value="DOWN">DOWN</option>
+                               <option value="LEFT">LEFT</option>
+                               <option value="RIGHT">RIGHT</option>
+                       </options>
+               </state>
+       </channel-type>
+
+</thing:thing-descriptions>
index 7d6054735d904e66f04bfe1ebfc309a4ff4dfa1f..7149fd487b104ed7c389c711fe5ad4340ef36b64 100644 (file)
     <module>org.openhab.binding.tplinksmarthome</module>
     <module>org.openhab.binding.tradfri</module>
     <module>org.openhab.binding.unifi</module>
+    <module>org.openhab.binding.unifiedremote</module>
     <module>org.openhab.binding.upnpcontrol</module>
     <module>org.openhab.binding.upb</module>
     <module>org.openhab.binding.urtsi</module>