]> git.basschouten.com Git - openhab-addons.git/commitdiff
[remoteopenhab] Remote openHAB binding - initial contributionn (#8791)
authorlolodomo <lg.hc@free.fr>
Mon, 26 Oct 2020 21:39:19 +0000 (22:39 +0100)
committerGitHub <noreply@github.com>
Mon, 26 Oct 2020 21:39:19 +0000 (22:39 +0100)
Fix #8407

Signed-off-by: Laurent Garnier <lg.hc@free.fr>
28 files changed:
CODEOWNERS
bom/openhab-addons/pom.xml
bundles/org.openhab.binding.remoteopenhab/NOTICE [new file with mode: 0644]
bundles/org.openhab.binding.remoteopenhab/README.md [new file with mode: 0644]
bundles/org.openhab.binding.remoteopenhab/pom.xml [new file with mode: 0644]
bundles/org.openhab.binding.remoteopenhab/src/main/feature/feature.xml [new file with mode: 0644]
bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/RemoteopenhabBindingConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/RemoteopenhabChannelTypeProvider.java [new file with mode: 0644]
bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/RemoteopenhabHandlerFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/RemoteopenhabStateDescriptionOptionProvider.java [new file with mode: 0644]
bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/config/RemoteopenhabInstanceConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/data/Event.java [new file with mode: 0644]
bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/data/EventPayload.java [new file with mode: 0644]
bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/data/Item.java [new file with mode: 0644]
bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/data/Option.java [new file with mode: 0644]
bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/data/RestApi.java [new file with mode: 0644]
bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/data/RestApiEndpoint.java [new file with mode: 0644]
bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/data/RuntimeInfo.java [new file with mode: 0644]
bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/data/StateDescription.java [new file with mode: 0644]
bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/discovery/RemoteopenhabDiscoveryParticipant.java [new file with mode: 0644]
bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/exceptions/RemoteopenhabException.java [new file with mode: 0644]
bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/handler/RemoteopenhabBridgeHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/listener/RemoteopenhabStreamingDataListener.java [new file with mode: 0644]
bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/rest/RemoteopenhabRestClient.java [new file with mode: 0644]
bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/rest/RemoteopenhabStreamingRequestFilter.java [new file with mode: 0644]
bundles/org.openhab.binding.remoteopenhab/src/main/resources/OH-INF/binding/binding.xml [new file with mode: 0644]
bundles/org.openhab.binding.remoteopenhab/src/main/resources/OH-INF/thing/thing-types.xml [new file with mode: 0644]
bundles/pom.xml

index cf2250b775d0b64c9ecb2769d830d2b2683bfdea..ab7d0b6686eb9bb401e4be1cefd23f0ee4cbe686 100644 (file)
 /bundles/org.openhab.binding.pushbullet/ @hakan42
 /bundles/org.openhab.binding.radiothermostat/ @mlobstein
 /bundles/org.openhab.binding.regoheatpump/ @crnjan
+/bundles/org.openhab.binding.remoteopenhab/ @lolodomo
 /bundles/org.openhab.binding.rfxcom/ @martinvw @paulianttila
 /bundles/org.openhab.binding.rme/ @kgoderis
 /bundles/org.openhab.binding.robonect/ @reyem
index a6321b9c68c972aa82344c8997c05b5a9f77b567..169663f1803cecfbfc3709be998c3a2c5357d300 100644 (file)
       <artifactId>org.openhab.binding.regoheatpump</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>org.openhab.addons.bundles</groupId>
+      <artifactId>org.openhab.binding.remoteopenhab</artifactId>
+      <version>${project.version}</version>
+    </dependency>
     <dependency>
       <groupId>org.openhab.addons.bundles</groupId>
       <artifactId>org.openhab.binding.rfxcom</artifactId>
diff --git a/bundles/org.openhab.binding.remoteopenhab/NOTICE b/bundles/org.openhab.binding.remoteopenhab/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.remoteopenhab/README.md b/bundles/org.openhab.binding.remoteopenhab/README.md
new file mode 100644 (file)
index 0000000..0947b3f
--- /dev/null
@@ -0,0 +1,66 @@
+# Remote openHAB Binding
+
+The Remote openHAB binding allows to communicate with remote openHAB servers.
+The communication is bidirectional.
+The binding on the local server listens to any item state updates on the remote server and updates accordingly the linked channel on the local server.
+It also transfers any item command from the local server to the remote server.
+
+One first usage is the distribution of your home automation system on a set of openHAB servers.
+
+A second usage is for users having old openHAB v1 bindings running that were not migrated to openHAB v2 or openHAB v3.
+They can keep an openHAB v2 server to run their old openHAB v1 bindings and setup a new openHAB v3 server for everything else.
+The Remote openHAB binding installed on the openHAB v3 server will then allow to use the openHAB v1 bindings through communication with the openHAB v2 server.
+
+A third usage is for users that would like to keep unchanged an existing openHAB v2 server but would like to use the new UI from openHAB v3; they can simply setup a new openHAB v3 server with the Remote openHAB binding linked to their openHAB v2 server.
+
+## Supported Things
+
+There is one unique supported thing : the `server` bridge thing 
+
+## Discovery
+
+All openHAB servers in the local network are automatically discovered (through mDNS) by the binding.
+You will find in the inbox one discovery thing per remote server interface.
+So if your remote server has one IPv4 address and one IPv6 address, you will discover two things in the inbox.
+Just choose one of the two things.
+
+## Binding Configuration
+
+The binding has no configuration options, all configuration is done at Thing level.
+
+## Thing Configuration
+
+The thing has the following configuration parameters:
+
+| Parameter | Required | Description                                                                                            |
+|-----------|-------------------------------------------------------------------------------------------------------------------|
+| host      | yes      | The host name or IP address of the remote openHAB server.                                              |
+| port      | yes      | The HTTP port to be used to communicate with the remote openHAB server. Default is 8080.               |
+| restPath  | yes      | The subpath of the REST API on the remote openHAB server. Default is /rest                             |
+| token     | no       | The token to use when the remote openHAB server is setup to require authorization to run its REST API. |
+
+## Channels
+
+The channels are built dynamically and automatically by the binding.
+One channel is created for each item from the remote server.
+Only basic groups (with no state) are ignored.
+The channel id of the built channel corresponds to the name of the item on the remote server.
+
+## Limitations
+
+* The binding will not try to communicate with an openHAB v1 server.
+* The binding only uses the HTTP protocol for the communications with the remote server (not HTTPS).
+
+## Example
+
+### demo.things:
+
+```
+Bridge remoteopenhab:server:oh2 "OH2 server" [ host="192.168.0.100" ]
+```
+
+### demo.items:
+
+```
+DateTime MyDate "Date [%1$tA %1$td %1$tR]" <calendar> { channel="remoteopenhab:server:oh2:MyDate" }
+```
diff --git a/bundles/org.openhab.binding.remoteopenhab/pom.xml b/bundles/org.openhab.binding.remoteopenhab/pom.xml
new file mode 100644 (file)
index 0000000..d67a216
--- /dev/null
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+  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.remoteopenhab</artifactId>
+
+  <name>openHAB Add-ons :: Bundles :: Remote openHAB Binding</name>
+
+</project>
diff --git a/bundles/org.openhab.binding.remoteopenhab/src/main/feature/feature.xml b/bundles/org.openhab.binding.remoteopenhab/src/main/feature/feature.xml
new file mode 100644 (file)
index 0000000..61bb75b
--- /dev/null
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<features name="org.openhab.binding.remoteopenhab-${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-remoteopenhab" description="Remote openHAB Binding" version="${project.version}">
+               <feature>openhab-runtime-base</feature>
+               <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.remoteopenhab/${project.version}
+               </bundle>
+       </feature>
+</features>
diff --git a/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/RemoteopenhabBindingConstants.java b/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/RemoteopenhabBindingConstants.java
new file mode 100644 (file)
index 0000000..20407c7
--- /dev/null
@@ -0,0 +1,36 @@
+/**
+ * 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.remoteopenhab.internal;
+
+import java.util.Collections;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.thing.ThingTypeUID;
+
+/**
+ * The {@link RemoteopenhabBindingConstants} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author Laurent Garnier - Initial contribution
+ */
+@NonNullByDefault
+public class RemoteopenhabBindingConstants {
+
+    public static final String BINDING_ID = "remoteopenhab";
+
+    // List of all Thing Type UIDs
+    public static final ThingTypeUID BRIDGE_TYPE_SERVER = new ThingTypeUID(BINDING_ID, "server");
+
+    public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections.singleton(BRIDGE_TYPE_SERVER);
+}
diff --git a/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/RemoteopenhabChannelTypeProvider.java b/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/RemoteopenhabChannelTypeProvider.java
new file mode 100644 (file)
index 0000000..9119da4
--- /dev/null
@@ -0,0 +1,60 @@
+/**
+ * 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.remoteopenhab.internal;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.thing.type.ChannelType;
+import org.openhab.core.thing.type.ChannelTypeProvider;
+import org.openhab.core.thing.type.ChannelTypeUID;
+import org.osgi.service.component.annotations.Component;
+
+/**
+ * Channel type provider used for all the channel types built by the binding when building dynamically the channels.
+ * One different channel type is built for each different item type found on the remote openHAB server.
+ *
+ * @author Laurent Garnier - Initial contribution
+ */
+@Component(service = { ChannelTypeProvider.class, RemoteopenhabChannelTypeProvider.class })
+@NonNullByDefault
+public class RemoteopenhabChannelTypeProvider implements ChannelTypeProvider {
+    private final List<ChannelType> channelTypes = new CopyOnWriteArrayList<>();
+
+    @Override
+    public Collection<ChannelType> getChannelTypes(@Nullable Locale locale) {
+        return channelTypes;
+    }
+
+    @Override
+    public @Nullable ChannelType getChannelType(ChannelTypeUID channelTypeUID, @Nullable Locale locale) {
+        for (ChannelType channelType : channelTypes) {
+            if (channelType.getUID().equals(channelTypeUID)) {
+                return channelType;
+            }
+        }
+        return null;
+    }
+
+    public void addChannelType(ChannelType type) {
+        channelTypes.add(type);
+    }
+
+    public void removeChannelType(ChannelType type) {
+        channelTypes.remove(type);
+    }
+}
diff --git a/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/RemoteopenhabHandlerFactory.java b/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/RemoteopenhabHandlerFactory.java
new file mode 100644 (file)
index 0000000..cd57cca
--- /dev/null
@@ -0,0 +1,85 @@
+/**
+ * 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.remoteopenhab.internal;
+
+import static org.openhab.binding.remoteopenhab.internal.RemoteopenhabBindingConstants.*;
+
+import javax.ws.rs.client.ClientBuilder;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.remoteopenhab.internal.handler.RemoteopenhabBridgeHandler;
+import org.openhab.core.thing.Bridge;
+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;
+import org.osgi.service.jaxrs.client.SseEventSourceFactory;
+
+import com.google.gson.FieldNamingPolicy;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+
+/**
+ * The {@link RemoteopenhabHandlerFactory} is responsible for creating things and thing
+ * handlers.
+ *
+ * @author Laurent Garnier - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = ThingHandlerFactory.class, configurationPid = "binding.remoteopenhab")
+public class RemoteopenhabHandlerFactory extends BaseThingHandlerFactory {
+
+    private final ClientBuilder clientBuilder;
+    private final SseEventSourceFactory eventSourceFactory;
+    private final RemoteopenhabChannelTypeProvider channelTypeProvider;
+    private final RemoteopenhabStateDescriptionOptionProvider stateDescriptionProvider;
+    private final Gson jsonParser;
+
+    @Activate
+    public RemoteopenhabHandlerFactory(final @Reference ClientBuilder clientBuilder,
+            final @Reference SseEventSourceFactory eventSourceFactory,
+            final @Reference RemoteopenhabChannelTypeProvider channelTypeProvider,
+            final @Reference RemoteopenhabStateDescriptionOptionProvider stateDescriptionProvider) {
+        this.clientBuilder = clientBuilder;
+        this.eventSourceFactory = eventSourceFactory;
+        this.channelTypeProvider = channelTypeProvider;
+        this.stateDescriptionProvider = stateDescriptionProvider;
+        jsonParser = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.IDENTITY).create();
+    }
+
+    /**
+     * The things this factory supports creating.
+     */
+    @Override
+    public boolean supportsThingType(ThingTypeUID thingTypeUID) {
+        return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
+    }
+
+    /**
+     * Creates a handler for the specific thing.
+     */
+    @Override
+    protected @Nullable ThingHandler createHandler(Thing thing) {
+        ThingTypeUID thingTypeUID = thing.getThingTypeUID();
+
+        return BRIDGE_TYPE_SERVER.equals(thingTypeUID)
+                ? new RemoteopenhabBridgeHandler((Bridge) thing, clientBuilder, eventSourceFactory, channelTypeProvider,
+                        stateDescriptionProvider, jsonParser)
+                : null;
+    }
+}
diff --git a/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/RemoteopenhabStateDescriptionOptionProvider.java b/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/RemoteopenhabStateDescriptionOptionProvider.java
new file mode 100644 (file)
index 0000000..57a0a37
--- /dev/null
@@ -0,0 +1,50 @@
+/**
+ * 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.remoteopenhab.internal;
+
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.binding.BaseDynamicStateDescriptionProvider;
+import org.openhab.core.thing.i18n.ChannelTypeI18nLocalizationService;
+import org.openhab.core.thing.type.DynamicStateDescriptionProvider;
+import org.openhab.core.types.StateOption;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * Dynamic provider of state options while leaving other state description fields as original.
+ *
+ * @author Laurent Garnier - Initial contribution
+ */
+@Component(service = { DynamicStateDescriptionProvider.class, RemoteopenhabStateDescriptionOptionProvider.class })
+@NonNullByDefault
+public class RemoteopenhabStateDescriptionOptionProvider extends BaseDynamicStateDescriptionProvider {
+
+    public @Nullable List<StateOption> getStateOptions(ChannelUID channelUID) {
+        return channelOptionsMap.get(channelUID);
+    }
+
+    @Reference
+    protected void setChannelTypeI18nLocalizationService(
+            final ChannelTypeI18nLocalizationService channelTypeI18nLocalizationService) {
+        this.channelTypeI18nLocalizationService = channelTypeI18nLocalizationService;
+    }
+
+    protected void unsetChannelTypeI18nLocalizationService(
+            final ChannelTypeI18nLocalizationService channelTypeI18nLocalizationService) {
+        this.channelTypeI18nLocalizationService = null;
+    }
+}
diff --git a/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/config/RemoteopenhabInstanceConfiguration.java b/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/config/RemoteopenhabInstanceConfiguration.java
new file mode 100644 (file)
index 0000000..4b20930
--- /dev/null
@@ -0,0 +1,34 @@
+/**
+ * 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.remoteopenhab.internal.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link RemoteopenhabInstanceConfiguration} is responsible for holding
+ * configuration informations associated to a remote openHAB server
+ * thing type
+ *
+ * @author Laurent Garnier - Initial contribution
+ */
+@NonNullByDefault
+public class RemoteopenhabInstanceConfiguration {
+    public static final String HOST = "host";
+    public static final String PORT = "port";
+    public static final String REST_PATH = "restPath";
+
+    public String host = "";
+    public int port = 8080;
+    public String restPath = "/rest";
+    public String token = "";
+}
diff --git a/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/data/Event.java b/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/data/Event.java
new file mode 100644 (file)
index 0000000..e37cbf4
--- /dev/null
@@ -0,0 +1,28 @@
+/**
+ * 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.remoteopenhab.internal.data;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Event received through the SSE connection.
+ *
+ * @author Laurent Garnier - Initial contribution
+ */
+@NonNullByDefault
+public class Event {
+
+    public String type = "";
+    public String topic = "";
+    public String payload = "";
+}
diff --git a/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/data/EventPayload.java b/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/data/EventPayload.java
new file mode 100644 (file)
index 0000000..2fceb9d
--- /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.remoteopenhab.internal.data;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Payload from ItemStateEvent / GroupItemStateChangedEvent events received through the SSE connection.
+ *
+ * @author Laurent Garnier - Initial contribution
+ */
+@NonNullByDefault
+public class EventPayload {
+
+    public String type = "";
+    public String value = "";
+}
diff --git a/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/data/Item.java b/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/data/Item.java
new file mode 100644 (file)
index 0000000..6c0cab6
--- /dev/null
@@ -0,0 +1,32 @@
+/**
+ * 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.remoteopenhab.internal.data;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * Response to the API GET /rest/items
+ * Also payload from ItemAddedEvent / ItemRemovedEvent / ItemUpdatedEvent events received through the SSE connection.
+ *
+ * @author Laurent Garnier - Initial contribution
+ */
+@NonNullByDefault
+public class Item {
+
+    public String name = "";
+    public String type = "";
+    public String state = "";
+    public String groupType = "";
+    public @Nullable StateDescription stateDescription;
+}
diff --git a/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/data/Option.java b/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/data/Option.java
new file mode 100644 (file)
index 0000000..670eccf
--- /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.remoteopenhab.internal.data;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Part of {@link StateDescription} containing one state option
+ *
+ * @author Laurent Garnier - Initial contribution
+ */
+@NonNullByDefault
+public class Option {
+
+    public String value = "";
+    public String label = "";
+}
diff --git a/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/data/RestApi.java b/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/data/RestApi.java
new file mode 100644 (file)
index 0000000..0ac3e1c
--- /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.remoteopenhab.internal.data;
+
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * Response to the API GET /rest
+ *
+ * @author Laurent Garnier - Initial contribution
+ */
+public class RestApi {
+
+    public String version;
+    public RestApiEndpoint[] links;
+    public @Nullable RuntimeInfo runtimeInfo;
+}
diff --git a/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/data/RestApiEndpoint.java b/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/data/RestApiEndpoint.java
new file mode 100644 (file)
index 0000000..eda714e
--- /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.remoteopenhab.internal.data;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Subpart of the response to the API GET /rest
+ *
+ * @author Laurent Garnier - Initial contribution
+ */
+@NonNullByDefault
+public class RestApiEndpoint {
+
+    public String type = "";
+    public String url = "";
+}
diff --git a/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/data/RuntimeInfo.java b/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/data/RuntimeInfo.java
new file mode 100644 (file)
index 0000000..e17675d
--- /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.remoteopenhab.internal.data;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Subpart of the response to the API GET /rest containing the runtime information
+ *
+ * @author Laurent Garnier - Initial contribution
+ */
+@NonNullByDefault
+public class RuntimeInfo {
+
+    public String version = "";
+    public String buildString = "";
+}
diff --git a/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/data/StateDescription.java b/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/data/StateDescription.java
new file mode 100644 (file)
index 0000000..b2b7a48
--- /dev/null
@@ -0,0 +1,31 @@
+/**
+ * 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.remoteopenhab.internal.data;
+
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * Part of {@link Item} containing the state description
+ *
+ * @author Laurent Garnier - Initial contribution
+ */
+@NonNullByDefault
+public class StateDescription {
+
+    public String pattern = "";
+    public boolean readOnly;
+    public @Nullable List<Option> options;
+}
diff --git a/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/discovery/RemoteopenhabDiscoveryParticipant.java b/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/discovery/RemoteopenhabDiscoveryParticipant.java
new file mode 100644 (file)
index 0000000..9a5013a
--- /dev/null
@@ -0,0 +1,99 @@
+/**
+ * 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.remoteopenhab.internal.discovery;
+
+import static org.openhab.binding.remoteopenhab.internal.RemoteopenhabBindingConstants.*;
+import static org.openhab.binding.remoteopenhab.internal.config.RemoteopenhabInstanceConfiguration.*;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import javax.jmdns.ServiceInfo;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.config.discovery.mdns.MDNSDiscoveryParticipant;
+import org.openhab.core.net.NetUtil;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.osgi.service.component.annotations.Component;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link RemoteopenhabDiscoveryParticipant} is responsible for discovering
+ * the remote openHAB servers using mDNS discovery service.
+ *
+ * @author Laurent Garnier - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = MDNSDiscoveryParticipant.class, configurationPid = "mdnsdiscovery.remoteopenhab")
+public class RemoteopenhabDiscoveryParticipant implements MDNSDiscoveryParticipant {
+
+    private static final String SERVICE_TYPE = "_openhab-server._tcp.local.";
+
+    private final Logger logger = LoggerFactory.getLogger(RemoteopenhabDiscoveryParticipant.class);
+
+    @Override
+    public Set<ThingTypeUID> getSupportedThingTypeUIDs() {
+        return SUPPORTED_THING_TYPES_UIDS;
+    }
+
+    @Override
+    public String getServiceType() {
+        return SERVICE_TYPE;
+    }
+
+    @Override
+    public @Nullable ThingUID getThingUID(ServiceInfo service) {
+        // We use the first host address as thing ID
+        String ip = (service.getHostAddresses() != null && service.getHostAddresses().length > 0
+                && !service.getHostAddresses()[0].isEmpty()) ? service.getHostAddresses()[0].replaceAll("\\[|\\]", "")
+                        : null;
+        // Host address matching a local IP address are ignored
+        if (getServiceType().equals(service.getType()) && ip != null && !matchLocalIpAddress(ip)) {
+            return new ThingUID(BRIDGE_TYPE_SERVER, ip.replaceAll("[^A-Za-z0-9_]", "_"));
+        }
+        return null;
+    }
+
+    private boolean matchLocalIpAddress(String ipAddress) {
+        List<String> localIpAddresses = NetUtil.getAllInterfaceAddresses().stream()
+                .filter(a -> !a.getAddress().isLinkLocalAddress())
+                .map(a -> a.getAddress().getHostAddress().split("%")[0]).collect(Collectors.toList());
+        return localIpAddresses.contains(ipAddress);
+    }
+
+    @Override
+    public @Nullable DiscoveryResult createResult(ServiceInfo service) {
+        logger.debug("createResult ServiceInfo: {}", service);
+        DiscoveryResult result = null;
+        String ip = (service.getHostAddresses() != null && service.getHostAddresses().length > 0
+                && !service.getHostAddresses()[0].isEmpty()) ? service.getHostAddresses()[0].replaceAll("\\[|\\]", "")
+                        : null;
+        String restPath = service.getPropertyString("uri");
+        ThingUID thingUID = getThingUID(service);
+        if (thingUID != null && ip != null && restPath != null) {
+            String label = "openHAB server";
+            logger.debug("Created a DiscoveryResult for remote openHAB server {} with IP {}", thingUID, ip);
+            Map<String, Object> properties = Map.of(HOST, ip, REST_PATH, restPath);
+            result = DiscoveryResultBuilder.create(thingUID).withProperties(properties).withRepresentationProperty(HOST)
+                    .withLabel(label).build();
+        }
+        return result;
+    }
+}
diff --git a/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/exceptions/RemoteopenhabException.java b/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/exceptions/RemoteopenhabException.java
new file mode 100644 (file)
index 0000000..2f8d2dd
--- /dev/null
@@ -0,0 +1,37 @@
+/**
+ * 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.remoteopenhab.internal.exceptions;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Exceptions thrown by this binding.
+ *
+ * @author Laurent Garnier - Initial contribution
+ */
+@NonNullByDefault
+@SuppressWarnings("serial")
+public class RemoteopenhabException extends Exception {
+
+    public RemoteopenhabException(String message) {
+        super(message);
+    }
+
+    public RemoteopenhabException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    public RemoteopenhabException(Throwable cause) {
+        super(cause);
+    }
+}
diff --git a/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/handler/RemoteopenhabBridgeHandler.java b/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/handler/RemoteopenhabBridgeHandler.java
new file mode 100644 (file)
index 0000000..a4fc75f
--- /dev/null
@@ -0,0 +1,565 @@
+/**
+ * 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.remoteopenhab.internal.handler;
+
+import static org.openhab.binding.remoteopenhab.internal.RemoteopenhabBindingConstants.BINDING_ID;
+
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+import javax.ws.rs.client.ClientBuilder;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.remoteopenhab.internal.RemoteopenhabChannelTypeProvider;
+import org.openhab.binding.remoteopenhab.internal.RemoteopenhabStateDescriptionOptionProvider;
+import org.openhab.binding.remoteopenhab.internal.config.RemoteopenhabInstanceConfiguration;
+import org.openhab.binding.remoteopenhab.internal.data.Item;
+import org.openhab.binding.remoteopenhab.internal.data.Option;
+import org.openhab.binding.remoteopenhab.internal.data.StateDescription;
+import org.openhab.binding.remoteopenhab.internal.exceptions.RemoteopenhabException;
+import org.openhab.binding.remoteopenhab.internal.listener.RemoteopenhabStreamingDataListener;
+import org.openhab.binding.remoteopenhab.internal.rest.RemoteopenhabRestClient;
+import org.openhab.core.library.CoreItemFactory;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.HSBType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.OpenClosedType;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.library.types.PlayPauseType;
+import org.openhab.core.library.types.PointType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.RawType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.net.NetUtil;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.binding.BaseBridgeHandler;
+import org.openhab.core.thing.binding.builder.ChannelBuilder;
+import org.openhab.core.thing.binding.builder.ThingBuilder;
+import org.openhab.core.thing.type.AutoUpdatePolicy;
+import org.openhab.core.thing.type.ChannelKind;
+import org.openhab.core.thing.type.ChannelType;
+import org.openhab.core.thing.type.ChannelTypeBuilder;
+import org.openhab.core.thing.type.ChannelTypeUID;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.openhab.core.types.State;
+import org.openhab.core.types.StateDescriptionFragmentBuilder;
+import org.openhab.core.types.StateOption;
+import org.openhab.core.types.TypeParser;
+import org.openhab.core.types.UnDefType;
+import org.osgi.service.jaxrs.client.SseEventSourceFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+
+/**
+ * The {@link RemoteopenhabBridgeHandler} is responsible for handling commands and updating states
+ * using the REST API of the remote openHAB server.
+ *
+ * @author Laurent Garnier - Initial contribution
+ */
+@NonNullByDefault
+public class RemoteopenhabBridgeHandler extends BaseBridgeHandler implements RemoteopenhabStreamingDataListener {
+
+    private static final String DATE_FORMAT_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSSZ";
+    private static final DateTimeFormatter FORMATTER_DATE = DateTimeFormatter.ofPattern(DATE_FORMAT_PATTERN);
+
+    private static final long CONNECTION_TIMEOUT_MILLIS = TimeUnit.MILLISECONDS.convert(5, TimeUnit.MINUTES);
+    private static final int MAX_STATE_SIZE_FOR_LOGGING = 50;
+
+    private final Logger logger = LoggerFactory.getLogger(RemoteopenhabBridgeHandler.class);
+
+    private final ClientBuilder clientBuilder;
+    private final SseEventSourceFactory eventSourceFactory;
+    private final RemoteopenhabChannelTypeProvider channelTypeProvider;
+    private final RemoteopenhabStateDescriptionOptionProvider stateDescriptionProvider;
+    private final Gson jsonParser;
+
+    private final Object updateThingLock = new Object();
+
+    private @NonNullByDefault({}) RemoteopenhabInstanceConfiguration config;
+
+    private @Nullable ScheduledFuture<?> checkConnectionJob;
+    private @Nullable RemoteopenhabRestClient restClient;
+
+    public RemoteopenhabBridgeHandler(Bridge bridge, ClientBuilder clientBuilder,
+            SseEventSourceFactory eventSourceFactory, RemoteopenhabChannelTypeProvider channelTypeProvider,
+            RemoteopenhabStateDescriptionOptionProvider stateDescriptionProvider, final Gson jsonParser) {
+        super(bridge);
+        this.clientBuilder = clientBuilder;
+        this.eventSourceFactory = eventSourceFactory;
+        this.channelTypeProvider = channelTypeProvider;
+        this.stateDescriptionProvider = stateDescriptionProvider;
+        this.jsonParser = jsonParser;
+    }
+
+    @Override
+    public void initialize() {
+        logger.debug("Initializing remote openHAB handler for bridge {}", getThing().getUID());
+
+        config = getConfigAs(RemoteopenhabInstanceConfiguration.class);
+
+        String host = config.host.trim();
+        if (host.length() == 0) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    "Undefined server address setting in the thing configuration");
+            return;
+        }
+        List<String> localIpAddresses = NetUtil.getAllInterfaceAddresses().stream()
+                .filter(a -> !a.getAddress().isLinkLocalAddress())
+                .map(a -> a.getAddress().getHostAddress().split("%")[0]).collect(Collectors.toList());
+        if (localIpAddresses.contains(host)) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    "Do not use the local server as a remote server in the thing configuration");
+            return;
+        }
+        String path = config.restPath.trim();
+        if (path.length() == 0 || !path.startsWith("/")) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    "Invalid REST API path setting in the thing configuration");
+            return;
+        }
+        URL url;
+        try {
+            url = new URL("http", host, config.port, path);
+        } catch (MalformedURLException e) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    "Invalid REST URL built from the settings in the thing configuration");
+            return;
+        }
+
+        String urlStr = url.toString();
+        if (urlStr.endsWith("/")) {
+            urlStr = urlStr.substring(0, urlStr.length() - 1);
+        }
+        logger.debug("REST URL = {}", urlStr);
+
+        RemoteopenhabRestClient client = new RemoteopenhabRestClient(clientBuilder, eventSourceFactory, jsonParser,
+                config.token, urlStr);
+        restClient = client;
+
+        updateStatus(ThingStatus.UNKNOWN);
+
+        startCheckConnectionJob(client);
+    }
+
+    @Override
+    public void dispose() {
+        logger.debug("Disposing remote openHAB handler for bridge {}", getThing().getUID());
+        stopStreamingUpdates();
+        stopCheckConnectionJob();
+        this.restClient = null;
+    }
+
+    @Override
+    public void handleCommand(ChannelUID channelUID, Command command) {
+        if (getThing().getStatus() != ThingStatus.ONLINE) {
+            return;
+        }
+        RemoteopenhabRestClient client = restClient;
+        if (client == null) {
+            return;
+        }
+
+        try {
+            if (command instanceof RefreshType) {
+                String state = client.getRemoteItemState(channelUID.getId());
+                updateChannelState(channelUID.getId(), null, state);
+            } else if (isLinked(channelUID)) {
+                client.sendCommandToRemoteItem(channelUID.getId(), command);
+                String commandStr = command.toFullString();
+                logger.debug("Sending command {} to remote item {} succeeded",
+                        commandStr.length() < MAX_STATE_SIZE_FOR_LOGGING ? commandStr
+                                : commandStr.substring(0, MAX_STATE_SIZE_FOR_LOGGING) + "...",
+                        channelUID.getId());
+            }
+        } catch (RemoteopenhabException e) {
+            logger.debug("{}", e.getMessage());
+        }
+    }
+
+    private void createChannels(List<Item> items, boolean replace) {
+        synchronized (updateThingLock) {
+            int nbGroups = 0;
+            List<Channel> channels = new ArrayList<>();
+            for (Item item : items) {
+                String itemType = item.type;
+                boolean readOnly = false;
+                if ("Group".equals(itemType)) {
+                    if (item.groupType.isEmpty()) {
+                        // Standard groups are ignored
+                        nbGroups++;
+                        continue;
+                    } else {
+                        itemType = item.groupType;
+                    }
+                } else {
+                    if (item.stateDescription != null && item.stateDescription.readOnly) {
+                        readOnly = true;
+                    }
+                }
+                String channelTypeId = String.format("item%s%s", itemType.replace(":", ""), readOnly ? "RO" : "");
+                ChannelTypeUID channelTypeUID = new ChannelTypeUID(BINDING_ID, channelTypeId);
+                ChannelType channelType = channelTypeProvider.getChannelType(channelTypeUID, null);
+                String label;
+                String description;
+                if (channelType == null) {
+                    logger.trace("Create the channel type {} for item type {}", channelTypeUID, itemType);
+                    label = String.format("Remote %s Item", itemType);
+                    description = String.format("An item of type %s from the remote server.", itemType);
+                    channelType = ChannelTypeBuilder.state(channelTypeUID, label, itemType).withDescription(description)
+                            .withStateDescriptionFragment(
+                                    StateDescriptionFragmentBuilder.create().withReadOnly(readOnly).build())
+                            .withAutoUpdatePolicy(AutoUpdatePolicy.VETO).build();
+                    channelTypeProvider.addChannelType(channelType);
+                }
+                ChannelUID channelUID = new ChannelUID(getThing().getUID(), item.name);
+                logger.trace("Create the channel {} of type {}", channelUID, channelTypeUID);
+                label = "Item " + item.name;
+                description = String.format("Item %s from the remote server.", item.name);
+                channels.add(ChannelBuilder.create(channelUID, itemType).withType(channelTypeUID)
+                        .withKind(ChannelKind.STATE).withLabel(label).withDescription(description).build());
+            }
+            ThingBuilder thingBuilder = editThing();
+            if (replace) {
+                thingBuilder.withChannels(channels);
+                updateThing(thingBuilder.build());
+                logger.debug("{} channels defined for the thing {} (from {} items including {} groups)",
+                        channels.size(), getThing().getUID(), items.size(), nbGroups);
+            } else if (channels.size() > 0) {
+                int nbRemoved = 0;
+                for (Channel channel : channels) {
+                    if (getThing().getChannel(channel.getUID()) != null) {
+                        thingBuilder.withoutChannel(channel.getUID());
+                        nbRemoved++;
+                    }
+                }
+                if (nbRemoved > 0) {
+                    logger.debug("{} channels removed for the thing {} (from {} items)", nbRemoved, getThing().getUID(),
+                            items.size());
+                }
+                for (Channel channel : channels) {
+                    thingBuilder.withChannel(channel);
+                }
+                updateThing(thingBuilder.build());
+                if (nbGroups > 0) {
+                    logger.debug("{} channels added for the thing {} (from {} items including {} groups)",
+                            channels.size(), getThing().getUID(), items.size(), nbGroups);
+                } else {
+                    logger.debug("{} channels added for the thing {} (from {} items)", channels.size(),
+                            getThing().getUID(), items.size());
+                }
+            }
+        }
+    }
+
+    private void removeChannels(List<Item> items) {
+        synchronized (updateThingLock) {
+            int nbRemoved = 0;
+            ThingBuilder thingBuilder = editThing();
+            for (Item item : items) {
+                Channel channel = getThing().getChannel(item.name);
+                if (channel != null) {
+                    thingBuilder.withoutChannel(channel.getUID());
+                    nbRemoved++;
+                }
+            }
+            if (nbRemoved > 0) {
+                updateThing(thingBuilder.build());
+                logger.debug("{} channels removed for the thing {} (from {} items)", nbRemoved, getThing().getUID(),
+                        items.size());
+            }
+        }
+    }
+
+    private void setStateOptions(List<Item> items) {
+        for (Item item : items) {
+            Channel channel = getThing().getChannel(item.name);
+            StateDescription descr = item.stateDescription;
+            List<Option> options = descr == null ? null : descr.options;
+            if (channel != null && options != null && options.size() > 0) {
+                List<StateOption> stateOptions = new ArrayList<>();
+                for (Option option : options) {
+                    stateOptions.add(new StateOption(option.value, option.label));
+                }
+                stateDescriptionProvider.setStateOptions(channel.getUID(), stateOptions);
+                logger.trace("{} options set for the channel {}", options.size(), channel.getUID());
+            }
+        }
+    }
+
+    public void checkConnection(RemoteopenhabRestClient client) {
+        logger.debug("Try the root REST API...");
+        try {
+            client.tryApi();
+            if (client.getRestApiVersion() == null) {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                        "OH 1.x server not supported by the binding");
+            } else {
+                List<Item> items = client.getRemoteItems();
+
+                createChannels(items, true);
+                setStateOptions(items);
+                for (Item item : items) {
+                    updateChannelState(item.name, null, item.state);
+                }
+
+                updateStatus(ThingStatus.ONLINE);
+
+                restartStreamingUpdates();
+            }
+        } catch (RemoteopenhabException e) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
+            stopStreamingUpdates();
+        }
+    }
+
+    private void startCheckConnectionJob(RemoteopenhabRestClient client) {
+        ScheduledFuture<?> localCheckConnectionJob = checkConnectionJob;
+        if (localCheckConnectionJob == null || localCheckConnectionJob.isCancelled()) {
+            checkConnectionJob = scheduler.scheduleWithFixedDelay(() -> {
+                long millisSinceLastEvent = System.currentTimeMillis() - client.getLastEventTimestamp();
+                if (millisSinceLastEvent > CONNECTION_TIMEOUT_MILLIS) {
+                    logger.debug("Check: Disconnected from streaming events, millisSinceLastEvent={}",
+                            millisSinceLastEvent);
+                    checkConnection(client);
+                } else {
+                    logger.debug("Check: Receiving streaming events, millisSinceLastEvent={}", millisSinceLastEvent);
+                }
+            }, 0, CONNECTION_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
+        }
+    }
+
+    private void stopCheckConnectionJob() {
+        ScheduledFuture<?> localCheckConnectionJob = checkConnectionJob;
+        if (localCheckConnectionJob != null) {
+            localCheckConnectionJob.cancel(true);
+            checkConnectionJob = null;
+        }
+    }
+
+    private void restartStreamingUpdates() {
+        RemoteopenhabRestClient client = restClient;
+        if (client != null) {
+            synchronized (client) {
+                stopStreamingUpdates();
+                startStreamingUpdates();
+            }
+        }
+    }
+
+    private void startStreamingUpdates() {
+        RemoteopenhabRestClient client = restClient;
+        if (client != null) {
+            synchronized (client) {
+                client.addStreamingDataListener(this);
+                client.start();
+            }
+        }
+    }
+
+    private void stopStreamingUpdates() {
+        RemoteopenhabRestClient client = restClient;
+        if (client != null) {
+            synchronized (client) {
+                client.stop();
+                client.removeStreamingDataListener(this);
+            }
+        }
+    }
+
+    @Override
+    public void onConnected() {
+        updateStatus(ThingStatus.ONLINE);
+    }
+
+    @Override
+    public void onError(String message) {
+        updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, message);
+    }
+
+    @Override
+    public void onItemStateEvent(String itemName, String stateType, String state) {
+        updateChannelState(itemName, stateType, state);
+    }
+
+    @Override
+    public void onItemAdded(Item item) {
+        createChannels(List.of(item), false);
+    }
+
+    @Override
+    public void onItemRemoved(Item item) {
+        removeChannels(List.of(item));
+    }
+
+    @Override
+    public void onItemUpdated(Item newItem, Item oldItem) {
+        if (!newItem.type.equals(oldItem.type)) {
+            createChannels(List.of(newItem), false);
+        } else {
+            logger.trace("Updated remote item {} ignored because item type {} is unchanged", newItem.name,
+                    newItem.type);
+        }
+    }
+
+    private void updateChannelState(String itemName, @Nullable String stateType, String state) {
+        Channel channel = getThing().getChannel(itemName);
+        if (channel == null) {
+            logger.trace("No channel for item {}", itemName);
+            return;
+        }
+        String acceptedItemType = channel.getAcceptedItemType();
+        if (acceptedItemType == null) {
+            logger.trace("Channel without accepted item type for item {}", itemName);
+            return;
+        }
+        if (!isLinked(channel.getUID())) {
+            logger.trace("Unlinked channel {}", channel.getUID());
+            return;
+        }
+        State channelState = null;
+        if (stateType == null && "NULL".equals(state)) {
+            channelState = UnDefType.NULL;
+        } else if (stateType == null && "UNDEF".equals(state)) {
+            channelState = UnDefType.UNDEF;
+        } else if ("UnDef".equals(stateType)) {
+            switch (state) {
+                case "NULL":
+                    channelState = UnDefType.NULL;
+                    break;
+                case "UNDEF":
+                    channelState = UnDefType.UNDEF;
+                    break;
+                default:
+                    logger.debug("Invalid UnDef value {} for item {}", state, itemName);
+                    break;
+            }
+        } else if (acceptedItemType.startsWith(CoreItemFactory.NUMBER + ":")) {
+            // Item type Number with dimension
+            if (checkStateType(itemName, stateType, "Quantity")) {
+                List<Class<? extends State>> stateTypes = Collections.singletonList(QuantityType.class);
+                channelState = TypeParser.parseState(stateTypes, state);
+            }
+        } else {
+            switch (acceptedItemType) {
+                case CoreItemFactory.STRING:
+                    if (checkStateType(itemName, stateType, "String")) {
+                        channelState = new StringType(state);
+                    }
+                    break;
+                case CoreItemFactory.NUMBER:
+                    if (checkStateType(itemName, stateType, "Decimal")) {
+                        channelState = new DecimalType(state);
+                    }
+                    break;
+                case CoreItemFactory.SWITCH:
+                    if (checkStateType(itemName, stateType, "OnOff")) {
+                        channelState = "ON".equals(state) ? OnOffType.ON : OnOffType.OFF;
+                    }
+                    break;
+                case CoreItemFactory.CONTACT:
+                    if (checkStateType(itemName, stateType, "OpenClosed")) {
+                        channelState = "OPEN".equals(state) ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
+                    }
+                    break;
+                case CoreItemFactory.DIMMER:
+                    if (checkStateType(itemName, stateType, "Percent")) {
+                        channelState = new PercentType(state);
+                    }
+                    break;
+                case CoreItemFactory.COLOR:
+                    if (checkStateType(itemName, stateType, "HSB")) {
+                        channelState = HSBType.valueOf(state);
+                    }
+                    break;
+                case CoreItemFactory.DATETIME:
+                    if (checkStateType(itemName, stateType, "DateTime")) {
+                        try {
+                            channelState = new DateTimeType(ZonedDateTime.parse(state, FORMATTER_DATE));
+                        } catch (DateTimeParseException e) {
+                            logger.debug("Failed to parse date {} for item {}", state, itemName);
+                            channelState = null;
+                        }
+                    }
+                    break;
+                case CoreItemFactory.LOCATION:
+                    if (checkStateType(itemName, stateType, "Point")) {
+                        channelState = new PointType(state);
+                    }
+                    break;
+                case CoreItemFactory.IMAGE:
+                    if (checkStateType(itemName, stateType, "Raw")) {
+                        channelState = RawType.valueOf(state);
+                    }
+                    break;
+                case CoreItemFactory.PLAYER:
+                    if (checkStateType(itemName, stateType, "PlayPause")) {
+                        switch (state) {
+                            case "PLAY":
+                                channelState = PlayPauseType.PLAY;
+                                break;
+                            case "PAUSE":
+                                channelState = PlayPauseType.PAUSE;
+                                break;
+                            default:
+                                logger.debug("Unexpected value {} for item {}", state, itemName);
+                                break;
+                        }
+                    }
+                    break;
+                case CoreItemFactory.ROLLERSHUTTER:
+                    if (checkStateType(itemName, stateType, "Percent")) {
+                        channelState = new PercentType(state);
+                    }
+                    break;
+                default:
+                    logger.debug("Item type {} is not yet supported", acceptedItemType);
+                    break;
+            }
+        }
+        if (channelState != null) {
+            updateState(channel.getUID(), channelState);
+            String channelStateStr = channelState.toFullString();
+            logger.debug("updateState {} with {}", channel.getUID(),
+                    channelStateStr.length() < MAX_STATE_SIZE_FOR_LOGGING ? channelStateStr
+                            : channelStateStr.substring(0, MAX_STATE_SIZE_FOR_LOGGING) + "...");
+
+        }
+    }
+
+    private boolean checkStateType(String itemName, @Nullable String stateType, String expectedType) {
+        if (stateType != null && !expectedType.equals(stateType)) {
+            logger.debug("Unexpected value type {} for item {}", stateType, itemName);
+            return false;
+        } else {
+            return true;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/listener/RemoteopenhabStreamingDataListener.java b/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/listener/RemoteopenhabStreamingDataListener.java
new file mode 100644 (file)
index 0000000..b4e5406
--- /dev/null
@@ -0,0 +1,56 @@
+/**
+ * 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.remoteopenhab.internal.listener;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.remoteopenhab.internal.data.Item;
+import org.openhab.binding.remoteopenhab.internal.rest.RemoteopenhabRestClient;
+
+/**
+ * Interface for listeners of events generated by the {@link RemoteopenhabRestClient}.
+ *
+ * @author Laurent Garnier - Initial contribution
+ */
+@NonNullByDefault
+public interface RemoteopenhabStreamingDataListener {
+
+    /**
+     * The client successfully established a connection.
+     */
+    void onConnected();
+
+    /**
+     * An error message was published.
+     */
+    void onError(String message);
+
+    /**
+     * A new ItemStateEvent was published.
+     */
+    void onItemStateEvent(String itemName, String stateType, String state);
+
+    /**
+     * A new ItemAddedEvent was published.
+     */
+    void onItemAdded(Item item);
+
+    /**
+     * A new ItemRemovedEvent was published.
+     */
+    void onItemRemoved(Item item);
+
+    /**
+     * A new ItemUpdatedEvent was published.
+     */
+    void onItemUpdated(Item newItem, Item oldItem);
+}
diff --git a/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/rest/RemoteopenhabRestClient.java b/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/rest/RemoteopenhabRestClient.java
new file mode 100644 (file)
index 0000000..1f7b04a
--- /dev/null
@@ -0,0 +1,336 @@
+/**
+ * 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.remoteopenhab.internal.rest;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+
+import javax.ws.rs.client.Client;
+import javax.ws.rs.client.ClientBuilder;
+import javax.ws.rs.core.HttpHeaders;
+import javax.ws.rs.sse.InboundSseEvent;
+import javax.ws.rs.sse.SseEventSource;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.remoteopenhab.internal.data.Event;
+import org.openhab.binding.remoteopenhab.internal.data.EventPayload;
+import org.openhab.binding.remoteopenhab.internal.data.Item;
+import org.openhab.binding.remoteopenhab.internal.data.RestApi;
+import org.openhab.binding.remoteopenhab.internal.exceptions.RemoteopenhabException;
+import org.openhab.binding.remoteopenhab.internal.listener.RemoteopenhabStreamingDataListener;
+import org.openhab.core.io.net.http.HttpUtil;
+import org.openhab.core.types.Command;
+import org.osgi.service.jaxrs.client.SseEventSourceFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonSyntaxException;
+
+/**
+ * A client to use the openHAB REST API and to receive/parse events received from the openHAB REST API Server-Sent
+ * Events (SSE).
+ *
+ * @author Laurent Garnier - Initial contribution
+ */
+@NonNullByDefault
+public class RemoteopenhabRestClient {
+
+    private static final int REQUEST_TIMEOUT = (int) TimeUnit.SECONDS.toMillis(30);
+
+    private final Logger logger = LoggerFactory.getLogger(RemoteopenhabRestClient.class);
+
+    private final ClientBuilder clientBuilder;
+    private final SseEventSourceFactory eventSourceFactory;
+    private final Gson jsonParser;
+    private String accessToken;
+    private final String restUrl;
+
+    private final Object startStopLock = new Object();
+    private final List<RemoteopenhabStreamingDataListener> listeners = new CopyOnWriteArrayList<>();
+
+    private @Nullable String restApiVersion;
+    private @Nullable String restApiItems;
+    private @Nullable String restApiEvents;
+    private @Nullable String topicNamespace;
+    private boolean connected;
+
+    private @Nullable SseEventSource eventSource;
+    private long lastEventTimestamp;
+
+    public RemoteopenhabRestClient(final ClientBuilder clientBuilder, final SseEventSourceFactory eventSourceFactory,
+            final Gson jsonParser, final String accessToken, final String restUrl) {
+        this.clientBuilder = clientBuilder;
+        this.eventSourceFactory = eventSourceFactory;
+        this.jsonParser = jsonParser;
+        this.accessToken = accessToken;
+        this.restUrl = restUrl;
+    }
+
+    public void tryApi() throws RemoteopenhabException {
+        try {
+            Properties httpHeaders = new Properties();
+            if (!accessToken.isEmpty()) {
+                httpHeaders.put(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
+            }
+            httpHeaders.put(HttpHeaders.ACCEPT, "application/json");
+            String jsonResponse = HttpUtil.executeUrl("GET", restUrl, httpHeaders, null, null, REQUEST_TIMEOUT);
+            if (jsonResponse.isEmpty()) {
+                throw new RemoteopenhabException("Failed to execute the root REST API");
+            }
+            RestApi restApi = jsonParser.fromJson(jsonResponse, RestApi.class);
+            restApiVersion = restApi.version;
+            logger.debug("REST API version = {}", restApiVersion);
+            restApiItems = null;
+            for (int i = 0; i < restApi.links.length; i++) {
+                if ("items".equals(restApi.links[i].type)) {
+                    restApiItems = restApi.links[i].url;
+                } else if ("events".equals(restApi.links[i].type)) {
+                    restApiEvents = restApi.links[i].url;
+                }
+            }
+            logger.debug("REST API items = {}", restApiItems);
+            logger.debug("REST API events = {}", restApiEvents);
+            topicNamespace = restApi.runtimeInfo != null ? "openhab" : "smarthome";
+            logger.debug("topic namespace = {}", topicNamespace);
+        } catch (RemoteopenhabException e) {
+            throw new RemoteopenhabException(e.getMessage());
+        } catch (JsonSyntaxException e) {
+            throw new RemoteopenhabException("Failed to parse the result of the root REST API", e);
+        } catch (IOException e) {
+            throw new RemoteopenhabException("Failed to execute the root REST API", e);
+        }
+    }
+
+    public List<Item> getRemoteItems() throws RemoteopenhabException {
+        try {
+            Properties httpHeaders = new Properties();
+            if (!accessToken.isEmpty()) {
+                httpHeaders.put(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
+            }
+            httpHeaders.put(HttpHeaders.ACCEPT, "application/json");
+            String url = String.format("%s?recursive=fasle", getRestApiItems());
+            String jsonResponse = HttpUtil.executeUrl("GET", url, httpHeaders, null, null, REQUEST_TIMEOUT);
+            return Arrays.asList(jsonParser.fromJson(jsonResponse, Item[].class));
+        } catch (IOException | JsonSyntaxException e) {
+            throw new RemoteopenhabException("Failed to get the list of remote items using the items REST API", e);
+        }
+    }
+
+    public String getRemoteItemState(String itemName) throws RemoteopenhabException {
+        try {
+            Properties httpHeaders = new Properties();
+            if (!accessToken.isEmpty()) {
+                httpHeaders.put(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
+            }
+            httpHeaders.put(HttpHeaders.ACCEPT, "text/plain");
+            String url = String.format("%s/%s/state", getRestApiItems(), itemName);
+            return HttpUtil.executeUrl("GET", url, httpHeaders, null, null, REQUEST_TIMEOUT);
+        } catch (IOException e) {
+            throw new RemoteopenhabException(
+                    "Failed to get the state of remote item " + itemName + " using the items REST API", e);
+        }
+    }
+
+    public void sendCommandToRemoteItem(String itemName, Command command) throws RemoteopenhabException {
+        try {
+            Properties httpHeaders = new Properties();
+            if (!accessToken.isEmpty()) {
+                httpHeaders.put(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
+            }
+            httpHeaders.put(HttpHeaders.ACCEPT, "application/json");
+            String url = String.format("%s/%s", getRestApiItems(), itemName);
+            InputStream stream = new ByteArrayInputStream(command.toFullString().getBytes(StandardCharsets.UTF_8));
+            HttpUtil.executeUrl("POST", url, httpHeaders, stream, "text/plain", REQUEST_TIMEOUT);
+            stream.close();
+        } catch (IOException e) {
+            throw new RemoteopenhabException(
+                    "Failed to send command to the remote item " + itemName + " using the items REST API", e);
+        }
+    }
+
+    public @Nullable String getRestApiVersion() {
+        return restApiVersion;
+    }
+
+    public String getRestApiItems() {
+        String url = restApiItems;
+        return url != null ? url : restUrl + "/items";
+    }
+
+    public String getRestApiEvents() {
+        String url = restApiEvents;
+        return url != null ? url : restUrl + "/events";
+    }
+
+    public String getTopicNamespace() {
+        String namespace = topicNamespace;
+        return namespace != null ? namespace : "openhab";
+    }
+
+    public void start() {
+        synchronized (startStopLock) {
+            logger.debug("Opening EventSource {}", getItemsRestSseUrl());
+            reopenEventSource();
+            logger.debug("EventSource started");
+        }
+    }
+
+    public void stop() {
+        synchronized (startStopLock) {
+            logger.debug("Closing EventSource {}", getItemsRestSseUrl());
+            closeEventSource(0, TimeUnit.SECONDS);
+            logger.debug("EventSource stopped");
+        }
+    }
+
+    private String getItemsRestSseUrl() {
+        return String.format("%s?topics=%s/items/*/*", getRestApiEvents(), getTopicNamespace());
+    }
+
+    private SseEventSource createEventSource(String restSseUrl) {
+        Client client = clientBuilder.register(new RemoteopenhabStreamingRequestFilter(accessToken)).build();
+        SseEventSource eventSource = eventSourceFactory.newSource(client.target(restSseUrl));
+        eventSource.register(this::onEvent, this::onError);
+        return eventSource;
+    }
+
+    private void reopenEventSource() {
+        logger.debug("Reopening EventSource");
+        closeEventSource(10, TimeUnit.SECONDS);
+
+        logger.debug("Opening new EventSource {}", getItemsRestSseUrl());
+        SseEventSource localEventSource = createEventSource(getItemsRestSseUrl());
+        localEventSource.open();
+
+        eventSource = localEventSource;
+    }
+
+    private void closeEventSource(long timeout, TimeUnit timeoutUnit) {
+        SseEventSource localEventSource = eventSource;
+        if (localEventSource != null) {
+            if (!localEventSource.isOpen()) {
+                logger.debug("Existing EventSource is already closed");
+            } else if (localEventSource.close(timeout, timeoutUnit)) {
+                logger.debug("Succesfully closed existing EventSource");
+            } else {
+                logger.debug("Failed to close existing EventSource");
+            }
+            eventSource = null;
+        }
+        connected = false;
+    }
+
+    public boolean addStreamingDataListener(RemoteopenhabStreamingDataListener listener) {
+        return listeners.add(listener);
+    }
+
+    public boolean removeStreamingDataListener(RemoteopenhabStreamingDataListener listener) {
+        return listeners.remove(listener);
+    }
+
+    public long getLastEventTimestamp() {
+        return lastEventTimestamp;
+    }
+
+    private void onEvent(InboundSseEvent inboundEvent) {
+        String name = inboundEvent.getName();
+        String data = inboundEvent.readData();
+        logger.trace("Received event name {} date {}", name, data);
+
+        lastEventTimestamp = System.currentTimeMillis();
+        if (!connected) {
+            logger.debug("Connected to streaming events");
+            connected = true;
+            listeners.forEach(listener -> listener.onConnected());
+        }
+
+        if (!"message".equals(name)) {
+            logger.debug("Received unhandled event with name '{}' and data '{}'", name, data);
+            return;
+        }
+
+        try {
+            Event event = jsonParser.fromJson(data, Event.class);
+            String itemName;
+            EventPayload payload;
+            Item item;
+            switch (event.type) {
+                case "ItemStateEvent":
+                    itemName = extractItemNameFromTopic(event.topic, event.type, "state");
+                    payload = jsonParser.fromJson(event.payload, EventPayload.class);
+                    listeners.forEach(listener -> listener.onItemStateEvent(itemName, payload.type, payload.value));
+                    break;
+                case "GroupItemStateChangedEvent":
+                    itemName = extractItemNameFromTopic(event.topic, event.type, "statechanged");
+                    payload = jsonParser.fromJson(event.payload, EventPayload.class);
+                    listeners.forEach(listener -> listener.onItemStateEvent(itemName, payload.type, payload.value));
+                    break;
+                case "ItemAddedEvent":
+                    itemName = extractItemNameFromTopic(event.topic, event.type, "added");
+                    item = jsonParser.fromJson(event.payload, Item.class);
+                    listeners.forEach(listener -> listener.onItemAdded(item));
+                    break;
+                case "ItemRemovedEvent":
+                    itemName = extractItemNameFromTopic(event.topic, event.type, "removed");
+                    item = jsonParser.fromJson(event.payload, Item.class);
+                    listeners.forEach(listener -> listener.onItemRemoved(item));
+                    break;
+                case "ItemUpdatedEvent":
+                    itemName = extractItemNameFromTopic(event.topic, event.type, "updated");
+                    Item[] updItem = jsonParser.fromJson(event.payload, Item[].class);
+                    if (updItem.length == 2) {
+                        listeners.forEach(listener -> listener.onItemUpdated(updItem[0], updItem[1]));
+                    } else {
+                        logger.debug("Invalid payload for event type {} for topic {}", event.type, event.topic);
+                    }
+                    break;
+                case "ItemStatePredictedEvent":
+                case "ItemStateChangedEvent":
+                case "ItemCommandEvent":
+                    logger.trace("Ignored event type {} for topic {}", event.type, event.topic);
+                    break;
+                default:
+                    logger.debug("Unexpected event type {} for topic {}", event.type, event.topic);
+                    break;
+            }
+        } catch (RemoteopenhabException | JsonSyntaxException e) {
+            logger.debug("An exception occurred while processing the inbound '{}' event containg data: {}", name, data,
+                    e);
+        }
+    }
+
+    private void onError(Throwable error) {
+        logger.debug("Error occurred while receiving events", error);
+        listeners.forEach(listener -> listener.onError("Error occurred while receiving events"));
+    }
+
+    private String extractItemNameFromTopic(String topic, String eventType, String finalPart)
+            throws RemoteopenhabException {
+        String[] parts = topic.split("/");
+        int expectedNbParts = "GroupItemStateChangedEvent".equals(eventType) ? 5 : 4;
+        if (parts.length != expectedNbParts || !getTopicNamespace().equals(parts[0]) || !"items".equals(parts[1])
+                || !finalPart.equals(parts[parts.length - 1])) {
+            throw new RemoteopenhabException("Invalid event topic " + topic + " for event type " + eventType);
+        }
+        return parts[2];
+    }
+}
diff --git a/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/rest/RemoteopenhabStreamingRequestFilter.java b/bundles/org.openhab.binding.remoteopenhab/src/main/java/org/openhab/binding/remoteopenhab/internal/rest/RemoteopenhabStreamingRequestFilter.java
new file mode 100644 (file)
index 0000000..d792237
--- /dev/null
@@ -0,0 +1,49 @@
+/**
+ * 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.remoteopenhab.internal.rest;
+
+import java.io.IOException;
+
+import javax.ws.rs.client.ClientRequestContext;
+import javax.ws.rs.client.ClientRequestFilter;
+import javax.ws.rs.core.HttpHeaders;
+import javax.ws.rs.core.MultivaluedMap;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * Inserts Authorization and Cache-Control headers for requests on the streaming REST API.
+ *
+ * @author Laurent Garnier - Initial contribution
+ */
+@NonNullByDefault
+public class RemoteopenhabStreamingRequestFilter implements ClientRequestFilter {
+
+    private final String accessToken;
+
+    public RemoteopenhabStreamingRequestFilter(String accessToken) {
+        this.accessToken = accessToken;
+    }
+
+    @Override
+    public void filter(@Nullable ClientRequestContext requestContext) throws IOException {
+        if (requestContext != null) {
+            MultivaluedMap<String, Object> headers = requestContext.getHeaders();
+            if (!accessToken.isEmpty()) {
+                headers.putSingle(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
+            }
+            headers.putSingle(HttpHeaders.CACHE_CONTROL, "no-cache");
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.remoteopenhab/src/main/resources/OH-INF/binding/binding.xml b/bundles/org.openhab.binding.remoteopenhab/src/main/resources/OH-INF/binding/binding.xml
new file mode 100644 (file)
index 0000000..b0994e2
--- /dev/null
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<binding:binding id="remoteopenhab" 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>Remote openHAB Binding</name>
+       <description>The Remote openHAB binding allows to communicate with remote openHAB servers.</description>
+       <author>Laurent Garnier</author>
+
+</binding:binding>
diff --git a/bundles/org.openhab.binding.remoteopenhab/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.remoteopenhab/src/main/resources/OH-INF/thing/thing-types.xml
new file mode 100644 (file)
index 0000000..01d9980
--- /dev/null
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="remoteopenhab"
+       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">
+
+       <bridge-type id="server">
+               <label>Remote openHAB Server</label>
+               <description>A remote openHAB server.</description>
+
+               <representation-property>host</representation-property>
+
+               <config-description>
+                       <parameter name="host" type="text">
+                               <context>network-address</context>
+                               <label>Server Address</label>
+                               <description>The host name or IP address of the remote openHAB server.</description>
+                               <required>true</required>
+                       </parameter>
+
+                       <parameter name="port" type="integer">
+                               <label>Server HTTP Port</label>
+                               <description>The HTTP port to be used to communicate with the remote openHAB server.</description>
+                               <required>true</required>
+                               <default>8080</default>
+                               <advanced>true</advanced>
+                       </parameter>
+
+                       <parameter name="restPath" type="text">
+                               <label>REST API Path</label>
+                               <description>The subpath of the REST API on the remote openHAB server.</description>
+                               <required>true</required>
+                               <default>/rest</default>
+                               <advanced>true</advanced>
+                       </parameter>
+
+                       <parameter name="token" type="text">
+                               <context>password</context>
+                               <label>Token</label>
+                               <description>The token to use when the remote openHAB server is setup to require authorization to run its REST API.</description>
+                               <required>false</required>
+                               <advanced>true</advanced>
+                       </parameter>
+               </config-description>
+       </bridge-type>
+
+</thing:thing-descriptions>
index 7149fd487b104ed7c389c711fe5ad4340ef36b64..e39d791514dbd796fc5e4198a7ee4f23da2d5aa0 100644 (file)
     <module>org.openhab.binding.pushbullet</module>
     <module>org.openhab.binding.radiothermostat</module>
     <module>org.openhab.binding.regoheatpump</module>
+    <module>org.openhab.binding.remoteopenhab</module>
     <module>org.openhab.binding.rfxcom</module>
     <module>org.openhab.binding.rme</module>
     <module>org.openhab.binding.robonect</module>