/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
<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>
--- /dev/null
+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
--- /dev/null
+# 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" }
+```
--- /dev/null
+<?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>
--- /dev/null
+<?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>
--- /dev/null
+/**
+ * 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);
+}
--- /dev/null
+/**
+ * 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);
+ }
+}
--- /dev/null
+/**
+ * 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;
+ }
+}
--- /dev/null
+/**
+ * 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;
+ }
+}
--- /dev/null
+/**
+ * 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 = "";
+}
--- /dev/null
+/**
+ * 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 = "";
+}
--- /dev/null
+/**
+ * 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 = "";
+}
--- /dev/null
+/**
+ * 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;
+}
--- /dev/null
+/**
+ * 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 = "";
+}
--- /dev/null
+/**
+ * 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;
+}
--- /dev/null
+/**
+ * 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 = "";
+}
--- /dev/null
+/**
+ * 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 = "";
+}
--- /dev/null
+/**
+ * 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;
+}
--- /dev/null
+/**
+ * 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;
+ }
+}
--- /dev/null
+/**
+ * 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);
+ }
+}
--- /dev/null
+/**
+ * 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;
+ }
+ }
+}
--- /dev/null
+/**
+ * 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);
+}
--- /dev/null
+/**
+ * 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];
+ }
+}
--- /dev/null
+/**
+ * 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");
+ }
+ }
+}
--- /dev/null
+<?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>
--- /dev/null
+<?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>
<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>