diff --git a/CODEOWNERS b/CODEOWNERS index 0f9c7f908e3..6304d89eb66 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -295,6 +295,7 @@ /bundles/org.openhab.binding.volvooncall/ @clinique /bundles/org.openhab.binding.weathercompany/ @mhilbush /bundles/org.openhab.binding.weatherunderground/ @lolodomo +/bundles/org.openhab.binding.webthing/ @grro /bundles/org.openhab.binding.wemo/ @hmerk /bundles/org.openhab.binding.wifiled/ @rvt @xylo /bundles/org.openhab.binding.windcentrale/ @marcelrv diff --git a/bundles/org.openhab.binding.webthing/NOTICE b/bundles/org.openhab.binding.webthing/NOTICE new file mode 100644 index 00000000000..38d625e3492 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/NOTICE @@ -0,0 +1,13 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons diff --git a/bundles/org.openhab.binding.webthing/README.md b/bundles/org.openhab.binding.webthing/README.md new file mode 100644 index 00000000000..0de308663f9 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/README.md @@ -0,0 +1,106 @@ +# WebThing Binding + +The WebThing binding supports an interface to smart home device supporting the Web Thing API. + +The [Web Thing API](https://iot.mozilla.org/wot/) describes an open and generic standard to discover and link smart home devices +like motion sensors, web-connected displays or awning controls. Devices implementing the Web Thing standard can be integrated +into smart home systems such as openHAB to monitor and control them. +These days, the Web Thing API is primarily used by makers to provide a common API to their physical devices. +For instance, the Web Thing API has been used by makers to provide an open way to control [LEDs on a ESP8266 board](https://github.com/WebThingsIO/webthing-arduino) +or to monitor [a PIR motion sensor on Raspberry Pi](https://pypi.org/project/pi-pir-webthing/). + +## Supported Things + +As a generic solution, the WebThing binding does not depend on specific devices. All devices implementing the Web Thing API should be accessible. + + +## Discovery + +Once the binding is activated all reachable **WebThing devices will be detected automatically** (via mDNS). + +## Binding Configuration + +No binding configuration required. + + +## Thing Configuration + +| Parameter | Description | Required | +|----------|--------|-------------| +| webThingURI | the URI of the WebThing | true | + +Due to the discovery support, **no manual Thing configuration is required** in general. However, under certain circumstances textual +Thing configuration may be preferred. In this case, the webThingURI has to be configured as shown in the webthing.things file below: + +``` +Thing webthing:generic:motionsensor [ webThingURI="http://192.168.1.27:9496/" ] +``` + +## Channels + +The supported channels depend on the WebThing device that is connected. Each mappable **WebThing property will be mapped to a dedicated channel, automatically**. For instance, to support the *motion property* of a Motion-Sensor WebThing, a dedicated *motion channel* will be created, automatically. + +| Thing | channel | type | description | +|--------|----------|--------|------------------------------| +| WebThing | Automatic | Automatic | All channels will be generated automatically based on the detected WebThing properties | + +## Full Example + +In the example below WebThings provided by the [Internet Monitor Service](https://pypi.org/project/internet-monitor-webthing/) will be connected. +This service does not require specific hardware or devices. To connect the WebThings, the service has to be installed inside your local network. + + +### Thing + +After installing the WebThing binding you should find the WebThings of your network in the things section of your openHAB administration interface as shown below. + +![discovery picture](docs/discovery.png) + +Here, the WebThings provided by the *Internet Monitor Service*: the *Internet Connectivity* WebThing as well as the +*Internet Speed Monitor* WebThing have been discovered. To add a WebThing as an openHAB Thing click the 'Add as Thing' button. + +![discovery picture](docs/speedmonitor.png) + +Alternatively, you may add the WebThing as a openHAB Thing by using a webthing.thing file that has to be located inside the things folder. + +``` +Thing webthing:generic:speedmonitor [ webThingURI="http://192.168.1.27:9496/0" ] +``` + +Please consider that the *Internet Monitor Service* in this example supports two WebThings. Both WebThings are bound on the +same hostname and port. However, the WebThing URI path of the speed monitor ends with '/0'. In contrast, +the connectivity WebThing URI path ends with '/1' in this example. + +Due to the fact that the WebThing API is based on web technologies, you can validate the WebThing description by opening the WebThing uri in a browser. + +![webthing picture](docs/webthing_description.png) + +### Items + +The *Internet Speed Monitor* WebThing used in this example supports properties such as *downloadspeed*, *uploadspeed* or *ping*. +For each property of the WebThing a dedicated openHAB channel will be created, automatically. The channelUID such +as *webthing:generic:speedmonitor:uploadspeed* is the combination of the thingUID *webthing:generic* and the +WebThing property name *uploadspeed*. + +![channels picture](docs/channels.png) + +These channels may be linked via the channels tab of the graphical user interface or manually via a webthing.items file as shown below + + ``` +Number uploadSpeed "uploadspeed speed [%.0f]" {channel="webthing:generic:speedmonitor:uploadspeed"} +Number downloadSpeed "download speed [%.0f]" {channel="webthing:generic:speedmonitor:downloadspeed"} + + ``` + +### Sitemap + +To add the newly linked WebThing items to the sitemap you place a sitemap file such as the internetmonitor.sitemap file shown below + +``` +sitemap internetmonitor label="Internet Speed Monitor" { + Text item=uploadSpeed + Text item=downloadSpeed +} +``` + +![sitemap picture](docs/sitemap.png) diff --git a/bundles/org.openhab.binding.webthing/docs/channels.png b/bundles/org.openhab.binding.webthing/docs/channels.png new file mode 100644 index 00000000000..d9460639381 Binary files /dev/null and b/bundles/org.openhab.binding.webthing/docs/channels.png differ diff --git a/bundles/org.openhab.binding.webthing/docs/discovery.png b/bundles/org.openhab.binding.webthing/docs/discovery.png new file mode 100644 index 00000000000..f78ca0979a2 Binary files /dev/null and b/bundles/org.openhab.binding.webthing/docs/discovery.png differ diff --git a/bundles/org.openhab.binding.webthing/docs/sitemap.png b/bundles/org.openhab.binding.webthing/docs/sitemap.png new file mode 100644 index 00000000000..e84e98ed7c4 Binary files /dev/null and b/bundles/org.openhab.binding.webthing/docs/sitemap.png differ diff --git a/bundles/org.openhab.binding.webthing/docs/speedmonitor.png b/bundles/org.openhab.binding.webthing/docs/speedmonitor.png new file mode 100644 index 00000000000..3277248b849 Binary files /dev/null and b/bundles/org.openhab.binding.webthing/docs/speedmonitor.png differ diff --git a/bundles/org.openhab.binding.webthing/docs/webthing_description.png b/bundles/org.openhab.binding.webthing/docs/webthing_description.png new file mode 100644 index 00000000000..f9d55a3287a Binary files /dev/null and b/bundles/org.openhab.binding.webthing/docs/webthing_description.png differ diff --git a/bundles/org.openhab.binding.webthing/pom.xml b/bundles/org.openhab.binding.webthing/pom.xml new file mode 100644 index 00000000000..d3a4ea30371 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/pom.xml @@ -0,0 +1,16 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 3.1.0-SNAPSHOT + + + org.openhab.binding.webthing + + openHAB Add-ons :: Bundles :: WebThing Binding + diff --git a/bundles/org.openhab.binding.webthing/src/main/feature/feature.xml b/bundles/org.openhab.binding.webthing/src/main/feature/feature.xml new file mode 100644 index 00000000000..df244f015c2 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/feature/feature.xml @@ -0,0 +1,10 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + openhab-transport-mdns + mvn:org.openhab.addons.bundles/org.openhab.binding.webthing/${project.version} + + diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/ChannelHandler.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/ChannelHandler.java new file mode 100644 index 00000000000..721136d4dbd --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/ChannelHandler.java @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; + +/** + * The {@link ChannelHandler} class is a simplified abstraction of an openHAB Channel implementing + * methods to observe a channel as well to update an Item associated to a channel + * + * @author Gregor Roth - Initial contribution + */ +@NonNullByDefault +public interface ChannelHandler { + + /** + * register a listener to observer the channel regarding item change events + * + * @param channelUID the channel identifier + * @param listener the listener to be notified + */ + void observeChannel(ChannelUID channelUID, ItemChangedListener listener); + + /** + * updates an Item state of a dedicated channel + * + * @param channelUID the channel identifier + * @param command the state update command + */ + void updateItemState(ChannelUID channelUID, Command command); + + /** + * Listener that will be notified, if a Item state is changed + */ + interface ItemChangedListener { + + /** + * item change callback method + * + * @param channelUID the channel identifier + * @param stateCommand the item change command + */ + void onItemStateChanged(ChannelUID channelUID, State stateCommand); + } +} diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingBindingConstants.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingBindingConstants.java new file mode 100644 index 00000000000..9038afdcd51 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingBindingConstants.java @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal; + +import java.util.Collection; +import java.util.Collections; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.ThingTypeUID; + +/** + * The {@link WebThingBindingConstants} class defines common constants, which are + * used across the whole binding. + * + * @author Gregor Roth - Initial contribution + */ +@NonNullByDefault +public class WebThingBindingConstants { + + public static final String BINDING_ID = "webthing"; + + public static final ThingTypeUID THING_TYPE_UID = new ThingTypeUID(BINDING_ID, "generic"); + + public static final Collection SUPPORTED_THING_TYPES_UIDS = Collections + .singleton(WebThingBindingConstants.THING_TYPE_UID); + + public static final String MDNS_SERVICE_TYPE = "_webthing._tcp.local."; +} diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingConfiguration.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingConfiguration.java new file mode 100644 index 00000000000..1a7d33783c0 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingConfiguration.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * The {@link WebThingConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Gregor Roth - Initial contribution + */ +@NonNullByDefault +public class WebThingConfiguration { + + /** + * The webThing uri. This URI will be detected within the discovery process + */ + @Nullable + public String webThingURI = null; +} diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingHandler.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingHandler.java new file mode 100644 index 00000000000..53406da838b --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingHandler.java @@ -0,0 +1,298 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal; + +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.websocket.client.WebSocketClient; +import org.openhab.binding.webthing.internal.channel.Channels; +import org.openhab.binding.webthing.internal.client.*; +import org.openhab.binding.webthing.internal.link.ChannelToPropertyLink; +import org.openhab.binding.webthing.internal.link.PropertyToChannelLink; +import org.openhab.binding.webthing.internal.link.UnknownPropertyException; +import org.openhab.core.thing.*; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.openhab.core.types.State; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link WebThingHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Gregor Roth - Initial contribution + */ +@NonNullByDefault +public class WebThingHandler extends BaseThingHandler implements ChannelHandler { + private static final Duration RECONNECT_PERIOD = Duration.ofHours(23); + private static final Duration HEALTH_CHECK_PERIOD = Duration.ofSeconds(70); + private static final ItemChangedListener EMPTY_ITEM_CHANGED_LISTENER = (channelUID, stateCommand) -> { + }; + + private final Logger logger = LoggerFactory.getLogger(WebThingHandler.class); + private final HttpClient httpClient; + private final WebSocketClient webSocketClient; + private final AtomicBoolean isActivated = new AtomicBoolean(true); + private final Map itemChangedListenerMap = new ConcurrentHashMap<>(); + private final AtomicReference> webThingConnectionRef = new AtomicReference<>( + Optional.empty()); + private final AtomicReference lastReconnect = new AtomicReference<>(Instant.now()); + private final AtomicReference>> watchdogHandle = new AtomicReference<>( + Optional.empty()); + private @Nullable URI webThingURI = null; + + public WebThingHandler(Thing thing, HttpClient httpClient, WebSocketClient webSocketClient) { + super(thing); + this.httpClient = httpClient; + this.webSocketClient = webSocketClient; + } + + private boolean isOnline() { + return getThing().getStatus() == ThingStatus.ONLINE; + } + + private boolean isDisconnected() { + return (getThing().getStatus() == ThingStatus.OFFLINE) || (getThing().getStatus() == ThingStatus.UNKNOWN); + } + + private boolean isAlive() { + return webThingConnectionRef.get().map(ConsumedThing::isAlive).orElse(false); + } + + @Override + public void initialize() { + updateStatus(ThingStatus.UNKNOWN); + isActivated.set(true); // set with true, even though the connect may fail. In this case retries will be + // triggered + + // perform connect in background + scheduler.execute(() -> { + // WebThing URI present? + var uri = toUri(getConfigAs(WebThingConfiguration.class).webThingURI); + if (uri != null) { + logger.debug("try to connect WebThing {}", uri); + var connected = tryReconnect(uri); + if (connected) { + logger.debug("WebThing {} connected", getWebThingLabel()); + } + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "webThing uri has not been set"); + logger.warn("could not initialize WebThing. URI is not set or invalid. {}", this.webThingURI); + } + }); + + // starting watchdog that checks the healthiness of the WebThing connection, periodically + watchdogHandle + .getAndSet(Optional.of(scheduler.scheduleWithFixedDelay(this::checkWebThingConnection, + HEALTH_CHECK_PERIOD.getSeconds(), HEALTH_CHECK_PERIOD.getSeconds(), TimeUnit.SECONDS))) + .ifPresent(future -> future.cancel(true)); + } + + private @Nullable URI toUri(@Nullable String uri) { + try { + if (uri != null) { + return URI.create(uri); + } + } catch (IllegalArgumentException illegalURIException) { + return null; + } + return null; + } + + @Override + public void dispose() { + try { + isActivated.set(false); // set to false to avoid reconnecting + + // terminate WebThing connection as well as the alive watchdog + webThingConnectionRef.getAndSet(Optional.empty()).ifPresent(ConsumedThing::close); + watchdogHandle.getAndSet(Optional.empty()).ifPresent(future -> future.cancel(true)); + } finally { + super.dispose(); + } + } + + private boolean tryReconnect(@Nullable URI uri) { + if (isActivated.get()) { // will try reconnect only, if activated + try { + // create the client-side WebThing representation + if (uri != null) { + var webThing = ConsumedThingFactory.instance().create(webSocketClient, httpClient, uri, scheduler, + this::onError); + this.webThingConnectionRef.getAndSet(Optional.of(webThing)).ifPresent(ConsumedThing::close); + + // update the Thing structure based on the WebThing description + thingStructureChanged(webThing); + + // link the Thing's channels with the WebThing properties to forward properties/item updates + establishWebThingChannelLinks(webThing); + + lastReconnect.set(Instant.now()); + updateStatus(ThingStatus.ONLINE); + return true; + } + } catch (IOException e) { + var msg = e.getMessage(); + if (msg == null) { + msg = ""; + } + onError(msg); + } + } + return false; + } + + public void onError(String reason) { + var wasConnectedBefore = isOnline(); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, reason); + + // close the WebThing connection. If the handler is still active, the WebThing connection + // will be re-established within the periodically watchdog task + webThingConnectionRef.getAndSet(Optional.empty()).ifPresent(ConsumedThing::close); + + if (wasConnectedBefore) { // to reduce log messages, just log in case of connection state changed + logger.debug("WebThing {} disconnected {}. Try reconnect (each {} sec)", getWebThingLabel(), reason, + HEALTH_CHECK_PERIOD.getSeconds()); + } else { + logger.debug("WebThing {} is offline {}. Try reconnect (each {} sec)", getWebThingLabel(), reason, + HEALTH_CHECK_PERIOD.getSeconds()); + } + } + + private String getWebThingLabel() { + if (getThing().getLabel() == null) { + return "" + webThingURI; + } else { + return "'" + getThing().getLabel() + "' (" + webThingURI + ")"; + } + } + + /** + * updates the thing structure. Refer https://www.openhab.org/docs/developer/bindings/#updating-the-thing-structure + * + * @param webThing the WebThing that is used for the new structure + */ + private void thingStructureChanged(ConsumedThing webThing) { + var thingBuilder = editThing().withLabel(webThing.getThingDescription().title); + + // create a channel for each WebThing property + for (var entry : webThing.getThingDescription().properties.entrySet()) { + var channel = Channels.createChannel(thing.getUID(), entry.getKey(), entry.getValue()); + // add channel (and remove a previous one, if exist) + thingBuilder.withoutChannel(channel.getUID()).withChannel(channel); + } + var thing = thingBuilder.build(); + + // and update the thing + updateThing(thing); + } + + /** + * connects each WebThing property with a corresponding openHAB channel. After this changes will be synchronized + * between a WebThing property and the openHAB channel + * + * @param webThing the WebThing to be connected + * @throws IOException if the channels can not be connected + */ + private void establishWebThingChannelLinks(ConsumedThing webThing) throws IOException { + // remove all registered listeners + itemChangedListenerMap.clear(); + + // create new links (listeners will be registered, implicitly) + for (var namePropertyPair : webThing.getThingDescription().properties.entrySet()) { + try { + // determine the name of the associated channel + var channelUID = Channels.createChannelUID(getThing().getUID(), namePropertyPair.getKey()); + + // will try to establish a link, if channel is present + var channel = getThing().getChannel(channelUID); + if (channel != null) { + // establish downstream link + PropertyToChannelLink.establish(webThing, namePropertyPair.getKey(), this, channel); + + // establish upstream link + if (!namePropertyPair.getValue().readOnly) { + ChannelToPropertyLink.establish(this, channel, webThing, namePropertyPair.getKey()); + } + } + } catch (UnknownPropertyException upe) { + logger.warn("WebThing {} property {} could not be linked with a channel", getWebThingLabel(), + namePropertyPair.getKey(), upe); + } + } + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + if (command instanceof State) { + itemChangedListenerMap.getOrDefault(channelUID, EMPTY_ITEM_CHANGED_LISTENER).onItemStateChanged(channelUID, + (State) command); + } else if (command instanceof RefreshType) { + tryReconnect(webThingURI); + } + } + + ///////////// + // ChannelHandler methods + @Override + public void observeChannel(ChannelUID channelUID, ItemChangedListener listener) { + itemChangedListenerMap.put(channelUID, listener); + } + + @Override + public void updateItemState(ChannelUID channelUID, Command command) { + if (isActivated.get()) { + postCommand(channelUID, command); + } + } + // + ///////////// + + private void checkWebThingConnection() { + // try reconnect, if necessary + if (isDisconnected() || (isOnline() && !isAlive())) { + logger.debug("try reconnecting WebThing {}", getWebThingLabel()); + if (tryReconnect(webThingURI)) { + logger.debug("WebThing {} reconnected", getWebThingLabel()); + } + + } else { + // force reconnecting periodically, to fix erroneous states that occurs for unknown reasons + var elapsedSinceLastReconnect = Duration.between(lastReconnect.get(), Instant.now()); + if (isOnline() && (elapsedSinceLastReconnect.getSeconds() > RECONNECT_PERIOD.getSeconds())) { + if (tryReconnect(webThingURI)) { + logger.debug("WebThing {} reconnected. Initiated by periodic reconnect", getWebThingLabel()); + } else { + logger.debug("could not reconnect WebThing {} (periodic reconnect failed). Next trial in {} sec", + getWebThingLabel(), HEALTH_CHECK_PERIOD.getSeconds()); + } + + } + } + } +} diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingHandlerFactory.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingHandlerFactory.java new file mode 100644 index 00000000000..e138454a11d --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/WebThingHandlerFactory.java @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.websocket.client.WebSocketClient; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.io.net.http.WebSocketFactory; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.binding.BaseThingHandlerFactory; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerFactory; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * The {@link WebThingHandlerFactory} is responsible for creating things and thing + * handlers. + * + * @author Gregor Roth - Initial contribution + */ +@NonNullByDefault +@Component(configurationPid = "binding.webthing", service = ThingHandlerFactory.class) +public class WebThingHandlerFactory extends BaseThingHandlerFactory { + private final HttpClient httpClient; + private final WebSocketClient webSocketClient; + + @Activate + public WebThingHandlerFactory(@Reference HttpClientFactory httpClientFactory, + @Reference WebSocketFactory webSocketFactory) { + this.httpClient = httpClientFactory.getCommonHttpClient(); + this.webSocketClient = webSocketFactory.getCommonWebSocketClient(); + } + + @Override + public boolean supportsThingType(ThingTypeUID thingTypeUID) { + return WebThingBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID); + } + + @Override + protected @Nullable ThingHandler createHandler(Thing thing) { + return new WebThingHandler(thing, httpClient, webSocketClient); + } +} diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/channel/Channels.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/channel/Channels.java new file mode 100644 index 00000000000..8ffb417dfef --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/channel/Channels.java @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal.channel; + +import static org.openhab.binding.webthing.internal.WebThingBindingConstants.BINDING_ID; + +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.webthing.internal.client.dto.Property; +import org.openhab.binding.webthing.internal.link.TypeMapping; +import org.openhab.core.thing.Channel; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.binding.builder.ChannelBuilder; +import org.openhab.core.thing.type.ChannelTypeUID; + +/** + * The {@link Channels} class is an utility class to create Channel based on the property characteristics as + * well as ChannelUID identifier + * + * @author Gregor Roth - Initial contribution + */ +@NonNullByDefault +public class Channels { + + /** + * create a ChannelUIFD identifier for a given property name + * + * @param thingUID the thing uid of the associated WebThing + * @param propertyName the property name + * @return the ChannelUID identifier + */ + public static ChannelUID createChannelUID(ThingUID thingUID, String propertyName) { + return new ChannelUID(thingUID.toString() + ":" + propertyName); + } + + /** + * create a Channel base on a given WebThing property + * + * @param thingUID the thing uid of the associated WebThing + * @param propertyName the property name + * @param property the WebThing property + * @return the Channel according to the properties characteristics + */ + public static Channel createChannel(ThingUID thingUID, String propertyName, Property property) { + var itemType = TypeMapping.toItemType(property); + var channelUID = createChannelUID(thingUID, propertyName); + var channelBuilder = ChannelBuilder.create(channelUID, itemType.getType()); + + // Currently, few predefined, generic channel types such as number, string or color are defined + // inside the thing-types.xml file. A better solution would be to create the channel types + // dynamically based on the WebThing description to make most of the meta data of a WebThing. + // The goal of the WebThing meta data is to enable semantic interoperability for connected things + channelBuilder.withType(new ChannelTypeUID(BINDING_ID, itemType.getType())); + channelBuilder.withDescription(property.description); + channelBuilder.withLabel(property.title); + var defaultTag = itemType.getTag(); + if (defaultTag != null) { + channelBuilder.withDefaultTags(Set.of(defaultTag)); + } + return channelBuilder.build(); + } +} diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/ConsumedThing.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/ConsumedThing.java new file mode 100644 index 00000000000..4ae43fb712e --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/ConsumedThing.java @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal.client; + +import java.util.function.BiConsumer; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.webthing.internal.client.dto.WebThingDescription; + +/** + * A WebThing represents the client-side proxy of a remote devices implementing the Web Thing API according to + * https://iot.mozilla.org/wot/ + * The API design is oriented on https://www.w3.org/TR/wot-scripting-api/#the-consumedthing-interface + * + * @author Gregor Roth - Initial contribution + */ +@NonNullByDefault +public interface ConsumedThing { + + /** + * @return the description (meta data) of the WebThing + */ + WebThingDescription getThingDescription(); + + /** + * Makes a request for Property value change notifications + * + * @param propertyName the property to be observed + * @param listener the listener to call on changes + */ + void observeProperty(String propertyName, BiConsumer listener); + + /** + * Writes a single Property. + * + * @param propertyName the propertyName + * @return the current propertyValue + * @throws PropertyAccessException if the property can not be read + */ + Object readProperty(String propertyName) throws PropertyAccessException; + + /** + * Writes a single Property. + * + * @param propertyName the propertyName + * @param newValue the new propertyValue + * @throws PropertyAccessException if the property can not be written + */ + void writeProperty(String propertyName, Object newValue) throws PropertyAccessException; + + /** + * @return true, if connection is alive + */ + boolean isAlive(); + + /** + * closes the connection + */ + void close(); +} diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/ConsumedThingFactory.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/ConsumedThingFactory.java new file mode 100644 index 00000000000..a926a71647d --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/ConsumedThingFactory.java @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal.client; + +import java.io.IOException; +import java.net.URI; +import java.util.concurrent.ScheduledExecutorService; +import java.util.function.Consumer; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.websocket.client.WebSocketClient; + +/** + * Factory to create new instances of the WebThing client-side proxy + * + * @author Gregor Roth - Initial contribution + */ +@NonNullByDefault +public interface ConsumedThingFactory { + + /** + * @param webSocketClient the webSocketClient to use + * @param httpClient the http client to use + * @param webThingURI the identifier of a WebThing resource + * @param executor executor + * @param errorHandler the error handler + * @return the newly created WebThing + * @throws IOException if the WebThing can not be connected + */ + ConsumedThing create(WebSocketClient webSocketClient, HttpClient httpClient, URI webThingURI, + ScheduledExecutorService executor, Consumer errorHandler) throws IOException; + + /** + * @return the default instance of the factory + */ + static ConsumedThingFactory instance() { + return ConsumedThingImpl::new; + } +} diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/ConsumedThingImpl.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/ConsumedThingImpl.java new file mode 100644 index 00000000000..9dc7d453b67 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/ConsumedThingImpl.java @@ -0,0 +1,262 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal.client; + +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.util.StringContentProvider; +import org.eclipse.jetty.websocket.client.WebSocketClient; +import org.openhab.binding.webthing.internal.client.dto.Property; +import org.openhab.binding.webthing.internal.client.dto.WebThingDescription; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; + +/** + * The implementation of the client-side Webthing representation. This is based on HTTP. Bindings to alternative + * application protocols such as CoAP may be defined in the future (which may be implemented by a another class) + * + * @author Gregor Roth - Initial contribution + */ +@NonNullByDefault +public class ConsumedThingImpl implements ConsumedThing { + private static final Duration DEFAULT_PING_PERIOD = Duration.ofSeconds(80); + private final Logger logger = LoggerFactory.getLogger(ConsumedThingImpl.class); + private final URI webThingURI; + private final Gson gson = new Gson(); + private final HttpClient httpClient; + private final Consumer errorHandler; + private final WebThingDescription description; + private final WebSocketConnection websocketDownstream; + private final AtomicBoolean isOpen = new AtomicBoolean(true); + + /** + * constructor + * + * @param webSocketClient the web socket client to use + * @param httpClient the http client to use + * @param webThingURI the identifier of a WebThing resource + * @param executor executor to use + * @param errorHandler the error handler + * @throws IOException it the WebThing can not be connected + */ + ConsumedThingImpl(WebSocketClient webSocketClient, HttpClient httpClient, URI webThingURI, + ScheduledExecutorService executor, Consumer errorHandler) throws IOException { + this(httpClient, webThingURI, executor, errorHandler, WebSocketConnectionFactory.instance(webSocketClient)); + } + + /** + * constructor + * + * @param httpClient the http client to use + * @param webthingUrl the identifier of a WebThing resource + * @param executor executor to use + * @param errorHandler the error handler + * @param webSocketConnectionFactory the Websocket connectino fctory to be used + * @throws IOException if the WebThing can not be connected + */ + ConsumedThingImpl(HttpClient httpClient, URI webthingUrl, ScheduledExecutorService executor, + Consumer errorHandler, WebSocketConnectionFactory webSocketConnectionFactory) throws IOException { + this(httpClient, webthingUrl, executor, errorHandler, webSocketConnectionFactory, DEFAULT_PING_PERIOD); + } + + /** + * constructor + * + * @param httpClient the http client to use + * @param webthingUrl the identifier of a WebThing resource + * @param executor executor to use + * @param errorHandler the error handler + * @param webSocketConnectionFactory the Websocket connectino fctory to be used + * @param pingPeriod the ping period tothe the healthiness of the connection + * @throws IOException if the WebThing can not be connected + */ + ConsumedThingImpl(HttpClient httpClient, URI webthingUrl, ScheduledExecutorService executor, + Consumer errorHandler, WebSocketConnectionFactory webSocketConnectionFactory, Duration pingPeriod) + throws IOException { + this.webThingURI = webthingUrl; + this.httpClient = httpClient; + this.errorHandler = errorHandler; + this.description = new DescriptionLoader(httpClient).loadWebthingDescription(webThingURI, + Duration.ofSeconds(20)); + + // opens a websocket downstream to be notified if a property value will be changed + var optionalEventStreamUri = this.description.getEventStreamUri(); + if (optionalEventStreamUri.isPresent()) { + this.websocketDownstream = webSocketConnectionFactory.create(optionalEventStreamUri.get(), executor, + this::onError, pingPeriod); + } else { + throw new IOException("WebThing " + webThingURI + " does not support websocket uri. WebThing description: " + + this.description); + } + } + + private Optional getPropertyUri(String propertyName) { + var optionalProperty = description.getProperty(propertyName); + if (optionalProperty.isPresent()) { + var propertyDescription = optionalProperty.get(); + for (var link : propertyDescription.links) { + if ((link.rel != null) && (link.href != null) && link.rel.equals("property")) { + return Optional.of(webThingURI.resolve(link.href)); + } + } + } + return Optional.empty(); + } + + @Override + public boolean isAlive() { + return isOpen.get() && this.websocketDownstream.isAlive(); + } + + @Override + public void close() { + isOpen.set(false); + this.websocketDownstream.close(); + } + + void onError(String reason) { + logger.debug("WebThing {} error occurred. {}", webThingURI, reason); + if (isOpen.get()) { + errorHandler.accept(reason); + } + close(); + } + + @Override + public WebThingDescription getThingDescription() { + return this.description; + } + + @Override + public void observeProperty(String propertyName, BiConsumer listener) { + this.websocketDownstream.observeProperty(propertyName, listener); + + // it may take a long time before the observed property value will be changed. For this reason + // read and notify the current property value (as starting point) + try { + var value = readProperty(propertyName); + listener.accept(propertyName, value); + } catch (PropertyAccessException pae) { + logger.warn("could not read WebThing {} property {}", webThingURI, propertyName, pae); + } + } + + @Override + public Object readProperty(String propertyName) throws PropertyAccessException { + var optionalPropertyUri = getPropertyUri(propertyName); + if (optionalPropertyUri.isPresent()) { + var propertyUri = optionalPropertyUri.get(); + try { + var response = httpClient.newRequest(propertyUri).timeout(30, TimeUnit.SECONDS) + .accept("application/json").send(); + if (response.getStatus() < 200 || response.getStatus() >= 300) { + onError("WebThing " + webThingURI + " disconnected"); + throw new PropertyAccessException("could not read " + propertyName + " (" + propertyUri + ")"); + } + var body = response.getContentAsString(); + var properties = gson.fromJson(body, Map.class); + if (properties == null) { + onError("WebThing " + webThingURI + " erroneous"); + throw new PropertyAccessException("could not read " + propertyName + " (" + propertyUri + + "). Response does not include any property (" + propertyUri + "): " + body); + } else { + var value = properties.get(propertyName); + if (value != null) { + return value; + } else { + onError("WebThing " + webThingURI + " erroneous"); + throw new PropertyAccessException("could not read " + propertyName + " (" + propertyUri + + "). Response does not include " + propertyName + "(" + propertyUri + "): " + body); + } + } + } catch (ExecutionException | TimeoutException | InterruptedException e) { + onError("WebThing resource " + webThingURI + " disconnected"); + throw new PropertyAccessException("could not read " + propertyName + " (" + propertyUri + ").", e); + } + } else { + onError("WebThing " + webThingURI + " does not support " + propertyName); + throw new PropertyAccessException("WebThing " + webThingURI + " does not support " + propertyName); + } + } + + @Override + public void writeProperty(String propertyName, Object newValue) throws PropertyAccessException { + var optionalPropertyUri = getPropertyUri(propertyName); + if (optionalPropertyUri.isPresent()) { + var propertyUri = optionalPropertyUri.get(); + var optionalProperty = description.getProperty(propertyName); + if (optionalProperty.isPresent()) { + try { + if (optionalProperty.get().readOnly) { + throw new PropertyAccessException("could not write " + propertyName + " (" + propertyUri + + ") with " + newValue + ". Property is readOnly"); + } else { + logger.debug("updating {} with {}", propertyName, newValue); + Map payload = Map.of(propertyName, newValue); + var json = gson.toJson(payload); + var response = httpClient.newRequest(propertyUri).method("PUT") + .content(new StringContentProvider(json), "application/json") + .timeout(30, TimeUnit.SECONDS).send(); + if (response.getStatus() < 200 || response.getStatus() >= 300) { + onError("WebThing " + webThingURI + "could not write " + propertyName + " (" + propertyUri + + ") with " + newValue); + throw new PropertyAccessException( + "could not write " + propertyName + " (" + propertyUri + ") with " + newValue); + } + } + } catch (ExecutionException | TimeoutException | InterruptedException e) { + onError("WebThing resource " + webThingURI + " disconnected"); + throw new PropertyAccessException( + "could not write " + propertyName + " (" + propertyUri + ") with " + newValue, e); + } + } else { + throw new PropertyAccessException("could not write " + propertyName + " (" + propertyUri + ") with " + + newValue + " WebTing does not support a property named " + propertyName); + } + } else { + onError("WebThing " + webThingURI + " does not support " + propertyName); + throw new PropertyAccessException("WebThing " + webThingURI + " does not support " + propertyName); + } + } + + /** + * Gets the property description + * + * @param propertyName the propertyName + * @return the description (meta data) of the property + */ + public @Nullable Property getPropertyDescription(String propertyName) { + return description.properties.get(propertyName); + } + + @Override + public String toString() { + return "WebThing " + description.title + " (" + webThingURI + ")"; + } +} diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/DescriptionLoader.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/DescriptionLoader.java new file mode 100644 index 00000000000..f172ff79373 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/DescriptionLoader.java @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal.client; + +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.Locale; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.webthing.internal.client.dto.WebThingDescription; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; + +/** + * Utility class to load the WebThing description (meta data). Refer https://iot.mozilla.org/wot/#web-thing-description + * + * @author Gregor Roth - Initial contribution + */ +@NonNullByDefault +public class DescriptionLoader { + private final Logger logger = LoggerFactory.getLogger(DescriptionLoader.class); + private final Gson gson = new Gson(); + private final HttpClient httpClient; + + /** + * constructor + * + * @param httpClient the http client to use + */ + public DescriptionLoader(HttpClient httpClient) { + this.httpClient = httpClient; + } + + /** + * loads the WebThing meta data + * + * @param webthingURI the WebThing URI + * @param timeout the timeout + * @return the Webthing description + * @throws IOException if the WebThing can not be connected + */ + public WebThingDescription loadWebthingDescription(URI webthingURI, Duration timeout) throws IOException { + try { + var response = httpClient.newRequest(webthingURI).timeout(30, TimeUnit.SECONDS).accept("application/json") + .send(); + if (response.getStatus() < 200 || response.getStatus() >= 300) { + throw new IOException( + "could not read resource description " + webthingURI + ". Got " + response.getStatus()); + } + var body = response.getContentAsString(); + var description = gson.fromJson(body, WebThingDescription.class); + if ((description != null) && (description.properties != null) && (description.properties.size() > 0)) { + if ((description.contextKeyword == null) || description.contextKeyword.trim().length() == 0) { + description.contextKeyword = "https://webthings.io/schemas"; + } + var schema = description.contextKeyword.replaceFirst("/$", "").toLowerCase(Locale.US).trim(); + + // currently, the old and new location of the WebThings schema are supported only. + // In the future, other schemas such as http://iotschema.org/docs/full.html may be supported + if (schema.equals("https://webthings.io/schemas") || schema.equals("https://iot.mozilla.org/schemas")) { + return description; + } + logger.debug( + "WebThing {} detected with unsupported schema {} (Supported schemas are https://webthings.io/schemas and https://iot.mozilla.org/schemas)", + webthingURI, description.contextKeyword); + throw new IOException("unsupported schema (@context parameter) " + description.contextKeyword + + " (Supported schemas are https://webthings.io/schemas and https://iot.mozilla.org/schemas)"); + } else { + throw new IOException("description does not include properties"); + } + } catch (ExecutionException | TimeoutException e) { + throw new IOException("error occurred by calling WebThing", e); + } catch (JsonSyntaxException se) { + throw new IOException("resource seems not to be a WebThing. Typo?"); + } catch (InterruptedException ie) { + throw new IOException("resource seems not to be reachable"); + } + } +} diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/PropertyAccessException.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/PropertyAccessException.java new file mode 100644 index 00000000000..dba60bc73f4 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/PropertyAccessException.java @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal.client; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link PropertyAccessException} indicates a WebThing property can not be accessed + * + * @author Gregor Roth - Initial contribution + */ +@NonNullByDefault +public class PropertyAccessException extends Exception { + private static final long serialVersionUID = 5177277585758195790L; + + /** + * contructor + * + * @param message the error message + */ + PropertyAccessException(String message) { + super(message); + } + + /** + * contructor + * + * @param message the error message + * @param cause the error cause + */ + PropertyAccessException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/WebSocketConnection.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/WebSocketConnection.java new file mode 100644 index 00000000000..26293ef433a --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/WebSocketConnection.java @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal.client; + +import java.util.function.BiConsumer; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The WebsocketConnection represents an open WebSocket connection on the Web Thing. It provides a realtime mechanism + * to be notified of events as soon as they happen. Refer https://iot.mozilla.org/wot/#web-thing-websocket-api + * + * @author Gregor Roth - Initial contribution + */ +@NonNullByDefault +interface WebSocketConnection { + + /** + * Makes a request for Property value change notifications + * + * @param propertyName the property to be observed + * @param listener the listener to call on changes + */ + void observeProperty(String propertyName, BiConsumer listener); + + /** + * closes the WebSocket connection + */ + void close(); + + /** + * @return true, if connection is alive + */ + boolean isAlive(); +} diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/WebSocketConnectionFactory.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/WebSocketConnectionFactory.java new file mode 100644 index 00000000000..b46ab195b07 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/WebSocketConnectionFactory.java @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal.client; + +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.concurrent.ScheduledExecutorService; +import java.util.function.Consumer; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.websocket.client.WebSocketClient; + +/** + * Factory to create new instances of a WebSocket connection + * + * @author Gregor Roth - Initial contribution + */ +@NonNullByDefault +interface WebSocketConnectionFactory { + + /** + * create (and opens) a new WebSocket connection + * + * @param webSocketURI the websocket uri + * @param executor the executor to use + * @param errorHandler the error handler + * @param pingPeriod the ping period to check the healthiness of the connection + * @return the newly opened WebSocket connection + * @throws IOException if the web socket connection can not be established + */ + WebSocketConnection create(URI webSocketURI, ScheduledExecutorService executor, Consumer errorHandler, + Duration pingPeriod) throws IOException; + + /** + * @param webSocketClient the web socket client to use + * @return the default instance of the factory + */ + static WebSocketConnectionFactory instance(WebSocketClient webSocketClient) { + return (webSocketURI, executor, errorHandler, pingPeriod) -> { + var webSocketConnection = new WebSocketConnectionImpl(executor, errorHandler, pingPeriod); + webSocketClient.connect(webSocketConnection, webSocketURI); + return webSocketConnection; + }; + } +} diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/WebSocketConnectionImpl.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/WebSocketConnectionImpl.java new file mode 100644 index 00000000000..e491a9e8dcc --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/WebSocketConnectionImpl.java @@ -0,0 +1,185 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal.client; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.WebSocketListener; +import org.eclipse.jetty.websocket.api.WebSocketPingPongListener; +import org.openhab.binding.webthing.internal.client.dto.PropertyStatusMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; + +/** + * The WebsocketConnection implementation + * + * @author Gregor Roth - Initial contribution + */ +@NonNullByDefault +public class WebSocketConnectionImpl implements WebSocketConnection, WebSocketListener, WebSocketPingPongListener { + private static final BiConsumer EMPTY_PROPERTY_CHANGED_LISTENER = (String propertyName, + Object value) -> { + }; + private final Logger logger = LoggerFactory.getLogger(WebSocketConnectionImpl.class); + private final Gson gson = new Gson(); + private final Duration pingPeriod; + private final Consumer errorHandler; + private final ScheduledFuture watchDogHandle; + private final ScheduledFuture pingHandle; + private final Map> propertyChangedListeners = new HashMap<>(); + private final AtomicReference lastTimeReceived = new AtomicReference<>(Instant.now()); + private final AtomicReference> sessionRef = new AtomicReference<>(Optional.empty()); + + /** + * constructor + * + * @param executor the executor to use + * @param errorHandler the errorHandler + * @param pingPeriod the period pings should be sent + */ + WebSocketConnectionImpl(ScheduledExecutorService executor, Consumer errorHandler, Duration pingPeriod) { + this.errorHandler = errorHandler; + this.pingPeriod = pingPeriod; + + // send a ping message are x seconds to validate if the connection is not broken + this.pingHandle = executor.scheduleWithFixedDelay(this::sendPing, pingPeriod.dividedBy(2).toMillis(), + pingPeriod.toMillis(), TimeUnit.MILLISECONDS); + + // checks if a message (regular message or pong message) has been received recently. If not, connection is + // seen as broken + this.watchDogHandle = executor.scheduleWithFixedDelay(this::checkConnection, pingPeriod.toMillis(), + pingPeriod.toMillis(), TimeUnit.MILLISECONDS); + } + + @Override + public void close() { + sessionRef.getAndSet(Optional.empty()).ifPresent(Session::close); + watchDogHandle.cancel(true); + pingHandle.cancel(true); + } + + @Override + public void observeProperty(@NonNull String propertyName, @NonNull BiConsumer listener) { + propertyChangedListeners.put(propertyName, listener); + } + + @Override + public void onWebSocketConnect(@Nullable Session session) { + sessionRef.set(Optional.ofNullable(session)); // save websocket session to be able to send ping + } + + @Override + public void onWebSocketPing(@Nullable ByteBuffer payload) { + } + + @Override + public void onWebSocketPong(@Nullable ByteBuffer payload) { + lastTimeReceived.set(Instant.now()); + } + + @Override + public void onWebSocketBinary(byte @Nullable [] payload, int offset, int len) { + } + + @Override + public void onWebSocketText(@Nullable String message) { + try { + if (message != null) { + var propertyStatus = gson.fromJson(message, PropertyStatusMessage.class); + if ((propertyStatus != null) && (propertyStatus.messageType != null) + && (propertyStatus.messageType.equals("propertyStatus"))) { + for (var propertyEntry : propertyStatus.data.entrySet()) { + var listener = propertyChangedListeners.getOrDefault(propertyEntry.getKey(), + EMPTY_PROPERTY_CHANGED_LISTENER); + try { + listener.accept(propertyEntry.getKey(), propertyEntry.getValue()); + } catch (RuntimeException re) { + logger.warn("calling property change listener {} failed. {}", listener, re.getMessage()); + } + } + } else { + logger.debug("Ignoring received message of unknown type: {}", message); + } + } + } catch (JsonSyntaxException se) { + logger.warn("received invalid message: {}", message); + } + } + + @Override + public void onWebSocketClose(int statusCode, @Nullable String reason) { + onWebSocketError(new IOException("websocket closed by peer. " + Optional.ofNullable(reason).orElse(""))); + } + + @Override + public void onWebSocketError(@Nullable Throwable cause) { + var reason = ""; + if (cause != null) { + reason = cause.getMessage(); + } + onError(reason); + } + + private void onError(@Nullable String message) { + if (message == null) { + message = ""; + } + errorHandler.accept(message); + } + + private void sendPing() { + var optionalSession = sessionRef.get(); + if (optionalSession.isPresent()) { + try { + optionalSession.get().getRemote().sendPing(ByteBuffer.wrap(Instant.now().toString().getBytes())); + } catch (IOException e) { + onError("could not send ping " + e.getMessage()); + } + } + } + + @Override + public boolean isAlive() { + var elapsedSinceLastReceived = Duration.between(lastTimeReceived.get(), Instant.now()); + var thresholdOverdued = pingPeriod.multipliedBy(3); + var isOverdued = elapsedSinceLastReceived.toMillis() > thresholdOverdued.toMillis(); + return sessionRef.get().isPresent() && !isOverdued; + } + + private void checkConnection() { + // check if connection is alive (message has been received recently) + if (!isAlive()) { + onError("connection seems to be broken (last message received at " + lastTimeReceived.get() + ", " + + Duration.between(lastTimeReceived.get(), Instant.now()).getSeconds() + " sec ago)"); + } + } +} diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/Link.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/Link.java new file mode 100644 index 00000000000..e4598ace7bc --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/Link.java @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal.client.dto; + +/** + * The Web Thing Description Link object. Refer https://iot.mozilla.org/wot/#link-object + * + * @author Gregor Roth - Initial contribution + */ +public class Link { + + public String rel = null; + + public String href = null; +} diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/Property.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/Property.java new file mode 100644 index 00000000000..50420d4fd6d --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/Property.java @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal.client.dto; + +import java.util.List; + +import com.google.gson.annotations.SerializedName; + +/** + * The Web Thing Description Property object. Refer https://iot.mozilla.org/wot/#property-object + * + * @author Gregor Roth - Initial contribution + */ +public class Property { + + public String title = ""; + + @SerializedName("@type") + public String typeKeyword = ""; + + public String type = "string"; + + public String unit = null; + + public boolean readOnly = false; + + public String description = ""; + + public List links = List.of(); +} diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/PropertyStatusMessage.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/PropertyStatusMessage.java new file mode 100644 index 00000000000..caca178a162 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/PropertyStatusMessage.java @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal.client.dto; + +import java.util.Map; + +/** + * Web Thing WebSocket API property status message. Refer https://iot.mozilla.org/wot/#propertystatus-message + * + * @author Gregor Roth - Initial contribution + */ +public class PropertyStatusMessage { + + public String messageType = ""; + + public Map data = Map.of(); + + @Override + public String toString() { + return "PropertyStatusMessage{" + "messageType='" + messageType + '\'' + ", data=" + data + '}'; + } +} diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/WebThingDescription.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/WebThingDescription.java new file mode 100644 index 00000000000..e7a45812bae --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/client/dto/WebThingDescription.java @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal.client.dto; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import com.google.gson.annotations.SerializedName; + +/** + * The Web Thing Description. Refer https://iot.mozilla.org/wot/#web-thing-description + * + * @author Gregor Roth - Initial contribution + */ +public class WebThingDescription { + + public String id = null; + + public String title = ""; + + @SerializedName("@context") + public String contextKeyword = ""; + + public Map properties = Map.of(); + + public List links = List.of(); + + /** + * convenience method to read properties + * + * @param propertyName the property name to read + * @return the property value + */ + public Optional getProperty(String propertyName) { + return Optional.ofNullable(properties.get(propertyName)); + } + + /** + * convenience method to read the event stream uri + * + * @return the optional event stream uri + */ + public Optional getEventStreamUri() { + for (var link : this.links) { + var href = link.href; + if ((href != null) && href.startsWith("ws")) { + var rel = Optional.ofNullable(link.rel).orElse(""); + if (rel.equals("alternate")) { + return Optional.of(URI.create(href)); + } + } + } + return Optional.empty(); + } +} diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/discovery/WebthingDiscoveryService.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/discovery/WebthingDiscoveryService.java new file mode 100644 index 00000000000..0e056221e8b --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/discovery/WebthingDiscoveryService.java @@ -0,0 +1,297 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal.discovery; + +import static org.openhab.binding.webthing.internal.WebThingBindingConstants.MDNS_SERVICE_TYPE; +import static org.openhab.binding.webthing.internal.WebThingBindingConstants.THING_TYPE_UID; + +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.*; + +import javax.jmdns.ServiceEvent; +import javax.jmdns.ServiceInfo; +import javax.jmdns.ServiceListener; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.webthing.internal.client.DescriptionLoader; +import org.openhab.core.config.discovery.AbstractDiscoveryService; +import org.openhab.core.config.discovery.DiscoveryResult; +import org.openhab.core.config.discovery.DiscoveryResultBuilder; +import org.openhab.core.config.discovery.DiscoveryService; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.io.transport.mdns.MDNSClient; +import org.openhab.core.scheduler.Scheduler; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.ThingUID; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * WebThing discovery service based on mDNS. Refer https://iot.mozilla.org/wot/#web-thing-discovery + * + * @author Gregor Roth - Initial contribution + */ +@NonNullByDefault +@Component(service = DiscoveryService.class, configurationPid = "webthingdiscovery.mdns") +public class WebthingDiscoveryService extends AbstractDiscoveryService implements ServiceListener { + private static final Duration FOREGROUND_SCAN_TIMEOUT = Duration.ofMillis(200); + public static final String ID = "id"; + public static final String SCHEMAS = "schemas"; + public static final String WEB_THING_URI = "webThingURI"; + private final Logger logger = LoggerFactory.getLogger(WebthingDiscoveryService.class); + private final DescriptionLoader descriptionLoader; + private final MDNSClient mdnsClient; + private final List>> runningDiscoveryTasks = new CopyOnWriteArrayList<>(); + + /** + * constructor + * + * @param configProperties the config props + * @param mdnsClient the underlying mDNS client + */ + @Activate + public WebthingDiscoveryService(@Nullable Map configProperties, @Reference MDNSClient mdnsClient, + @Reference Scheduler executor, @Reference HttpClientFactory httpClientFactory) { + super(30); + this.mdnsClient = mdnsClient; + this.descriptionLoader = new DescriptionLoader(httpClientFactory.getCommonHttpClient()); + super.activate(configProperties); + if (isBackgroundDiscoveryEnabled()) { + mdnsClient.addServiceListener(MDNS_SERVICE_TYPE, this); + } + } + + @Override + public Set getSupportedThingTypes() { + return Set.of(THING_TYPE_UID); + } + + @Deactivate + @Override + protected void deactivate() { + super.deactivate(); + mdnsClient.removeServiceListener(MDNS_SERVICE_TYPE, this); + } + + @Override + public void serviceAdded(@NonNullByDefault({}) ServiceEvent serviceEvent) { + considerService(serviceEvent); + } + + @Override + public void serviceResolved(@NonNullByDefault({}) ServiceEvent serviceEvent) { + considerService(serviceEvent); + } + + @Override + public void serviceRemoved(@NonNullByDefault({}) ServiceEvent serviceEvent) { + for (var discoveryResult : discoverWebThing(serviceEvent.getInfo())) { + thingRemoved(discoveryResult.getThingUID()); + } + } + + @Override + protected void startBackgroundDiscovery() { + mdnsClient.addServiceListener(MDNS_SERVICE_TYPE, this); + startScan(true); + } + + @Override + protected void stopBackgroundDiscovery() { + mdnsClient.removeServiceListener(MDNS_SERVICE_TYPE, this); + } + + private void startScan(boolean isBackground) { + scheduler.submit(() -> scan(isBackground)); + } + + @Override + protected void startScan() { + startScan(false); + } + + @Override + protected synchronized void stopScan() { + removeOlderResults(Instant.now().minus(Duration.ofMinutes(10)).toEpochMilli()); + + // stop running discovery tasks + for (var future : runningDiscoveryTasks) { + future.cancel(true); + runningDiscoveryTasks.remove(future); + } + super.stopScan(); + } + + /** + * scans the network via mDNS + * + * @param isBackground true, if is background task + */ + private void scan(boolean isBackground) { + var serviceInfos = isBackground ? mdnsClient.list(MDNS_SERVICE_TYPE) + : mdnsClient.list(MDNS_SERVICE_TYPE, FOREGROUND_SCAN_TIMEOUT); + logger.debug("got {} mDNS entries", serviceInfos.length); + + // create discovery task for each detected service and process these in parallel to increase total + // discovery speed + for (var serviceInfo : serviceInfos) { + var future = scheduler.submit(new DiscoveryTask(serviceInfo)); + runningDiscoveryTasks.add(future); + } + + // wait until all tasks are completed + for (var future : runningDiscoveryTasks) { + try { + future.get(5, TimeUnit.MINUTES); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + logger.warn("discovering task {} terminated", future); + } + runningDiscoveryTasks.remove(future); + } + } + + private class DiscoveryTask implements Callable> { + private final ServiceInfo serviceInfo; + + DiscoveryTask(ServiceInfo serviceInfo) { + this.serviceInfo = serviceInfo; + } + + @Override + public Set call() { + var results = new HashSet(); + for (var discoveryResult : discoverWebThing(serviceInfo)) { + results.add(discoveryResult); + thingDiscovered(discoveryResult); + logger.debug("WebThing '{}' (uri: {}, id: {}, schemas: {}) discovered", discoveryResult.getLabel(), + discoveryResult.getProperties().get(WEB_THING_URI), discoveryResult.getProperties().get(ID), + discoveryResult.getProperties().get(SCHEMAS)); + } + return results; + } + + @Override + public String toString() { + return "DiscoveryTask{" + "serviceInfo=" + serviceInfo + '}'; + } + } + + /** + * convert the serviceInfo result of the mDNS scan to discovery results + * + * @param serviceInfo the service info + * @return the associated discovery result + */ + private Set discoverWebThing(ServiceInfo serviceInfo) { + var discoveryResults = new HashSet(); + + if (serviceInfo.getHostAddresses().length > 0) { + var host = serviceInfo.getHostAddresses()[0]; + var port = serviceInfo.getPort(); + var path = "/"; + if (Collections.list(serviceInfo.getPropertyNames()).contains("path")) { + path = serviceInfo.getPropertyString("path"); + if (!path.endsWith("/")) { + path = path + "/"; + } + } + + // There are two kinds of WebThing endpoints: Endpoints supporting a single WebThing as well as + // endpoints supporting multiple WebThings. + // + // In the routine below the enpoint will be checked for single WebThings first, than for multiple + // WebThings if a ingle WebTHing has not been found. + // Furthermore, first it will be tried to connect the endpoint using https. If this fails, as fallback + // plain http is used. + + // check single WebThing path via https (e.g. https://192.168.0.23:8433/) + var optionalDiscoveryResult = discoverWebThing(toURI(host, port, path, true)); + if (optionalDiscoveryResult.isPresent()) { + discoveryResults.add(optionalDiscoveryResult.get()); + } else { + // check single WebThing path via plain http (e.g. http://192.168.0.23:8433/) + optionalDiscoveryResult = discoverWebThing(toURI(host, port, path, false)); + if (optionalDiscoveryResult.isPresent()) { + discoveryResults.add(optionalDiscoveryResult.get()); + } else { + // check multiple WebThing path via https (e.g. https://192.168.0.23:8433/0, + // https://192.168.0.23:8433/1,...) + outer: for (int i = 0; i < 50; i++) { // search 50 entries at maximum + optionalDiscoveryResult = discoverWebThing(toURI(host, port, path + i + "/", true)); + if (optionalDiscoveryResult.isPresent()) { + discoveryResults.add(optionalDiscoveryResult.get()); + } else if (i == 0) { + // check multiple WebThing path via plain http (e.g. http://192.168.0.23:8433/0, + // http://192.168.0.23:8433/1,...) + for (int j = 0; j < 50; j++) { // search 50 entries at maximum + optionalDiscoveryResult = discoverWebThing(toURI(host, port, path + j + "/", false)); + if (optionalDiscoveryResult.isPresent()) { + discoveryResults.add(optionalDiscoveryResult.get()); + } else { + break outer; + } + } + } else { + break; + } + } + } + } + } + + return discoveryResults; + } + + private Optional discoverWebThing(URI uri) { + try { + var description = descriptionLoader.loadWebthingDescription(uri, Duration.ofSeconds(5)); + + var id = uri.getHost().replaceAll("\\W", "_") + "_" + uri.getPort(); + if (uri.getPath().length() > 1) { + id = id + "_" + uri.getPath().replaceAll("\\W", ""); + } + + var thingUID = new ThingUID(THING_TYPE_UID, id); + Map properties = new HashMap<>(2); + properties.put(ID, id); + properties.put(SCHEMAS, description.contextKeyword); + return Optional.of(DiscoveryResultBuilder.create(thingUID).withThingType(THING_TYPE_UID) + .withProperty(WEB_THING_URI, uri).withLabel(description.title).withProperties(properties) + .withRepresentationProperty(ID).build()); + } catch (IOException ioe) { + return Optional.empty(); + } + } + + private URI toURI(String host, int port, String path, boolean isHttps) { + return isHttps ? URI.create("https://" + host + ":" + port + path) + : URI.create("http://" + host + ":" + port + path); + } + + private void considerService(ServiceEvent serviceEvent) { + if (isBackgroundDiscoveryEnabled()) { + for (var discoveryResult : discoverWebThing(serviceEvent.getInfo())) { + thingDiscovered(discoveryResult); + } + } + } +} diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/ChannelToPropertyLink.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/ChannelToPropertyLink.java new file mode 100644 index 00000000000..eff672d7ac1 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/ChannelToPropertyLink.java @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal.link; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.webthing.internal.ChannelHandler; +import org.openhab.binding.webthing.internal.WebThingHandler; +import org.openhab.binding.webthing.internal.client.ConsumedThing; +import org.openhab.binding.webthing.internal.client.PropertyAccessException; +import org.openhab.core.thing.Channel; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.types.State; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link ChannelToPropertyLink} represents an upstream link from a Channel to a WebThing property. + * This link is used to update a the value of a property + * + * @author Gregor Roth - Initial contribution + */ +@NonNullByDefault +public class ChannelToPropertyLink implements WebThingHandler.ItemChangedListener { + private final Logger logger = LoggerFactory.getLogger(ChannelToPropertyLink.class); + private final String propertyName; + private final String propertyType; + private final ConsumedThing webThing; + private final TypeConverter typeConverter; + + /** + * establish a upstream link from a Channel to a WebThing property + * + * @param channelHandler the channel handler that provides registering an ItemChangedListener + * @param channel the channel to be linked + * @param webthing the WebThing to be linked + * @param propertyName the property name + * @throws UnknownPropertyException if the a WebThing property should be link that does not exist + */ + public static void establish(ChannelHandler channelHandler, Channel channel, ConsumedThing webthing, + String propertyName) throws UnknownPropertyException { + new ChannelToPropertyLink(channelHandler, channel, webthing, propertyName); + } + + private ChannelToPropertyLink(ChannelHandler channelHandler, Channel channel, ConsumedThing webThing, + String propertyName) throws UnknownPropertyException { + this.webThing = webThing; + var optionalProperty = webThing.getThingDescription().getProperty(propertyName); + if (optionalProperty.isPresent()) { + this.propertyType = optionalProperty.get().type; + var acceptedType = channel.getAcceptedItemType(); + if (acceptedType == null) { + this.typeConverter = TypeConverters.create("String", propertyType); + } else { + this.typeConverter = TypeConverters.create(acceptedType, propertyType); + } + this.propertyName = propertyName; + channelHandler.observeChannel(channel.getUID(), this); + } else { + throw new UnknownPropertyException("property " + propertyName + " does not exits"); + } + } + + @Override + public void onItemStateChanged(ChannelUID channelUID, State stateCommand) { + try { + var propertyValue = typeConverter.toPropertyValue(stateCommand); + webThing.writeProperty(propertyName, typeConverter.toPropertyValue((State) stateCommand)); + logger.debug("property {} updated with {} ({}) ", propertyName, propertyValue, this.propertyType); + } catch (PropertyAccessException pae) { + logger.warn("could not write WebThing property {} with new channel value. {}", propertyName, + pae.getMessage()); + } + } +} diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/PropertyToChannelLink.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/PropertyToChannelLink.java new file mode 100644 index 00000000000..107b5490dcc --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/PropertyToChannelLink.java @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal.link; + +import java.util.function.BiConsumer; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.webthing.internal.ChannelHandler; +import org.openhab.binding.webthing.internal.client.ConsumedThing; +import org.openhab.core.thing.Channel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link PropertyToChannelLink} represents a downstream link from a WebThing property to a Channel. + * + * @author Gregor Roth - Initial contribution + */ +@NonNullByDefault +public class PropertyToChannelLink implements BiConsumer { + private final Logger logger = LoggerFactory.getLogger(PropertyToChannelLink.class); + private final ChannelHandler channelHandler; + private final Channel channel; + private final TypeConverter typeConverter; + + /** + * establish downstream link from a WebTHing property to a Channel + * + * @param webThing the WebThing to be linked + * @param propertyName the property name + * @param channelHandler the channel handler that provides updating the Item state of a channel + * @param channel the channel to be linked + * @throws UnknownPropertyException if the a WebThing property should be link that does not exist + */ + public static void establish(ConsumedThing webThing, String propertyName, ChannelHandler channelHandler, + Channel channel) throws UnknownPropertyException { + new PropertyToChannelLink(webThing, propertyName, channelHandler, channel); + } + + private PropertyToChannelLink(ConsumedThing webThing, String propertyName, ChannelHandler channelHandler, + Channel channel) throws UnknownPropertyException { + this.channel = channel; + var optionalProperty = webThing.getThingDescription().getProperty(propertyName); + if (optionalProperty.isPresent()) { + var propertyType = optionalProperty.get().type; + var acceptedType = channel.getAcceptedItemType(); + if (acceptedType == null) { + this.typeConverter = TypeConverters.create("String", propertyType); + } else { + this.typeConverter = TypeConverters.create(acceptedType, propertyType); + } + this.channelHandler = channelHandler; + webThing.observeProperty(propertyName, this); + } else { + throw new UnknownPropertyException("property " + propertyName + " does not exits"); + } + } + + @Override + public void accept(String propertyName, Object value) { + var stateCommand = typeConverter.toStateCommand(value); + channelHandler.updateItemState(channel.getUID(), stateCommand); + logger.debug("channel {} updated with {} ({})", channel.getUID().getAsString(), value, + channel.getAcceptedItemType()); + } +} diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/TypeConverter.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/TypeConverter.java new file mode 100644 index 00000000000..ce00d559986 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/TypeConverter.java @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal.link; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; + +/** + * The {@link TypeConverter} class map Item state <-> Property value + * + * @author Gregor Roth - Initial contribution + */ +@NonNullByDefault +interface TypeConverter { + + /** + * * maps a Property value to an Item state command + * + * @param propertyValue the Property value + * @return the Item state command + */ + Command toStateCommand(Object propertyValue); + + /** + * maps an Item state to a Property value + * + * @param state the Item state + * @return the Property value + */ + Object toPropertyValue(State state); +} diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/TypeConverters.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/TypeConverters.java new file mode 100644 index 00000000000..b7665f5fcb1 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/TypeConverters.java @@ -0,0 +1,179 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal.link; + +import java.awt.*; +import java.math.BigDecimal; +import java.util.Collection; +import java.util.Locale; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.library.types.*; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; + +/** + * Helper class to create a TypeConverter + * + * @author Gregor Roth - Initial contribution + */ +@NonNullByDefault +class TypeConverters { + + /** + * create a TypeConverter for a given Item type and property type + * + * @param itemType the item type + * @param propertyType the property type + * @return the type converter + */ + static TypeConverter create(String itemType, String propertyType) { + switch (itemType.toLowerCase(Locale.ENGLISH)) { + case "switch": + return new SwitchTypeConverter(); + case "dimmer": + return new DimmerTypeConverter(); + case "contact": + return new ContactTypeConverter(); + case "color": + return new ColorTypeConverter(); + case "number": + if (propertyType.toLowerCase(Locale.ENGLISH).equals("integer")) { + return new IntegerTypeConverter(); + } else { + return new NumberTypeConverter(); + } + default: + return new StringTypeConverter(); + } + } + + private static boolean toBoolean(Object propertyValue) { + return Boolean.parseBoolean(propertyValue.toString()); + } + + private static BigDecimal toDecimal(Object propertyValue) { + return new BigDecimal(propertyValue.toString()); + } + + private static final class ColorTypeConverter implements TypeConverter { + + @Override + public Command toStateCommand(Object propertyValue) { + var value = propertyValue.toString(); + if (!value.contains("#")) { + value = "#" + value; + } + Color rgb = Color.decode(value); + return HSBType.fromRGB(rgb.getRed(), rgb.getGreen(), rgb.getBlue()); + } + + @Override + public Object toPropertyValue(State state) { + var hsb = ((HSBType) state); + + // Get HSB values + Float hue = hsb.getHue().floatValue(); + Float saturation = hsb.getSaturation().floatValue(); + Float brightness = hsb.getBrightness().floatValue(); + + // Convert HSB to RGB and then to HTML hex + Color rgb = Color.getHSBColor(hue / 360, saturation / 100, brightness / 100); + return String.format("#%02x%02x%02x", rgb.getRed(), rgb.getGreen(), rgb.getBlue()); + } + } + + private static final class SwitchTypeConverter implements TypeConverter { + + @Override + public Command toStateCommand(Object propertyValue) { + return toBoolean(propertyValue) ? OnOffType.ON : OnOffType.OFF; + } + + @Override + public Object toPropertyValue(State state) { + return state == OnOffType.ON; + } + } + + private static final class ContactTypeConverter implements TypeConverter { + + @Override + public Command toStateCommand(Object propertyValue) { + return toBoolean(propertyValue) ? OpenClosedType.OPEN : OpenClosedType.CLOSED; + } + + @Override + public Object toPropertyValue(State state) { + return state == OpenClosedType.OPEN; + } + } + + private static final class DimmerTypeConverter implements TypeConverter { + + @Override + public Command toStateCommand(Object propertyValue) { + return new PercentType(toDecimal(propertyValue)); + } + + @Override + public Object toPropertyValue(State state) { + return ((DecimalType) state).toBigDecimal().intValue(); + } + } + + private static final class NumberTypeConverter implements TypeConverter { + + @Override + public Command toStateCommand(Object propertyValue) { + return new DecimalType(toDecimal(propertyValue)); + } + + @Override + public Object toPropertyValue(State state) { + return ((DecimalType) state).doubleValue(); + } + } + + private static final class IntegerTypeConverter implements TypeConverter { + + @Override + public Command toStateCommand(Object propertyValue) { + return new DecimalType(toDecimal(propertyValue)); + } + + @Override + public Object toPropertyValue(State state) { + return ((DecimalType) state).intValue(); + } + } + + private static final class StringTypeConverter implements TypeConverter { + + @SuppressWarnings("unchecked") + @Override + public Command toStateCommand(Object propertyValue) { + String textValue = propertyValue.toString(); + if (propertyValue instanceof Collection) { + textValue = ((Collection) propertyValue).stream() + .reduce("", (entry1, entry2) -> entry1.toString() + "\n" + entry2.toString()).toString(); + } + return StringType.valueOf(textValue); + } + + @Override + public Object toPropertyValue(State state) { + return state.toString(); + } + } +} diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/TypeMapping.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/TypeMapping.java new file mode 100644 index 00000000000..945d03622cc --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/TypeMapping.java @@ -0,0 +1,143 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal.link; + +import java.util.Locale; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.webthing.internal.client.dto.Property; + +/** + * The {@link TypeMapping} class defines the mapping of Item types <-> WebThing Property types. + * + * Please consider that changes of 'Item types <-> WebThing Property types' mapping will break the + * compatibility to former releases + * + * @author Gregor Roth - Initial contribution + */ +@NonNullByDefault +public class TypeMapping { + + /** + * maps a property type to an item type + * + * @param propertyMetadata the property meta data + * @return the associated item type + */ + public static ItemType toItemType(Property propertyMetadata) { + String type = "string"; + @Nullable + String tag = null; + + switch (propertyMetadata.typeKeyword) { + case "AlarmProperty": + case "BooleanProperty": + case "LeakProperty": + case "LockedProperty": + case "MotionProperty": + case "OnOffProperty": + case "PushedProperty": + type = "switch"; + tag = "Switchable"; + break; + case "CurrentProperty": + case "FrequencyProperty": + case "InstantaneousPowerProperty": + case "VoltageProperty": + type = "number"; + break; + case "HeatingCoolingProperty": + case "ImageProperty": + case "VideoProperty": + type = "string"; + break; + case "BrightnessProperty": + case "HumidityProperty": + type = "dimmer"; + break; + case "ColorModeProperty": + type = "string"; + tag = "lighting"; + break; + case "ColorProperty": + type = "color"; + tag = "Lighting"; + break; + case "ColorTemperatureProperty": + type = "dimmer"; + tag = "Lighting"; + break; + case "OpenProperty": + type = "contact"; + tag = "ContactSensor"; + break; + case "TargetTemperatureProperty": + type = "number"; + tag = "TargetTemperature"; + break; + case "TemperatureProperty": + type = "number"; + tag = "CurrentTemperature"; + break; + case "ThermostatModeProperty": + break; + case "LevelProperty": + if ((propertyMetadata.unit != null) + && propertyMetadata.unit.toLowerCase(Locale.ENGLISH).equals("percent")) { + type = "dimmer"; + } else { + type = "number"; + } + break; + default: + switch (propertyMetadata.type.toLowerCase(Locale.ENGLISH)) { + case "boolean": + type = "switch"; + tag = "Switchable"; + break; + case "integer": + case "number": + type = "number"; + break; + default: + type = "string"; + break; + } + break; + } + + return new ItemType(type, tag); + } + + /** + * The item type description + */ + public static class ItemType { + private final String type; + private final @Nullable String tag; + + ItemType(String type, @Nullable String tag) { + this.type = type; + this.tag = tag; + } + + public String getType() { + return type; + } + + public @Nullable String getTag() { + return tag; + } + } +} diff --git a/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/UnknownPropertyException.java b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/UnknownPropertyException.java new file mode 100644 index 00000000000..ca2128e43f8 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/java/org/openhab/binding/webthing/internal/link/UnknownPropertyException.java @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal.link; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link UnknownPropertyException} indicates addressing a WebThing property that does not exist + * + * @author Gregor Roth - Initial contribution + */ +@NonNullByDefault +public class UnknownPropertyException extends Exception { + private static final long serialVersionUID = -5302763943749264616L; + + /** + * contructor + * + * @param message the error message + */ + UnknownPropertyException(String message) { + super(message); + } +} diff --git a/bundles/org.openhab.binding.webthing/src/main/resources/OH-INF/binding/binding.xml b/bundles/org.openhab.binding.webthing/src/main/resources/OH-INF/binding/binding.xml new file mode 100644 index 00000000000..ca5f51919d3 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/resources/OH-INF/binding/binding.xml @@ -0,0 +1,8 @@ + + + + WebThing Binding + The WebThing binding supports an interface to remote devices implementing the Web Thing API. + diff --git a/bundles/org.openhab.binding.webthing/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.webthing/src/main/resources/OH-INF/thing/thing-types.xml new file mode 100644 index 00000000000..3458f13b497 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,60 @@ + + + + + + The WebThing to be connected + + + + url + + 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 + + + + + + + + Number + + Number channel for Webthing Binding + + + + String + + String channel for Webthing Binding + + + + Contact + + Contact channel for Webthing Binding + + + + Switch + + Switch channel for Webthing Binding + + + + Color + + Color channel for Webthing Binding + + + + Dimmer + + Dimmer channel for Webthing Binding + + + + diff --git a/bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/client/DescriptionTest.java b/bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/client/DescriptionTest.java new file mode 100644 index 00000000000..eb7cff09ea2 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/client/DescriptionTest.java @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.time.Duration; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; + +/** + * + * + * @author Gregor Roth - Initial contribution + */ +@NonNullByDefault +public class DescriptionTest { + + @Test + public void testDescriptionEventStreamUri() throws Exception { + var httpClient = mock(org.eclipse.jetty.client.HttpClient.class); + var request = Mocks.mockRequest(null, load("/awning_response.json")); + when(httpClient.newRequest(URI.create("http://example.org:8090"))).thenReturn(request); + + var loader = new DescriptionLoader(httpClient); + var description = loader.loadWebthingDescription(URI.create("http://example.org:8090"), Duration.ofSeconds(2)); + assertEquals("ws://192.168.4.12:9040/0", description.getEventStreamUri().get().toString()); + } + + @Test + public void testDescriptionEventStreamUriServerlaAlternateParts() throws Exception { + var httpClient = mock(org.eclipse.jetty.client.HttpClient.class); + var request = Mocks.mockRequest(null, load("/virtual-things_response.json")); + when(httpClient.newRequest(URI.create("http://example.org:8090"))).thenReturn(request); + + var loader = new DescriptionLoader(httpClient); + var description = loader.loadWebthingDescription(URI.create("http://example.org:8090"), Duration.ofSeconds(2)); + assertEquals("ws://webthings/things/virtual-things-7", description.getEventStreamUri().get().toString()); + } + + public static String load(String name) throws Exception { + return new String(Files.readAllBytes(Paths.get(WebthingTest.class.getResource(name).toURI()))); + } +} diff --git a/bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/client/Mocks.java b/bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/client/Mocks.java new file mode 100644 index 00000000000..92f386167a2 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/client/Mocks.java @@ -0,0 +1,78 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal.client; + +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.api.ContentProvider; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; + +/** + * Mock helper + * + * @author Gregor Roth - Initial contribution + */ +@NonNullByDefault +public class Mocks { + + public static Request mockRequest(@Nullable String requestContent, String responseContent) throws Exception { + return mockRequest(requestContent, responseContent, 200, 200); + } + + public static Request mockRequest(@Nullable String requestContent, String responseContent, int getResponse, + int postResponse) throws Exception { + var request = mock(Request.class); + + // GET request -> request.timeout(30, TimeUnit.SECONDS).send(); + var getRequest = mock(Request.class); + var getContentResponse = mock(ContentResponse.class); + when(getContentResponse.getStatus()).thenReturn(getResponse); + when(getContentResponse.getContentAsString()).thenReturn(responseContent); + when(getRequest.send()).thenReturn(getContentResponse); + when(getRequest.accept("application/json")).thenReturn(getRequest); + when(request.timeout(30, TimeUnit.SECONDS)).thenReturn(getRequest); + + // POST request -> request.method("PUT").content(new StringContentProvider(json)).timeout(30, + // TimeUnit.SECONDS).send(); + if (requestContent != null) { + var postRequest = mock(Request.class); + when(postRequest.content(argThat((ContentProvider content) -> bufToString(content).equals(requestContent)), + eq("application/json"))).thenReturn(postRequest); + when(postRequest.timeout(30, TimeUnit.SECONDS)).thenReturn(postRequest); + + var postContentResponse = mock(ContentResponse.class); + when(postContentResponse.getStatus()).thenReturn(postResponse); + when(postRequest.send()).thenReturn(postContentResponse); + when(request.method("PUT")).thenReturn(postRequest); + } + return request; + } + + private static String bufToString(Iterable data) { + var result = ""; + for (var byteBuffer : data) { + result += StandardCharsets.UTF_8.decode(byteBuffer).toString(); + } + return result; + } +} diff --git a/bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/client/WebthingTest.java b/bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/client/WebthingTest.java new file mode 100644 index 00000000000..8d7699e73c3 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/client/WebthingTest.java @@ -0,0 +1,501 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal.client; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.WebSocket; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.websocket.api.BatchMode; +import org.eclipse.jetty.websocket.api.CloseStatus; +import org.eclipse.jetty.websocket.api.RemoteEndpoint; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.SuspendToken; +import org.eclipse.jetty.websocket.api.UpgradeRequest; +import org.eclipse.jetty.websocket.api.UpgradeResponse; +import org.eclipse.jetty.websocket.api.WebSocketListener; +import org.eclipse.jetty.websocket.api.WebSocketPingPongListener; +import org.eclipse.jetty.websocket.api.WebSocketPolicy; +import org.eclipse.jetty.websocket.api.WriteCallback; +import org.junit.jupiter.api.Test; +import org.openhab.binding.webthing.internal.client.dto.PropertyStatusMessage; + +import com.google.gson.Gson; + +/** + * + * + * @author Gregor Roth - Initial contribution + */ +@NonNullByDefault +public class WebthingTest { + private static final Gson GSON = new Gson(); + + @Test + public void testWebthingDescription() throws Exception { + var httpClient = mock(org.eclipse.jetty.client.HttpClient.class); + var request = Mocks.mockRequest(null, load("/windsensor_response.json")); + when(httpClient.newRequest(URI.create("http://example.org:8090"))).thenReturn(request); + + var request2 = Mocks.mockRequest(null, load("/windsensor_property.json")); + when(httpClient.newRequest(URI.create("http://example.org:8090/properties/windspeed"))).thenReturn(request2); + + var webthing = createTestWebthing("http://example.org:8090", httpClient); + var metadata = webthing.getThingDescription(); + assertEquals("Wind", metadata.title); + } + + @Test + public void testWebthingDescriptionUnsetSchema() throws Exception { + var httpClient = mock(org.eclipse.jetty.client.HttpClient.class); + var request = Mocks.mockRequest(null, load("/unsetschema_response.json")); + when(httpClient.newRequest(URI.create("http://example.org:8090"))).thenReturn(request); + + var request2 = Mocks.mockRequest(null, load("/windsensor_property.json")); + when(httpClient.newRequest(URI.create("http://example.org:8090/properties/windspeed"))).thenReturn(request2); + + var webthing = createTestWebthing("http://example.org:8090", httpClient); + var metadata = webthing.getThingDescription(); + assertEquals("Wind", metadata.title); + } + + @Test + public void testWebthingDescriptionUNsupportedSchema() throws Exception { + var httpClient = mock(org.eclipse.jetty.client.HttpClient.class); + var request = Mocks.mockRequest(null, load("/unknownschema_response.json")); + when(httpClient.newRequest(URI.create("http://example.org:8090"))).thenReturn(request); + + try { + createTestWebthing("http://example.org:8090", httpClient); + fail(); + } catch (IOException e) { + assertEquals( + "unsupported schema (@context parameter) https://www.w3.org/2019/wot/td/v1 (Supported schemas are https://webthings.io/schemas and https://iot.mozilla.org/schemas)", + e.getMessage()); + } + } + + @Test + public void testReadReadOnlyProperty() throws Exception { + var httpClient = mock(org.eclipse.jetty.client.HttpClient.class); + var request = Mocks.mockRequest(null, load("/windsensor_response.json")); + when(httpClient.newRequest(URI.create("http://example.org:8090"))).thenReturn(request); + + var request2 = Mocks.mockRequest(null, load("/windsensor_property.json")); + when(httpClient.newRequest(URI.create("http://example.org:8090/properties/windspeed"))).thenReturn(request2); + + var webthing = createTestWebthing("http://example.org:8090", httpClient); + + assertEquals(34.0, webthing.readProperty("windspeed")); + try { + webthing.writeProperty("windspeed", 23.0); + fail(); + } catch (PropertyAccessException e) { + assertEquals( + "could not write windspeed (http://example.org:8090/properties/windspeed) with 23.0. Property is readOnly", + e.getMessage()); + } + } + + @Test + public void testReadPropertyTest() throws Exception { + var httpClient = mock(org.eclipse.jetty.client.HttpClient.class); + var request = Mocks.mockRequest(null, load("/awning_response.json")); + when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request); + + var request2 = Mocks.mockRequest(null, load("/awning_property.json")); + when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position"))) + .thenReturn(request2); + + var webthing = createTestWebthing("http://example.org:8090/0", httpClient); + + assertEquals(85.0, webthing.readProperty("target_position")); + } + + @Test + public void testWriteProperty() throws Exception { + var httpClient = mock(org.eclipse.jetty.client.HttpClient.class); + var request = Mocks.mockRequest(null, load("/awning_response.json")); + when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request); + + var request2 = Mocks.mockRequest("{\"target_position\":10}", load("/awning_property.json")); + when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position"))) + .thenReturn(request2); + + var webthing = createTestWebthing("http://example.org:8090/0", httpClient); + webthing.writeProperty("target_position", 10); + } + + @Test + public void testWritePropertyError() throws Exception { + var httpClient = mock(org.eclipse.jetty.client.HttpClient.class); + var request = Mocks.mockRequest(null, load("/awning_response.json")); + when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request); + + var request2 = Mocks.mockRequest("{\"target_position\":10}", load("/awning_property.json"), 200, 400); + when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position"))) + .thenReturn(request2); + + var webthing = createTestWebthing("http://example.org:8090/0", httpClient); + try { + webthing.writeProperty("target_position", 10); + fail(); + } catch (PropertyAccessException e) { + assertEquals( + "could not write target_position (http://example.org:8090/0/properties/target_position) with 10", + e.getMessage()); + } + } + + @Test + public void testReadPropertyError() throws Exception { + var httpClient = mock(org.eclipse.jetty.client.HttpClient.class); + var request = Mocks.mockRequest(null, load("/windsensor_response.json")); + when(httpClient.newRequest(URI.create("http://example.org:8090"))).thenReturn(request); + + var request2 = Mocks.mockRequest(null, load("/windsensor_response.json"), 500, 200); + when(httpClient.newRequest(URI.create("http://example.org:8090/properties/windspeed"))).thenReturn(request2); + + var webthing = createTestWebthing("http://example.org:8090", httpClient); + try { + webthing.readProperty("windspeed"); + fail(); + } catch (PropertyAccessException e) { + assertEquals("could not read windspeed (http://example.org:8090/properties/windspeed)", e.getMessage()); + } + } + + @Test + public void testWebSocket() throws Exception { + var httpClient = mock(org.eclipse.jetty.client.HttpClient.class); + var request = Mocks.mockRequest(null, load("/awning_response.json")); + when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request); + + var request2 = Mocks.mockRequest(null, load("/awning_property.json")); + when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position"))) + .thenReturn(request2); + + var errorHandler = new ErrorHandler(); + var webSocketFactory = new TestWebsocketConnectionFactory(); + var webthing = createTestWebthing("http://example.org:8090/0", httpClient, errorHandler, webSocketFactory); + + var propertyChangedListenerImpl = new PropertyChangedListenerImpl(); + webthing.observeProperty("target_position", propertyChangedListenerImpl); + + var webSocketServerSide = webSocketFactory.webSocketRef.get(); + var message = new PropertyStatusMessage(); + message.messageType = "propertyStatus"; + message.data = Map.of("target_position", 33); + webSocketServerSide.sendToClient(message); + + while (propertyChangedListenerImpl.valueRef.get() == null) { + try { + Thread.sleep(100); + } catch (InterruptedException ignore) { + } + } + assertEquals(33.0, propertyChangedListenerImpl.valueRef.get()); + + webSocketServerSide.sendCloseToClient(); + assertEquals("websocket closed by peer. ", errorHandler.errorRef.get()); + } + + @Test + public void testWebSocketReceiveTimout() throws Exception { + var httpClient = mock(org.eclipse.jetty.client.HttpClient.class); + var request = Mocks.mockRequest(null, load("/awning_response.json")); + when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request); + + var request2 = Mocks.mockRequest(null, load("/awning_property.json")); + when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position"))) + .thenReturn(request2); + + var errorHandler = new ErrorHandler(); + var webSocketFactory = new TestWebsocketConnectionFactory(); + var pingPeriod = Duration.ofMillis(300); + var webthing = createTestWebthing("http://example.org:8090/0", httpClient, errorHandler, webSocketFactory, + pingPeriod); + + var propertyChangedListenerImpl = new PropertyChangedListenerImpl(); + webthing.observeProperty("target_position", propertyChangedListenerImpl); + webSocketFactory.webSocketRef.get().ignorePing.set(true); + + try { + Thread.sleep(pingPeriod.dividedBy(2).toMillis()); + } catch (InterruptedException ignore) { + } + assertNull(errorHandler.errorRef.get()); + + try { + Thread.sleep(pingPeriod.multipliedBy(4).toMillis()); + } catch (InterruptedException ignore) { + } + assertTrue(errorHandler.errorRef.get().startsWith("connection seems to be broken (last message received at")); + } + + public static String load(String name) throws Exception { + return new String(Files.readAllBytes(Paths.get(WebthingTest.class.getResource(name).toURI()))); + } + + public static ConsumedThingImpl createTestWebthing(String uri, HttpClient httpClient) throws IOException { + return createTestWebthing(uri, httpClient, (String) -> { + }, new TestWebsocketConnectionFactory()); + } + + public static ConsumedThingImpl createTestWebthing(String uri, HttpClient httpClient, Consumer 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 errorHandler, + WebSocketConnectionFactory websocketConnectionFactory) throws IOException { + return createTestWebthing(uri, httpClient, errorHandler, websocketConnectionFactory, Duration.ofSeconds(100)); + } + + public static class TestWebsocketConnectionFactory implements WebSocketConnectionFactory { + public final AtomicReference webSocketRef = new AtomicReference<>(); + + @Override + public WebSocketConnection create(@NonNull URI webSocketURI, @NonNull ScheduledExecutorService executor, + @NonNull Consumer 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 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 { + public final AtomicReference propertyNameRef = new AtomicReference<>(); + public final AtomicReference valueRef = new AtomicReference<>(); + + @Override + public void accept(String propertyName, Object value) { + propertyNameRef.set(propertyName); + valueRef.set(value); + } + } + + public static class ErrorHandler implements Consumer { + public final AtomicReference errorRef = new AtomicReference<>(); + + @Override + public void accept(String error) { + errorRef.set(error); + } + } +} diff --git a/bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/link/TypeConverterTest.java b/bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/link/TypeConverterTest.java new file mode 100644 index 00000000000..20f94c19119 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/link/TypeConverterTest.java @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal.link; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.types.State; + +/** + * + * @author Gregor Roth - Initial contribution + */ +@NonNullByDefault +public class TypeConverterTest { + + @Test + public void testStringType() throws Exception { + var typeConverter = TypeConverters.create("String", "String"); + var state = typeConverter.toStateCommand("motion"); + assumeTrue(state instanceof StringType); + assertEquals("motion", typeConverter.toPropertyValue((State) state)); + } + + @Test + public void testNumberType() throws Exception { + var typeConverter = TypeConverters.create("Number", "Number"); + var state = typeConverter.toStateCommand(45.6); + assumeTrue(state instanceof DecimalType); + assertEquals(45.6, typeConverter.toPropertyValue((State) state)); + } + + @Test + public void testNumberIntegerType() throws Exception { + var typeConverter = TypeConverters.create("Number", "Integer"); + var state = typeConverter.toStateCommand(45); + assumeTrue(state instanceof DecimalType); + assertEquals(45, typeConverter.toPropertyValue((State) state)); + + state = typeConverter.toStateCommand(45.2); + assumeTrue(state instanceof DecimalType); + assertEquals(45, typeConverter.toPropertyValue((State) state)); + } +} diff --git a/bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/link/WebthingChannelLinkTest.java b/bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/link/WebthingChannelLinkTest.java new file mode 100644 index 00000000000..cbdd6349701 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/test/java/org/openhab/binding/webthing/internal/link/WebthingChannelLinkTest.java @@ -0,0 +1,206 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.webthing.internal.link; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.binding.webthing.internal.ChannelHandler; +import org.openhab.binding.webthing.internal.channel.Channels; +import org.openhab.binding.webthing.internal.client.Mocks; +import org.openhab.binding.webthing.internal.client.WebthingTest; +import org.openhab.binding.webthing.internal.client.dto.PropertyStatusMessage; +import org.openhab.core.library.types.*; +import org.openhab.core.thing.*; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; + +import com.google.gson.Gson; + +/** + * Mapping test. + * + * Please consider that changes of 'ItemType<->PropertyType mapping' validated by this test + * will break the compatibility to former releases. + * + * @author Gregor Roth - Initial contribution + */ +@NonNullByDefault +public class WebthingChannelLinkTest { + private final Gson gson = new Gson(); + + @Test + public void testChannelToProperty() throws Exception { + var httpClient = mock(org.eclipse.jetty.client.HttpClient.class); + var request = Mocks.mockRequest(null, load("/awning_response.json")); + when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request); + + var request2 = Mocks.mockRequest("{\"target_position\":10}", load("/awning_property.json")); + when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position"))) + .thenReturn(request2); + + var thingUID = new ThingUID("webthing", "anwing"); + var channelUID = Channels.createChannelUID(thingUID, "target_position"); + + var webthing = WebthingTest.createTestWebthing("http://example.org:8090/0", httpClient); + var channel = Channels.createChannel(thingUID, "target_position", + Objects.requireNonNull(webthing.getPropertyDescription("target_position"))); + + var testWebthingThingHandler = new TestWebthingThingHandler(); + ChannelToPropertyLink.establish(testWebthingThingHandler, channel, webthing, "target_position"); + + testWebthingThingHandler.listeners.get(channelUID).onItemStateChanged(channelUID, new DecimalType(10)); + } + + @Test + public void testChannelToPropertyServerError() throws Exception { + var httpClient = mock(org.eclipse.jetty.client.HttpClient.class); + var request = Mocks.mockRequest(null, load("/awning_response.json")); + when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request); + + var request2 = Mocks.mockRequest("{\"target_position\":130}", load("/awning_property.json"), 200, 500); + when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position"))) + .thenReturn(request2); + + var thingUID = new ThingUID("webthing", "anwing"); + var channelUID = Channels.createChannelUID(thingUID, "target_position"); + + var webthing = WebthingTest.createTestWebthing("http://example.org:8090/0", httpClient); + var channel = Channels.createChannel(thingUID, "target_position", + Objects.requireNonNull(webthing.getPropertyDescription("target_position"))); + + var testWebthingThingHandler = new TestWebthingThingHandler(); + ChannelToPropertyLink.establish(testWebthingThingHandler, channel, webthing, "target_position"); + + testWebthingThingHandler.listeners.get(channelUID).onItemStateChanged(channelUID, new DecimalType(130)); + } + + @Test + public void testPropertyToChannel() throws Exception { + var httpClient = mock(org.eclipse.jetty.client.HttpClient.class); + var request = Mocks.mockRequest(null, load("/awning_response.json")); + when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request); + + var request2 = Mocks.mockRequest("{\"target_position\":10}", load("/awning_property.json")); + when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position"))) + .thenReturn(request2); + + var thingUID = new ThingUID("webthing", "anwing"); + var channelUID = Channels.createChannelUID(thingUID, "target_position"); + + var errorHandler = new WebthingTest.ErrorHandler(); + var websocketConnectionFactory = new WebthingTest.TestWebsocketConnectionFactory(); + var webthing = WebthingTest.createTestWebthing("http://example.org:8090/0", httpClient, errorHandler, + websocketConnectionFactory); + var channel = Channels.createChannel(thingUID, "target_position", + Objects.requireNonNull(webthing.getPropertyDescription("target_position"))); + + var testWebthingThingHandler = new TestWebthingThingHandler(); + PropertyToChannelLink.establish(webthing, "target_position", testWebthingThingHandler, channel); + + var message = new PropertyStatusMessage(); + message.messageType = "propertyStatus"; + message.data = Map.of("target_position", 77); + websocketConnectionFactory.webSocketRef.get().sendToClient(message); + + assertEquals(new DecimalType(77), testWebthingThingHandler.itemState.get(channelUID)); + } + + @Test + public void testDataTypeMapping() throws Exception { + performDataTypeMappingTest("level_prop", 56.5, new DecimalType(56.5), 3.5, new DecimalType(3.5)); + performDataTypeMappingTest("level_unit_prop", 10, new PercentType(10), 90, new PercentType(90)); + performDataTypeMappingTest("thermo_prop", "off", new StringType("off"), "auto", new StringType("auto")); + performDataTypeMappingTest("temp_prop", 18.6, new DecimalType(18.6), 23.2, new DecimalType(23.2)); + performDataTypeMappingTest("targettemp_prop", 18.6, new DecimalType(18.6), 23.2, new DecimalType(23.2)); + performDataTypeMappingTest("open_prop", true, OpenClosedType.OPEN, false, OpenClosedType.CLOSED); + performDataTypeMappingTest("colortemp_prop", 10, new PercentType(10), 60, new PercentType(60)); + performDataTypeMappingTest("color_prop", "#f2fe00", new HSBType("62,100,99"), "#ff0000", + new HSBType("0.0,100.0,100.0")); + performDataTypeMappingTest("colormode_prop", "color", new StringType("color"), "temperature", + new StringType("temperature")); + performDataTypeMappingTest("brightness_prop", 33, new PercentType(33), 65, new PercentType(65)); + performDataTypeMappingTest("voltage_prop", 4.5, new DecimalType(4.5), 30.2, new DecimalType(30.2)); + performDataTypeMappingTest("heating_prop", "off", new StringType("off"), "cooling", new StringType("cooling")); + performDataTypeMappingTest("onoff_prop", true, OnOffType.ON, false, OnOffType.OFF); + performDataTypeMappingTest("string_prop", "initial", new StringType("initial"), "updated", + new StringType("updated")); + performDataTypeMappingTest("number_prop", 80.5, new DecimalType(80.5), 60.9, new DecimalType(60.9)); + performDataTypeMappingTest("integer_prop", 11, new DecimalType(11), 77, new DecimalType(77)); + performDataTypeMappingTest("boolean_prop", true, OnOffType.ON, false, OnOffType.OFF); + } + + private void performDataTypeMappingTest(String propertyName, Object initialValue, State initialState, + Object updatedValue, State updatedState) throws Exception { + var httpClient = mock(org.eclipse.jetty.client.HttpClient.class); + var request = Mocks.mockRequest(null, load("/datatypes_test_response.json")); + when(httpClient.newRequest(URI.create("http://example.org:8090/"))).thenReturn(request); + + var request2 = Mocks.mockRequest(gson.toJson(Map.of(propertyName, updatedValue)), + gson.toJson(Map.of(propertyName, initialValue))); + when(httpClient.newRequest(URI.create("http://example.org:8090/properties/" + propertyName))) + .thenReturn(request2); + + var thingUID = new ThingUID("webthing", "test"); + var channelUID = Channels.createChannelUID(thingUID, propertyName); + + var errorHandler = new WebthingTest.ErrorHandler(); + var websocketConnectionFactory = new WebthingTest.TestWebsocketConnectionFactory(); + var webthing = WebthingTest.createTestWebthing("http://example.org:8090/", httpClient, errorHandler, + websocketConnectionFactory); + var channel = Channels.createChannel(thingUID, propertyName, + Objects.requireNonNull(webthing.getPropertyDescription(propertyName))); + + var testWebthingThingHandler = new TestWebthingThingHandler(); + + PropertyToChannelLink.establish(webthing, propertyName, testWebthingThingHandler, channel); + + var message = new PropertyStatusMessage(); + message.messageType = "propertyStatus"; + message.data = Map.of(propertyName, initialValue); + websocketConnectionFactory.webSocketRef.get().sendToClient(message); + + assertEquals(initialState, testWebthingThingHandler.itemState.get(channelUID)); + + ChannelToPropertyLink.establish(testWebthingThingHandler, channel, webthing, propertyName); + testWebthingThingHandler.listeners.get(channelUID).onItemStateChanged(channelUID, updatedState); + } + + public static String load(String name) throws Exception { + return new String(Files.readAllBytes(Paths.get(WebthingTest.class.getResource(name).toURI()))); + } + + private static class TestWebthingThingHandler implements ChannelHandler { + public final Map listeners = new ConcurrentHashMap<>(); + public final Map itemState = new ConcurrentHashMap<>(); + + @Override + public void observeChannel(ChannelUID channelUID, ItemChangedListener listener) { + listeners.put(channelUID, listener); + } + + @Override + public void updateItemState(ChannelUID channelUID, Command command) { + itemState.put(channelUID, command); + } + } +} diff --git a/bundles/org.openhab.binding.webthing/src/test/resources/awning_array_response.json b/bundles/org.openhab.binding.webthing/src/test/resources/awning_array_response.json new file mode 100644 index 00000000000..b33a0723721 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/test/resources/awning_array_response.json @@ -0,0 +1,394 @@ +[ + { + "id":"urn:dev:ops:anwing-TB6612FNG", + "title":"Awning lane1", + "@context":"https://iot.mozilla.org/schemas", + "properties":{ + "target_position":{ + "@type":"LevelProperty", + "title":"awning lane1 target position", + "type":"integer", + "minimum":0, + "maximum":100, + "description":"awning lane1 target position", + "links":[ + { + "rel":"property", + "href":"/0/properties/target_position" + } + ] + }, + "current_position":{ + "@type":"LevelProperty", + "title":"awning lane1 current position", + "type":"integer", + "minimum":0, + "maximum":100, + "readOnly":true, + "description":"awning lane1 current position", + "links":[ + { + "rel":"property", + "href":"/0/properties/current_position" + } + ] + }, + "retracting":{ + "@type":"BooleanProperty", + "title":"lane1 is retracting", + "type":"boolean", + "readOnly":true, + "description":"lane1 is retracting", + "links":[ + { + "rel":"property", + "href":"/0/properties/retracting" + } + ] + }, + "extending":{ + "@type":"BooleanProperty", + "title":"lane1 is extending", + "type":"boolean", + "readOnly":true, + "description":"lane1 is extending", + "links":[ + { + "rel":"property", + "href":"/0/properties/extending" + } + ] + } + }, + "actions":{ + + }, + "events":{ + + }, + "links":[ + { + "rel":"properties", + "href":"/0/properties" + }, + { + "rel":"actions", + "href":"/0/actions" + }, + { + "rel":"events", + "href":"/0/events" + }, + { + "rel":"alternate", + "href":"ws://192.168.4.12:9040/0" + } + ], + "description":"A web connected patio awnings controller on Raspberry Pi", + "@type":[ + "MultiLevelSensor" + ], + "href":"/0", + "base":"http://192.168.4.12:9040/0", + "securityDefinitions":{ + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security":"nosec_sc" + }, + { + "id":"urn:dev:ops:anwing-TB6612FNG", + "title":"Awning lane2", + "@context":"https://iot.mozilla.org/schemas", + "properties":{ + "target_position":{ + "@type":"LevelProperty", + "title":"awning lane2 target position", + "type":"integer", + "minimum":0, + "maximum":100, + "description":"awning lane2 target position", + "links":[ + { + "rel":"property", + "href":"/1/properties/target_position" + } + ] + }, + "current_position":{ + "@type":"LevelProperty", + "title":"awning lane2 current position", + "type":"integer", + "minimum":0, + "maximum":100, + "readOnly":true, + "description":"awning lane2 current position", + "links":[ + { + "rel":"property", + "href":"/1/properties/current_position" + } + ] + }, + "retracting":{ + "@type":"BooleanProperty", + "title":"lane2 is retracting", + "type":"boolean", + "readOnly":true, + "description":"lane2 is retracting", + "links":[ + { + "rel":"property", + "href":"/1/properties/retracting" + } + ] + }, + "extending":{ + "@type":"BooleanProperty", + "title":"lane2 is extending", + "type":"boolean", + "readOnly":true, + "description":"lane2 is extending", + "links":[ + { + "rel":"property", + "href":"/1/properties/extending" + } + ] + } + }, + "actions":{ + + }, + "events":{ + + }, + "links":[ + { + "rel":"properties", + "href":"/1/properties" + }, + { + "rel":"actions", + "href":"/1/actions" + }, + { + "rel":"events", + "href":"/1/events" + }, + { + "rel":"alternate", + "href":"ws://192.168.4.12:9040/1" + } + ], + "description":"A web connected patio awnings controller on Raspberry Pi", + "@type":[ + "MultiLevelSensor" + ], + "href":"/1", + "base":"http://192.168.4.12:9040/1", + "securityDefinitions":{ + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security":"nosec_sc" + }, + { + "id":"urn:dev:ops:anwing-TB6612FNG", + "title":"Awning lane3", + "@context":"https://iot.mozilla.org/schemas", + "properties":{ + "target_position":{ + "@type":"LevelProperty", + "title":"awning lane3 target position", + "type":"integer", + "minimum":0, + "maximum":100, + "description":"awning lane3 target position", + "links":[ + { + "rel":"property", + "href":"/2/properties/target_position" + } + ] + }, + "current_position":{ + "@type":"LevelProperty", + "title":"awning lane3 current position", + "type":"integer", + "minimum":0, + "maximum":100, + "readOnly":true, + "description":"awning lane3 current position", + "links":[ + { + "rel":"property", + "href":"/2/properties/current_position" + } + ] + }, + "retracting":{ + "@type":"BooleanProperty", + "title":"lane3 is retracting", + "type":"boolean", + "readOnly":true, + "description":"lane3 is retracting", + "links":[ + { + "rel":"property", + "href":"/2/properties/retracting" + } + ] + }, + "extending":{ + "@type":"BooleanProperty", + "title":"lane3 is extending", + "type":"boolean", + "readOnly":true, + "description":"lane3 is extending", + "links":[ + { + "rel":"property", + "href":"/2/properties/extending" + } + ] + } + }, + "actions":{ + + }, + "events":{ + + }, + "links":[ + { + "rel":"properties", + "href":"/2/properties" + }, + { + "rel":"actions", + "href":"/2/actions" + }, + { + "rel":"events", + "href":"/2/events" + }, + { + "rel":"alternate", + "href":"ws://192.168.4.12:9040/2" + } + ], + "description":"A web connected patio awnings controller on Raspberry Pi", + "@type":[ + "MultiLevelSensor" + ], + "href":"/2", + "base":"http://192.168.4.12:9040/2", + "securityDefinitions":{ + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security":"nosec_sc" + }, + { + "id":"urn:dev:ops:anwing-TB6612FNG", + "title":"Awning lane4", + "@context":"https://iot.mozilla.org/schemas", + "properties":{ + "target_position":{ + "@type":"LevelProperty", + "title":"awning lane4 target position", + "type":"integer", + "minimum":0, + "maximum":100, + "description":"awning lane4 target position", + "links":[ + { + "rel":"property", + "href":"/3/properties/target_position" + } + ] + }, + "current_position":{ + "@type":"LevelProperty", + "title":"awning lane4 current position", + "type":"integer", + "minimum":0, + "maximum":100, + "readOnly":true, + "description":"awning lane4 current position", + "links":[ + { + "rel":"property", + "href":"/3/properties/current_position" + } + ] + }, + "retracting":{ + "@type":"BooleanProperty", + "title":"lane4 is retracting", + "type":"boolean", + "readOnly":true, + "description":"lane4 is retracting", + "links":[ + { + "rel":"property", + "href":"/3/properties/retracting" + } + ] + }, + "extending":{ + "@type":"BooleanProperty", + "title":"lane4 is extending", + "type":"boolean", + "readOnly":true, + "description":"lane4 is extending", + "links":[ + { + "rel":"property", + "href":"/3/properties/extending" + } + ] + } + }, + "actions":{ + + }, + "events":{ + + }, + "links":[ + { + "rel":"properties", + "href":"/3/properties" + }, + { + "rel":"actions", + "href":"/3/actions" + }, + { + "rel":"events", + "href":"/3/events" + }, + { + "rel":"alternate", + "href":"ws://192.168.4.12:9040/3" + } + ], + "description":"A web connected patio awnings controller on Raspberry Pi", + "@type":[ + "MultiLevelSensor" + ], + "href":"/3", + "base":"http://192.168.4.12:9040/3", + "securityDefinitions":{ + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security":"nosec_sc" + } +] \ No newline at end of file diff --git a/bundles/org.openhab.binding.webthing/src/test/resources/awning_property.json b/bundles/org.openhab.binding.webthing/src/test/resources/awning_property.json new file mode 100644 index 00000000000..b6d1c4bfb1e --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/test/resources/awning_property.json @@ -0,0 +1 @@ +{"target_position":85} \ No newline at end of file diff --git a/bundles/org.openhab.binding.webthing/src/test/resources/awning_response.json b/bundles/org.openhab.binding.webthing/src/test/resources/awning_response.json new file mode 100644 index 00000000000..3a95d46fe0d --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/test/resources/awning_response.json @@ -0,0 +1,98 @@ +{ + "id":"urn:dev:ops:anwing-TB6612FNG", + "title":"Awning lane1", + "@context":"https://webthings.io/schemas", + "properties":{ + "target_position":{ + "@type":"LevelProperty", + "title":"awning lane1 target position", + "type":"integer", + "minimum":0, + "maximum":100, + "description":"awning lane1 target position", + "links":[ + { + "rel":"property", + "href":"/0/properties/target_position" + } + ] + }, + "current_position":{ + "@type":"LevelProperty", + "title":"awning lane1 current position", + "type":"integer", + "minimum":0, + "maximum":100, + "readOnly":true, + "description":"awning lane1 current position", + "links":[ + { + "rel":"property", + "href":"/0/properties/current_position" + } + ] + }, + "retracting":{ + "@type":"BooleanProperty", + "title":"lane1 is retracting", + "type":"boolean", + "readOnly":true, + "description":"lane1 is retracting", + "links":[ + { + "rel":"property", + "href":"/0/properties/retracting" + } + ] + }, + "extending":{ + "@type":"BooleanProperty", + "title":"lane1 is extending", + "type":"boolean", + "readOnly":true, + "description":"lane1 is extending", + "links":[ + { + "rel":"property", + "href":"/0/properties/extending" + } + ] + } + }, + "actions":{ + + }, + "events":{ + + }, + "links":[ + { + "rel":"properties", + "href":"/0/properties" + }, + { + "rel":"actions", + "href":"/0/actions" + }, + { + "rel":"events", + "href":"/0/events" + }, + { + "rel":"alternate", + "href":"ws://192.168.4.12:9040/0" + } + ], + "description":"A web connected patio awnings controller on Raspberry Pi", + "@type":[ + "MultiLevelSensor" + ], + "href":"/0", + "base":"http://192.168.4.12:9040/0", + "securityDefinitions":{ + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security":"nosec_sc" +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.webthing/src/test/resources/datatypes_test_response.json b/bundles/org.openhab.binding.webthing/src/test/resources/datatypes_test_response.json new file mode 100644 index 00000000000..95402b26c5a --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/test/resources/datatypes_test_response.json @@ -0,0 +1,209 @@ +{ + "id":"urn:dev:ops:test-1", + "title":"Test Device", + "@context":"https://iot.mozilla.org/schemas", + "properties":{ + "number_prop":{ + "type":"number", + "links":[ + { + "rel":"property", + "href":"/properties/number_prop" + } + ] + }, + "integer_prop":{ + "type":"integer", + "links":[ + { + "rel":"property", + "href":"/properties/integer_prop" + } + ] + }, + "string_prop":{ + "type":"string", + "links":[ + { + "rel":"property", + "href":"/properties/string_prop" + } + ] + }, + "boolean_prop":{ + "type":"boolean", + "links":[ + { + "rel":"property", + "href":"/properties/boolean_prop" + } + ] + }, + "onoff_prop":{ + "@type": "OnOffProperty", + "type":"boolean", + "links":[ + { + "rel":"property", + "href":"/properties/onoff_prop" + } + ] + }, + "heating_prop":{ + "@type": "HeatingCoolingProperty", + "type":"string", + "links":[ + { + "rel":"property", + "href":"/properties/heating_prop" + } + ] + }, + "voltage_prop":{ + "@type": "VoltageProperty", + "type":"number", + "links":[ + { + "rel":"property", + "href":"/properties/voltage_prop" + } + ] + }, + "brightness_prop":{ + "@type": "BrightnessProperty", + "type":"number", + "links":[ + { + "rel":"property", + "href":"/properties/brightness_prop" + } + ] + }, + "colormode_prop":{ + "@type": "ColorModeProperty", + "type":"string", + "links":[ + { + "rel":"property", + "href":"/properties/colormode_prop" + } + ] + }, + "colortemp_prop":{ + "@type": "ColorTemperatureProperty", + "type":"integer", + "links":[ + { + "rel":"property", + "href":"/properties/colortemp_prop" + } + ] + }, + "color_prop":{ + "@type": "ColorProperty", + "type":"string", + "links":[ + { + "rel":"property", + "href":"/properties/color_prop" + } + ] + }, + "open_prop":{ + "@type": "OpenProperty", + "type":"boolean", + "links":[ + { + "rel":"property", + "href":"/properties/open_prop" + } + ] + }, + "targettemp_prop":{ + "@type": "TargetTemperatureProperty", + "type":"number", + "links":[ + { + "rel":"property", + "href":"/properties/targettemp_prop" + } + ] + }, + "temp_prop":{ + "@type": "TemperatureProperty", + "type":"number", + "links":[ + { + "rel":"property", + "href":"/properties/temp_prop" + } + ] + }, + "thermo_prop":{ + "@type": "ThermostatModeProperty", + "type":"string", + "links":[ + { + "rel":"property", + "href":"/properties/thermo_prop" + } + ] + }, + "level_unit_prop":{ + "@type": "LevelProperty", + "type":"number", + "unit": "percent", + "links":[ + { + "rel":"property", + "href":"/properties/level_unit_prop" + } + ] + }, + "level_prop":{ + "@type": "LevelProperty", + "type":"number", + "links":[ + { + "rel":"property", + "href":"/properties/level_prop" + } + ] + } + }, + "actions":{ + + }, + "events":{ + + }, + "links":[ + { + "rel":"properties", + "href":"/properties" + }, + { + "rel":"actions", + "href":"/actions" + }, + { + "rel":"events", + "href":"/events" + }, + { + "rel":"alternate", + "href":"ws://192.168.0.23:9060/" + } + ], + "description":"test", + "@type":[ + "MultiLevelSensor" + ], + "base":"http://192.168.0.23:9060/", + "securityDefinitions":{ + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security":"nosec_sc" +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.webthing/src/test/resources/number_prop.json b/bundles/org.openhab.binding.webthing/src/test/resources/number_prop.json new file mode 100644 index 00000000000..8ad0cc117e2 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/test/resources/number_prop.json @@ -0,0 +1 @@ +{"number_prop":80.5} \ No newline at end of file diff --git a/bundles/org.openhab.binding.webthing/src/test/resources/unknownschema_response.json b/bundles/org.openhab.binding.webthing/src/test/resources/unknownschema_response.json new file mode 100644 index 00000000000..8ed08e7ae16 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/test/resources/unknownschema_response.json @@ -0,0 +1,98 @@ +{ + "id":"urn:dev:ops:anwing-TB6612FNG", + "title":"Awning lane1", + "@context":"https://www.w3.org/2019/wot/td/v1", + "properties":{ + "target_position":{ + "@type":"LevelProperty", + "title":"awning lane1 target position", + "type":"integer", + "minimum":0, + "maximum":100, + "description":"awning lane1 target position", + "links":[ + { + "rel":"property", + "href":"/0/properties/target_position" + } + ] + }, + "current_position":{ + "@type":"LevelProperty", + "title":"awning lane1 current position", + "type":"integer", + "minimum":0, + "maximum":100, + "readOnly":true, + "description":"awning lane1 current position", + "links":[ + { + "rel":"property", + "href":"/0/properties/current_position" + } + ] + }, + "retracting":{ + "@type":"BooleanProperty", + "title":"lane1 is retracting", + "type":"boolean", + "readOnly":true, + "description":"lane1 is retracting", + "links":[ + { + "rel":"property", + "href":"/0/properties/retracting" + } + ] + }, + "extending":{ + "@type":"BooleanProperty", + "title":"lane1 is extending", + "type":"boolean", + "readOnly":true, + "description":"lane1 is extending", + "links":[ + { + "rel":"property", + "href":"/0/properties/extending" + } + ] + } + }, + "actions":{ + + }, + "events":{ + + }, + "links":[ + { + "rel":"properties", + "href":"/0/properties" + }, + { + "rel":"actions", + "href":"/0/actions" + }, + { + "rel":"events", + "href":"/0/events" + }, + { + "rel":"alternate", + "href":"ws://192.168.4.12:9040/0" + } + ], + "description":"A web connected patio awnings controller on Raspberry Pi", + "@type":[ + "MultiLevelSensor" + ], + "href":"/0", + "base":"http://192.168.4.12:9040/0", + "securityDefinitions":{ + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security":"nosec_sc" +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.webthing/src/test/resources/unsetschema_response.json b/bundles/org.openhab.binding.webthing/src/test/resources/unsetschema_response.json new file mode 100644 index 00000000000..82e15c206dc --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/test/resources/unsetschema_response.json @@ -0,0 +1,55 @@ +{ + "id":"urn:dev:ops:eltakowsSensor-1", + "title":"Wind", + "properties":{ + "windspeed":{ + "@type":"LevelProperty", + "title":"Windspeed", + "type":"number", + "description":"The current windspeed", + "unit":"km/h", + "readOnly":true, + "links":[ + { + "rel":"property", + "href":"/properties/windspeed" + } + ] + } + }, + "actions":{ + + }, + "events":{ + + }, + "links":[ + { + "rel":"properties", + "href":"/properties" + }, + { + "rel":"actions", + "href":"/actions" + }, + { + "rel":"events", + "href":"/events" + }, + { + "rel":"alternate", + "href":"ws://192.168.0.23:9060/" + } + ], + "description":"A web connected Eltako windsensor measuring wind speed on Raspberry Pi", + "@type":[ + "MultiLevelSensor" + ], + "base":"http://192.168.0.23:9060/", + "securityDefinitions":{ + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security":"nosec_sc" +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.webthing/src/test/resources/virtual-things_response.json b/bundles/org.openhab.binding.webthing/src/test/resources/virtual-things_response.json new file mode 100644 index 00000000000..6f070827ec4 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/test/resources/virtual-things_response.json @@ -0,0 +1,75 @@ +{ + "title":"Virtual On/Off Light", + "@context":"https://iot.mozilla.org/schemas", + "@type":[ + "OnOffSwitch", + "Light" + ], + "description":"", + "href":"/things/virtual-things-7", + "properties":{ + "on":{ + "name":"on", + "value":false, + "visible":true, + "title":"On/Off", + "type":"boolean", + "@type":"OnOffProperty", + "links":[ + { + "rel":"property", + "href":"/things/virtual-things-7/properties/on" + } + ] + } + }, + "actions":{ + + }, + "events":{ + + }, + "links":[ + { + "rel":"properties", + "href":"/things/virtual-things-7/properties" + }, + { + "rel":"actions", + "href":"/things/virtual-things-7/actions" + }, + { + "rel":"events", + "href":"/things/virtual-things-7/events" + }, + { + "rel":"alternate", + "mediaType":"text/html", + "href":"/things/virtual-things-7" + }, + { + "rel":"alternate", + "href":"ws://webthings/things/virtual-things-7" + } + ], + "layoutIndex":0, + "selectedCapability":"Light", + "iconHref":null, + "id":"http://webthings/things/virtual-things-7", + "base":"http://webthings/", + "securityDefinitions":{ + "oauth2_sc":{ + "scheme":"oauth2", + "flow":"code", + "authorization":"http://webthings/oauth/authorize", + "token":"http://webthings/oauth/token", + "scopes":[ + "/things/virtual-things-7:readwrite", + "/things/virtual-things-7", + "/things:readwrite", + "/things" + ] + } + }, + "security":"oauth2_sc" +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.webthing/src/test/resources/windsensor_property.json b/bundles/org.openhab.binding.webthing/src/test/resources/windsensor_property.json new file mode 100644 index 00000000000..2d3f0f99436 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/test/resources/windsensor_property.json @@ -0,0 +1,3 @@ +{ + "windspeed":34 +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.webthing/src/test/resources/windsensor_response.json b/bundles/org.openhab.binding.webthing/src/test/resources/windsensor_response.json new file mode 100644 index 00000000000..4dfdc5f5c34 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/test/resources/windsensor_response.json @@ -0,0 +1,56 @@ +{ + "id":"urn:dev:ops:eltakowsSensor-1", + "title":"Wind", + "@context":"https://iot.mozilla.org/schemas/", + "properties":{ + "windspeed":{ + "@type":"LevelProperty", + "title":"Windspeed", + "type":"number", + "description":"The current windspeed", + "unit":"km/h", + "readOnly":true, + "links":[ + { + "rel":"property", + "href":"/properties/windspeed" + } + ] + } + }, + "actions":{ + + }, + "events":{ + + }, + "links":[ + { + "rel":"properties", + "href":"/properties" + }, + { + "rel":"actions", + "href":"/actions" + }, + { + "rel":"events", + "href":"/events" + }, + { + "rel":"alternate", + "href":"ws://192.168.0.23:9060/" + } + ], + "description":"A web connected Eltako windsensor measuring wind speed on Raspberry Pi", + "@type":[ + "MultiLevelSensor" + ], + "base":"http://192.168.0.23:9060/", + "securityDefinitions":{ + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security":"nosec_sc" +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.webthing/src/test/resources/windsensor_response_without_websocketuri.json b/bundles/org.openhab.binding.webthing/src/test/resources/windsensor_response_without_websocketuri.json new file mode 100644 index 00000000000..ea909d03b98 --- /dev/null +++ b/bundles/org.openhab.binding.webthing/src/test/resources/windsensor_response_without_websocketuri.json @@ -0,0 +1,52 @@ +{ + "id":"urn:dev:ops:eltakowsSensor-1", + "title":"Wind", + "@context":"https://iot.mozilla.org/schemas", + "properties":{ + "windspeed":{ + "@type":"LevelProperty", + "title":"Windspeed", + "type":"number", + "description":"The current windspeed", + "unit":"km/h", + "readOnly":true, + "links":[ + { + "rel":"property", + "href":"/properties/windspeed" + } + ] + } + }, + "actions":{ + + }, + "events":{ + + }, + "links":[ + { + "rel":"properties", + "href":"/properties" + }, + { + "rel":"actions", + "href":"/actions" + }, + { + "rel":"events", + "href":"/events" + } + ], + "description":"A web connected Eltako windsensor measuring wind speed on Raspberry Pi", + "@type":[ + "MultiLevelSensor" + ], + "base":"http://192.168.0.23:9060/", + "securityDefinitions":{ + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security":"nosec_sc" +} \ No newline at end of file diff --git a/bundles/pom.xml b/bundles/pom.xml index 7e6b4fd55da..bb91aa1cd92 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -326,6 +326,7 @@ org.openhab.binding.volvooncall org.openhab.binding.weathercompany org.openhab.binding.weatherunderground + org.openhab.binding.webthing org.openhab.binding.wemo org.openhab.binding.wifiled org.openhab.binding.windcentrale