diff --git a/CODEOWNERS b/CODEOWNERS
index 0f9c7f908e3..6304d89eb66 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -295,6 +295,7 @@
/bundles/org.openhab.binding.volvooncall/ @clinique
/bundles/org.openhab.binding.weathercompany/ @mhilbush
/bundles/org.openhab.binding.weatherunderground/ @lolodomo
+/bundles/org.openhab.binding.webthing/ @grro
/bundles/org.openhab.binding.wemo/ @hmerk
/bundles/org.openhab.binding.wifiled/ @rvt @xylo
/bundles/org.openhab.binding.windcentrale/ @marcelrv
diff --git a/bundles/org.openhab.binding.webthing/NOTICE b/bundles/org.openhab.binding.webthing/NOTICE
new file mode 100644
index 00000000000..38d625e3492
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/NOTICE
@@ -0,0 +1,13 @@
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-addons
diff --git a/bundles/org.openhab.binding.webthing/README.md b/bundles/org.openhab.binding.webthing/README.md
new file mode 100644
index 00000000000..0de308663f9
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/README.md
@@ -0,0 +1,106 @@
+# WebThing Binding
+
+The WebThing binding supports an interface to smart home device supporting the Web Thing API.
+
+The [Web Thing API](https://iot.mozilla.org/wot/) describes an open and generic standard to discover and link smart home devices
+like motion sensors, web-connected displays or awning controls. Devices implementing the Web Thing standard can be integrated
+into smart home systems such as openHAB to monitor and control them.
+These days, the Web Thing API is primarily used by makers to provide a common API to their physical devices.
+For instance, the Web Thing API has been used by makers to provide an open way to control [LEDs on a ESP8266 board](https://github.com/WebThingsIO/webthing-arduino)
+or to monitor [a PIR motion sensor on Raspberry Pi](https://pypi.org/project/pi-pir-webthing/).
+
+## Supported Things
+
+As a generic solution, the WebThing binding does not depend on specific devices. All devices implementing the Web Thing API should be accessible.
+
+
+## Discovery
+
+Once the binding is activated all reachable **WebThing devices will be detected automatically** (via mDNS).
+
+## Binding Configuration
+
+No binding configuration required.
+
+
+## Thing Configuration
+
+| Parameter | Description | Required |
+|----------|--------|-------------|
+| webThingURI | the URI of the WebThing | true |
+
+Due to the discovery support, **no manual Thing configuration is required** in general. However, under certain circumstances textual
+Thing configuration may be preferred. In this case, the webThingURI has to be configured as shown in the webthing.things file below:
+
+```
+Thing webthing:generic:motionsensor [ webThingURI="http://192.168.1.27:9496/" ]
+```
+
+## Channels
+
+The supported channels depend on the WebThing device that is connected. Each mappable **WebThing property will be mapped to a dedicated channel, automatically**. For instance, to support the *motion property* of a Motion-Sensor WebThing, a dedicated *motion channel* will be created, automatically.
+
+| Thing | channel | type | description |
+|--------|----------|--------|------------------------------|
+| WebThing | Automatic | Automatic | All channels will be generated automatically based on the detected WebThing properties |
+
+## Full Example
+
+In the example below WebThings provided by the [Internet Monitor Service](https://pypi.org/project/internet-monitor-webthing/) will be connected.
+This service does not require specific hardware or devices. To connect the WebThings, the service has to be installed inside your local network.
+
+
+### Thing
+
+After installing the WebThing binding you should find the WebThings of your network in the things section of your openHAB administration interface as shown below.
+
+![discovery picture](docs/discovery.png)
+
+Here, the WebThings provided by the *Internet Monitor Service*: the *Internet Connectivity* WebThing as well as the
+*Internet Speed Monitor* WebThing have been discovered. To add a WebThing as an openHAB Thing click the 'Add as Thing' button.
+
+![discovery picture](docs/speedmonitor.png)
+
+Alternatively, you may add the WebThing as a openHAB Thing by using a webthing.thing file that has to be located inside the things folder.
+
+```
+Thing webthing:generic:speedmonitor [ webThingURI="http://192.168.1.27:9496/0" ]
+```
+
+Please consider that the *Internet Monitor Service* in this example supports two WebThings. Both WebThings are bound on the
+same hostname and port. However, the WebThing URI path of the speed monitor ends with '/0'. In contrast,
+the connectivity WebThing URI path ends with '/1' in this example.
+
+Due to the fact that the WebThing API is based on web technologies, you can validate the WebThing description by opening the WebThing uri in a browser.
+
+![webthing picture](docs/webthing_description.png)
+
+### Items
+
+The *Internet Speed Monitor* WebThing used in this example supports properties such as *downloadspeed*, *uploadspeed* or *ping*.
+For each property of the WebThing a dedicated openHAB channel will be created, automatically. The channelUID such
+as *webthing:generic:speedmonitor:uploadspeed* is the combination of the thingUID *webthing:generic* and the
+WebThing property name *uploadspeed*.
+
+![channels picture](docs/channels.png)
+
+These channels may be linked via the channels tab of the graphical user interface or manually via a webthing.items file as shown below
+
+ ```
+Number uploadSpeed "uploadspeed speed [%.0f]" {channel="webthing:generic:speedmonitor:uploadspeed"}
+Number downloadSpeed "download speed [%.0f]" {channel="webthing:generic:speedmonitor:downloadspeed"}
+
+ ```
+
+### Sitemap
+
+To add the newly linked WebThing items to the sitemap you place a sitemap file such as the internetmonitor.sitemap file shown below
+
+```
+sitemap internetmonitor label="Internet Speed Monitor" {
+ Text item=uploadSpeed
+ Text item=downloadSpeed
+}
+```
+
+![sitemap picture](docs/sitemap.png)
diff --git a/bundles/org.openhab.binding.webthing/docs/channels.png b/bundles/org.openhab.binding.webthing/docs/channels.png
new file mode 100644
index 00000000000..d9460639381
Binary files /dev/null and b/bundles/org.openhab.binding.webthing/docs/channels.png differ
diff --git a/bundles/org.openhab.binding.webthing/docs/discovery.png b/bundles/org.openhab.binding.webthing/docs/discovery.png
new file mode 100644
index 00000000000..f78ca0979a2
Binary files /dev/null and b/bundles/org.openhab.binding.webthing/docs/discovery.png differ
diff --git a/bundles/org.openhab.binding.webthing/docs/sitemap.png b/bundles/org.openhab.binding.webthing/docs/sitemap.png
new file mode 100644
index 00000000000..e84e98ed7c4
Binary files /dev/null and b/bundles/org.openhab.binding.webthing/docs/sitemap.png differ
diff --git a/bundles/org.openhab.binding.webthing/docs/speedmonitor.png b/bundles/org.openhab.binding.webthing/docs/speedmonitor.png
new file mode 100644
index 00000000000..3277248b849
Binary files /dev/null and b/bundles/org.openhab.binding.webthing/docs/speedmonitor.png differ
diff --git a/bundles/org.openhab.binding.webthing/docs/webthing_description.png b/bundles/org.openhab.binding.webthing/docs/webthing_description.png
new file mode 100644
index 00000000000..f9d55a3287a
Binary files /dev/null and b/bundles/org.openhab.binding.webthing/docs/webthing_description.png differ
diff --git a/bundles/org.openhab.binding.webthing/pom.xml b/bundles/org.openhab.binding.webthing/pom.xml
new file mode 100644
index 00000000000..d3a4ea30371
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/pom.xml
@@ -0,0 +1,16 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.addons.bundles
+ org.openhab.addons.reactor.bundles
+ 3.1.0-SNAPSHOT
+
+
+ org.openhab.binding.webthing
+
+ openHAB Add-ons :: Bundles :: WebThing Binding
+
diff --git a/bundles/org.openhab.binding.webthing/src/main/feature/feature.xml b/bundles/org.openhab.binding.webthing/src/main/feature/feature.xml
new file mode 100644
index 00000000000..df244f015c2
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/src/main/feature/feature.xml
@@ -0,0 +1,10 @@
+
+
+ mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features
+
+
+ openhab-runtime-base
+ openhab-transport-mdns
+ mvn:org.openhab.addons.bundles/org.openhab.binding.webthing/${project.version}
+
+
diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/ChannelHandler.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/ChannelHandler.java
new file mode 100644
index 00000000000..721136d4dbd
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/ChannelHandler.java
@@ -0,0 +1,58 @@
+/**
+ * Copyright (c) 2010-2021 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.webthing.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+
+/**
+ * The {@link ChannelHandler} class is a simplified abstraction of an openHAB Channel implementing
+ * methods to observe a channel as well to update an Item associated to a channel
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+@NonNullByDefault
+public interface ChannelHandler {
+
+ /**
+ * register a listener to observer the channel regarding item change events
+ *
+ * @param channelUID the channel identifier
+ * @param listener the listener to be notified
+ */
+ void observeChannel(ChannelUID channelUID, ItemChangedListener listener);
+
+ /**
+ * updates an Item state of a dedicated channel
+ *
+ * @param channelUID the channel identifier
+ * @param command the state update command
+ */
+ void updateItemState(ChannelUID channelUID, Command command);
+
+ /**
+ * Listener that will be notified, if a Item state is changed
+ */
+ interface ItemChangedListener {
+
+ /**
+ * item change callback method
+ *
+ * @param channelUID the channel identifier
+ * @param stateCommand the item change command
+ */
+ void onItemStateChanged(ChannelUID channelUID, State stateCommand);
+ }
+}
diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingBindingConstants.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingBindingConstants.java
new file mode 100644
index 00000000000..9038afdcd51
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingBindingConstants.java
@@ -0,0 +1,38 @@
+/**
+ * Copyright (c) 2010-2021 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.webthing.internal;
+
+import java.util.Collection;
+import java.util.Collections;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.thing.ThingTypeUID;
+
+/**
+ * The {@link WebThingBindingConstants} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+@NonNullByDefault
+public class WebThingBindingConstants {
+
+ public static final String BINDING_ID = "webthing";
+
+ public static final ThingTypeUID THING_TYPE_UID = new ThingTypeUID(BINDING_ID, "generic");
+
+ public static final Collection SUPPORTED_THING_TYPES_UIDS = Collections
+ .singleton(WebThingBindingConstants.THING_TYPE_UID);
+
+ public static final String MDNS_SERVICE_TYPE = "_webthing._tcp.local.";
+}
diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingConfiguration.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingConfiguration.java
new file mode 100644
index 00000000000..1a7d33783c0
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingConfiguration.java
@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) 2010-2021 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.webthing.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link WebThingConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+@NonNullByDefault
+public class WebThingConfiguration {
+
+ /**
+ * The webThing uri. This URI will be detected within the discovery process
+ */
+ @Nullable
+ public String webThingURI = null;
+}
diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingHandler.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingHandler.java
new file mode 100644
index 00000000000..53406da838b
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingHandler.java
@@ -0,0 +1,298 @@
+/**
+ * Copyright (c) 2010-2021 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.webthing.internal;
+
+import java.io.IOException;
+import java.net.URI;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.websocket.client.WebSocketClient;
+import org.openhab.binding.webthing.internal.channel.Channels;
+import org.openhab.binding.webthing.internal.client.*;
+import org.openhab.binding.webthing.internal.link.ChannelToPropertyLink;
+import org.openhab.binding.webthing.internal.link.PropertyToChannelLink;
+import org.openhab.binding.webthing.internal.link.UnknownPropertyException;
+import org.openhab.core.thing.*;
+import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.openhab.core.types.State;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link WebThingHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+@NonNullByDefault
+public class WebThingHandler extends BaseThingHandler implements ChannelHandler {
+ private static final Duration RECONNECT_PERIOD = Duration.ofHours(23);
+ private static final Duration HEALTH_CHECK_PERIOD = Duration.ofSeconds(70);
+ private static final ItemChangedListener EMPTY_ITEM_CHANGED_LISTENER = (channelUID, stateCommand) -> {
+ };
+
+ private final Logger logger = LoggerFactory.getLogger(WebThingHandler.class);
+ private final HttpClient httpClient;
+ private final WebSocketClient webSocketClient;
+ private final AtomicBoolean isActivated = new AtomicBoolean(true);
+ private final Map itemChangedListenerMap = new ConcurrentHashMap<>();
+ private final AtomicReference> webThingConnectionRef = new AtomicReference<>(
+ Optional.empty());
+ private final AtomicReference lastReconnect = new AtomicReference<>(Instant.now());
+ private final AtomicReference>> watchdogHandle = new AtomicReference<>(
+ Optional.empty());
+ private @Nullable URI webThingURI = null;
+
+ public WebThingHandler(Thing thing, HttpClient httpClient, WebSocketClient webSocketClient) {
+ super(thing);
+ this.httpClient = httpClient;
+ this.webSocketClient = webSocketClient;
+ }
+
+ private boolean isOnline() {
+ return getThing().getStatus() == ThingStatus.ONLINE;
+ }
+
+ private boolean isDisconnected() {
+ return (getThing().getStatus() == ThingStatus.OFFLINE) || (getThing().getStatus() == ThingStatus.UNKNOWN);
+ }
+
+ private boolean isAlive() {
+ return webThingConnectionRef.get().map(ConsumedThing::isAlive).orElse(false);
+ }
+
+ @Override
+ public void initialize() {
+ updateStatus(ThingStatus.UNKNOWN);
+ isActivated.set(true); // set with true, even though the connect may fail. In this case retries will be
+ // triggered
+
+ // perform connect in background
+ scheduler.execute(() -> {
+ // WebThing URI present?
+ var uri = toUri(getConfigAs(WebThingConfiguration.class).webThingURI);
+ if (uri != null) {
+ logger.debug("try to connect WebThing {}", uri);
+ var connected = tryReconnect(uri);
+ if (connected) {
+ logger.debug("WebThing {} connected", getWebThingLabel());
+ }
+ } else {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "webThing uri has not been set");
+ logger.warn("could not initialize WebThing. URI is not set or invalid. {}", this.webThingURI);
+ }
+ });
+
+ // starting watchdog that checks the healthiness of the WebThing connection, periodically
+ watchdogHandle
+ .getAndSet(Optional.of(scheduler.scheduleWithFixedDelay(this::checkWebThingConnection,
+ HEALTH_CHECK_PERIOD.getSeconds(), HEALTH_CHECK_PERIOD.getSeconds(), TimeUnit.SECONDS)))
+ .ifPresent(future -> future.cancel(true));
+ }
+
+ private @Nullable URI toUri(@Nullable String uri) {
+ try {
+ if (uri != null) {
+ return URI.create(uri);
+ }
+ } catch (IllegalArgumentException illegalURIException) {
+ return null;
+ }
+ return null;
+ }
+
+ @Override
+ public void dispose() {
+ try {
+ isActivated.set(false); // set to false to avoid reconnecting
+
+ // terminate WebThing connection as well as the alive watchdog
+ webThingConnectionRef.getAndSet(Optional.empty()).ifPresent(ConsumedThing::close);
+ watchdogHandle.getAndSet(Optional.empty()).ifPresent(future -> future.cancel(true));
+ } finally {
+ super.dispose();
+ }
+ }
+
+ private boolean tryReconnect(@Nullable URI uri) {
+ if (isActivated.get()) { // will try reconnect only, if activated
+ try {
+ // create the client-side WebThing representation
+ if (uri != null) {
+ var webThing = ConsumedThingFactory.instance().create(webSocketClient, httpClient, uri, scheduler,
+ this::onError);
+ this.webThingConnectionRef.getAndSet(Optional.of(webThing)).ifPresent(ConsumedThing::close);
+
+ // update the Thing structure based on the WebThing description
+ thingStructureChanged(webThing);
+
+ // link the Thing's channels with the WebThing properties to forward properties/item updates
+ establishWebThingChannelLinks(webThing);
+
+ lastReconnect.set(Instant.now());
+ updateStatus(ThingStatus.ONLINE);
+ return true;
+ }
+ } catch (IOException e) {
+ var msg = e.getMessage();
+ if (msg == null) {
+ msg = "";
+ }
+ onError(msg);
+ }
+ }
+ return false;
+ }
+
+ public void onError(String reason) {
+ var wasConnectedBefore = isOnline();
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, reason);
+
+ // close the WebThing connection. If the handler is still active, the WebThing connection
+ // will be re-established within the periodically watchdog task
+ webThingConnectionRef.getAndSet(Optional.empty()).ifPresent(ConsumedThing::close);
+
+ if (wasConnectedBefore) { // to reduce log messages, just log in case of connection state changed
+ logger.debug("WebThing {} disconnected {}. Try reconnect (each {} sec)", getWebThingLabel(), reason,
+ HEALTH_CHECK_PERIOD.getSeconds());
+ } else {
+ logger.debug("WebThing {} is offline {}. Try reconnect (each {} sec)", getWebThingLabel(), reason,
+ HEALTH_CHECK_PERIOD.getSeconds());
+ }
+ }
+
+ private String getWebThingLabel() {
+ if (getThing().getLabel() == null) {
+ return "" + webThingURI;
+ } else {
+ return "'" + getThing().getLabel() + "' (" + webThingURI + ")";
+ }
+ }
+
+ /**
+ * updates the thing structure. Refer https://www.openhab.org/docs/developer/bindings/#updating-the-thing-structure
+ *
+ * @param webThing the WebThing that is used for the new structure
+ */
+ private void thingStructureChanged(ConsumedThing webThing) {
+ var thingBuilder = editThing().withLabel(webThing.getThingDescription().title);
+
+ // create a channel for each WebThing property
+ for (var entry : webThing.getThingDescription().properties.entrySet()) {
+ var channel = Channels.createChannel(thing.getUID(), entry.getKey(), entry.getValue());
+ // add channel (and remove a previous one, if exist)
+ thingBuilder.withoutChannel(channel.getUID()).withChannel(channel);
+ }
+ var thing = thingBuilder.build();
+
+ // and update the thing
+ updateThing(thing);
+ }
+
+ /**
+ * connects each WebThing property with a corresponding openHAB channel. After this changes will be synchronized
+ * between a WebThing property and the openHAB channel
+ *
+ * @param webThing the WebThing to be connected
+ * @throws IOException if the channels can not be connected
+ */
+ private void establishWebThingChannelLinks(ConsumedThing webThing) throws IOException {
+ // remove all registered listeners
+ itemChangedListenerMap.clear();
+
+ // create new links (listeners will be registered, implicitly)
+ for (var namePropertyPair : webThing.getThingDescription().properties.entrySet()) {
+ try {
+ // determine the name of the associated channel
+ var channelUID = Channels.createChannelUID(getThing().getUID(), namePropertyPair.getKey());
+
+ // will try to establish a link, if channel is present
+ var channel = getThing().getChannel(channelUID);
+ if (channel != null) {
+ // establish downstream link
+ PropertyToChannelLink.establish(webThing, namePropertyPair.getKey(), this, channel);
+
+ // establish upstream link
+ if (!namePropertyPair.getValue().readOnly) {
+ ChannelToPropertyLink.establish(this, channel, webThing, namePropertyPair.getKey());
+ }
+ }
+ } catch (UnknownPropertyException upe) {
+ logger.warn("WebThing {} property {} could not be linked with a channel", getWebThingLabel(),
+ namePropertyPair.getKey(), upe);
+ }
+ }
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ if (command instanceof State) {
+ itemChangedListenerMap.getOrDefault(channelUID, EMPTY_ITEM_CHANGED_LISTENER).onItemStateChanged(channelUID,
+ (State) command);
+ } else if (command instanceof RefreshType) {
+ tryReconnect(webThingURI);
+ }
+ }
+
+ /////////////
+ // ChannelHandler methods
+ @Override
+ public void observeChannel(ChannelUID channelUID, ItemChangedListener listener) {
+ itemChangedListenerMap.put(channelUID, listener);
+ }
+
+ @Override
+ public void updateItemState(ChannelUID channelUID, Command command) {
+ if (isActivated.get()) {
+ postCommand(channelUID, command);
+ }
+ }
+ //
+ /////////////
+
+ private void checkWebThingConnection() {
+ // try reconnect, if necessary
+ if (isDisconnected() || (isOnline() && !isAlive())) {
+ logger.debug("try reconnecting WebThing {}", getWebThingLabel());
+ if (tryReconnect(webThingURI)) {
+ logger.debug("WebThing {} reconnected", getWebThingLabel());
+ }
+
+ } else {
+ // force reconnecting periodically, to fix erroneous states that occurs for unknown reasons
+ var elapsedSinceLastReconnect = Duration.between(lastReconnect.get(), Instant.now());
+ if (isOnline() && (elapsedSinceLastReconnect.getSeconds() > RECONNECT_PERIOD.getSeconds())) {
+ if (tryReconnect(webThingURI)) {
+ logger.debug("WebThing {} reconnected. Initiated by periodic reconnect", getWebThingLabel());
+ } else {
+ logger.debug("could not reconnect WebThing {} (periodic reconnect failed). Next trial in {} sec",
+ getWebThingLabel(), HEALTH_CHECK_PERIOD.getSeconds());
+ }
+
+ }
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingHandlerFactory.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingHandlerFactory.java
new file mode 100644
index 00000000000..e138454a11d
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingHandlerFactory.java
@@ -0,0 +1,58 @@
+/**
+ * Copyright (c) 2010-2021 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.webthing.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.websocket.client.WebSocketClient;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.openhab.core.io.net.http.WebSocketFactory;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.binding.BaseThingHandlerFactory;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerFactory;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * The {@link WebThingHandlerFactory} is responsible for creating things and thing
+ * handlers.
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+@NonNullByDefault
+@Component(configurationPid = "binding.webthing", service = ThingHandlerFactory.class)
+public class WebThingHandlerFactory extends BaseThingHandlerFactory {
+ private final HttpClient httpClient;
+ private final WebSocketClient webSocketClient;
+
+ @Activate
+ public WebThingHandlerFactory(@Reference HttpClientFactory httpClientFactory,
+ @Reference WebSocketFactory webSocketFactory) {
+ this.httpClient = httpClientFactory.getCommonHttpClient();
+ this.webSocketClient = webSocketFactory.getCommonWebSocketClient();
+ }
+
+ @Override
+ public boolean supportsThingType(ThingTypeUID thingTypeUID) {
+ return WebThingBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
+ }
+
+ @Override
+ protected @Nullable ThingHandler createHandler(Thing thing) {
+ return new WebThingHandler(thing, httpClient, webSocketClient);
+ }
+}
diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/channel/Channels.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/channel/Channels.java
new file mode 100644
index 00000000000..8ffb417dfef
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/channel/Channels.java
@@ -0,0 +1,74 @@
+/**
+ * Copyright (c) 2010-2021 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.webthing.internal.channel;
+
+import static org.openhab.binding.webthing.internal.WebThingBindingConstants.BINDING_ID;
+
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.webthing.internal.client.dto.Property;
+import org.openhab.binding.webthing.internal.link.TypeMapping;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.builder.ChannelBuilder;
+import org.openhab.core.thing.type.ChannelTypeUID;
+
+/**
+ * The {@link Channels} class is an utility class to create Channel based on the property characteristics as
+ * well as ChannelUID identifier
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+@NonNullByDefault
+public class Channels {
+
+ /**
+ * create a ChannelUIFD identifier for a given property name
+ *
+ * @param thingUID the thing uid of the associated WebThing
+ * @param propertyName the property name
+ * @return the ChannelUID identifier
+ */
+ public static ChannelUID createChannelUID(ThingUID thingUID, String propertyName) {
+ return new ChannelUID(thingUID.toString() + ":" + propertyName);
+ }
+
+ /**
+ * create a Channel base on a given WebThing property
+ *
+ * @param thingUID the thing uid of the associated WebThing
+ * @param propertyName the property name
+ * @param property the WebThing property
+ * @return the Channel according to the properties characteristics
+ */
+ public static Channel createChannel(ThingUID thingUID, String propertyName, Property property) {
+ var itemType = TypeMapping.toItemType(property);
+ var channelUID = createChannelUID(thingUID, propertyName);
+ var channelBuilder = ChannelBuilder.create(channelUID, itemType.getType());
+
+ // Currently, few predefined, generic channel types such as number, string or color are defined
+ // inside the thing-types.xml file. A better solution would be to create the channel types
+ // dynamically based on the WebThing description to make most of the meta data of a WebThing.
+ // The goal of the WebThing meta data is to enable semantic interoperability for connected things
+ channelBuilder.withType(new ChannelTypeUID(BINDING_ID, itemType.getType()));
+ channelBuilder.withDescription(property.description);
+ channelBuilder.withLabel(property.title);
+ var defaultTag = itemType.getTag();
+ if (defaultTag != null) {
+ channelBuilder.withDefaultTags(Set.of(defaultTag));
+ }
+ return channelBuilder.build();
+ }
+}
diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/ConsumedThing.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/ConsumedThing.java
new file mode 100644
index 00000000000..4ae43fb712e
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/ConsumedThing.java
@@ -0,0 +1,70 @@
+/**
+ * Copyright (c) 2010-2021 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.webthing.internal.client;
+
+import java.util.function.BiConsumer;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.webthing.internal.client.dto.WebThingDescription;
+
+/**
+ * A WebThing represents the client-side proxy of a remote devices implementing the Web Thing API according to
+ * https://iot.mozilla.org/wot/
+ * The API design is oriented on https://www.w3.org/TR/wot-scripting-api/#the-consumedthing-interface
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+@NonNullByDefault
+public interface ConsumedThing {
+
+ /**
+ * @return the description (meta data) of the WebThing
+ */
+ WebThingDescription getThingDescription();
+
+ /**
+ * Makes a request for Property value change notifications
+ *
+ * @param propertyName the property to be observed
+ * @param listener the listener to call on changes
+ */
+ void observeProperty(String propertyName, BiConsumer listener);
+
+ /**
+ * Writes a single Property.
+ *
+ * @param propertyName the propertyName
+ * @return the current propertyValue
+ * @throws PropertyAccessException if the property can not be read
+ */
+ Object readProperty(String propertyName) throws PropertyAccessException;
+
+ /**
+ * Writes a single Property.
+ *
+ * @param propertyName the propertyName
+ * @param newValue the new propertyValue
+ * @throws PropertyAccessException if the property can not be written
+ */
+ void writeProperty(String propertyName, Object newValue) throws PropertyAccessException;
+
+ /**
+ * @return true, if connection is alive
+ */
+ boolean isAlive();
+
+ /**
+ * closes the connection
+ */
+ void close();
+}
diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/ConsumedThingFactory.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/ConsumedThingFactory.java
new file mode 100644
index 00000000000..a926a71647d
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/ConsumedThingFactory.java
@@ -0,0 +1,50 @@
+/**
+ * Copyright (c) 2010-2021 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.webthing.internal.client;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.function.Consumer;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.websocket.client.WebSocketClient;
+
+/**
+ * Factory to create new instances of the WebThing client-side proxy
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+@NonNullByDefault
+public interface ConsumedThingFactory {
+
+ /**
+ * @param webSocketClient the webSocketClient to use
+ * @param httpClient the http client to use
+ * @param webThingURI the identifier of a WebThing resource
+ * @param executor executor
+ * @param errorHandler the error handler
+ * @return the newly created WebThing
+ * @throws IOException if the WebThing can not be connected
+ */
+ ConsumedThing create(WebSocketClient webSocketClient, HttpClient httpClient, URI webThingURI,
+ ScheduledExecutorService executor, Consumer errorHandler) throws IOException;
+
+ /**
+ * @return the default instance of the factory
+ */
+ static ConsumedThingFactory instance() {
+ return ConsumedThingImpl::new;
+ }
+}
diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/ConsumedThingImpl.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/ConsumedThingImpl.java
new file mode 100644
index 00000000000..9dc7d453b67
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/ConsumedThingImpl.java
@@ -0,0 +1,262 @@
+/**
+ * Copyright (c) 2010-2021 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.webthing.internal.client;
+
+import java.io.IOException;
+import java.net.URI;
+import java.time.Duration;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.util.StringContentProvider;
+import org.eclipse.jetty.websocket.client.WebSocketClient;
+import org.openhab.binding.webthing.internal.client.dto.Property;
+import org.openhab.binding.webthing.internal.client.dto.WebThingDescription;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+
+/**
+ * The implementation of the client-side Webthing representation. This is based on HTTP. Bindings to alternative
+ * application protocols such as CoAP may be defined in the future (which may be implemented by a another class)
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+@NonNullByDefault
+public class ConsumedThingImpl implements ConsumedThing {
+ private static final Duration DEFAULT_PING_PERIOD = Duration.ofSeconds(80);
+ private final Logger logger = LoggerFactory.getLogger(ConsumedThingImpl.class);
+ private final URI webThingURI;
+ private final Gson gson = new Gson();
+ private final HttpClient httpClient;
+ private final Consumer errorHandler;
+ private final WebThingDescription description;
+ private final WebSocketConnection websocketDownstream;
+ private final AtomicBoolean isOpen = new AtomicBoolean(true);
+
+ /**
+ * constructor
+ *
+ * @param webSocketClient the web socket client to use
+ * @param httpClient the http client to use
+ * @param webThingURI the identifier of a WebThing resource
+ * @param executor executor to use
+ * @param errorHandler the error handler
+ * @throws IOException it the WebThing can not be connected
+ */
+ ConsumedThingImpl(WebSocketClient webSocketClient, HttpClient httpClient, URI webThingURI,
+ ScheduledExecutorService executor, Consumer errorHandler) throws IOException {
+ this(httpClient, webThingURI, executor, errorHandler, WebSocketConnectionFactory.instance(webSocketClient));
+ }
+
+ /**
+ * constructor
+ *
+ * @param httpClient the http client to use
+ * @param webthingUrl the identifier of a WebThing resource
+ * @param executor executor to use
+ * @param errorHandler the error handler
+ * @param webSocketConnectionFactory the Websocket connectino fctory to be used
+ * @throws IOException if the WebThing can not be connected
+ */
+ ConsumedThingImpl(HttpClient httpClient, URI webthingUrl, ScheduledExecutorService executor,
+ Consumer errorHandler, WebSocketConnectionFactory webSocketConnectionFactory) throws IOException {
+ this(httpClient, webthingUrl, executor, errorHandler, webSocketConnectionFactory, DEFAULT_PING_PERIOD);
+ }
+
+ /**
+ * constructor
+ *
+ * @param httpClient the http client to use
+ * @param webthingUrl the identifier of a WebThing resource
+ * @param executor executor to use
+ * @param errorHandler the error handler
+ * @param webSocketConnectionFactory the Websocket connectino fctory to be used
+ * @param pingPeriod the ping period tothe the healthiness of the connection
+ * @throws IOException if the WebThing can not be connected
+ */
+ ConsumedThingImpl(HttpClient httpClient, URI webthingUrl, ScheduledExecutorService executor,
+ Consumer errorHandler, WebSocketConnectionFactory webSocketConnectionFactory, Duration pingPeriod)
+ throws IOException {
+ this.webThingURI = webthingUrl;
+ this.httpClient = httpClient;
+ this.errorHandler = errorHandler;
+ this.description = new DescriptionLoader(httpClient).loadWebthingDescription(webThingURI,
+ Duration.ofSeconds(20));
+
+ // opens a websocket downstream to be notified if a property value will be changed
+ var optionalEventStreamUri = this.description.getEventStreamUri();
+ if (optionalEventStreamUri.isPresent()) {
+ this.websocketDownstream = webSocketConnectionFactory.create(optionalEventStreamUri.get(), executor,
+ this::onError, pingPeriod);
+ } else {
+ throw new IOException("WebThing " + webThingURI + " does not support websocket uri. WebThing description: "
+ + this.description);
+ }
+ }
+
+ private Optional getPropertyUri(String propertyName) {
+ var optionalProperty = description.getProperty(propertyName);
+ if (optionalProperty.isPresent()) {
+ var propertyDescription = optionalProperty.get();
+ for (var link : propertyDescription.links) {
+ if ((link.rel != null) && (link.href != null) && link.rel.equals("property")) {
+ return Optional.of(webThingURI.resolve(link.href));
+ }
+ }
+ }
+ return Optional.empty();
+ }
+
+ @Override
+ public boolean isAlive() {
+ return isOpen.get() && this.websocketDownstream.isAlive();
+ }
+
+ @Override
+ public void close() {
+ isOpen.set(false);
+ this.websocketDownstream.close();
+ }
+
+ void onError(String reason) {
+ logger.debug("WebThing {} error occurred. {}", webThingURI, reason);
+ if (isOpen.get()) {
+ errorHandler.accept(reason);
+ }
+ close();
+ }
+
+ @Override
+ public WebThingDescription getThingDescription() {
+ return this.description;
+ }
+
+ @Override
+ public void observeProperty(String propertyName, BiConsumer listener) {
+ this.websocketDownstream.observeProperty(propertyName, listener);
+
+ // it may take a long time before the observed property value will be changed. For this reason
+ // read and notify the current property value (as starting point)
+ try {
+ var value = readProperty(propertyName);
+ listener.accept(propertyName, value);
+ } catch (PropertyAccessException pae) {
+ logger.warn("could not read WebThing {} property {}", webThingURI, propertyName, pae);
+ }
+ }
+
+ @Override
+ public Object readProperty(String propertyName) throws PropertyAccessException {
+ var optionalPropertyUri = getPropertyUri(propertyName);
+ if (optionalPropertyUri.isPresent()) {
+ var propertyUri = optionalPropertyUri.get();
+ try {
+ var response = httpClient.newRequest(propertyUri).timeout(30, TimeUnit.SECONDS)
+ .accept("application/json").send();
+ if (response.getStatus() < 200 || response.getStatus() >= 300) {
+ onError("WebThing " + webThingURI + " disconnected");
+ throw new PropertyAccessException("could not read " + propertyName + " (" + propertyUri + ")");
+ }
+ var body = response.getContentAsString();
+ var properties = gson.fromJson(body, Map.class);
+ if (properties == null) {
+ onError("WebThing " + webThingURI + " erroneous");
+ throw new PropertyAccessException("could not read " + propertyName + " (" + propertyUri
+ + "). Response does not include any property (" + propertyUri + "): " + body);
+ } else {
+ var value = properties.get(propertyName);
+ if (value != null) {
+ return value;
+ } else {
+ onError("WebThing " + webThingURI + " erroneous");
+ throw new PropertyAccessException("could not read " + propertyName + " (" + propertyUri
+ + "). Response does not include " + propertyName + "(" + propertyUri + "): " + body);
+ }
+ }
+ } catch (ExecutionException | TimeoutException | InterruptedException e) {
+ onError("WebThing resource " + webThingURI + " disconnected");
+ throw new PropertyAccessException("could not read " + propertyName + " (" + propertyUri + ").", e);
+ }
+ } else {
+ onError("WebThing " + webThingURI + " does not support " + propertyName);
+ throw new PropertyAccessException("WebThing " + webThingURI + " does not support " + propertyName);
+ }
+ }
+
+ @Override
+ public void writeProperty(String propertyName, Object newValue) throws PropertyAccessException {
+ var optionalPropertyUri = getPropertyUri(propertyName);
+ if (optionalPropertyUri.isPresent()) {
+ var propertyUri = optionalPropertyUri.get();
+ var optionalProperty = description.getProperty(propertyName);
+ if (optionalProperty.isPresent()) {
+ try {
+ if (optionalProperty.get().readOnly) {
+ throw new PropertyAccessException("could not write " + propertyName + " (" + propertyUri
+ + ") with " + newValue + ". Property is readOnly");
+ } else {
+ logger.debug("updating {} with {}", propertyName, newValue);
+ Map payload = Map.of(propertyName, newValue);
+ var json = gson.toJson(payload);
+ var response = httpClient.newRequest(propertyUri).method("PUT")
+ .content(new StringContentProvider(json), "application/json")
+ .timeout(30, TimeUnit.SECONDS).send();
+ if (response.getStatus() < 200 || response.getStatus() >= 300) {
+ onError("WebThing " + webThingURI + "could not write " + propertyName + " (" + propertyUri
+ + ") with " + newValue);
+ throw new PropertyAccessException(
+ "could not write " + propertyName + " (" + propertyUri + ") with " + newValue);
+ }
+ }
+ } catch (ExecutionException | TimeoutException | InterruptedException e) {
+ onError("WebThing resource " + webThingURI + " disconnected");
+ throw new PropertyAccessException(
+ "could not write " + propertyName + " (" + propertyUri + ") with " + newValue, e);
+ }
+ } else {
+ throw new PropertyAccessException("could not write " + propertyName + " (" + propertyUri + ") with "
+ + newValue + " WebTing does not support a property named " + propertyName);
+ }
+ } else {
+ onError("WebThing " + webThingURI + " does not support " + propertyName);
+ throw new PropertyAccessException("WebThing " + webThingURI + " does not support " + propertyName);
+ }
+ }
+
+ /**
+ * Gets the property description
+ *
+ * @param propertyName the propertyName
+ * @return the description (meta data) of the property
+ */
+ public @Nullable Property getPropertyDescription(String propertyName) {
+ return description.properties.get(propertyName);
+ }
+
+ @Override
+ public String toString() {
+ return "WebThing " + description.title + " (" + webThingURI + ")";
+ }
+}
diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/DescriptionLoader.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/DescriptionLoader.java
new file mode 100644
index 00000000000..f172ff79373
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/DescriptionLoader.java
@@ -0,0 +1,97 @@
+/**
+ * Copyright (c) 2010-2021 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.webthing.internal.client;
+
+import java.io.IOException;
+import java.net.URI;
+import java.time.Duration;
+import java.util.Locale;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jetty.client.HttpClient;
+import org.openhab.binding.webthing.internal.client.dto.WebThingDescription;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonSyntaxException;
+
+/**
+ * Utility class to load the WebThing description (meta data). Refer https://iot.mozilla.org/wot/#web-thing-description
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+@NonNullByDefault
+public class DescriptionLoader {
+ private final Logger logger = LoggerFactory.getLogger(DescriptionLoader.class);
+ private final Gson gson = new Gson();
+ private final HttpClient httpClient;
+
+ /**
+ * constructor
+ *
+ * @param httpClient the http client to use
+ */
+ public DescriptionLoader(HttpClient httpClient) {
+ this.httpClient = httpClient;
+ }
+
+ /**
+ * loads the WebThing meta data
+ *
+ * @param webthingURI the WebThing URI
+ * @param timeout the timeout
+ * @return the Webthing description
+ * @throws IOException if the WebThing can not be connected
+ */
+ public WebThingDescription loadWebthingDescription(URI webthingURI, Duration timeout) throws IOException {
+ try {
+ var response = httpClient.newRequest(webthingURI).timeout(30, TimeUnit.SECONDS).accept("application/json")
+ .send();
+ if (response.getStatus() < 200 || response.getStatus() >= 300) {
+ throw new IOException(
+ "could not read resource description " + webthingURI + ". Got " + response.getStatus());
+ }
+ var body = response.getContentAsString();
+ var description = gson.fromJson(body, WebThingDescription.class);
+ if ((description != null) && (description.properties != null) && (description.properties.size() > 0)) {
+ if ((description.contextKeyword == null) || description.contextKeyword.trim().length() == 0) {
+ description.contextKeyword = "https://webthings.io/schemas";
+ }
+ var schema = description.contextKeyword.replaceFirst("/$", "").toLowerCase(Locale.US).trim();
+
+ // currently, the old and new location of the WebThings schema are supported only.
+ // In the future, other schemas such as http://iotschema.org/docs/full.html may be supported
+ if (schema.equals("https://webthings.io/schemas") || schema.equals("https://iot.mozilla.org/schemas")) {
+ return description;
+ }
+ logger.debug(
+ "WebThing {} detected with unsupported schema {} (Supported schemas are https://webthings.io/schemas and https://iot.mozilla.org/schemas)",
+ webthingURI, description.contextKeyword);
+ throw new IOException("unsupported schema (@context parameter) " + description.contextKeyword
+ + " (Supported schemas are https://webthings.io/schemas and https://iot.mozilla.org/schemas)");
+ } else {
+ throw new IOException("description does not include properties");
+ }
+ } catch (ExecutionException | TimeoutException e) {
+ throw new IOException("error occurred by calling WebThing", e);
+ } catch (JsonSyntaxException se) {
+ throw new IOException("resource seems not to be a WebThing. Typo?");
+ } catch (InterruptedException ie) {
+ throw new IOException("resource seems not to be reachable");
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/PropertyAccessException.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/PropertyAccessException.java
new file mode 100644
index 00000000000..dba60bc73f4
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/PropertyAccessException.java
@@ -0,0 +1,44 @@
+/**
+ * Copyright (c) 2010-2021 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.webthing.internal.client;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link PropertyAccessException} indicates a WebThing property can not be accessed
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+@NonNullByDefault
+public class PropertyAccessException extends Exception {
+ private static final long serialVersionUID = 5177277585758195790L;
+
+ /**
+ * contructor
+ *
+ * @param message the error message
+ */
+ PropertyAccessException(String message) {
+ super(message);
+ }
+
+ /**
+ * contructor
+ *
+ * @param message the error message
+ * @param cause the error cause
+ */
+ PropertyAccessException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/WebSocketConnection.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/WebSocketConnection.java
new file mode 100644
index 00000000000..26293ef433a
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/WebSocketConnection.java
@@ -0,0 +1,45 @@
+/**
+ * Copyright (c) 2010-2021 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.webthing.internal.client;
+
+import java.util.function.BiConsumer;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The WebsocketConnection represents an open WebSocket connection on the Web Thing. It provides a realtime mechanism
+ * to be notified of events as soon as they happen. Refer https://iot.mozilla.org/wot/#web-thing-websocket-api
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+@NonNullByDefault
+interface WebSocketConnection {
+
+ /**
+ * Makes a request for Property value change notifications
+ *
+ * @param propertyName the property to be observed
+ * @param listener the listener to call on changes
+ */
+ void observeProperty(String propertyName, BiConsumer listener);
+
+ /**
+ * closes the WebSocket connection
+ */
+ void close();
+
+ /**
+ * @return true, if connection is alive
+ */
+ boolean isAlive();
+}
diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/WebSocketConnectionFactory.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/WebSocketConnectionFactory.java
new file mode 100644
index 00000000000..b46ab195b07
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/WebSocketConnectionFactory.java
@@ -0,0 +1,56 @@
+/**
+ * Copyright (c) 2010-2021 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.webthing.internal.client;
+
+import java.io.IOException;
+import java.net.URI;
+import java.time.Duration;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.function.Consumer;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jetty.websocket.client.WebSocketClient;
+
+/**
+ * Factory to create new instances of a WebSocket connection
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+@NonNullByDefault
+interface WebSocketConnectionFactory {
+
+ /**
+ * create (and opens) a new WebSocket connection
+ *
+ * @param webSocketURI the websocket uri
+ * @param executor the executor to use
+ * @param errorHandler the error handler
+ * @param pingPeriod the ping period to check the healthiness of the connection
+ * @return the newly opened WebSocket connection
+ * @throws IOException if the web socket connection can not be established
+ */
+ WebSocketConnection create(URI webSocketURI, ScheduledExecutorService executor, Consumer errorHandler,
+ Duration pingPeriod) throws IOException;
+
+ /**
+ * @param webSocketClient the web socket client to use
+ * @return the default instance of the factory
+ */
+ static WebSocketConnectionFactory instance(WebSocketClient webSocketClient) {
+ return (webSocketURI, executor, errorHandler, pingPeriod) -> {
+ var webSocketConnection = new WebSocketConnectionImpl(executor, errorHandler, pingPeriod);
+ webSocketClient.connect(webSocketConnection, webSocketURI);
+ return webSocketConnection;
+ };
+ }
+}
diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/WebSocketConnectionImpl.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/WebSocketConnectionImpl.java
new file mode 100644
index 00000000000..e491a9e8dcc
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/WebSocketConnectionImpl.java
@@ -0,0 +1,185 @@
+/**
+ * Copyright (c) 2010-2021 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.webthing.internal.client;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+
+import org.eclipse.jdt.annotation.NonNull;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.websocket.api.Session;
+import org.eclipse.jetty.websocket.api.WebSocketListener;
+import org.eclipse.jetty.websocket.api.WebSocketPingPongListener;
+import org.openhab.binding.webthing.internal.client.dto.PropertyStatusMessage;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonSyntaxException;
+
+/**
+ * The WebsocketConnection implementation
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+@NonNullByDefault
+public class WebSocketConnectionImpl implements WebSocketConnection, WebSocketListener, WebSocketPingPongListener {
+ private static final BiConsumer EMPTY_PROPERTY_CHANGED_LISTENER = (String propertyName,
+ Object value) -> {
+ };
+ private final Logger logger = LoggerFactory.getLogger(WebSocketConnectionImpl.class);
+ private final Gson gson = new Gson();
+ private final Duration pingPeriod;
+ private final Consumer errorHandler;
+ private final ScheduledFuture> watchDogHandle;
+ private final ScheduledFuture> pingHandle;
+ private final Map> propertyChangedListeners = new HashMap<>();
+ private final AtomicReference lastTimeReceived = new AtomicReference<>(Instant.now());
+ private final AtomicReference> sessionRef = new AtomicReference<>(Optional.empty());
+
+ /**
+ * constructor
+ *
+ * @param executor the executor to use
+ * @param errorHandler the errorHandler
+ * @param pingPeriod the period pings should be sent
+ */
+ WebSocketConnectionImpl(ScheduledExecutorService executor, Consumer errorHandler, Duration pingPeriod) {
+ this.errorHandler = errorHandler;
+ this.pingPeriod = pingPeriod;
+
+ // send a ping message are x seconds to validate if the connection is not broken
+ this.pingHandle = executor.scheduleWithFixedDelay(this::sendPing, pingPeriod.dividedBy(2).toMillis(),
+ pingPeriod.toMillis(), TimeUnit.MILLISECONDS);
+
+ // checks if a message (regular message or pong message) has been received recently. If not, connection is
+ // seen as broken
+ this.watchDogHandle = executor.scheduleWithFixedDelay(this::checkConnection, pingPeriod.toMillis(),
+ pingPeriod.toMillis(), TimeUnit.MILLISECONDS);
+ }
+
+ @Override
+ public void close() {
+ sessionRef.getAndSet(Optional.empty()).ifPresent(Session::close);
+ watchDogHandle.cancel(true);
+ pingHandle.cancel(true);
+ }
+
+ @Override
+ public void observeProperty(@NonNull String propertyName, @NonNull BiConsumer listener) {
+ propertyChangedListeners.put(propertyName, listener);
+ }
+
+ @Override
+ public void onWebSocketConnect(@Nullable Session session) {
+ sessionRef.set(Optional.ofNullable(session)); // save websocket session to be able to send ping
+ }
+
+ @Override
+ public void onWebSocketPing(@Nullable ByteBuffer payload) {
+ }
+
+ @Override
+ public void onWebSocketPong(@Nullable ByteBuffer payload) {
+ lastTimeReceived.set(Instant.now());
+ }
+
+ @Override
+ public void onWebSocketBinary(byte @Nullable [] payload, int offset, int len) {
+ }
+
+ @Override
+ public void onWebSocketText(@Nullable String message) {
+ try {
+ if (message != null) {
+ var propertyStatus = gson.fromJson(message, PropertyStatusMessage.class);
+ if ((propertyStatus != null) && (propertyStatus.messageType != null)
+ && (propertyStatus.messageType.equals("propertyStatus"))) {
+ for (var propertyEntry : propertyStatus.data.entrySet()) {
+ var listener = propertyChangedListeners.getOrDefault(propertyEntry.getKey(),
+ EMPTY_PROPERTY_CHANGED_LISTENER);
+ try {
+ listener.accept(propertyEntry.getKey(), propertyEntry.getValue());
+ } catch (RuntimeException re) {
+ logger.warn("calling property change listener {} failed. {}", listener, re.getMessage());
+ }
+ }
+ } else {
+ logger.debug("Ignoring received message of unknown type: {}", message);
+ }
+ }
+ } catch (JsonSyntaxException se) {
+ logger.warn("received invalid message: {}", message);
+ }
+ }
+
+ @Override
+ public void onWebSocketClose(int statusCode, @Nullable String reason) {
+ onWebSocketError(new IOException("websocket closed by peer. " + Optional.ofNullable(reason).orElse("")));
+ }
+
+ @Override
+ public void onWebSocketError(@Nullable Throwable cause) {
+ var reason = "";
+ if (cause != null) {
+ reason = cause.getMessage();
+ }
+ onError(reason);
+ }
+
+ private void onError(@Nullable String message) {
+ if (message == null) {
+ message = "";
+ }
+ errorHandler.accept(message);
+ }
+
+ private void sendPing() {
+ var optionalSession = sessionRef.get();
+ if (optionalSession.isPresent()) {
+ try {
+ optionalSession.get().getRemote().sendPing(ByteBuffer.wrap(Instant.now().toString().getBytes()));
+ } catch (IOException e) {
+ onError("could not send ping " + e.getMessage());
+ }
+ }
+ }
+
+ @Override
+ public boolean isAlive() {
+ var elapsedSinceLastReceived = Duration.between(lastTimeReceived.get(), Instant.now());
+ var thresholdOverdued = pingPeriod.multipliedBy(3);
+ var isOverdued = elapsedSinceLastReceived.toMillis() > thresholdOverdued.toMillis();
+ return sessionRef.get().isPresent() && !isOverdued;
+ }
+
+ private void checkConnection() {
+ // check if connection is alive (message has been received recently)
+ if (!isAlive()) {
+ onError("connection seems to be broken (last message received at " + lastTimeReceived.get() + ", "
+ + Duration.between(lastTimeReceived.get(), Instant.now()).getSeconds() + " sec ago)");
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/Link.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/Link.java
new file mode 100644
index 00000000000..e4598ace7bc
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/Link.java
@@ -0,0 +1,25 @@
+/**
+ * Copyright (c) 2010-2021 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.webthing.internal.client.dto;
+
+/**
+ * The Web Thing Description Link object. Refer https://iot.mozilla.org/wot/#link-object
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+public class Link {
+
+ public String rel = null;
+
+ public String href = null;
+}
diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/Property.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/Property.java
new file mode 100644
index 00000000000..50420d4fd6d
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/Property.java
@@ -0,0 +1,40 @@
+/**
+ * Copyright (c) 2010-2021 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.webthing.internal.client.dto;
+
+import java.util.List;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The Web Thing Description Property object. Refer https://iot.mozilla.org/wot/#property-object
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+public class Property {
+
+ public String title = "";
+
+ @SerializedName("@type")
+ public String typeKeyword = "";
+
+ public String type = "string";
+
+ public String unit = null;
+
+ public boolean readOnly = false;
+
+ public String description = "";
+
+ public List links = List.of();
+}
diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/PropertyStatusMessage.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/PropertyStatusMessage.java
new file mode 100644
index 00000000000..caca178a162
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/PropertyStatusMessage.java
@@ -0,0 +1,32 @@
+/**
+ * Copyright (c) 2010-2021 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.webthing.internal.client.dto;
+
+import java.util.Map;
+
+/**
+ * Web Thing WebSocket API property status message. Refer https://iot.mozilla.org/wot/#propertystatus-message
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+public class PropertyStatusMessage {
+
+ public String messageType = "";
+
+ public Map data = Map.of();
+
+ @Override
+ public String toString() {
+ return "PropertyStatusMessage{" + "messageType='" + messageType + '\'' + ", data=" + data + '}';
+ }
+}
diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/WebThingDescription.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/WebThingDescription.java
new file mode 100644
index 00000000000..e7a45812bae
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/WebThingDescription.java
@@ -0,0 +1,67 @@
+/**
+ * Copyright (c) 2010-2021 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.webthing.internal.client.dto;
+
+import java.net.URI;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The Web Thing Description. Refer https://iot.mozilla.org/wot/#web-thing-description
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+public class WebThingDescription {
+
+ public String id = null;
+
+ public String title = "";
+
+ @SerializedName("@context")
+ public String contextKeyword = "";
+
+ public Map properties = Map.of();
+
+ public List links = List.of();
+
+ /**
+ * convenience method to read properties
+ *
+ * @param propertyName the property name to read
+ * @return the property value
+ */
+ public Optional getProperty(String propertyName) {
+ return Optional.ofNullable(properties.get(propertyName));
+ }
+
+ /**
+ * convenience method to read the event stream uri
+ *
+ * @return the optional event stream uri
+ */
+ public Optional getEventStreamUri() {
+ for (var link : this.links) {
+ var href = link.href;
+ if ((href != null) && href.startsWith("ws")) {
+ var rel = Optional.ofNullable(link.rel).orElse("");
+ if (rel.equals("alternate")) {
+ return Optional.of(URI.create(href));
+ }
+ }
+ }
+ return Optional.empty();
+ }
+}
diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/discovery/WebthingDiscoveryService.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/discovery/WebthingDiscoveryService.java
new file mode 100644
index 00000000000..0e056221e8b
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/discovery/WebthingDiscoveryService.java
@@ -0,0 +1,297 @@
+/**
+ * Copyright (c) 2010-2021 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.webthing.internal.discovery;
+
+import static org.openhab.binding.webthing.internal.WebThingBindingConstants.MDNS_SERVICE_TYPE;
+import static org.openhab.binding.webthing.internal.WebThingBindingConstants.THING_TYPE_UID;
+
+import java.io.IOException;
+import java.net.URI;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.*;
+import java.util.concurrent.*;
+
+import javax.jmdns.ServiceEvent;
+import javax.jmdns.ServiceInfo;
+import javax.jmdns.ServiceListener;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.webthing.internal.client.DescriptionLoader;
+import org.openhab.core.config.discovery.AbstractDiscoveryService;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.config.discovery.DiscoveryService;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.openhab.core.io.transport.mdns.MDNSClient;
+import org.openhab.core.scheduler.Scheduler;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Deactivate;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * WebThing discovery service based on mDNS. Refer https://iot.mozilla.org/wot/#web-thing-discovery
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = DiscoveryService.class, configurationPid = "webthingdiscovery.mdns")
+public class WebthingDiscoveryService extends AbstractDiscoveryService implements ServiceListener {
+ private static final Duration FOREGROUND_SCAN_TIMEOUT = Duration.ofMillis(200);
+ public static final String ID = "id";
+ public static final String SCHEMAS = "schemas";
+ public static final String WEB_THING_URI = "webThingURI";
+ private final Logger logger = LoggerFactory.getLogger(WebthingDiscoveryService.class);
+ private final DescriptionLoader descriptionLoader;
+ private final MDNSClient mdnsClient;
+ private final List>> runningDiscoveryTasks = new CopyOnWriteArrayList<>();
+
+ /**
+ * constructor
+ *
+ * @param configProperties the config props
+ * @param mdnsClient the underlying mDNS client
+ */
+ @Activate
+ public WebthingDiscoveryService(@Nullable Map configProperties, @Reference MDNSClient mdnsClient,
+ @Reference Scheduler executor, @Reference HttpClientFactory httpClientFactory) {
+ super(30);
+ this.mdnsClient = mdnsClient;
+ this.descriptionLoader = new DescriptionLoader(httpClientFactory.getCommonHttpClient());
+ super.activate(configProperties);
+ if (isBackgroundDiscoveryEnabled()) {
+ mdnsClient.addServiceListener(MDNS_SERVICE_TYPE, this);
+ }
+ }
+
+ @Override
+ public Set getSupportedThingTypes() {
+ return Set.of(THING_TYPE_UID);
+ }
+
+ @Deactivate
+ @Override
+ protected void deactivate() {
+ super.deactivate();
+ mdnsClient.removeServiceListener(MDNS_SERVICE_TYPE, this);
+ }
+
+ @Override
+ public void serviceAdded(@NonNullByDefault({}) ServiceEvent serviceEvent) {
+ considerService(serviceEvent);
+ }
+
+ @Override
+ public void serviceResolved(@NonNullByDefault({}) ServiceEvent serviceEvent) {
+ considerService(serviceEvent);
+ }
+
+ @Override
+ public void serviceRemoved(@NonNullByDefault({}) ServiceEvent serviceEvent) {
+ for (var discoveryResult : discoverWebThing(serviceEvent.getInfo())) {
+ thingRemoved(discoveryResult.getThingUID());
+ }
+ }
+
+ @Override
+ protected void startBackgroundDiscovery() {
+ mdnsClient.addServiceListener(MDNS_SERVICE_TYPE, this);
+ startScan(true);
+ }
+
+ @Override
+ protected void stopBackgroundDiscovery() {
+ mdnsClient.removeServiceListener(MDNS_SERVICE_TYPE, this);
+ }
+
+ private void startScan(boolean isBackground) {
+ scheduler.submit(() -> scan(isBackground));
+ }
+
+ @Override
+ protected void startScan() {
+ startScan(false);
+ }
+
+ @Override
+ protected synchronized void stopScan() {
+ removeOlderResults(Instant.now().minus(Duration.ofMinutes(10)).toEpochMilli());
+
+ // stop running discovery tasks
+ for (var future : runningDiscoveryTasks) {
+ future.cancel(true);
+ runningDiscoveryTasks.remove(future);
+ }
+ super.stopScan();
+ }
+
+ /**
+ * scans the network via mDNS
+ *
+ * @param isBackground true, if is background task
+ */
+ private void scan(boolean isBackground) {
+ var serviceInfos = isBackground ? mdnsClient.list(MDNS_SERVICE_TYPE)
+ : mdnsClient.list(MDNS_SERVICE_TYPE, FOREGROUND_SCAN_TIMEOUT);
+ logger.debug("got {} mDNS entries", serviceInfos.length);
+
+ // create discovery task for each detected service and process these in parallel to increase total
+ // discovery speed
+ for (var serviceInfo : serviceInfos) {
+ var future = scheduler.submit(new DiscoveryTask(serviceInfo));
+ runningDiscoveryTasks.add(future);
+ }
+
+ // wait until all tasks are completed
+ for (var future : runningDiscoveryTasks) {
+ try {
+ future.get(5, TimeUnit.MINUTES);
+ } catch (InterruptedException | ExecutionException | TimeoutException e) {
+ logger.warn("discovering task {} terminated", future);
+ }
+ runningDiscoveryTasks.remove(future);
+ }
+ }
+
+ private class DiscoveryTask implements Callable> {
+ private final ServiceInfo serviceInfo;
+
+ DiscoveryTask(ServiceInfo serviceInfo) {
+ this.serviceInfo = serviceInfo;
+ }
+
+ @Override
+ public Set call() {
+ var results = new HashSet();
+ for (var discoveryResult : discoverWebThing(serviceInfo)) {
+ results.add(discoveryResult);
+ thingDiscovered(discoveryResult);
+ logger.debug("WebThing '{}' (uri: {}, id: {}, schemas: {}) discovered", discoveryResult.getLabel(),
+ discoveryResult.getProperties().get(WEB_THING_URI), discoveryResult.getProperties().get(ID),
+ discoveryResult.getProperties().get(SCHEMAS));
+ }
+ return results;
+ }
+
+ @Override
+ public String toString() {
+ return "DiscoveryTask{" + "serviceInfo=" + serviceInfo + '}';
+ }
+ }
+
+ /**
+ * convert the serviceInfo result of the mDNS scan to discovery results
+ *
+ * @param serviceInfo the service info
+ * @return the associated discovery result
+ */
+ private Set discoverWebThing(ServiceInfo serviceInfo) {
+ var discoveryResults = new HashSet();
+
+ if (serviceInfo.getHostAddresses().length > 0) {
+ var host = serviceInfo.getHostAddresses()[0];
+ var port = serviceInfo.getPort();
+ var path = "/";
+ if (Collections.list(serviceInfo.getPropertyNames()).contains("path")) {
+ path = serviceInfo.getPropertyString("path");
+ if (!path.endsWith("/")) {
+ path = path + "/";
+ }
+ }
+
+ // There are two kinds of WebThing endpoints: Endpoints supporting a single WebThing as well as
+ // endpoints supporting multiple WebThings.
+ //
+ // In the routine below the enpoint will be checked for single WebThings first, than for multiple
+ // WebThings if a ingle WebTHing has not been found.
+ // Furthermore, first it will be tried to connect the endpoint using https. If this fails, as fallback
+ // plain http is used.
+
+ // check single WebThing path via https (e.g. https://192.168.0.23:8433/)
+ var optionalDiscoveryResult = discoverWebThing(toURI(host, port, path, true));
+ if (optionalDiscoveryResult.isPresent()) {
+ discoveryResults.add(optionalDiscoveryResult.get());
+ } else {
+ // check single WebThing path via plain http (e.g. http://192.168.0.23:8433/)
+ optionalDiscoveryResult = discoverWebThing(toURI(host, port, path, false));
+ if (optionalDiscoveryResult.isPresent()) {
+ discoveryResults.add(optionalDiscoveryResult.get());
+ } else {
+ // check multiple WebThing path via https (e.g. https://192.168.0.23:8433/0,
+ // https://192.168.0.23:8433/1,...)
+ outer: for (int i = 0; i < 50; i++) { // search 50 entries at maximum
+ optionalDiscoveryResult = discoverWebThing(toURI(host, port, path + i + "/", true));
+ if (optionalDiscoveryResult.isPresent()) {
+ discoveryResults.add(optionalDiscoveryResult.get());
+ } else if (i == 0) {
+ // check multiple WebThing path via plain http (e.g. http://192.168.0.23:8433/0,
+ // http://192.168.0.23:8433/1,...)
+ for (int j = 0; j < 50; j++) { // search 50 entries at maximum
+ optionalDiscoveryResult = discoverWebThing(toURI(host, port, path + j + "/", false));
+ if (optionalDiscoveryResult.isPresent()) {
+ discoveryResults.add(optionalDiscoveryResult.get());
+ } else {
+ break outer;
+ }
+ }
+ } else {
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ return discoveryResults;
+ }
+
+ private Optional discoverWebThing(URI uri) {
+ try {
+ var description = descriptionLoader.loadWebthingDescription(uri, Duration.ofSeconds(5));
+
+ var id = uri.getHost().replaceAll("\\W", "_") + "_" + uri.getPort();
+ if (uri.getPath().length() > 1) {
+ id = id + "_" + uri.getPath().replaceAll("\\W", "");
+ }
+
+ var thingUID = new ThingUID(THING_TYPE_UID, id);
+ Map properties = new HashMap<>(2);
+ properties.put(ID, id);
+ properties.put(SCHEMAS, description.contextKeyword);
+ return Optional.of(DiscoveryResultBuilder.create(thingUID).withThingType(THING_TYPE_UID)
+ .withProperty(WEB_THING_URI, uri).withLabel(description.title).withProperties(properties)
+ .withRepresentationProperty(ID).build());
+ } catch (IOException ioe) {
+ return Optional.empty();
+ }
+ }
+
+ private URI toURI(String host, int port, String path, boolean isHttps) {
+ return isHttps ? URI.create("https://" + host + ":" + port + path)
+ : URI.create("http://" + host + ":" + port + path);
+ }
+
+ private void considerService(ServiceEvent serviceEvent) {
+ if (isBackgroundDiscoveryEnabled()) {
+ for (var discoveryResult : discoverWebThing(serviceEvent.getInfo())) {
+ thingDiscovered(discoveryResult);
+ }
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/ChannelToPropertyLink.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/ChannelToPropertyLink.java
new file mode 100644
index 00000000000..eff672d7ac1
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/ChannelToPropertyLink.java
@@ -0,0 +1,84 @@
+/**
+ * Copyright (c) 2010-2021 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.webthing.internal.link;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.webthing.internal.ChannelHandler;
+import org.openhab.binding.webthing.internal.WebThingHandler;
+import org.openhab.binding.webthing.internal.client.ConsumedThing;
+import org.openhab.binding.webthing.internal.client.PropertyAccessException;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.types.State;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link ChannelToPropertyLink} represents an upstream link from a Channel to a WebThing property.
+ * This link is used to update a the value of a property
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+@NonNullByDefault
+public class ChannelToPropertyLink implements WebThingHandler.ItemChangedListener {
+ private final Logger logger = LoggerFactory.getLogger(ChannelToPropertyLink.class);
+ private final String propertyName;
+ private final String propertyType;
+ private final ConsumedThing webThing;
+ private final TypeConverter typeConverter;
+
+ /**
+ * establish a upstream link from a Channel to a WebThing property
+ *
+ * @param channelHandler the channel handler that provides registering an ItemChangedListener
+ * @param channel the channel to be linked
+ * @param webthing the WebThing to be linked
+ * @param propertyName the property name
+ * @throws UnknownPropertyException if the a WebThing property should be link that does not exist
+ */
+ public static void establish(ChannelHandler channelHandler, Channel channel, ConsumedThing webthing,
+ String propertyName) throws UnknownPropertyException {
+ new ChannelToPropertyLink(channelHandler, channel, webthing, propertyName);
+ }
+
+ private ChannelToPropertyLink(ChannelHandler channelHandler, Channel channel, ConsumedThing webThing,
+ String propertyName) throws UnknownPropertyException {
+ this.webThing = webThing;
+ var optionalProperty = webThing.getThingDescription().getProperty(propertyName);
+ if (optionalProperty.isPresent()) {
+ this.propertyType = optionalProperty.get().type;
+ var acceptedType = channel.getAcceptedItemType();
+ if (acceptedType == null) {
+ this.typeConverter = TypeConverters.create("String", propertyType);
+ } else {
+ this.typeConverter = TypeConverters.create(acceptedType, propertyType);
+ }
+ this.propertyName = propertyName;
+ channelHandler.observeChannel(channel.getUID(), this);
+ } else {
+ throw new UnknownPropertyException("property " + propertyName + " does not exits");
+ }
+ }
+
+ @Override
+ public void onItemStateChanged(ChannelUID channelUID, State stateCommand) {
+ try {
+ var propertyValue = typeConverter.toPropertyValue(stateCommand);
+ webThing.writeProperty(propertyName, typeConverter.toPropertyValue((State) stateCommand));
+ logger.debug("property {} updated with {} ({}) ", propertyName, propertyValue, this.propertyType);
+ } catch (PropertyAccessException pae) {
+ logger.warn("could not write WebThing property {} with new channel value. {}", propertyName,
+ pae.getMessage());
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/PropertyToChannelLink.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/PropertyToChannelLink.java
new file mode 100644
index 00000000000..107b5490dcc
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/PropertyToChannelLink.java
@@ -0,0 +1,76 @@
+/**
+ * Copyright (c) 2010-2021 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.webthing.internal.link;
+
+import java.util.function.BiConsumer;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.webthing.internal.ChannelHandler;
+import org.openhab.binding.webthing.internal.client.ConsumedThing;
+import org.openhab.core.thing.Channel;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link PropertyToChannelLink} represents a downstream link from a WebThing property to a Channel.
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+@NonNullByDefault
+public class PropertyToChannelLink implements BiConsumer {
+ private final Logger logger = LoggerFactory.getLogger(PropertyToChannelLink.class);
+ private final ChannelHandler channelHandler;
+ private final Channel channel;
+ private final TypeConverter typeConverter;
+
+ /**
+ * establish downstream link from a WebTHing property to a Channel
+ *
+ * @param webThing the WebThing to be linked
+ * @param propertyName the property name
+ * @param channelHandler the channel handler that provides updating the Item state of a channel
+ * @param channel the channel to be linked
+ * @throws UnknownPropertyException if the a WebThing property should be link that does not exist
+ */
+ public static void establish(ConsumedThing webThing, String propertyName, ChannelHandler channelHandler,
+ Channel channel) throws UnknownPropertyException {
+ new PropertyToChannelLink(webThing, propertyName, channelHandler, channel);
+ }
+
+ private PropertyToChannelLink(ConsumedThing webThing, String propertyName, ChannelHandler channelHandler,
+ Channel channel) throws UnknownPropertyException {
+ this.channel = channel;
+ var optionalProperty = webThing.getThingDescription().getProperty(propertyName);
+ if (optionalProperty.isPresent()) {
+ var propertyType = optionalProperty.get().type;
+ var acceptedType = channel.getAcceptedItemType();
+ if (acceptedType == null) {
+ this.typeConverter = TypeConverters.create("String", propertyType);
+ } else {
+ this.typeConverter = TypeConverters.create(acceptedType, propertyType);
+ }
+ this.channelHandler = channelHandler;
+ webThing.observeProperty(propertyName, this);
+ } else {
+ throw new UnknownPropertyException("property " + propertyName + " does not exits");
+ }
+ }
+
+ @Override
+ public void accept(String propertyName, Object value) {
+ var stateCommand = typeConverter.toStateCommand(value);
+ channelHandler.updateItemState(channel.getUID(), stateCommand);
+ logger.debug("channel {} updated with {} ({})", channel.getUID().getAsString(), value,
+ channel.getAcceptedItemType());
+ }
+}
diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/TypeConverter.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/TypeConverter.java
new file mode 100644
index 00000000000..ce00d559986
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/TypeConverter.java
@@ -0,0 +1,42 @@
+/**
+ * Copyright (c) 2010-2021 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.webthing.internal.link;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+
+/**
+ * The {@link TypeConverter} class map Item state <-> Property value
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+@NonNullByDefault
+interface TypeConverter {
+
+ /**
+ * * maps a Property value to an Item state command
+ *
+ * @param propertyValue the Property value
+ * @return the Item state command
+ */
+ Command toStateCommand(Object propertyValue);
+
+ /**
+ * maps an Item state to a Property value
+ *
+ * @param state the Item state
+ * @return the Property value
+ */
+ Object toPropertyValue(State state);
+}
diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/TypeConverters.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/TypeConverters.java
new file mode 100644
index 00000000000..b7665f5fcb1
--- /dev/null
+++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/TypeConverters.java
@@ -0,0 +1,179 @@
+/**
+ * Copyright (c) 2010-2021 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.webthing.internal.link;
+
+import java.awt.*;
+import java.math.BigDecimal;
+import java.util.Collection;
+import java.util.Locale;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.library.types.*;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+
+/**
+ * Helper class to create a TypeConverter
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+@NonNullByDefault
+class TypeConverters {
+
+ /**
+ * create a TypeConverter for a given Item type and property type
+ *
+ * @param itemType the item type
+ * @param propertyType the property type
+ * @return the type converter
+ */
+ static TypeConverter create(String itemType, String propertyType) {
+ switch (itemType.toLowerCase(Locale.ENGLISH)) {
+ case "switch":
+ return new SwitchTypeConverter();
+ case "dimmer":
+ return new DimmerTypeConverter();
+ case "contact":
+ return new ContactTypeConverter();
+ case "color":
+ return new ColorTypeConverter();
+ case "number":
+ if (propertyType.toLowerCase(Locale.ENGLISH).equals("integer")) {
+ return new IntegerTypeConverter();
+ } else {
+ return new NumberTypeConverter();
+ }
+ default:
+ return new StringTypeConverter();
+ }
+ }
+
+ private static boolean toBoolean(Object propertyValue) {
+ return Boolean.parseBoolean(propertyValue.toString());
+ }
+
+ private static BigDecimal toDecimal(Object propertyValue) {
+ return new BigDecimal(propertyValue.toString());
+ }
+
+ private static final class ColorTypeConverter implements TypeConverter {
+
+ @Override
+ public Command toStateCommand(Object propertyValue) {
+ var value = propertyValue.toString();
+ if (!value.contains("#")) {
+ value = "#" + value;
+ }
+ Color rgb = Color.decode(value);
+ return HSBType.fromRGB(rgb.getRed(), rgb.getGreen(), rgb.getBlue());
+ }
+
+ @Override
+ public Object toPropertyValue(State state) {
+ var hsb = ((HSBType) state);
+
+ // Get HSB values
+ Float hue = hsb.getHue().floatValue();
+ Float saturation = hsb.getSaturation().floatValue();
+ Float brightness = hsb.getBrightness().floatValue();
+
+ // Convert HSB to RGB and then to HTML hex
+ Color rgb = Color.getHSBColor(hue / 360, saturation / 100, brightness / 100);
+ return String.format("#%02x%02x%02x", rgb.getRed(), rgb.getGreen(), rgb.getBlue());
+ }
+ }
+
+ private static final class SwitchTypeConverter implements TypeConverter {
+
+ @Override
+ public Command toStateCommand(Object propertyValue) {
+ return toBoolean(propertyValue) ? OnOffType.ON : OnOffType.OFF;
+ }
+
+ @Override
+ public Object toPropertyValue(State state) {
+ return state == OnOffType.ON;
+ }
+ }
+
+ private static final class ContactTypeConverter implements TypeConverter {
+
+ @Override
+ public Command toStateCommand(Object propertyValue) {
+ return toBoolean(propertyValue) ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
+ }
+
+ @Override
+ public Object toPropertyValue(State state) {
+ return state == OpenClosedType.OPEN;
+ }
+ }
+
+ private static final class DimmerTypeConverter implements TypeConverter {
+
+ @Override
+ public Command toStateCommand(Object propertyValue) {
+ return new PercentType(toDecimal(propertyValue));
+ }
+
+ @Override
+ public Object toPropertyValue(State state) {
+ return ((DecimalType) state).toBigDecimal().intValue();
+ }
+ }
+
+ private static final class NumberTypeConverter implements TypeConverter {
+
+ @Override
+ public Command toStateCommand(Object propertyValue) {
+ return new DecimalType(toDecimal(propertyValue));
+ }
+
+ @Override
+ public Object toPropertyValue(State state) {
+ return ((DecimalType) state).doubleValue();
+ }
+ }
+
+ private static final class IntegerTypeConverter implements TypeConverter {
+
+ @Override
+ public Command toStateCommand(Object propertyValue) {
+ return new DecimalType(toDecimal(propertyValue));
+ }
+
+ @Override
+ public Object toPropertyValue(State state) {
+ return ((DecimalType) state).intValue();
+ }
+ }
+
+ private static final class StringTypeConverter implements TypeConverter {
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public Command toStateCommand(Object propertyValue) {
+ String textValue = propertyValue.toString();
+ if (propertyValue instanceof Collection) {
+ textValue = ((Collection