diff --git a/CODEOWNERS b/CODEOWNERS
index 45b6eaaceed..cf2250b775d 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 b10448e27b6..a6321b9c68c 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 00000000000..38d625e3492
--- /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 00000000000..407b23540c3
--- /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 00000000000..475b0af8133
--- /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 00000000000..94409d2a88c
--- /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 00000000000..e98f9a99579
--- /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 00000000000..1e13710db32
--- /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 00000000000..603f766d7a0
--- /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 00000000000..6e230664c4a
--- /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 00000000000..78c4f9e509c
--- /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 00000000000..30cb21ea9e4
--- /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 00000000000..6c455fb126f
--- /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 00000000000..7409898c151
--- /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 7d6054735d9..7149fd487b1 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