]> git.basschouten.com Git - openhab-addons.git/commitdiff
[WebThing] Initial contribution (#9555)
authorgrro <grro@users.noreply.github.com>
Sun, 11 Apr 2021 17:47:27 +0000 (19:47 +0200)
committerGitHub <noreply@github.com>
Sun, 11 Apr 2021 17:47:27 +0000 (19:47 +0200)
Signed-off-by: Gregor Roth <gregor.roth@web.de>
54 files changed:
CODEOWNERS
bundles/org.openhab.binding.webthing/NOTICE [new file with mode: 0644]
bundles/org.openhab.binding.webthing/README.md [new file with mode: 0644]
bundles/org.openhab.binding.webthing/docs/channels.png [new file with mode: 0644]
bundles/org.openhab.binding.webthing/docs/discovery.png [new file with mode: 0644]
bundles/org.openhab.binding.webthing/docs/sitemap.png [new file with mode: 0644]
bundles/org.openhab.binding.webthing/docs/speedmonitor.png [new file with mode: 0644]
bundles/org.openhab.binding.webthing/docs/webthing_description.png [new file with mode: 0644]
bundles/org.openhab.binding.webthing/pom.xml [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/feature/feature.xml [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/ChannelHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingBindingConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingHandlerFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/channel/Channels.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/ConsumedThing.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/ConsumedThingFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/ConsumedThingImpl.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/DescriptionLoader.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/PropertyAccessException.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/WebSocketConnection.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/WebSocketConnectionFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/WebSocketConnectionImpl.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/Link.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/Property.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/PropertyStatusMessage.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/WebThingDescription.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/discovery/WebthingDiscoveryService.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/ChannelToPropertyLink.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/PropertyToChannelLink.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/TypeConverter.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/TypeConverters.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/TypeMapping.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/UnknownPropertyException.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/resources/OH-INF/binding/binding.xml [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/main/resources/OH-INF/thing/thing-types.xml [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/client/DescriptionTest.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/client/Mocks.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/client/WebthingTest.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/link/TypeConverterTest.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/link/WebthingChannelLinkTest.java [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/test/resources/awning_array_response.json [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/test/resources/awning_property.json [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/test/resources/awning_response.json [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/test/resources/datatypes_test_response.json [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/test/resources/number_prop.json [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/test/resources/unknownschema_response.json [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/test/resources/unsetschema_response.json [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/test/resources/virtual-things_response.json [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/test/resources/windsensor_property.json [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/test/resources/windsensor_response.json [new file with mode: 0644]
bundles/org.openhab.binding.webthing/src/test/resources/windsensor_response_without_websocketuri.json [new file with mode: 0644]
bundles/pom.xml

index 0f9c7f908e331f424eb26e485165c8d8fac362e8..6304d89eb66d066e3d7288af1664ef28e8980c32 100644 (file)
 /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 (file)
index 0000000..38d625e
--- /dev/null
@@ -0,0 +1,13 @@
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-addons
diff --git a/bundles/org.openhab.binding.webthing/README.md b/bundles/org.openhab.binding.webthing/README.md
new file mode 100644 (file)
index 0000000..0de3086
--- /dev/null
@@ -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 (file)
index 0000000..d946063
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 (file)
index 0000000..f78ca09
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 (file)
index 0000000..e84e98e
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 (file)
index 0000000..3277248
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 (file)
index 0000000..f9d55a3
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 (file)
index 0000000..d3a4ea3
--- /dev/null
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>org.openhab.addons.bundles</groupId>
+    <artifactId>org.openhab.addons.reactor.bundles</artifactId>
+    <version>3.1.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>org.openhab.binding.webthing</artifactId>
+
+  <name>openHAB Add-ons :: Bundles :: WebThing Binding</name>
+</project>
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 (file)
index 0000000..df244f0
--- /dev/null
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<features name="org.openhab.binding.webthing-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
+       <repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
+
+       <feature name="openhab-binding-webthing" description="WebThing Binding" version="${project.version}">
+               <feature>openhab-runtime-base</feature>
+               <feature>openhab-transport-mdns</feature>
+               <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.webthing/${project.version}</bundle>
+       </feature>
+</features>
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 (file)
index 0000000..721136d
--- /dev/null
@@ -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 (file)
index 0000000..9038afd
--- /dev/null
@@ -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<ThingTypeUID> 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 (file)
index 0000000..1a7d337
--- /dev/null
@@ -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 (file)
index 0000000..53406da
--- /dev/null
@@ -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<ChannelUID, ItemChangedListener> itemChangedListenerMap = new ConcurrentHashMap<>();
+    private final AtomicReference<Optional<ConsumedThing>> webThingConnectionRef = new AtomicReference<>(
+            Optional.empty());
+    private final AtomicReference<Instant> lastReconnect = new AtomicReference<>(Instant.now());
+    private final AtomicReference<Optional<ScheduledFuture<?>>> 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 (file)
index 0000000..e138454
--- /dev/null
@@ -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 (file)
index 0000000..8ffb417
--- /dev/null
@@ -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 (file)
index 0000000..4ae43fb
--- /dev/null
@@ -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<String, Object> 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 (file)
index 0000000..a926a71
--- /dev/null
@@ -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<String> 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 (file)
index 0000000..9dc7d45
--- /dev/null
@@ -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<String> 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<String> 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<String> 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<String> 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<URI> 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<String, Object> 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<String, Object> 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 (file)
index 0000000..f172ff7
--- /dev/null
@@ -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 (file)
index 0000000..dba60bc
--- /dev/null
@@ -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 (file)
index 0000000..26293ef
--- /dev/null
@@ -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<String, Object> 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 (file)
index 0000000..b46ab19
--- /dev/null
@@ -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<String> 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 (file)
index 0000000..e491a9e
--- /dev/null
@@ -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<String, Object> 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<String> errorHandler;
+    private final ScheduledFuture<?> watchDogHandle;
+    private final ScheduledFuture<?> pingHandle;
+    private final Map<String, BiConsumer<String, Object>> propertyChangedListeners = new HashMap<>();
+    private final AtomicReference<Instant> lastTimeReceived = new AtomicReference<>(Instant.now());
+    private final AtomicReference<Optional<Session>> 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<String> 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<String, Object> 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 (file)
index 0000000..e4598ac
--- /dev/null
@@ -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 (file)
index 0000000..50420d4
--- /dev/null
@@ -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<Link> 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 (file)
index 0000000..caca178
--- /dev/null
@@ -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 = "<undefined>";
+
+    public Map<String, Object> 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 (file)
index 0000000..e7a4581
--- /dev/null
@@ -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<String, Property> properties = Map.of();
+
+    public List<Link> links = List.of();
+
+    /**
+     * convenience method to read properties
+     * 
+     * @param propertyName the property name to read
+     * @return the property value
+     */
+    public Optional<Property> 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<URI> getEventStreamUri() {
+        for (var link : this.links) {
+            var href = link.href;
+            if ((href != null) && href.startsWith("ws")) {
+                var rel = Optional.ofNullable(link.rel).orElse("<undefined>");
+                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 (file)
index 0000000..0e05622
--- /dev/null
@@ -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<Future<Set<DiscoveryResult>>> runningDiscoveryTasks = new CopyOnWriteArrayList<>();
+
+    /**
+     * constructor
+     *
+     * @param configProperties the config props
+     * @param mdnsClient the underlying mDNS client
+     */
+    @Activate
+    public WebthingDiscoveryService(@Nullable Map<String, Object> 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<ThingTypeUID> 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<Set<DiscoveryResult>> {
+        private final ServiceInfo serviceInfo;
+
+        DiscoveryTask(ServiceInfo serviceInfo) {
+            this.serviceInfo = serviceInfo;
+        }
+
+        @Override
+        public Set<DiscoveryResult> call() {
+            var results = new HashSet<DiscoveryResult>();
+            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<DiscoveryResult> discoverWebThing(ServiceInfo serviceInfo) {
+        var discoveryResults = new HashSet<DiscoveryResult>();
+
+        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<DiscoveryResult> 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<String, Object> 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 (file)
index 0000000..eff672d
--- /dev/null
@@ -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 (file)
index 0000000..107b549
--- /dev/null
@@ -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<String, Object> {
+    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 (file)
index 0000000..ce00d55
--- /dev/null
@@ -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 (file)
index 0000000..b7665f5
--- /dev/null
@@ -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<Object>) propertyValue).stream()
+                        .reduce("", (entry1, entry2) -> entry1.toString() + "\n" + entry2.toString()).toString();
+            }
+            return StringType.valueOf(textValue);
+        }
+
+        @Override
+        public Object toPropertyValue(State state) {
+            return state.toString();
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/TypeMapping.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/TypeMapping.java
new file mode 100644 (file)
index 0000000..945d036
--- /dev/null
@@ -0,0 +1,143 @@
+/**
+ * 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.Locale;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.webthing.internal.client.dto.Property;
+
+/**
+ * The {@link TypeMapping} class defines the mapping of Item types <-> WebThing Property types.
+ *
+ * Please consider that changes of 'Item types <-> WebThing Property types' mapping will break the
+ * compatibility to former releases
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+@NonNullByDefault
+public class TypeMapping {
+
+    /**
+     * maps a property type to an item type
+     * 
+     * @param propertyMetadata the property meta data
+     * @return the associated item type
+     */
+    public static ItemType toItemType(Property propertyMetadata) {
+        String type = "string";
+        @Nullable
+        String tag = null;
+
+        switch (propertyMetadata.typeKeyword) {
+            case "AlarmProperty":
+            case "BooleanProperty":
+            case "LeakProperty":
+            case "LockedProperty":
+            case "MotionProperty":
+            case "OnOffProperty":
+            case "PushedProperty":
+                type = "switch";
+                tag = "Switchable";
+                break;
+            case "CurrentProperty":
+            case "FrequencyProperty":
+            case "InstantaneousPowerProperty":
+            case "VoltageProperty":
+                type = "number";
+                break;
+            case "HeatingCoolingProperty":
+            case "ImageProperty":
+            case "VideoProperty":
+                type = "string";
+                break;
+            case "BrightnessProperty":
+            case "HumidityProperty":
+                type = "dimmer";
+                break;
+            case "ColorModeProperty":
+                type = "string";
+                tag = "lighting";
+                break;
+            case "ColorProperty":
+                type = "color";
+                tag = "Lighting";
+                break;
+            case "ColorTemperatureProperty":
+                type = "dimmer";
+                tag = "Lighting";
+                break;
+            case "OpenProperty":
+                type = "contact";
+                tag = "ContactSensor";
+                break;
+            case "TargetTemperatureProperty":
+                type = "number";
+                tag = "TargetTemperature";
+                break;
+            case "TemperatureProperty":
+                type = "number";
+                tag = "CurrentTemperature";
+                break;
+            case "ThermostatModeProperty":
+                break;
+            case "LevelProperty":
+                if ((propertyMetadata.unit != null)
+                        && propertyMetadata.unit.toLowerCase(Locale.ENGLISH).equals("percent")) {
+                    type = "dimmer";
+                } else {
+                    type = "number";
+                }
+                break;
+            default:
+                switch (propertyMetadata.type.toLowerCase(Locale.ENGLISH)) {
+                    case "boolean":
+                        type = "switch";
+                        tag = "Switchable";
+                        break;
+                    case "integer":
+                    case "number":
+                        type = "number";
+                        break;
+                    default:
+                        type = "string";
+                        break;
+                }
+                break;
+        }
+
+        return new ItemType(type, tag);
+    }
+
+    /**
+     * The item type description
+     */
+    public static class ItemType {
+        private final String type;
+        private final @Nullable String tag;
+
+        ItemType(String type, @Nullable String tag) {
+            this.type = type;
+            this.tag = tag;
+        }
+
+        public String getType() {
+            return type;
+        }
+
+        public @Nullable String getTag() {
+            return tag;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/UnknownPropertyException.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/UnknownPropertyException.java
new file mode 100644 (file)
index 0000000..ca2128e
--- /dev/null
@@ -0,0 +1,34 @@
+/**
+ * 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;
+
+/**
+ * The {@link UnknownPropertyException} indicates addressing a WebThing property that does not exist
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+@NonNullByDefault
+public class UnknownPropertyException extends Exception {
+    private static final long serialVersionUID = -5302763943749264616L;
+
+    /**
+     * contructor
+     * 
+     * @param message the error message
+     */
+    UnknownPropertyException(String message) {
+        super(message);
+    }
+}
diff --git a/bundles/org.openhab.binding.webthing/src/main/resources/OH-INF/binding/binding.xml b/bundles/org.openhab.binding.webthing/src/main/resources/OH-INF/binding/binding.xml
new file mode 100644 (file)
index 0000000..ca5f519
--- /dev/null
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<binding:binding id="webthing" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:binding="https://openhab.org/schemas/binding/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/binding/v1.0.0 https://openhab.org/schemas/binding-1.0.0.xsd">
+
+       <name>WebThing Binding</name>
+       <description>The WebThing binding supports an interface to remote devices implementing the Web Thing API.</description>
+</binding:binding>
diff --git a/bundles/org.openhab.binding.webthing/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.webthing/src/main/resources/OH-INF/thing/thing-types.xml
new file mode 100644 (file)
index 0000000..3458f13
--- /dev/null
@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="webthing"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <thing-type id="generic">
+               <label>WebThing</label>
+               <description>The WebThing to be connected</description>
+
+               <config-description>
+                       <parameter name="webThingURI" type="text" required="true">
+                               <context>url</context>
+                               <label>URI</label>
+                               <description>The URI of the WebThing to be connected. E.g. the URI of a web-connected MotionSensor or a URI of a
+                                       web-connected Display</description>
+                       </parameter>
+               </config-description>
+
+       </thing-type>
+
+
+       <channel-type id="number">
+               <item-type>Number</item-type>
+               <label>Webthing Binding Channel</label>
+               <description>Number channel for Webthing Binding</description>
+       </channel-type>
+
+       <channel-type id="string">
+               <item-type>String</item-type>
+               <label>Webthing Binding Channel</label>
+               <description>String channel for Webthing Binding</description>
+       </channel-type>
+
+       <channel-type id="contact">
+               <item-type>Contact</item-type>
+               <label>Webthing Binding Channel</label>
+               <description>Contact channel for Webthing Binding</description>
+       </channel-type>
+
+       <channel-type id="switch">
+               <item-type>Switch</item-type>
+               <label>Webthing Binding Channel</label>
+               <description>Switch channel for Webthing Binding</description>
+       </channel-type>
+
+       <channel-type id="color">
+               <item-type>Color</item-type>
+               <label>Webthing Binding Channel</label>
+               <description>Color channel for Webthing Binding</description>
+       </channel-type>
+
+       <channel-type id="dimmer">
+               <item-type>Dimmer</item-type>
+               <label>Webthing Binding Channel</label>
+               <description>Dimmer channel for Webthing Binding</description>
+       </channel-type>
+
+
+</thing:thing-descriptions>
diff --git a/bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/client/DescriptionTest.java b/bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/client/DescriptionTest.java
new file mode 100644 (file)
index 0000000..eb7cff0
--- /dev/null
@@ -0,0 +1,60 @@
+/**
+ * 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 static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.time.Duration;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+
+/**
+ *
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+@NonNullByDefault
+public class DescriptionTest {
+
+    @Test
+    public void testDescriptionEventStreamUri() throws Exception {
+        var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
+        var request = Mocks.mockRequest(null, load("/awning_response.json"));
+        when(httpClient.newRequest(URI.create("http://example.org:8090"))).thenReturn(request);
+
+        var loader = new DescriptionLoader(httpClient);
+        var description = loader.loadWebthingDescription(URI.create("http://example.org:8090"), Duration.ofSeconds(2));
+        assertEquals("ws://192.168.4.12:9040/0", description.getEventStreamUri().get().toString());
+    }
+
+    @Test
+    public void testDescriptionEventStreamUriServerlaAlternateParts() throws Exception {
+        var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
+        var request = Mocks.mockRequest(null, load("/virtual-things_response.json"));
+        when(httpClient.newRequest(URI.create("http://example.org:8090"))).thenReturn(request);
+
+        var loader = new DescriptionLoader(httpClient);
+        var description = loader.loadWebthingDescription(URI.create("http://example.org:8090"), Duration.ofSeconds(2));
+        assertEquals("ws://webthings/things/virtual-things-7", description.getEventStreamUri().get().toString());
+    }
+
+    public static String load(String name) throws Exception {
+        return new String(Files.readAllBytes(Paths.get(WebthingTest.class.getResource(name).toURI())));
+    }
+}
diff --git a/bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/client/Mocks.java b/bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/client/Mocks.java
new file mode 100644 (file)
index 0000000..92f3861
--- /dev/null
@@ -0,0 +1,78 @@
+/**
+ * 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 static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.*;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.api.ContentProvider;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.client.api.Request;
+
+/**
+ * Mock helper
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+@NonNullByDefault
+public class Mocks {
+
+    public static Request mockRequest(@Nullable String requestContent, String responseContent) throws Exception {
+        return mockRequest(requestContent, responseContent, 200, 200);
+    }
+
+    public static Request mockRequest(@Nullable String requestContent, String responseContent, int getResponse,
+            int postResponse) throws Exception {
+        var request = mock(Request.class);
+
+        // GET request -> request.timeout(30, TimeUnit.SECONDS).send();
+        var getRequest = mock(Request.class);
+        var getContentResponse = mock(ContentResponse.class);
+        when(getContentResponse.getStatus()).thenReturn(getResponse);
+        when(getContentResponse.getContentAsString()).thenReturn(responseContent);
+        when(getRequest.send()).thenReturn(getContentResponse);
+        when(getRequest.accept("application/json")).thenReturn(getRequest);
+        when(request.timeout(30, TimeUnit.SECONDS)).thenReturn(getRequest);
+
+        // POST request -> request.method("PUT").content(new StringContentProvider(json)).timeout(30,
+        // TimeUnit.SECONDS).send();
+        if (requestContent != null) {
+            var postRequest = mock(Request.class);
+            when(postRequest.content(argThat((ContentProvider content) -> bufToString(content).equals(requestContent)),
+                    eq("application/json"))).thenReturn(postRequest);
+            when(postRequest.timeout(30, TimeUnit.SECONDS)).thenReturn(postRequest);
+
+            var postContentResponse = mock(ContentResponse.class);
+            when(postContentResponse.getStatus()).thenReturn(postResponse);
+            when(postRequest.send()).thenReturn(postContentResponse);
+            when(request.method("PUT")).thenReturn(postRequest);
+        }
+        return request;
+    }
+
+    private static String bufToString(Iterable<ByteBuffer> data) {
+        var result = "";
+        for (var byteBuffer : data) {
+            result += StandardCharsets.UTF_8.decode(byteBuffer).toString();
+        }
+        return result;
+    }
+}
diff --git a/bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/client/WebthingTest.java b/bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/client/WebthingTest.java
new file mode 100644 (file)
index 0000000..8d7699e
--- /dev/null
@@ -0,0 +1,501 @@
+/**
+ * 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 static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.net.http.WebSocket;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.time.Duration;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.atomic.AtomicBoolean;
+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.client.HttpClient;
+import org.eclipse.jetty.websocket.api.BatchMode;
+import org.eclipse.jetty.websocket.api.CloseStatus;
+import org.eclipse.jetty.websocket.api.RemoteEndpoint;
+import org.eclipse.jetty.websocket.api.Session;
+import org.eclipse.jetty.websocket.api.SuspendToken;
+import org.eclipse.jetty.websocket.api.UpgradeRequest;
+import org.eclipse.jetty.websocket.api.UpgradeResponse;
+import org.eclipse.jetty.websocket.api.WebSocketListener;
+import org.eclipse.jetty.websocket.api.WebSocketPingPongListener;
+import org.eclipse.jetty.websocket.api.WebSocketPolicy;
+import org.eclipse.jetty.websocket.api.WriteCallback;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.webthing.internal.client.dto.PropertyStatusMessage;
+
+import com.google.gson.Gson;
+
+/**
+ *
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+@NonNullByDefault
+public class WebthingTest {
+    private static final Gson GSON = new Gson();
+
+    @Test
+    public void testWebthingDescription() throws Exception {
+        var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
+        var request = Mocks.mockRequest(null, load("/windsensor_response.json"));
+        when(httpClient.newRequest(URI.create("http://example.org:8090"))).thenReturn(request);
+
+        var request2 = Mocks.mockRequest(null, load("/windsensor_property.json"));
+        when(httpClient.newRequest(URI.create("http://example.org:8090/properties/windspeed"))).thenReturn(request2);
+
+        var webthing = createTestWebthing("http://example.org:8090", httpClient);
+        var metadata = webthing.getThingDescription();
+        assertEquals("Wind", metadata.title);
+    }
+
+    @Test
+    public void testWebthingDescriptionUnsetSchema() throws Exception {
+        var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
+        var request = Mocks.mockRequest(null, load("/unsetschema_response.json"));
+        when(httpClient.newRequest(URI.create("http://example.org:8090"))).thenReturn(request);
+
+        var request2 = Mocks.mockRequest(null, load("/windsensor_property.json"));
+        when(httpClient.newRequest(URI.create("http://example.org:8090/properties/windspeed"))).thenReturn(request2);
+
+        var webthing = createTestWebthing("http://example.org:8090", httpClient);
+        var metadata = webthing.getThingDescription();
+        assertEquals("Wind", metadata.title);
+    }
+
+    @Test
+    public void testWebthingDescriptionUNsupportedSchema() throws Exception {
+        var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
+        var request = Mocks.mockRequest(null, load("/unknownschema_response.json"));
+        when(httpClient.newRequest(URI.create("http://example.org:8090"))).thenReturn(request);
+
+        try {
+            createTestWebthing("http://example.org:8090", httpClient);
+            fail();
+        } catch (IOException e) {
+            assertEquals(
+                    "unsupported schema (@context parameter) https://www.w3.org/2019/wot/td/v1 (Supported schemas are https://webthings.io/schemas and https://iot.mozilla.org/schemas)",
+                    e.getMessage());
+        }
+    }
+
+    @Test
+    public void testReadReadOnlyProperty() throws Exception {
+        var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
+        var request = Mocks.mockRequest(null, load("/windsensor_response.json"));
+        when(httpClient.newRequest(URI.create("http://example.org:8090"))).thenReturn(request);
+
+        var request2 = Mocks.mockRequest(null, load("/windsensor_property.json"));
+        when(httpClient.newRequest(URI.create("http://example.org:8090/properties/windspeed"))).thenReturn(request2);
+
+        var webthing = createTestWebthing("http://example.org:8090", httpClient);
+
+        assertEquals(34.0, webthing.readProperty("windspeed"));
+        try {
+            webthing.writeProperty("windspeed", 23.0);
+            fail();
+        } catch (PropertyAccessException e) {
+            assertEquals(
+                    "could not write windspeed (http://example.org:8090/properties/windspeed) with 23.0. Property is readOnly",
+                    e.getMessage());
+        }
+    }
+
+    @Test
+    public void testReadPropertyTest() throws Exception {
+        var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
+        var request = Mocks.mockRequest(null, load("/awning_response.json"));
+        when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request);
+
+        var request2 = Mocks.mockRequest(null, load("/awning_property.json"));
+        when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position")))
+                .thenReturn(request2);
+
+        var webthing = createTestWebthing("http://example.org:8090/0", httpClient);
+
+        assertEquals(85.0, webthing.readProperty("target_position"));
+    }
+
+    @Test
+    public void testWriteProperty() throws Exception {
+        var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
+        var request = Mocks.mockRequest(null, load("/awning_response.json"));
+        when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request);
+
+        var request2 = Mocks.mockRequest("{\"target_position\":10}", load("/awning_property.json"));
+        when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position")))
+                .thenReturn(request2);
+
+        var webthing = createTestWebthing("http://example.org:8090/0", httpClient);
+        webthing.writeProperty("target_position", 10);
+    }
+
+    @Test
+    public void testWritePropertyError() throws Exception {
+        var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
+        var request = Mocks.mockRequest(null, load("/awning_response.json"));
+        when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request);
+
+        var request2 = Mocks.mockRequest("{\"target_position\":10}", load("/awning_property.json"), 200, 400);
+        when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position")))
+                .thenReturn(request2);
+
+        var webthing = createTestWebthing("http://example.org:8090/0", httpClient);
+        try {
+            webthing.writeProperty("target_position", 10);
+            fail();
+        } catch (PropertyAccessException e) {
+            assertEquals(
+                    "could not write target_position (http://example.org:8090/0/properties/target_position) with 10",
+                    e.getMessage());
+        }
+    }
+
+    @Test
+    public void testReadPropertyError() throws Exception {
+        var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
+        var request = Mocks.mockRequest(null, load("/windsensor_response.json"));
+        when(httpClient.newRequest(URI.create("http://example.org:8090"))).thenReturn(request);
+
+        var request2 = Mocks.mockRequest(null, load("/windsensor_response.json"), 500, 200);
+        when(httpClient.newRequest(URI.create("http://example.org:8090/properties/windspeed"))).thenReturn(request2);
+
+        var webthing = createTestWebthing("http://example.org:8090", httpClient);
+        try {
+            webthing.readProperty("windspeed");
+            fail();
+        } catch (PropertyAccessException e) {
+            assertEquals("could not read windspeed (http://example.org:8090/properties/windspeed)", e.getMessage());
+        }
+    }
+
+    @Test
+    public void testWebSocket() throws Exception {
+        var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
+        var request = Mocks.mockRequest(null, load("/awning_response.json"));
+        when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request);
+
+        var request2 = Mocks.mockRequest(null, load("/awning_property.json"));
+        when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position")))
+                .thenReturn(request2);
+
+        var errorHandler = new ErrorHandler();
+        var webSocketFactory = new TestWebsocketConnectionFactory();
+        var webthing = createTestWebthing("http://example.org:8090/0", httpClient, errorHandler, webSocketFactory);
+
+        var propertyChangedListenerImpl = new PropertyChangedListenerImpl();
+        webthing.observeProperty("target_position", propertyChangedListenerImpl);
+
+        var webSocketServerSide = webSocketFactory.webSocketRef.get();
+        var message = new PropertyStatusMessage();
+        message.messageType = "propertyStatus";
+        message.data = Map.of("target_position", 33);
+        webSocketServerSide.sendToClient(message);
+
+        while (propertyChangedListenerImpl.valueRef.get() == null) {
+            try {
+                Thread.sleep(100);
+            } catch (InterruptedException ignore) {
+            }
+        }
+        assertEquals(33.0, propertyChangedListenerImpl.valueRef.get());
+
+        webSocketServerSide.sendCloseToClient();
+        assertEquals("websocket closed by peer. ", errorHandler.errorRef.get());
+    }
+
+    @Test
+    public void testWebSocketReceiveTimout() throws Exception {
+        var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
+        var request = Mocks.mockRequest(null, load("/awning_response.json"));
+        when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request);
+
+        var request2 = Mocks.mockRequest(null, load("/awning_property.json"));
+        when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position")))
+                .thenReturn(request2);
+
+        var errorHandler = new ErrorHandler();
+        var webSocketFactory = new TestWebsocketConnectionFactory();
+        var pingPeriod = Duration.ofMillis(300);
+        var webthing = createTestWebthing("http://example.org:8090/0", httpClient, errorHandler, webSocketFactory,
+                pingPeriod);
+
+        var propertyChangedListenerImpl = new PropertyChangedListenerImpl();
+        webthing.observeProperty("target_position", propertyChangedListenerImpl);
+        webSocketFactory.webSocketRef.get().ignorePing.set(true);
+
+        try {
+            Thread.sleep(pingPeriod.dividedBy(2).toMillis());
+        } catch (InterruptedException ignore) {
+        }
+        assertNull(errorHandler.errorRef.get());
+
+        try {
+            Thread.sleep(pingPeriod.multipliedBy(4).toMillis());
+        } catch (InterruptedException ignore) {
+        }
+        assertTrue(errorHandler.errorRef.get().startsWith("connection seems to be broken (last message received at"));
+    }
+
+    public static String load(String name) throws Exception {
+        return new String(Files.readAllBytes(Paths.get(WebthingTest.class.getResource(name).toURI())));
+    }
+
+    public static ConsumedThingImpl createTestWebthing(String uri, HttpClient httpClient) throws IOException {
+        return createTestWebthing(uri, httpClient, (String) -> {
+        }, new TestWebsocketConnectionFactory());
+    }
+
+    public static ConsumedThingImpl createTestWebthing(String uri, HttpClient httpClient, Consumer<String> errorHandler,
+            WebSocketConnectionFactory websocketConnectionFactory, Duration pingPeriod) throws IOException {
+        return new ConsumedThingImpl(httpClient, URI.create(uri), Executors.newSingleThreadScheduledExecutor(),
+                errorHandler, websocketConnectionFactory, pingPeriod);
+    }
+
+    public static ConsumedThingImpl createTestWebthing(String uri, HttpClient httpClient, Consumer<String> errorHandler,
+            WebSocketConnectionFactory websocketConnectionFactory) throws IOException {
+        return createTestWebthing(uri, httpClient, errorHandler, websocketConnectionFactory, Duration.ofSeconds(100));
+    }
+
+    public static class TestWebsocketConnectionFactory implements WebSocketConnectionFactory {
+        public final AtomicReference<WebSocketImpl> webSocketRef = new AtomicReference<>();
+
+        @Override
+        public WebSocketConnection create(@NonNull URI webSocketURI, @NonNull ScheduledExecutorService executor,
+                @NonNull Consumer<String> errorHandler, @NonNull Duration pingPeriod) {
+            var webSocketConnection = new WebSocketConnectionImpl(executor, errorHandler, pingPeriod);
+            var webSocket = new WebSocketImpl(webSocketConnection);
+            webSocketRef.set(webSocket);
+            webSocketConnection.onWebSocketConnect(webSocket);
+            return webSocketConnection;
+        }
+    }
+
+    public static final class WebSocketImpl implements Session {
+        private final WebSocketListener listener;
+        private final WebSocketPingPongListener pongListener;
+        public AtomicBoolean ignorePing = new AtomicBoolean(false);
+
+        WebSocketImpl(WebSocketConnectionImpl connection) {
+            this.listener = connection;
+            this.pongListener = connection;
+        }
+
+        @Override
+        public void close() {
+        }
+
+        @Override
+        public void close(@Nullable CloseStatus closeStatus) {
+        }
+
+        @Override
+        public void close(int statusCode, @Nullable String reason) {
+        }
+
+        @Override
+        public void disconnect() throws IOException {
+        }
+
+        @Override
+        public long getIdleTimeout() {
+            return 0;
+        }
+
+        @Override
+        public InetSocketAddress getLocalAddress() {
+            return InetSocketAddress.createUnresolved("test", 23);
+        }
+
+        @Override
+        public WebSocketPolicy getPolicy() {
+            return WebSocketPolicy.newClientPolicy();
+        }
+
+        @Override
+        public String getProtocolVersion() {
+            return "1";
+        }
+
+        @Override
+        public RemoteEndpoint getRemote() {
+            return new RemoteEndpoint() {
+                @Override
+                public void sendBytes(@Nullable ByteBuffer data) throws IOException {
+                }
+
+                @Override
+                public Future sendBytesByFuture(@Nullable ByteBuffer data) {
+                    throw new UnsupportedOperationException();
+                }
+
+                @Override
+                public void sendBytes(@Nullable ByteBuffer data, @Nullable WriteCallback callback) {
+                }
+
+                @Override
+                public void sendPartialBytes(@Nullable ByteBuffer fragment, boolean isLast) throws IOException {
+                }
+
+                @Override
+                public void sendPartialString(@Nullable String fragment, boolean isLast) throws IOException {
+                }
+
+                @Override
+                public void sendPing(@Nullable ByteBuffer applicationData) throws IOException {
+                    if (!ignorePing.get()) {
+                        pongListener.onWebSocketPong(applicationData);
+                    }
+                }
+
+                @Override
+                public void sendPong(@Nullable ByteBuffer applicationData) throws IOException {
+                }
+
+                @Override
+                public void sendString(@Nullable String text) throws IOException {
+                }
+
+                @Override
+                public Future sendStringByFuture(@Nullable String text) {
+                    throw new UnsupportedOperationException();
+                }
+
+                @Override
+                public void sendString(@Nullable String text, @Nullable WriteCallback callback) {
+                }
+
+                @Override
+                public BatchMode getBatchMode() {
+                    return BatchMode.AUTO;
+                }
+
+                @Override
+                public void setBatchMode(@Nullable BatchMode mode) {
+                }
+
+                @Override
+                public InetSocketAddress getInetSocketAddress() {
+                    throw new UnsupportedOperationException();
+                }
+
+                @Override
+                public void flush() throws IOException {
+                }
+
+                @Override
+                public int getMaxOutgoingFrames() {
+                    return 0;
+                }
+
+                @Override
+                public void setMaxOutgoingFrames(int maxOutgoingFrames) {
+                }
+            };
+        }
+
+        @Override
+        public InetSocketAddress getRemoteAddress() {
+            return InetSocketAddress.createUnresolved("test", 12);
+        }
+
+        @Override
+        public UpgradeRequest getUpgradeRequest() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public UpgradeResponse getUpgradeResponse() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public boolean isOpen() {
+            return false;
+        }
+
+        @Override
+        public boolean isSecure() {
+            return false;
+        }
+
+        @Override
+        public void setIdleTimeout(long ms) {
+        }
+
+        @Override
+        public SuspendToken suspend() {
+            return new SuspendToken() {
+
+                @Override
+                public void resume() {
+                }
+            };
+        }
+
+        public void sendToClient(PropertyStatusMessage message) {
+            var data = GSON.toJson(message);
+            listener.onWebSocketText(data);
+        }
+
+        public void sendCloseToClient() {
+            listener.onWebSocketClose(200, "");
+        }
+
+        public CompletableFuture<WebSocket> sendPing(String message) {
+            if (!ignorePing.get()) {
+                var bytes = message.getBytes(StandardCharsets.UTF_8);
+                listener.onWebSocketBinary(bytes, 0, bytes.length);
+            }
+            return CompletableFuture.completedFuture(null);
+        }
+    }
+
+    private static final class PropertyChangedListenerImpl implements BiConsumer<String, Object> {
+        public final AtomicReference<String> propertyNameRef = new AtomicReference<>();
+        public final AtomicReference<Object> valueRef = new AtomicReference<>();
+
+        @Override
+        public void accept(String propertyName, Object value) {
+            propertyNameRef.set(propertyName);
+            valueRef.set(value);
+        }
+    }
+
+    public static class ErrorHandler implements Consumer<String> {
+        public final AtomicReference<String> errorRef = new AtomicReference<>();
+
+        @Override
+        public void accept(String error) {
+            errorRef.set(error);
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/link/TypeConverterTest.java b/bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/link/TypeConverterTest.java
new file mode 100644 (file)
index 0000000..20f94c1
--- /dev/null
@@ -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.link;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.types.State;
+
+/**
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+@NonNullByDefault
+public class TypeConverterTest {
+
+    @Test
+    public void testStringType() throws Exception {
+        var typeConverter = TypeConverters.create("String", "String");
+        var state = typeConverter.toStateCommand("motion");
+        assumeTrue(state instanceof StringType);
+        assertEquals("motion", typeConverter.toPropertyValue((State) state));
+    }
+
+    @Test
+    public void testNumberType() throws Exception {
+        var typeConverter = TypeConverters.create("Number", "Number");
+        var state = typeConverter.toStateCommand(45.6);
+        assumeTrue(state instanceof DecimalType);
+        assertEquals(45.6, typeConverter.toPropertyValue((State) state));
+    }
+
+    @Test
+    public void testNumberIntegerType() throws Exception {
+        var typeConverter = TypeConverters.create("Number", "Integer");
+        var state = typeConverter.toStateCommand(45);
+        assumeTrue(state instanceof DecimalType);
+        assertEquals(45, typeConverter.toPropertyValue((State) state));
+
+        state = typeConverter.toStateCommand(45.2);
+        assumeTrue(state instanceof DecimalType);
+        assertEquals(45, typeConverter.toPropertyValue((State) state));
+    }
+}
diff --git a/bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/link/WebthingChannelLinkTest.java b/bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/link/WebthingChannelLinkTest.java
new file mode 100644 (file)
index 0000000..cbdd634
--- /dev/null
@@ -0,0 +1,206 @@
+/**
+ * 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 static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.webthing.internal.ChannelHandler;
+import org.openhab.binding.webthing.internal.channel.Channels;
+import org.openhab.binding.webthing.internal.client.Mocks;
+import org.openhab.binding.webthing.internal.client.WebthingTest;
+import org.openhab.binding.webthing.internal.client.dto.PropertyStatusMessage;
+import org.openhab.core.library.types.*;
+import org.openhab.core.thing.*;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+
+import com.google.gson.Gson;
+
+/**
+ * Mapping test.
+ *
+ * Please consider that changes of 'ItemType<->PropertyType mapping' validated by this test
+ * will break the compatibility to former releases.
+ *
+ * @author Gregor Roth - Initial contribution
+ */
+@NonNullByDefault
+public class WebthingChannelLinkTest {
+    private final Gson gson = new Gson();
+
+    @Test
+    public void testChannelToProperty() throws Exception {
+        var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
+        var request = Mocks.mockRequest(null, load("/awning_response.json"));
+        when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request);
+
+        var request2 = Mocks.mockRequest("{\"target_position\":10}", load("/awning_property.json"));
+        when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position")))
+                .thenReturn(request2);
+
+        var thingUID = new ThingUID("webthing", "anwing");
+        var channelUID = Channels.createChannelUID(thingUID, "target_position");
+
+        var webthing = WebthingTest.createTestWebthing("http://example.org:8090/0", httpClient);
+        var channel = Channels.createChannel(thingUID, "target_position",
+                Objects.requireNonNull(webthing.getPropertyDescription("target_position")));
+
+        var testWebthingThingHandler = new TestWebthingThingHandler();
+        ChannelToPropertyLink.establish(testWebthingThingHandler, channel, webthing, "target_position");
+
+        testWebthingThingHandler.listeners.get(channelUID).onItemStateChanged(channelUID, new DecimalType(10));
+    }
+
+    @Test
+    public void testChannelToPropertyServerError() throws Exception {
+        var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
+        var request = Mocks.mockRequest(null, load("/awning_response.json"));
+        when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request);
+
+        var request2 = Mocks.mockRequest("{\"target_position\":130}", load("/awning_property.json"), 200, 500);
+        when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position")))
+                .thenReturn(request2);
+
+        var thingUID = new ThingUID("webthing", "anwing");
+        var channelUID = Channels.createChannelUID(thingUID, "target_position");
+
+        var webthing = WebthingTest.createTestWebthing("http://example.org:8090/0", httpClient);
+        var channel = Channels.createChannel(thingUID, "target_position",
+                Objects.requireNonNull(webthing.getPropertyDescription("target_position")));
+
+        var testWebthingThingHandler = new TestWebthingThingHandler();
+        ChannelToPropertyLink.establish(testWebthingThingHandler, channel, webthing, "target_position");
+
+        testWebthingThingHandler.listeners.get(channelUID).onItemStateChanged(channelUID, new DecimalType(130));
+    }
+
+    @Test
+    public void testPropertyToChannel() throws Exception {
+        var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
+        var request = Mocks.mockRequest(null, load("/awning_response.json"));
+        when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request);
+
+        var request2 = Mocks.mockRequest("{\"target_position\":10}", load("/awning_property.json"));
+        when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position")))
+                .thenReturn(request2);
+
+        var thingUID = new ThingUID("webthing", "anwing");
+        var channelUID = Channels.createChannelUID(thingUID, "target_position");
+
+        var errorHandler = new WebthingTest.ErrorHandler();
+        var websocketConnectionFactory = new WebthingTest.TestWebsocketConnectionFactory();
+        var webthing = WebthingTest.createTestWebthing("http://example.org:8090/0", httpClient, errorHandler,
+                websocketConnectionFactory);
+        var channel = Channels.createChannel(thingUID, "target_position",
+                Objects.requireNonNull(webthing.getPropertyDescription("target_position")));
+
+        var testWebthingThingHandler = new TestWebthingThingHandler();
+        PropertyToChannelLink.establish(webthing, "target_position", testWebthingThingHandler, channel);
+
+        var message = new PropertyStatusMessage();
+        message.messageType = "propertyStatus";
+        message.data = Map.of("target_position", 77);
+        websocketConnectionFactory.webSocketRef.get().sendToClient(message);
+
+        assertEquals(new DecimalType(77), testWebthingThingHandler.itemState.get(channelUID));
+    }
+
+    @Test
+    public void testDataTypeMapping() throws Exception {
+        performDataTypeMappingTest("level_prop", 56.5, new DecimalType(56.5), 3.5, new DecimalType(3.5));
+        performDataTypeMappingTest("level_unit_prop", 10, new PercentType(10), 90, new PercentType(90));
+        performDataTypeMappingTest("thermo_prop", "off", new StringType("off"), "auto", new StringType("auto"));
+        performDataTypeMappingTest("temp_prop", 18.6, new DecimalType(18.6), 23.2, new DecimalType(23.2));
+        performDataTypeMappingTest("targettemp_prop", 18.6, new DecimalType(18.6), 23.2, new DecimalType(23.2));
+        performDataTypeMappingTest("open_prop", true, OpenClosedType.OPEN, false, OpenClosedType.CLOSED);
+        performDataTypeMappingTest("colortemp_prop", 10, new PercentType(10), 60, new PercentType(60));
+        performDataTypeMappingTest("color_prop", "#f2fe00", new HSBType("62,100,99"), "#ff0000",
+                new HSBType("0.0,100.0,100.0"));
+        performDataTypeMappingTest("colormode_prop", "color", new StringType("color"), "temperature",
+                new StringType("temperature"));
+        performDataTypeMappingTest("brightness_prop", 33, new PercentType(33), 65, new PercentType(65));
+        performDataTypeMappingTest("voltage_prop", 4.5, new DecimalType(4.5), 30.2, new DecimalType(30.2));
+        performDataTypeMappingTest("heating_prop", "off", new StringType("off"), "cooling", new StringType("cooling"));
+        performDataTypeMappingTest("onoff_prop", true, OnOffType.ON, false, OnOffType.OFF);
+        performDataTypeMappingTest("string_prop", "initial", new StringType("initial"), "updated",
+                new StringType("updated"));
+        performDataTypeMappingTest("number_prop", 80.5, new DecimalType(80.5), 60.9, new DecimalType(60.9));
+        performDataTypeMappingTest("integer_prop", 11, new DecimalType(11), 77, new DecimalType(77));
+        performDataTypeMappingTest("boolean_prop", true, OnOffType.ON, false, OnOffType.OFF);
+    }
+
+    private void performDataTypeMappingTest(String propertyName, Object initialValue, State initialState,
+            Object updatedValue, State updatedState) throws Exception {
+        var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
+        var request = Mocks.mockRequest(null, load("/datatypes_test_response.json"));
+        when(httpClient.newRequest(URI.create("http://example.org:8090/"))).thenReturn(request);
+
+        var request2 = Mocks.mockRequest(gson.toJson(Map.of(propertyName, updatedValue)),
+                gson.toJson(Map.of(propertyName, initialValue)));
+        when(httpClient.newRequest(URI.create("http://example.org:8090/properties/" + propertyName)))
+                .thenReturn(request2);
+
+        var thingUID = new ThingUID("webthing", "test");
+        var channelUID = Channels.createChannelUID(thingUID, propertyName);
+
+        var errorHandler = new WebthingTest.ErrorHandler();
+        var websocketConnectionFactory = new WebthingTest.TestWebsocketConnectionFactory();
+        var webthing = WebthingTest.createTestWebthing("http://example.org:8090/", httpClient, errorHandler,
+                websocketConnectionFactory);
+        var channel = Channels.createChannel(thingUID, propertyName,
+                Objects.requireNonNull(webthing.getPropertyDescription(propertyName)));
+
+        var testWebthingThingHandler = new TestWebthingThingHandler();
+
+        PropertyToChannelLink.establish(webthing, propertyName, testWebthingThingHandler, channel);
+
+        var message = new PropertyStatusMessage();
+        message.messageType = "propertyStatus";
+        message.data = Map.of(propertyName, initialValue);
+        websocketConnectionFactory.webSocketRef.get().sendToClient(message);
+
+        assertEquals(initialState, testWebthingThingHandler.itemState.get(channelUID));
+
+        ChannelToPropertyLink.establish(testWebthingThingHandler, channel, webthing, propertyName);
+        testWebthingThingHandler.listeners.get(channelUID).onItemStateChanged(channelUID, updatedState);
+    }
+
+    public static String load(String name) throws Exception {
+        return new String(Files.readAllBytes(Paths.get(WebthingTest.class.getResource(name).toURI())));
+    }
+
+    private static class TestWebthingThingHandler implements ChannelHandler {
+        public final Map<ChannelUID, ItemChangedListener> listeners = new ConcurrentHashMap<>();
+        public final Map<ChannelUID, Command> itemState = new ConcurrentHashMap<>();
+
+        @Override
+        public void observeChannel(ChannelUID channelUID, ItemChangedListener listener) {
+            listeners.put(channelUID, listener);
+        }
+
+        @Override
+        public void updateItemState(ChannelUID channelUID, Command command) {
+            itemState.put(channelUID, command);
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.webthing/src/test/resources/awning_array_response.json b/bundles/org.openhab.binding.webthing/src/test/resources/awning_array_response.json
new file mode 100644 (file)
index 0000000..b33a072
--- /dev/null
@@ -0,0 +1,394 @@
+[
+  {
+    "id":"urn:dev:ops:anwing-TB6612FNG",
+    "title":"Awning lane1",
+    "@context":"https://iot.mozilla.org/schemas",
+    "properties":{
+      "target_position":{
+        "@type":"LevelProperty",
+        "title":"awning lane1 target position",
+        "type":"integer",
+        "minimum":0,
+        "maximum":100,
+        "description":"awning lane1 target position",
+        "links":[
+          {
+            "rel":"property",
+            "href":"/0/properties/target_position"
+          }
+        ]
+      },
+      "current_position":{
+        "@type":"LevelProperty",
+        "title":"awning lane1 current position",
+        "type":"integer",
+        "minimum":0,
+        "maximum":100,
+        "readOnly":true,
+        "description":"awning lane1 current position",
+        "links":[
+          {
+            "rel":"property",
+            "href":"/0/properties/current_position"
+          }
+        ]
+      },
+      "retracting":{
+        "@type":"BooleanProperty",
+        "title":"lane1 is retracting",
+        "type":"boolean",
+        "readOnly":true,
+        "description":"lane1 is retracting",
+        "links":[
+          {
+            "rel":"property",
+            "href":"/0/properties/retracting"
+          }
+        ]
+      },
+      "extending":{
+        "@type":"BooleanProperty",
+        "title":"lane1 is extending",
+        "type":"boolean",
+        "readOnly":true,
+        "description":"lane1 is extending",
+        "links":[
+          {
+            "rel":"property",
+            "href":"/0/properties/extending"
+          }
+        ]
+      }
+    },
+    "actions":{
+
+    },
+    "events":{
+
+    },
+    "links":[
+      {
+        "rel":"properties",
+        "href":"/0/properties"
+      },
+      {
+        "rel":"actions",
+        "href":"/0/actions"
+      },
+      {
+        "rel":"events",
+        "href":"/0/events"
+      },
+      {
+        "rel":"alternate",
+        "href":"ws://192.168.4.12:9040/0"
+      }
+    ],
+    "description":"A web connected patio awnings controller on Raspberry Pi",
+    "@type":[
+      "MultiLevelSensor"
+    ],
+    "href":"/0",
+    "base":"http://192.168.4.12:9040/0",
+    "securityDefinitions":{
+      "nosec_sc":{
+        "scheme":"nosec"
+      }
+    },
+    "security":"nosec_sc"
+  },
+  {
+    "id":"urn:dev:ops:anwing-TB6612FNG",
+    "title":"Awning lane2",
+    "@context":"https://iot.mozilla.org/schemas",
+    "properties":{
+      "target_position":{
+        "@type":"LevelProperty",
+        "title":"awning lane2 target position",
+        "type":"integer",
+        "minimum":0,
+        "maximum":100,
+        "description":"awning lane2 target position",
+        "links":[
+          {
+            "rel":"property",
+            "href":"/1/properties/target_position"
+          }
+        ]
+      },
+      "current_position":{
+        "@type":"LevelProperty",
+        "title":"awning lane2 current position",
+        "type":"integer",
+        "minimum":0,
+        "maximum":100,
+        "readOnly":true,
+        "description":"awning lane2 current position",
+        "links":[
+          {
+            "rel":"property",
+            "href":"/1/properties/current_position"
+          }
+        ]
+      },
+      "retracting":{
+        "@type":"BooleanProperty",
+        "title":"lane2 is retracting",
+        "type":"boolean",
+        "readOnly":true,
+        "description":"lane2 is retracting",
+        "links":[
+          {
+            "rel":"property",
+            "href":"/1/properties/retracting"
+          }
+        ]
+      },
+      "extending":{
+        "@type":"BooleanProperty",
+        "title":"lane2 is extending",
+        "type":"boolean",
+        "readOnly":true,
+        "description":"lane2 is extending",
+        "links":[
+          {
+            "rel":"property",
+            "href":"/1/properties/extending"
+          }
+        ]
+      }
+    },
+    "actions":{
+
+    },
+    "events":{
+
+    },
+    "links":[
+      {
+        "rel":"properties",
+        "href":"/1/properties"
+      },
+      {
+        "rel":"actions",
+        "href":"/1/actions"
+      },
+      {
+        "rel":"events",
+        "href":"/1/events"
+      },
+      {
+        "rel":"alternate",
+        "href":"ws://192.168.4.12:9040/1"
+      }
+    ],
+    "description":"A web connected patio awnings controller on Raspberry Pi",
+    "@type":[
+      "MultiLevelSensor"
+    ],
+    "href":"/1",
+    "base":"http://192.168.4.12:9040/1",
+    "securityDefinitions":{
+      "nosec_sc":{
+        "scheme":"nosec"
+      }
+    },
+    "security":"nosec_sc"
+  },
+  {
+    "id":"urn:dev:ops:anwing-TB6612FNG",
+    "title":"Awning lane3",
+    "@context":"https://iot.mozilla.org/schemas",
+    "properties":{
+      "target_position":{
+        "@type":"LevelProperty",
+        "title":"awning lane3 target position",
+        "type":"integer",
+        "minimum":0,
+        "maximum":100,
+        "description":"awning lane3 target position",
+        "links":[
+          {
+            "rel":"property",
+            "href":"/2/properties/target_position"
+          }
+        ]
+      },
+      "current_position":{
+        "@type":"LevelProperty",
+        "title":"awning lane3 current position",
+        "type":"integer",
+        "minimum":0,
+        "maximum":100,
+        "readOnly":true,
+        "description":"awning lane3 current position",
+        "links":[
+          {
+            "rel":"property",
+            "href":"/2/properties/current_position"
+          }
+        ]
+      },
+      "retracting":{
+        "@type":"BooleanProperty",
+        "title":"lane3 is retracting",
+        "type":"boolean",
+        "readOnly":true,
+        "description":"lane3 is retracting",
+        "links":[
+          {
+            "rel":"property",
+            "href":"/2/properties/retracting"
+          }
+        ]
+      },
+      "extending":{
+        "@type":"BooleanProperty",
+        "title":"lane3 is extending",
+        "type":"boolean",
+        "readOnly":true,
+        "description":"lane3 is extending",
+        "links":[
+          {
+            "rel":"property",
+            "href":"/2/properties/extending"
+          }
+        ]
+      }
+    },
+    "actions":{
+
+    },
+    "events":{
+
+    },
+    "links":[
+      {
+        "rel":"properties",
+        "href":"/2/properties"
+      },
+      {
+        "rel":"actions",
+        "href":"/2/actions"
+      },
+      {
+        "rel":"events",
+        "href":"/2/events"
+      },
+      {
+        "rel":"alternate",
+        "href":"ws://192.168.4.12:9040/2"
+      }
+    ],
+    "description":"A web connected patio awnings controller on Raspberry Pi",
+    "@type":[
+      "MultiLevelSensor"
+    ],
+    "href":"/2",
+    "base":"http://192.168.4.12:9040/2",
+    "securityDefinitions":{
+      "nosec_sc":{
+        "scheme":"nosec"
+      }
+    },
+    "security":"nosec_sc"
+  },
+  {
+    "id":"urn:dev:ops:anwing-TB6612FNG",
+    "title":"Awning lane4",
+    "@context":"https://iot.mozilla.org/schemas",
+    "properties":{
+      "target_position":{
+        "@type":"LevelProperty",
+        "title":"awning lane4 target position",
+        "type":"integer",
+        "minimum":0,
+        "maximum":100,
+        "description":"awning lane4 target position",
+        "links":[
+          {
+            "rel":"property",
+            "href":"/3/properties/target_position"
+          }
+        ]
+      },
+      "current_position":{
+        "@type":"LevelProperty",
+        "title":"awning lane4 current position",
+        "type":"integer",
+        "minimum":0,
+        "maximum":100,
+        "readOnly":true,
+        "description":"awning lane4 current position",
+        "links":[
+          {
+            "rel":"property",
+            "href":"/3/properties/current_position"
+          }
+        ]
+      },
+      "retracting":{
+        "@type":"BooleanProperty",
+        "title":"lane4 is retracting",
+        "type":"boolean",
+        "readOnly":true,
+        "description":"lane4 is retracting",
+        "links":[
+          {
+            "rel":"property",
+            "href":"/3/properties/retracting"
+          }
+        ]
+      },
+      "extending":{
+        "@type":"BooleanProperty",
+        "title":"lane4 is extending",
+        "type":"boolean",
+        "readOnly":true,
+        "description":"lane4 is extending",
+        "links":[
+          {
+            "rel":"property",
+            "href":"/3/properties/extending"
+          }
+        ]
+      }
+    },
+    "actions":{
+
+    },
+    "events":{
+
+    },
+    "links":[
+      {
+        "rel":"properties",
+        "href":"/3/properties"
+      },
+      {
+        "rel":"actions",
+        "href":"/3/actions"
+      },
+      {
+        "rel":"events",
+        "href":"/3/events"
+      },
+      {
+        "rel":"alternate",
+        "href":"ws://192.168.4.12:9040/3"
+      }
+    ],
+    "description":"A web connected patio awnings controller on Raspberry Pi",
+    "@type":[
+      "MultiLevelSensor"
+    ],
+    "href":"/3",
+    "base":"http://192.168.4.12:9040/3",
+    "securityDefinitions":{
+      "nosec_sc":{
+        "scheme":"nosec"
+      }
+    },
+    "security":"nosec_sc"
+  }
+]
\ No newline at end of file
diff --git a/bundles/org.openhab.binding.webthing/src/test/resources/awning_property.json b/bundles/org.openhab.binding.webthing/src/test/resources/awning_property.json
new file mode 100644 (file)
index 0000000..b6d1c4b
--- /dev/null
@@ -0,0 +1 @@
+{"target_position":85}
\ No newline at end of file
diff --git a/bundles/org.openhab.binding.webthing/src/test/resources/awning_response.json b/bundles/org.openhab.binding.webthing/src/test/resources/awning_response.json
new file mode 100644 (file)
index 0000000..3a95d46
--- /dev/null
@@ -0,0 +1,98 @@
+{
+  "id":"urn:dev:ops:anwing-TB6612FNG",
+  "title":"Awning lane1",
+  "@context":"https://webthings.io/schemas",
+  "properties":{
+    "target_position":{
+      "@type":"LevelProperty",
+      "title":"awning lane1 target position",
+      "type":"integer",
+      "minimum":0,
+      "maximum":100,
+      "description":"awning lane1 target position",
+      "links":[
+        {
+          "rel":"property",
+          "href":"/0/properties/target_position"
+        }
+      ]
+    },
+    "current_position":{
+      "@type":"LevelProperty",
+      "title":"awning lane1 current position",
+      "type":"integer",
+      "minimum":0,
+      "maximum":100,
+      "readOnly":true,
+      "description":"awning lane1 current position",
+      "links":[
+        {
+          "rel":"property",
+          "href":"/0/properties/current_position"
+        }
+      ]
+    },
+    "retracting":{
+      "@type":"BooleanProperty",
+      "title":"lane1 is retracting",
+      "type":"boolean",
+      "readOnly":true,
+      "description":"lane1 is retracting",
+      "links":[
+        {
+          "rel":"property",
+          "href":"/0/properties/retracting"
+        }
+      ]
+    },
+    "extending":{
+      "@type":"BooleanProperty",
+      "title":"lane1 is extending",
+      "type":"boolean",
+      "readOnly":true,
+      "description":"lane1 is extending",
+      "links":[
+        {
+          "rel":"property",
+          "href":"/0/properties/extending"
+        }
+      ]
+    }
+  },
+  "actions":{
+
+  },
+  "events":{
+
+  },
+  "links":[
+    {
+      "rel":"properties",
+      "href":"/0/properties"
+    },
+    {
+      "rel":"actions",
+      "href":"/0/actions"
+    },
+    {
+      "rel":"events",
+      "href":"/0/events"
+    },
+    {
+      "rel":"alternate",
+      "href":"ws://192.168.4.12:9040/0"
+    }
+  ],
+  "description":"A web connected patio awnings controller on Raspberry Pi",
+  "@type":[
+    "MultiLevelSensor"
+  ],
+  "href":"/0",
+  "base":"http://192.168.4.12:9040/0",
+  "securityDefinitions":{
+    "nosec_sc":{
+      "scheme":"nosec"
+    }
+  },
+  "security":"nosec_sc"
+}
\ No newline at end of file
diff --git a/bundles/org.openhab.binding.webthing/src/test/resources/datatypes_test_response.json b/bundles/org.openhab.binding.webthing/src/test/resources/datatypes_test_response.json
new file mode 100644 (file)
index 0000000..95402b2
--- /dev/null
@@ -0,0 +1,209 @@
+{
+  "id":"urn:dev:ops:test-1",
+  "title":"Test Device",
+  "@context":"https://iot.mozilla.org/schemas",
+  "properties":{
+    "number_prop":{
+      "type":"number",
+      "links":[
+        {
+          "rel":"property",
+          "href":"/properties/number_prop"
+        }
+      ]
+    },
+    "integer_prop":{
+      "type":"integer",
+      "links":[
+        {
+          "rel":"property",
+          "href":"/properties/integer_prop"
+        }
+      ]
+    },
+    "string_prop":{
+      "type":"string",
+      "links":[
+        {
+          "rel":"property",
+          "href":"/properties/string_prop"
+        }
+      ]
+    },
+    "boolean_prop":{
+      "type":"boolean",
+      "links":[
+        {
+          "rel":"property",
+          "href":"/properties/boolean_prop"
+        }
+      ]
+    },
+    "onoff_prop":{
+      "@type": "OnOffProperty",
+      "type":"boolean",
+      "links":[
+        {
+          "rel":"property",
+          "href":"/properties/onoff_prop"
+        }
+      ]
+    },
+    "heating_prop":{
+      "@type": "HeatingCoolingProperty",
+      "type":"string",
+      "links":[
+        {
+          "rel":"property",
+          "href":"/properties/heating_prop"
+        }
+      ]
+    },
+    "voltage_prop":{
+      "@type": "VoltageProperty",
+      "type":"number",
+      "links":[
+        {
+          "rel":"property",
+          "href":"/properties/voltage_prop"
+        }
+      ]
+    },
+    "brightness_prop":{
+      "@type": "BrightnessProperty",
+      "type":"number",
+      "links":[
+        {
+          "rel":"property",
+          "href":"/properties/brightness_prop"
+        }
+      ]
+    },
+    "colormode_prop":{
+      "@type": "ColorModeProperty",
+      "type":"string",
+      "links":[
+        {
+          "rel":"property",
+          "href":"/properties/colormode_prop"
+        }
+      ]
+    },
+    "colortemp_prop":{
+      "@type": "ColorTemperatureProperty",
+      "type":"integer",
+      "links":[
+        {
+          "rel":"property",
+          "href":"/properties/colortemp_prop"
+        }
+      ]
+    },
+    "color_prop":{
+      "@type": "ColorProperty",
+      "type":"string",
+      "links":[
+        {
+          "rel":"property",
+          "href":"/properties/color_prop"
+        }
+      ]
+    },
+    "open_prop":{
+      "@type": "OpenProperty",
+      "type":"boolean",
+      "links":[
+        {
+          "rel":"property",
+          "href":"/properties/open_prop"
+        }
+      ]
+    },
+    "targettemp_prop":{
+      "@type": "TargetTemperatureProperty",
+      "type":"number",
+      "links":[
+        {
+          "rel":"property",
+          "href":"/properties/targettemp_prop"
+        }
+      ]
+    },
+    "temp_prop":{
+      "@type": "TemperatureProperty",
+      "type":"number",
+      "links":[
+        {
+          "rel":"property",
+          "href":"/properties/temp_prop"
+        }
+      ]
+    },
+    "thermo_prop":{
+      "@type": "ThermostatModeProperty",
+      "type":"string",
+      "links":[
+        {
+          "rel":"property",
+          "href":"/properties/thermo_prop"
+        }
+      ]
+    },
+    "level_unit_prop":{
+      "@type": "LevelProperty",
+      "type":"number",
+      "unit": "percent",
+      "links":[
+        {
+          "rel":"property",
+          "href":"/properties/level_unit_prop"
+        }
+      ]
+    },
+    "level_prop":{
+      "@type": "LevelProperty",
+      "type":"number",
+      "links":[
+        {
+          "rel":"property",
+          "href":"/properties/level_prop"
+        }
+      ]
+    }
+  },
+  "actions":{
+
+  },
+  "events":{
+
+  },
+  "links":[
+    {
+      "rel":"properties",
+      "href":"/properties"
+    },
+    {
+      "rel":"actions",
+      "href":"/actions"
+    },
+    {
+      "rel":"events",
+      "href":"/events"
+    },
+    {
+      "rel":"alternate",
+      "href":"ws://192.168.0.23:9060/"
+    }
+  ],
+  "description":"test",
+  "@type":[
+    "MultiLevelSensor"
+  ],
+  "base":"http://192.168.0.23:9060/",
+  "securityDefinitions":{
+    "nosec_sc":{
+      "scheme":"nosec"
+    }
+  },
+  "security":"nosec_sc"
+}
\ No newline at end of file
diff --git a/bundles/org.openhab.binding.webthing/src/test/resources/number_prop.json b/bundles/org.openhab.binding.webthing/src/test/resources/number_prop.json
new file mode 100644 (file)
index 0000000..8ad0cc1
--- /dev/null
@@ -0,0 +1 @@
+{"number_prop":80.5}
\ No newline at end of file
diff --git a/bundles/org.openhab.binding.webthing/src/test/resources/unknownschema_response.json b/bundles/org.openhab.binding.webthing/src/test/resources/unknownschema_response.json
new file mode 100644 (file)
index 0000000..8ed08e7
--- /dev/null
@@ -0,0 +1,98 @@
+{
+  "id":"urn:dev:ops:anwing-TB6612FNG",
+  "title":"Awning lane1",
+  "@context":"https://www.w3.org/2019/wot/td/v1",
+  "properties":{
+    "target_position":{
+      "@type":"LevelProperty",
+      "title":"awning lane1 target position",
+      "type":"integer",
+      "minimum":0,
+      "maximum":100,
+      "description":"awning lane1 target position",
+      "links":[
+        {
+          "rel":"property",
+          "href":"/0/properties/target_position"
+        }
+      ]
+    },
+    "current_position":{
+      "@type":"LevelProperty",
+      "title":"awning lane1 current position",
+      "type":"integer",
+      "minimum":0,
+      "maximum":100,
+      "readOnly":true,
+      "description":"awning lane1 current position",
+      "links":[
+        {
+          "rel":"property",
+          "href":"/0/properties/current_position"
+        }
+      ]
+    },
+    "retracting":{
+      "@type":"BooleanProperty",
+      "title":"lane1 is retracting",
+      "type":"boolean",
+      "readOnly":true,
+      "description":"lane1 is retracting",
+      "links":[
+        {
+          "rel":"property",
+          "href":"/0/properties/retracting"
+        }
+      ]
+    },
+    "extending":{
+      "@type":"BooleanProperty",
+      "title":"lane1 is extending",
+      "type":"boolean",
+      "readOnly":true,
+      "description":"lane1 is extending",
+      "links":[
+        {
+          "rel":"property",
+          "href":"/0/properties/extending"
+        }
+      ]
+    }
+  },
+  "actions":{
+
+  },
+  "events":{
+
+  },
+  "links":[
+    {
+      "rel":"properties",
+      "href":"/0/properties"
+    },
+    {
+      "rel":"actions",
+      "href":"/0/actions"
+    },
+    {
+      "rel":"events",
+      "href":"/0/events"
+    },
+    {
+      "rel":"alternate",
+      "href":"ws://192.168.4.12:9040/0"
+    }
+  ],
+  "description":"A web connected patio awnings controller on Raspberry Pi",
+  "@type":[
+    "MultiLevelSensor"
+  ],
+  "href":"/0",
+  "base":"http://192.168.4.12:9040/0",
+  "securityDefinitions":{
+    "nosec_sc":{
+      "scheme":"nosec"
+    }
+  },
+  "security":"nosec_sc"
+}
\ No newline at end of file
diff --git a/bundles/org.openhab.binding.webthing/src/test/resources/unsetschema_response.json b/bundles/org.openhab.binding.webthing/src/test/resources/unsetschema_response.json
new file mode 100644 (file)
index 0000000..82e15c2
--- /dev/null
@@ -0,0 +1,55 @@
+{
+  "id":"urn:dev:ops:eltakowsSensor-1",
+  "title":"Wind",
+  "properties":{
+    "windspeed":{
+      "@type":"LevelProperty",
+      "title":"Windspeed",
+      "type":"number",
+      "description":"The current windspeed",
+      "unit":"km/h",
+      "readOnly":true,
+      "links":[
+        {
+          "rel":"property",
+          "href":"/properties/windspeed"
+        }
+      ]
+    }
+  },
+  "actions":{
+
+  },
+  "events":{
+
+  },
+  "links":[
+    {
+      "rel":"properties",
+      "href":"/properties"
+    },
+    {
+      "rel":"actions",
+      "href":"/actions"
+    },
+    {
+      "rel":"events",
+      "href":"/events"
+    },
+    {
+      "rel":"alternate",
+      "href":"ws://192.168.0.23:9060/"
+    }
+  ],
+  "description":"A web connected Eltako windsensor measuring wind speed on Raspberry Pi",
+  "@type":[
+    "MultiLevelSensor"
+  ],
+  "base":"http://192.168.0.23:9060/",
+  "securityDefinitions":{
+    "nosec_sc":{
+      "scheme":"nosec"
+    }
+  },
+  "security":"nosec_sc"
+}
\ No newline at end of file
diff --git a/bundles/org.openhab.binding.webthing/src/test/resources/virtual-things_response.json b/bundles/org.openhab.binding.webthing/src/test/resources/virtual-things_response.json
new file mode 100644 (file)
index 0000000..6f07082
--- /dev/null
@@ -0,0 +1,75 @@
+{
+  "title":"Virtual On/Off Light",
+  "@context":"https://iot.mozilla.org/schemas",
+  "@type":[
+    "OnOffSwitch",
+    "Light"
+  ],
+  "description":"",
+  "href":"/things/virtual-things-7",
+  "properties":{
+    "on":{
+      "name":"on",
+      "value":false,
+      "visible":true,
+      "title":"On/Off",
+      "type":"boolean",
+      "@type":"OnOffProperty",
+      "links":[
+        {
+          "rel":"property",
+          "href":"/things/virtual-things-7/properties/on"
+        }
+      ]
+    }
+  },
+  "actions":{
+
+  },
+  "events":{
+
+  },
+  "links":[
+    {
+      "rel":"properties",
+      "href":"/things/virtual-things-7/properties"
+    },
+    {
+      "rel":"actions",
+      "href":"/things/virtual-things-7/actions"
+    },
+    {
+      "rel":"events",
+      "href":"/things/virtual-things-7/events"
+    },
+    {
+      "rel":"alternate",
+      "mediaType":"text/html",
+      "href":"/things/virtual-things-7"
+    },
+    {
+      "rel":"alternate",
+      "href":"ws://webthings/things/virtual-things-7"
+    }
+  ],
+  "layoutIndex":0,
+  "selectedCapability":"Light",
+  "iconHref":null,
+  "id":"http://webthings/things/virtual-things-7",
+  "base":"http://webthings/",
+  "securityDefinitions":{
+    "oauth2_sc":{
+      "scheme":"oauth2",
+      "flow":"code",
+      "authorization":"http://webthings/oauth/authorize",
+      "token":"http://webthings/oauth/token",
+      "scopes":[
+        "/things/virtual-things-7:readwrite",
+        "/things/virtual-things-7",
+        "/things:readwrite",
+        "/things"
+      ]
+    }
+  },
+  "security":"oauth2_sc"
+}
\ No newline at end of file
diff --git a/bundles/org.openhab.binding.webthing/src/test/resources/windsensor_property.json b/bundles/org.openhab.binding.webthing/src/test/resources/windsensor_property.json
new file mode 100644 (file)
index 0000000..2d3f0f9
--- /dev/null
@@ -0,0 +1,3 @@
+{
+  "windspeed":34
+}
\ No newline at end of file
diff --git a/bundles/org.openhab.binding.webthing/src/test/resources/windsensor_response.json b/bundles/org.openhab.binding.webthing/src/test/resources/windsensor_response.json
new file mode 100644 (file)
index 0000000..4dfdc5f
--- /dev/null
@@ -0,0 +1,56 @@
+{
+  "id":"urn:dev:ops:eltakowsSensor-1",
+  "title":"Wind",
+  "@context":"https://iot.mozilla.org/schemas/",
+  "properties":{
+    "windspeed":{
+      "@type":"LevelProperty",
+      "title":"Windspeed",
+      "type":"number",
+      "description":"The current windspeed",
+      "unit":"km/h",
+      "readOnly":true,
+      "links":[
+        {
+          "rel":"property",
+          "href":"/properties/windspeed"
+        }
+      ]
+    }
+  },
+  "actions":{
+
+  },
+  "events":{
+
+  },
+  "links":[
+    {
+      "rel":"properties",
+      "href":"/properties"
+    },
+    {
+      "rel":"actions",
+      "href":"/actions"
+    },
+    {
+      "rel":"events",
+      "href":"/events"
+    },
+    {
+      "rel":"alternate",
+      "href":"ws://192.168.0.23:9060/"
+    }
+  ],
+  "description":"A web connected Eltako windsensor measuring wind speed on Raspberry Pi",
+  "@type":[
+    "MultiLevelSensor"
+  ],
+  "base":"http://192.168.0.23:9060/",
+  "securityDefinitions":{
+    "nosec_sc":{
+      "scheme":"nosec"
+    }
+  },
+  "security":"nosec_sc"
+}
\ No newline at end of file
diff --git a/bundles/org.openhab.binding.webthing/src/test/resources/windsensor_response_without_websocketuri.json b/bundles/org.openhab.binding.webthing/src/test/resources/windsensor_response_without_websocketuri.json
new file mode 100644 (file)
index 0000000..ea909d0
--- /dev/null
@@ -0,0 +1,52 @@
+{
+  "id":"urn:dev:ops:eltakowsSensor-1",
+  "title":"Wind",
+  "@context":"https://iot.mozilla.org/schemas",
+  "properties":{
+    "windspeed":{
+      "@type":"LevelProperty",
+      "title":"Windspeed",
+      "type":"number",
+      "description":"The current windspeed",
+      "unit":"km/h",
+      "readOnly":true,
+      "links":[
+        {
+          "rel":"property",
+          "href":"/properties/windspeed"
+        }
+      ]
+    }
+  },
+  "actions":{
+
+  },
+  "events":{
+
+  },
+  "links":[
+    {
+      "rel":"properties",
+      "href":"/properties"
+    },
+    {
+      "rel":"actions",
+      "href":"/actions"
+    },
+    {
+      "rel":"events",
+      "href":"/events"
+    }
+  ],
+  "description":"A web connected Eltako windsensor measuring wind speed on Raspberry Pi",
+  "@type":[
+    "MultiLevelSensor"
+  ],
+  "base":"http://192.168.0.23:9060/",
+  "securityDefinitions":{
+    "nosec_sc":{
+      "scheme":"nosec"
+    }
+  },
+  "security":"nosec_sc"
+}
\ No newline at end of file
index 7e6b4fd55dae734bd2b77024a668bee8c5aff762..bb91aa1cd92f645bc65893027ba76ddaae1411fb 100644 (file)
     <module>org.openhab.binding.volvooncall</module>
     <module>org.openhab.binding.weathercompany</module>
     <module>org.openhab.binding.weatherunderground</module>
+    <module>org.openhab.binding.webthing</module>
     <module>org.openhab.binding.wemo</module>
     <module>org.openhab.binding.wifiled</module>
     <module>org.openhab.binding.windcentrale</module>