/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
--- /dev/null
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-addons
--- /dev/null
+# 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.
+
+
+
+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.
+
+
+
+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.
+
+
+
+### 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*.
+
+
+
+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
+}
+```
+
+
--- /dev/null
+<?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>
--- /dev/null
+<?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>
--- /dev/null
+/**
+ * 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);
+ }
+}
--- /dev/null
+/**
+ * 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.";
+}
--- /dev/null
+/**
+ * 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;
+}
--- /dev/null
+/**
+ * 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());
+ }
+
+ }
+ }
+ }
+}
--- /dev/null
+/**
+ * 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);
+ }
+}
--- /dev/null
+/**
+ * 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();
+ }
+}
--- /dev/null
+/**
+ * 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();
+}
--- /dev/null
+/**
+ * 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;
+ }
+}
--- /dev/null
+/**
+ * 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 + ")";
+ }
+}
--- /dev/null
+/**
+ * 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");
+ }
+ }
+}
--- /dev/null
+/**
+ * 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);
+ }
+}
--- /dev/null
+/**
+ * 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();
+}
--- /dev/null
+/**
+ * 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;
+ };
+ }
+}
--- /dev/null
+/**
+ * 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)");
+ }
+ }
+}
--- /dev/null
+/**
+ * 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;
+}
--- /dev/null
+/**
+ * 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();
+}
--- /dev/null
+/**
+ * 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 + '}';
+ }
+}
--- /dev/null
+/**
+ * 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();
+ }
+}
--- /dev/null
+/**
+ * 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);
+ }
+ }
+ }
+}
--- /dev/null
+/**
+ * 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());
+ }
+ }
+}
--- /dev/null
+/**
+ * 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());
+ }
+}
--- /dev/null
+/**
+ * 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);
+}
--- /dev/null
+/**
+ * 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();
+ }
+ }
+}
--- /dev/null
+/**
+ * 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;
+ }
+ }
+}
--- /dev/null
+/**
+ * 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);
+ }
+}
--- /dev/null
+<?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>
--- /dev/null
+<?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>
--- /dev/null
+/**
+ * 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())));
+ }
+}
--- /dev/null
+/**
+ * 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;
+ }
+}
--- /dev/null
+/**
+ * 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);
+ }
+ }
+}
--- /dev/null
+/**
+ * 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));
+ }
+}
--- /dev/null
+/**
+ * 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);
+ }
+ }
+}
--- /dev/null
+[
+ {
+ "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
--- /dev/null
+{"target_position":85}
\ No newline at end of file
--- /dev/null
+{
+ "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
--- /dev/null
+{
+ "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
--- /dev/null
+{"number_prop":80.5}
\ No newline at end of file
--- /dev/null
+{
+ "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
--- /dev/null
+{
+ "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
--- /dev/null
+{
+ "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
--- /dev/null
+{
+ "windspeed":34
+}
\ No newline at end of file
--- /dev/null
+{
+ "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
--- /dev/null
+{
+ "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
<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>