[WebThing] Initial contribution (#9555)

Signed-off-by: Gregor Roth <gregor.roth@web.de>
This commit is contained in:
grro 2021-04-11 19:47:27 +02:00 committed by GitHub
parent 9bfb2f4313
commit d9ed461950
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 4545 additions and 0 deletions

View File

@ -295,6 +295,7 @@
/bundles/org.openhab.binding.volvooncall/ @clinique /bundles/org.openhab.binding.volvooncall/ @clinique
/bundles/org.openhab.binding.weathercompany/ @mhilbush /bundles/org.openhab.binding.weathercompany/ @mhilbush
/bundles/org.openhab.binding.weatherunderground/ @lolodomo /bundles/org.openhab.binding.weatherunderground/ @lolodomo
/bundles/org.openhab.binding.webthing/ @grro
/bundles/org.openhab.binding.wemo/ @hmerk /bundles/org.openhab.binding.wemo/ @hmerk
/bundles/org.openhab.binding.wifiled/ @rvt @xylo /bundles/org.openhab.binding.wifiled/ @rvt @xylo
/bundles/org.openhab.binding.windcentrale/ @marcelrv /bundles/org.openhab.binding.windcentrale/ @marcelrv

View File

@ -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

View File

@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>3.1.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.webthing</artifactId>
<name>openHAB Add-ons :: Bundles :: WebThing Binding</name>
</project>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.webthing-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<feature name="openhab-binding-webthing" description="WebThing Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<feature>openhab-transport-mdns</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.webthing/${project.version}</bundle>
</feature>
</features>

View File

@ -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);
}
}

View File

@ -0,0 +1,38 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.webthing.internal;
import java.util.Collection;
import java.util.Collections;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingTypeUID;
/**
* The {@link WebThingBindingConstants} class defines common constants, which are
* used across the whole binding.
*
* @author Gregor Roth - Initial contribution
*/
@NonNullByDefault
public class WebThingBindingConstants {
public static final String BINDING_ID = "webthing";
public static final ThingTypeUID THING_TYPE_UID = new ThingTypeUID(BINDING_ID, "generic");
public static final Collection<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections
.singleton(WebThingBindingConstants.THING_TYPE_UID);
public static final String MDNS_SERVICE_TYPE = "_webthing._tcp.local.";
}

View File

@ -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;
}

View File

@ -0,0 +1,298 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.webthing.internal;
import java.io.IOException;
import java.net.URI;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.websocket.client.WebSocketClient;
import org.openhab.binding.webthing.internal.channel.Channels;
import org.openhab.binding.webthing.internal.client.*;
import org.openhab.binding.webthing.internal.link.ChannelToPropertyLink;
import org.openhab.binding.webthing.internal.link.PropertyToChannelLink;
import org.openhab.binding.webthing.internal.link.UnknownPropertyException;
import org.openhab.core.thing.*;
import org.openhab.core.thing.binding.BaseThingHandler;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link WebThingHandler} is responsible for handling commands, which are
* sent to one of the channels.
*
* @author Gregor Roth - Initial contribution
*/
@NonNullByDefault
public class WebThingHandler extends BaseThingHandler implements ChannelHandler {
private static final Duration RECONNECT_PERIOD = Duration.ofHours(23);
private static final Duration HEALTH_CHECK_PERIOD = Duration.ofSeconds(70);
private static final ItemChangedListener EMPTY_ITEM_CHANGED_LISTENER = (channelUID, stateCommand) -> {
};
private final Logger logger = LoggerFactory.getLogger(WebThingHandler.class);
private final HttpClient httpClient;
private final WebSocketClient webSocketClient;
private final AtomicBoolean isActivated = new AtomicBoolean(true);
private final Map<ChannelUID, ItemChangedListener> itemChangedListenerMap = new ConcurrentHashMap<>();
private final AtomicReference<Optional<ConsumedThing>> webThingConnectionRef = new AtomicReference<>(
Optional.empty());
private final AtomicReference<Instant> lastReconnect = new AtomicReference<>(Instant.now());
private final AtomicReference<Optional<ScheduledFuture<?>>> watchdogHandle = new AtomicReference<>(
Optional.empty());
private @Nullable URI webThingURI = null;
public WebThingHandler(Thing thing, HttpClient httpClient, WebSocketClient webSocketClient) {
super(thing);
this.httpClient = httpClient;
this.webSocketClient = webSocketClient;
}
private boolean isOnline() {
return getThing().getStatus() == ThingStatus.ONLINE;
}
private boolean isDisconnected() {
return (getThing().getStatus() == ThingStatus.OFFLINE) || (getThing().getStatus() == ThingStatus.UNKNOWN);
}
private boolean isAlive() {
return webThingConnectionRef.get().map(ConsumedThing::isAlive).orElse(false);
}
@Override
public void initialize() {
updateStatus(ThingStatus.UNKNOWN);
isActivated.set(true); // set with true, even though the connect may fail. In this case retries will be
// triggered
// perform connect in background
scheduler.execute(() -> {
// WebThing URI present?
var uri = toUri(getConfigAs(WebThingConfiguration.class).webThingURI);
if (uri != null) {
logger.debug("try to connect WebThing {}", uri);
var connected = tryReconnect(uri);
if (connected) {
logger.debug("WebThing {} connected", getWebThingLabel());
}
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"webThing uri has not been set");
logger.warn("could not initialize WebThing. URI is not set or invalid. {}", this.webThingURI);
}
});
// starting watchdog that checks the healthiness of the WebThing connection, periodically
watchdogHandle
.getAndSet(Optional.of(scheduler.scheduleWithFixedDelay(this::checkWebThingConnection,
HEALTH_CHECK_PERIOD.getSeconds(), HEALTH_CHECK_PERIOD.getSeconds(), TimeUnit.SECONDS)))
.ifPresent(future -> future.cancel(true));
}
private @Nullable URI toUri(@Nullable String uri) {
try {
if (uri != null) {
return URI.create(uri);
}
} catch (IllegalArgumentException illegalURIException) {
return null;
}
return null;
}
@Override
public void dispose() {
try {
isActivated.set(false); // set to false to avoid reconnecting
// terminate WebThing connection as well as the alive watchdog
webThingConnectionRef.getAndSet(Optional.empty()).ifPresent(ConsumedThing::close);
watchdogHandle.getAndSet(Optional.empty()).ifPresent(future -> future.cancel(true));
} finally {
super.dispose();
}
}
private boolean tryReconnect(@Nullable URI uri) {
if (isActivated.get()) { // will try reconnect only, if activated
try {
// create the client-side WebThing representation
if (uri != null) {
var webThing = ConsumedThingFactory.instance().create(webSocketClient, httpClient, uri, scheduler,
this::onError);
this.webThingConnectionRef.getAndSet(Optional.of(webThing)).ifPresent(ConsumedThing::close);
// update the Thing structure based on the WebThing description
thingStructureChanged(webThing);
// link the Thing's channels with the WebThing properties to forward properties/item updates
establishWebThingChannelLinks(webThing);
lastReconnect.set(Instant.now());
updateStatus(ThingStatus.ONLINE);
return true;
}
} catch (IOException e) {
var msg = e.getMessage();
if (msg == null) {
msg = "";
}
onError(msg);
}
}
return false;
}
public void onError(String reason) {
var wasConnectedBefore = isOnline();
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, reason);
// close the WebThing connection. If the handler is still active, the WebThing connection
// will be re-established within the periodically watchdog task
webThingConnectionRef.getAndSet(Optional.empty()).ifPresent(ConsumedThing::close);
if (wasConnectedBefore) { // to reduce log messages, just log in case of connection state changed
logger.debug("WebThing {} disconnected {}. Try reconnect (each {} sec)", getWebThingLabel(), reason,
HEALTH_CHECK_PERIOD.getSeconds());
} else {
logger.debug("WebThing {} is offline {}. Try reconnect (each {} sec)", getWebThingLabel(), reason,
HEALTH_CHECK_PERIOD.getSeconds());
}
}
private String getWebThingLabel() {
if (getThing().getLabel() == null) {
return "" + webThingURI;
} else {
return "'" + getThing().getLabel() + "' (" + webThingURI + ")";
}
}
/**
* updates the thing structure. Refer https://www.openhab.org/docs/developer/bindings/#updating-the-thing-structure
*
* @param webThing the WebThing that is used for the new structure
*/
private void thingStructureChanged(ConsumedThing webThing) {
var thingBuilder = editThing().withLabel(webThing.getThingDescription().title);
// create a channel for each WebThing property
for (var entry : webThing.getThingDescription().properties.entrySet()) {
var channel = Channels.createChannel(thing.getUID(), entry.getKey(), entry.getValue());
// add channel (and remove a previous one, if exist)
thingBuilder.withoutChannel(channel.getUID()).withChannel(channel);
}
var thing = thingBuilder.build();
// and update the thing
updateThing(thing);
}
/**
* connects each WebThing property with a corresponding openHAB channel. After this changes will be synchronized
* between a WebThing property and the openHAB channel
*
* @param webThing the WebThing to be connected
* @throws IOException if the channels can not be connected
*/
private void establishWebThingChannelLinks(ConsumedThing webThing) throws IOException {
// remove all registered listeners
itemChangedListenerMap.clear();
// create new links (listeners will be registered, implicitly)
for (var namePropertyPair : webThing.getThingDescription().properties.entrySet()) {
try {
// determine the name of the associated channel
var channelUID = Channels.createChannelUID(getThing().getUID(), namePropertyPair.getKey());
// will try to establish a link, if channel is present
var channel = getThing().getChannel(channelUID);
if (channel != null) {
// establish downstream link
PropertyToChannelLink.establish(webThing, namePropertyPair.getKey(), this, channel);
// establish upstream link
if (!namePropertyPair.getValue().readOnly) {
ChannelToPropertyLink.establish(this, channel, webThing, namePropertyPair.getKey());
}
}
} catch (UnknownPropertyException upe) {
logger.warn("WebThing {} property {} could not be linked with a channel", getWebThingLabel(),
namePropertyPair.getKey(), upe);
}
}
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (command instanceof State) {
itemChangedListenerMap.getOrDefault(channelUID, EMPTY_ITEM_CHANGED_LISTENER).onItemStateChanged(channelUID,
(State) command);
} else if (command instanceof RefreshType) {
tryReconnect(webThingURI);
}
}
/////////////
// ChannelHandler methods
@Override
public void observeChannel(ChannelUID channelUID, ItemChangedListener listener) {
itemChangedListenerMap.put(channelUID, listener);
}
@Override
public void updateItemState(ChannelUID channelUID, Command command) {
if (isActivated.get()) {
postCommand(channelUID, command);
}
}
//
/////////////
private void checkWebThingConnection() {
// try reconnect, if necessary
if (isDisconnected() || (isOnline() && !isAlive())) {
logger.debug("try reconnecting WebThing {}", getWebThingLabel());
if (tryReconnect(webThingURI)) {
logger.debug("WebThing {} reconnected", getWebThingLabel());
}
} else {
// force reconnecting periodically, to fix erroneous states that occurs for unknown reasons
var elapsedSinceLastReconnect = Duration.between(lastReconnect.get(), Instant.now());
if (isOnline() && (elapsedSinceLastReconnect.getSeconds() > RECONNECT_PERIOD.getSeconds())) {
if (tryReconnect(webThingURI)) {
logger.debug("WebThing {} reconnected. Initiated by periodic reconnect", getWebThingLabel());
} else {
logger.debug("could not reconnect WebThing {} (periodic reconnect failed). Next trial in {} sec",
getWebThingLabel(), HEALTH_CHECK_PERIOD.getSeconds());
}
}
}
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -0,0 +1,70 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.webthing.internal.client;
import java.util.function.BiConsumer;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.webthing.internal.client.dto.WebThingDescription;
/**
* A WebThing represents the client-side proxy of a remote devices implementing the Web Thing API according to
* https://iot.mozilla.org/wot/
* The API design is oriented on https://www.w3.org/TR/wot-scripting-api/#the-consumedthing-interface
*
* @author Gregor Roth - Initial contribution
*/
@NonNullByDefault
public interface ConsumedThing {
/**
* @return the description (meta data) of the WebThing
*/
WebThingDescription getThingDescription();
/**
* Makes a request for Property value change notifications
*
* @param propertyName the property to be observed
* @param listener the listener to call on changes
*/
void observeProperty(String propertyName, BiConsumer<String, Object> listener);
/**
* Writes a single Property.
*
* @param propertyName the propertyName
* @return the current propertyValue
* @throws PropertyAccessException if the property can not be read
*/
Object readProperty(String propertyName) throws PropertyAccessException;
/**
* Writes a single Property.
*
* @param propertyName the propertyName
* @param newValue the new propertyValue
* @throws PropertyAccessException if the property can not be written
*/
void writeProperty(String propertyName, Object newValue) throws PropertyAccessException;
/**
* @return true, if connection is alive
*/
boolean isAlive();
/**
* closes the connection
*/
void close();
}

View File

@ -0,0 +1,50 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.webthing.internal.client;
import java.io.IOException;
import java.net.URI;
import java.util.concurrent.ScheduledExecutorService;
import java.util.function.Consumer;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.websocket.client.WebSocketClient;
/**
* Factory to create new instances of the WebThing client-side proxy
*
* @author Gregor Roth - Initial contribution
*/
@NonNullByDefault
public interface ConsumedThingFactory {
/**
* @param webSocketClient the webSocketClient to use
* @param httpClient the http client to use
* @param webThingURI the identifier of a WebThing resource
* @param executor executor
* @param errorHandler the error handler
* @return the newly created WebThing
* @throws IOException if the WebThing can not be connected
*/
ConsumedThing create(WebSocketClient webSocketClient, HttpClient httpClient, URI webThingURI,
ScheduledExecutorService executor, Consumer<String> errorHandler) throws IOException;
/**
* @return the default instance of the factory
*/
static ConsumedThingFactory instance() {
return ConsumedThingImpl::new;
}
}

View File

@ -0,0 +1,262 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.webthing.internal.client;
import java.io.IOException;
import java.net.URI;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.util.StringContentProvider;
import org.eclipse.jetty.websocket.client.WebSocketClient;
import org.openhab.binding.webthing.internal.client.dto.Property;
import org.openhab.binding.webthing.internal.client.dto.WebThingDescription;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
/**
* The implementation of the client-side Webthing representation. This is based on HTTP. Bindings to alternative
* application protocols such as CoAP may be defined in the future (which may be implemented by a another class)
*
* @author Gregor Roth - Initial contribution
*/
@NonNullByDefault
public class ConsumedThingImpl implements ConsumedThing {
private static final Duration DEFAULT_PING_PERIOD = Duration.ofSeconds(80);
private final Logger logger = LoggerFactory.getLogger(ConsumedThingImpl.class);
private final URI webThingURI;
private final Gson gson = new Gson();
private final HttpClient httpClient;
private final Consumer<String> errorHandler;
private final WebThingDescription description;
private final WebSocketConnection websocketDownstream;
private final AtomicBoolean isOpen = new AtomicBoolean(true);
/**
* constructor
*
* @param webSocketClient the web socket client to use
* @param httpClient the http client to use
* @param webThingURI the identifier of a WebThing resource
* @param executor executor to use
* @param errorHandler the error handler
* @throws IOException it the WebThing can not be connected
*/
ConsumedThingImpl(WebSocketClient webSocketClient, HttpClient httpClient, URI webThingURI,
ScheduledExecutorService executor, Consumer<String> errorHandler) throws IOException {
this(httpClient, webThingURI, executor, errorHandler, WebSocketConnectionFactory.instance(webSocketClient));
}
/**
* constructor
*
* @param httpClient the http client to use
* @param webthingUrl the identifier of a WebThing resource
* @param executor executor to use
* @param errorHandler the error handler
* @param webSocketConnectionFactory the Websocket connectino fctory to be used
* @throws IOException if the WebThing can not be connected
*/
ConsumedThingImpl(HttpClient httpClient, URI webthingUrl, ScheduledExecutorService executor,
Consumer<String> errorHandler, WebSocketConnectionFactory webSocketConnectionFactory) throws IOException {
this(httpClient, webthingUrl, executor, errorHandler, webSocketConnectionFactory, DEFAULT_PING_PERIOD);
}
/**
* constructor
*
* @param httpClient the http client to use
* @param webthingUrl the identifier of a WebThing resource
* @param executor executor to use
* @param errorHandler the error handler
* @param webSocketConnectionFactory the Websocket connectino fctory to be used
* @param pingPeriod the ping period tothe the healthiness of the connection
* @throws IOException if the WebThing can not be connected
*/
ConsumedThingImpl(HttpClient httpClient, URI webthingUrl, ScheduledExecutorService executor,
Consumer<String> errorHandler, WebSocketConnectionFactory webSocketConnectionFactory, Duration pingPeriod)
throws IOException {
this.webThingURI = webthingUrl;
this.httpClient = httpClient;
this.errorHandler = errorHandler;
this.description = new DescriptionLoader(httpClient).loadWebthingDescription(webThingURI,
Duration.ofSeconds(20));
// opens a websocket downstream to be notified if a property value will be changed
var optionalEventStreamUri = this.description.getEventStreamUri();
if (optionalEventStreamUri.isPresent()) {
this.websocketDownstream = webSocketConnectionFactory.create(optionalEventStreamUri.get(), executor,
this::onError, pingPeriod);
} else {
throw new IOException("WebThing " + webThingURI + " does not support websocket uri. WebThing description: "
+ this.description);
}
}
private Optional<URI> getPropertyUri(String propertyName) {
var optionalProperty = description.getProperty(propertyName);
if (optionalProperty.isPresent()) {
var propertyDescription = optionalProperty.get();
for (var link : propertyDescription.links) {
if ((link.rel != null) && (link.href != null) && link.rel.equals("property")) {
return Optional.of(webThingURI.resolve(link.href));
}
}
}
return Optional.empty();
}
@Override
public boolean isAlive() {
return isOpen.get() && this.websocketDownstream.isAlive();
}
@Override
public void close() {
isOpen.set(false);
this.websocketDownstream.close();
}
void onError(String reason) {
logger.debug("WebThing {} error occurred. {}", webThingURI, reason);
if (isOpen.get()) {
errorHandler.accept(reason);
}
close();
}
@Override
public WebThingDescription getThingDescription() {
return this.description;
}
@Override
public void observeProperty(String propertyName, BiConsumer<String, Object> listener) {
this.websocketDownstream.observeProperty(propertyName, listener);
// it may take a long time before the observed property value will be changed. For this reason
// read and notify the current property value (as starting point)
try {
var value = readProperty(propertyName);
listener.accept(propertyName, value);
} catch (PropertyAccessException pae) {
logger.warn("could not read WebThing {} property {}", webThingURI, propertyName, pae);
}
}
@Override
public Object readProperty(String propertyName) throws PropertyAccessException {
var optionalPropertyUri = getPropertyUri(propertyName);
if (optionalPropertyUri.isPresent()) {
var propertyUri = optionalPropertyUri.get();
try {
var response = httpClient.newRequest(propertyUri).timeout(30, TimeUnit.SECONDS)
.accept("application/json").send();
if (response.getStatus() < 200 || response.getStatus() >= 300) {
onError("WebThing " + webThingURI + " disconnected");
throw new PropertyAccessException("could not read " + propertyName + " (" + propertyUri + ")");
}
var body = response.getContentAsString();
var properties = gson.fromJson(body, Map.class);
if (properties == null) {
onError("WebThing " + webThingURI + " erroneous");
throw new PropertyAccessException("could not read " + propertyName + " (" + propertyUri
+ "). Response does not include any property (" + propertyUri + "): " + body);
} else {
var value = properties.get(propertyName);
if (value != null) {
return value;
} else {
onError("WebThing " + webThingURI + " erroneous");
throw new PropertyAccessException("could not read " + propertyName + " (" + propertyUri
+ "). Response does not include " + propertyName + "(" + propertyUri + "): " + body);
}
}
} catch (ExecutionException | TimeoutException | InterruptedException e) {
onError("WebThing resource " + webThingURI + " disconnected");
throw new PropertyAccessException("could not read " + propertyName + " (" + propertyUri + ").", e);
}
} else {
onError("WebThing " + webThingURI + " does not support " + propertyName);
throw new PropertyAccessException("WebThing " + webThingURI + " does not support " + propertyName);
}
}
@Override
public void writeProperty(String propertyName, Object newValue) throws PropertyAccessException {
var optionalPropertyUri = getPropertyUri(propertyName);
if (optionalPropertyUri.isPresent()) {
var propertyUri = optionalPropertyUri.get();
var optionalProperty = description.getProperty(propertyName);
if (optionalProperty.isPresent()) {
try {
if (optionalProperty.get().readOnly) {
throw new PropertyAccessException("could not write " + propertyName + " (" + propertyUri
+ ") with " + newValue + ". Property is readOnly");
} else {
logger.debug("updating {} with {}", propertyName, newValue);
Map<String, Object> payload = Map.of(propertyName, newValue);
var json = gson.toJson(payload);
var response = httpClient.newRequest(propertyUri).method("PUT")
.content(new StringContentProvider(json), "application/json")
.timeout(30, TimeUnit.SECONDS).send();
if (response.getStatus() < 200 || response.getStatus() >= 300) {
onError("WebThing " + webThingURI + "could not write " + propertyName + " (" + propertyUri
+ ") with " + newValue);
throw new PropertyAccessException(
"could not write " + propertyName + " (" + propertyUri + ") with " + newValue);
}
}
} catch (ExecutionException | TimeoutException | InterruptedException e) {
onError("WebThing resource " + webThingURI + " disconnected");
throw new PropertyAccessException(
"could not write " + propertyName + " (" + propertyUri + ") with " + newValue, e);
}
} else {
throw new PropertyAccessException("could not write " + propertyName + " (" + propertyUri + ") with "
+ newValue + " WebTing does not support a property named " + propertyName);
}
} else {
onError("WebThing " + webThingURI + " does not support " + propertyName);
throw new PropertyAccessException("WebThing " + webThingURI + " does not support " + propertyName);
}
}
/**
* Gets the property description
*
* @param propertyName the propertyName
* @return the description (meta data) of the property
*/
public @Nullable Property getPropertyDescription(String propertyName) {
return description.properties.get(propertyName);
}
@Override
public String toString() {
return "WebThing " + description.title + " (" + webThingURI + ")";
}
}

View File

@ -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");
}
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,45 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.webthing.internal.client;
import java.util.function.BiConsumer;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The WebsocketConnection represents an open WebSocket connection on the Web Thing. It provides a realtime mechanism
* to be notified of events as soon as they happen. Refer https://iot.mozilla.org/wot/#web-thing-websocket-api
*
* @author Gregor Roth - Initial contribution
*/
@NonNullByDefault
interface WebSocketConnection {
/**
* Makes a request for Property value change notifications
*
* @param propertyName the property to be observed
* @param listener the listener to call on changes
*/
void observeProperty(String propertyName, BiConsumer<String, Object> listener);
/**
* closes the WebSocket connection
*/
void close();
/**
* @return true, if connection is alive
*/
boolean isAlive();
}

View File

@ -0,0 +1,56 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.webthing.internal.client;
import java.io.IOException;
import java.net.URI;
import java.time.Duration;
import java.util.concurrent.ScheduledExecutorService;
import java.util.function.Consumer;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.websocket.client.WebSocketClient;
/**
* Factory to create new instances of a WebSocket connection
*
* @author Gregor Roth - Initial contribution
*/
@NonNullByDefault
interface WebSocketConnectionFactory {
/**
* create (and opens) a new WebSocket connection
*
* @param webSocketURI the websocket uri
* @param executor the executor to use
* @param errorHandler the error handler
* @param pingPeriod the ping period to check the healthiness of the connection
* @return the newly opened WebSocket connection
* @throws IOException if the web socket connection can not be established
*/
WebSocketConnection create(URI webSocketURI, ScheduledExecutorService executor, Consumer<String> errorHandler,
Duration pingPeriod) throws IOException;
/**
* @param webSocketClient the web socket client to use
* @return the default instance of the factory
*/
static WebSocketConnectionFactory instance(WebSocketClient webSocketClient) {
return (webSocketURI, executor, errorHandler, pingPeriod) -> {
var webSocketConnection = new WebSocketConnectionImpl(executor, errorHandler, pingPeriod);
webSocketClient.connect(webSocketConnection, webSocketURI);
return webSocketConnection;
};
}
}

View File

@ -0,0 +1,185 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.webthing.internal.client;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.websocket.api.Session;
import org.eclipse.jetty.websocket.api.WebSocketListener;
import org.eclipse.jetty.websocket.api.WebSocketPingPongListener;
import org.openhab.binding.webthing.internal.client.dto.PropertyStatusMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;
/**
* The WebsocketConnection implementation
*
* @author Gregor Roth - Initial contribution
*/
@NonNullByDefault
public class WebSocketConnectionImpl implements WebSocketConnection, WebSocketListener, WebSocketPingPongListener {
private static final BiConsumer<String, Object> EMPTY_PROPERTY_CHANGED_LISTENER = (String propertyName,
Object value) -> {
};
private final Logger logger = LoggerFactory.getLogger(WebSocketConnectionImpl.class);
private final Gson gson = new Gson();
private final Duration pingPeriod;
private final Consumer<String> errorHandler;
private final ScheduledFuture<?> watchDogHandle;
private final ScheduledFuture<?> pingHandle;
private final Map<String, BiConsumer<String, Object>> propertyChangedListeners = new HashMap<>();
private final AtomicReference<Instant> lastTimeReceived = new AtomicReference<>(Instant.now());
private final AtomicReference<Optional<Session>> sessionRef = new AtomicReference<>(Optional.empty());
/**
* constructor
*
* @param executor the executor to use
* @param errorHandler the errorHandler
* @param pingPeriod the period pings should be sent
*/
WebSocketConnectionImpl(ScheduledExecutorService executor, Consumer<String> errorHandler, Duration pingPeriod) {
this.errorHandler = errorHandler;
this.pingPeriod = pingPeriod;
// send a ping message are x seconds to validate if the connection is not broken
this.pingHandle = executor.scheduleWithFixedDelay(this::sendPing, pingPeriod.dividedBy(2).toMillis(),
pingPeriod.toMillis(), TimeUnit.MILLISECONDS);
// checks if a message (regular message or pong message) has been received recently. If not, connection is
// seen as broken
this.watchDogHandle = executor.scheduleWithFixedDelay(this::checkConnection, pingPeriod.toMillis(),
pingPeriod.toMillis(), TimeUnit.MILLISECONDS);
}
@Override
public void close() {
sessionRef.getAndSet(Optional.empty()).ifPresent(Session::close);
watchDogHandle.cancel(true);
pingHandle.cancel(true);
}
@Override
public void observeProperty(@NonNull String propertyName, @NonNull BiConsumer<String, Object> listener) {
propertyChangedListeners.put(propertyName, listener);
}
@Override
public void onWebSocketConnect(@Nullable Session session) {
sessionRef.set(Optional.ofNullable(session)); // save websocket session to be able to send ping
}
@Override
public void onWebSocketPing(@Nullable ByteBuffer payload) {
}
@Override
public void onWebSocketPong(@Nullable ByteBuffer payload) {
lastTimeReceived.set(Instant.now());
}
@Override
public void onWebSocketBinary(byte @Nullable [] payload, int offset, int len) {
}
@Override
public void onWebSocketText(@Nullable String message) {
try {
if (message != null) {
var propertyStatus = gson.fromJson(message, PropertyStatusMessage.class);
if ((propertyStatus != null) && (propertyStatus.messageType != null)
&& (propertyStatus.messageType.equals("propertyStatus"))) {
for (var propertyEntry : propertyStatus.data.entrySet()) {
var listener = propertyChangedListeners.getOrDefault(propertyEntry.getKey(),
EMPTY_PROPERTY_CHANGED_LISTENER);
try {
listener.accept(propertyEntry.getKey(), propertyEntry.getValue());
} catch (RuntimeException re) {
logger.warn("calling property change listener {} failed. {}", listener, re.getMessage());
}
}
} else {
logger.debug("Ignoring received message of unknown type: {}", message);
}
}
} catch (JsonSyntaxException se) {
logger.warn("received invalid message: {}", message);
}
}
@Override
public void onWebSocketClose(int statusCode, @Nullable String reason) {
onWebSocketError(new IOException("websocket closed by peer. " + Optional.ofNullable(reason).orElse("")));
}
@Override
public void onWebSocketError(@Nullable Throwable cause) {
var reason = "";
if (cause != null) {
reason = cause.getMessage();
}
onError(reason);
}
private void onError(@Nullable String message) {
if (message == null) {
message = "";
}
errorHandler.accept(message);
}
private void sendPing() {
var optionalSession = sessionRef.get();
if (optionalSession.isPresent()) {
try {
optionalSession.get().getRemote().sendPing(ByteBuffer.wrap(Instant.now().toString().getBytes()));
} catch (IOException e) {
onError("could not send ping " + e.getMessage());
}
}
}
@Override
public boolean isAlive() {
var elapsedSinceLastReceived = Duration.between(lastTimeReceived.get(), Instant.now());
var thresholdOverdued = pingPeriod.multipliedBy(3);
var isOverdued = elapsedSinceLastReceived.toMillis() > thresholdOverdued.toMillis();
return sessionRef.get().isPresent() && !isOverdued;
}
private void checkConnection() {
// check if connection is alive (message has been received recently)
if (!isAlive()) {
onError("connection seems to be broken (last message received at " + lastTimeReceived.get() + ", "
+ Duration.between(lastTimeReceived.get(), Instant.now()).getSeconds() + " sec ago)");
}
}
}

View File

@ -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;
}

View File

@ -0,0 +1,40 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.webthing.internal.client.dto;
import java.util.List;
import com.google.gson.annotations.SerializedName;
/**
* The Web Thing Description Property object. Refer https://iot.mozilla.org/wot/#property-object
*
* @author Gregor Roth - Initial contribution
*/
public class Property {
public String title = "";
@SerializedName("@type")
public String typeKeyword = "";
public String type = "string";
public String unit = null;
public boolean readOnly = false;
public String description = "";
public List<Link> links = List.of();
}

View File

@ -0,0 +1,32 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.webthing.internal.client.dto;
import java.util.Map;
/**
* Web Thing WebSocket API property status message. Refer https://iot.mozilla.org/wot/#propertystatus-message
*
* @author Gregor Roth - Initial contribution
*/
public class PropertyStatusMessage {
public String messageType = "<undefined>";
public Map<String, Object> data = Map.of();
@Override
public String toString() {
return "PropertyStatusMessage{" + "messageType='" + messageType + '\'' + ", data=" + data + '}';
}
}

View File

@ -0,0 +1,67 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.webthing.internal.client.dto;
import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import com.google.gson.annotations.SerializedName;
/**
* The Web Thing Description. Refer https://iot.mozilla.org/wot/#web-thing-description
*
* @author Gregor Roth - Initial contribution
*/
public class WebThingDescription {
public String id = null;
public String title = "";
@SerializedName("@context")
public String contextKeyword = "";
public Map<String, Property> properties = Map.of();
public List<Link> links = List.of();
/**
* convenience method to read properties
*
* @param propertyName the property name to read
* @return the property value
*/
public Optional<Property> getProperty(String propertyName) {
return Optional.ofNullable(properties.get(propertyName));
}
/**
* convenience method to read the event stream uri
*
* @return the optional event stream uri
*/
public Optional<URI> getEventStreamUri() {
for (var link : this.links) {
var href = link.href;
if ((href != null) && href.startsWith("ws")) {
var rel = Optional.ofNullable(link.rel).orElse("<undefined>");
if (rel.equals("alternate")) {
return Optional.of(URI.create(href));
}
}
}
return Optional.empty();
}
}

View File

@ -0,0 +1,297 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.webthing.internal.discovery;
import static org.openhab.binding.webthing.internal.WebThingBindingConstants.MDNS_SERVICE_TYPE;
import static org.openhab.binding.webthing.internal.WebThingBindingConstants.THING_TYPE_UID;
import java.io.IOException;
import java.net.URI;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.*;
import javax.jmdns.ServiceEvent;
import javax.jmdns.ServiceInfo;
import javax.jmdns.ServiceListener;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.webthing.internal.client.DescriptionLoader;
import org.openhab.core.config.discovery.AbstractDiscoveryService;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.config.discovery.DiscoveryService;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.io.transport.mdns.MDNSClient;
import org.openhab.core.scheduler.Scheduler;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.ThingUID;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* WebThing discovery service based on mDNS. Refer https://iot.mozilla.org/wot/#web-thing-discovery
*
* @author Gregor Roth - Initial contribution
*/
@NonNullByDefault
@Component(service = DiscoveryService.class, configurationPid = "webthingdiscovery.mdns")
public class WebthingDiscoveryService extends AbstractDiscoveryService implements ServiceListener {
private static final Duration FOREGROUND_SCAN_TIMEOUT = Duration.ofMillis(200);
public static final String ID = "id";
public static final String SCHEMAS = "schemas";
public static final String WEB_THING_URI = "webThingURI";
private final Logger logger = LoggerFactory.getLogger(WebthingDiscoveryService.class);
private final DescriptionLoader descriptionLoader;
private final MDNSClient mdnsClient;
private final List<Future<Set<DiscoveryResult>>> runningDiscoveryTasks = new CopyOnWriteArrayList<>();
/**
* constructor
*
* @param configProperties the config props
* @param mdnsClient the underlying mDNS client
*/
@Activate
public WebthingDiscoveryService(@Nullable Map<String, Object> configProperties, @Reference MDNSClient mdnsClient,
@Reference Scheduler executor, @Reference HttpClientFactory httpClientFactory) {
super(30);
this.mdnsClient = mdnsClient;
this.descriptionLoader = new DescriptionLoader(httpClientFactory.getCommonHttpClient());
super.activate(configProperties);
if (isBackgroundDiscoveryEnabled()) {
mdnsClient.addServiceListener(MDNS_SERVICE_TYPE, this);
}
}
@Override
public Set<ThingTypeUID> getSupportedThingTypes() {
return Set.of(THING_TYPE_UID);
}
@Deactivate
@Override
protected void deactivate() {
super.deactivate();
mdnsClient.removeServiceListener(MDNS_SERVICE_TYPE, this);
}
@Override
public void serviceAdded(@NonNullByDefault({}) ServiceEvent serviceEvent) {
considerService(serviceEvent);
}
@Override
public void serviceResolved(@NonNullByDefault({}) ServiceEvent serviceEvent) {
considerService(serviceEvent);
}
@Override
public void serviceRemoved(@NonNullByDefault({}) ServiceEvent serviceEvent) {
for (var discoveryResult : discoverWebThing(serviceEvent.getInfo())) {
thingRemoved(discoveryResult.getThingUID());
}
}
@Override
protected void startBackgroundDiscovery() {
mdnsClient.addServiceListener(MDNS_SERVICE_TYPE, this);
startScan(true);
}
@Override
protected void stopBackgroundDiscovery() {
mdnsClient.removeServiceListener(MDNS_SERVICE_TYPE, this);
}
private void startScan(boolean isBackground) {
scheduler.submit(() -> scan(isBackground));
}
@Override
protected void startScan() {
startScan(false);
}
@Override
protected synchronized void stopScan() {
removeOlderResults(Instant.now().minus(Duration.ofMinutes(10)).toEpochMilli());
// stop running discovery tasks
for (var future : runningDiscoveryTasks) {
future.cancel(true);
runningDiscoveryTasks.remove(future);
}
super.stopScan();
}
/**
* scans the network via mDNS
*
* @param isBackground true, if is background task
*/
private void scan(boolean isBackground) {
var serviceInfos = isBackground ? mdnsClient.list(MDNS_SERVICE_TYPE)
: mdnsClient.list(MDNS_SERVICE_TYPE, FOREGROUND_SCAN_TIMEOUT);
logger.debug("got {} mDNS entries", serviceInfos.length);
// create discovery task for each detected service and process these in parallel to increase total
// discovery speed
for (var serviceInfo : serviceInfos) {
var future = scheduler.submit(new DiscoveryTask(serviceInfo));
runningDiscoveryTasks.add(future);
}
// wait until all tasks are completed
for (var future : runningDiscoveryTasks) {
try {
future.get(5, TimeUnit.MINUTES);
} catch (InterruptedException | ExecutionException | TimeoutException e) {
logger.warn("discovering task {} terminated", future);
}
runningDiscoveryTasks.remove(future);
}
}
private class DiscoveryTask implements Callable<Set<DiscoveryResult>> {
private final ServiceInfo serviceInfo;
DiscoveryTask(ServiceInfo serviceInfo) {
this.serviceInfo = serviceInfo;
}
@Override
public Set<DiscoveryResult> call() {
var results = new HashSet<DiscoveryResult>();
for (var discoveryResult : discoverWebThing(serviceInfo)) {
results.add(discoveryResult);
thingDiscovered(discoveryResult);
logger.debug("WebThing '{}' (uri: {}, id: {}, schemas: {}) discovered", discoveryResult.getLabel(),
discoveryResult.getProperties().get(WEB_THING_URI), discoveryResult.getProperties().get(ID),
discoveryResult.getProperties().get(SCHEMAS));
}
return results;
}
@Override
public String toString() {
return "DiscoveryTask{" + "serviceInfo=" + serviceInfo + '}';
}
}
/**
* convert the serviceInfo result of the mDNS scan to discovery results
*
* @param serviceInfo the service info
* @return the associated discovery result
*/
private Set<DiscoveryResult> discoverWebThing(ServiceInfo serviceInfo) {
var discoveryResults = new HashSet<DiscoveryResult>();
if (serviceInfo.getHostAddresses().length > 0) {
var host = serviceInfo.getHostAddresses()[0];
var port = serviceInfo.getPort();
var path = "/";
if (Collections.list(serviceInfo.getPropertyNames()).contains("path")) {
path = serviceInfo.getPropertyString("path");
if (!path.endsWith("/")) {
path = path + "/";
}
}
// There are two kinds of WebThing endpoints: Endpoints supporting a single WebThing as well as
// endpoints supporting multiple WebThings.
//
// In the routine below the enpoint will be checked for single WebThings first, than for multiple
// WebThings if a ingle WebTHing has not been found.
// Furthermore, first it will be tried to connect the endpoint using https. If this fails, as fallback
// plain http is used.
// check single WebThing path via https (e.g. https://192.168.0.23:8433/)
var optionalDiscoveryResult = discoverWebThing(toURI(host, port, path, true));
if (optionalDiscoveryResult.isPresent()) {
discoveryResults.add(optionalDiscoveryResult.get());
} else {
// check single WebThing path via plain http (e.g. http://192.168.0.23:8433/)
optionalDiscoveryResult = discoverWebThing(toURI(host, port, path, false));
if (optionalDiscoveryResult.isPresent()) {
discoveryResults.add(optionalDiscoveryResult.get());
} else {
// check multiple WebThing path via https (e.g. https://192.168.0.23:8433/0,
// https://192.168.0.23:8433/1,...)
outer: for (int i = 0; i < 50; i++) { // search 50 entries at maximum
optionalDiscoveryResult = discoverWebThing(toURI(host, port, path + i + "/", true));
if (optionalDiscoveryResult.isPresent()) {
discoveryResults.add(optionalDiscoveryResult.get());
} else if (i == 0) {
// check multiple WebThing path via plain http (e.g. http://192.168.0.23:8433/0,
// http://192.168.0.23:8433/1,...)
for (int j = 0; j < 50; j++) { // search 50 entries at maximum
optionalDiscoveryResult = discoverWebThing(toURI(host, port, path + j + "/", false));
if (optionalDiscoveryResult.isPresent()) {
discoveryResults.add(optionalDiscoveryResult.get());
} else {
break outer;
}
}
} else {
break;
}
}
}
}
}
return discoveryResults;
}
private Optional<DiscoveryResult> discoverWebThing(URI uri) {
try {
var description = descriptionLoader.loadWebthingDescription(uri, Duration.ofSeconds(5));
var id = uri.getHost().replaceAll("\\W", "_") + "_" + uri.getPort();
if (uri.getPath().length() > 1) {
id = id + "_" + uri.getPath().replaceAll("\\W", "");
}
var thingUID = new ThingUID(THING_TYPE_UID, id);
Map<String, Object> properties = new HashMap<>(2);
properties.put(ID, id);
properties.put(SCHEMAS, description.contextKeyword);
return Optional.of(DiscoveryResultBuilder.create(thingUID).withThingType(THING_TYPE_UID)
.withProperty(WEB_THING_URI, uri).withLabel(description.title).withProperties(properties)
.withRepresentationProperty(ID).build());
} catch (IOException ioe) {
return Optional.empty();
}
}
private URI toURI(String host, int port, String path, boolean isHttps) {
return isHttps ? URI.create("https://" + host + ":" + port + path)
: URI.create("http://" + host + ":" + port + path);
}
private void considerService(ServiceEvent serviceEvent) {
if (isBackgroundDiscoveryEnabled()) {
for (var discoveryResult : discoverWebThing(serviceEvent.getInfo())) {
thingDiscovered(discoveryResult);
}
}
}
}

View File

@ -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());
}
}
}

View File

@ -0,0 +1,76 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.webthing.internal.link;
import java.util.function.BiConsumer;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.webthing.internal.ChannelHandler;
import org.openhab.binding.webthing.internal.client.ConsumedThing;
import org.openhab.core.thing.Channel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link PropertyToChannelLink} represents a downstream link from a WebThing property to a Channel.
*
* @author Gregor Roth - Initial contribution
*/
@NonNullByDefault
public class PropertyToChannelLink implements BiConsumer<String, Object> {
private final Logger logger = LoggerFactory.getLogger(PropertyToChannelLink.class);
private final ChannelHandler channelHandler;
private final Channel channel;
private final TypeConverter typeConverter;
/**
* establish downstream link from a WebTHing property to a Channel
*
* @param webThing the WebThing to be linked
* @param propertyName the property name
* @param channelHandler the channel handler that provides updating the Item state of a channel
* @param channel the channel to be linked
* @throws UnknownPropertyException if the a WebThing property should be link that does not exist
*/
public static void establish(ConsumedThing webThing, String propertyName, ChannelHandler channelHandler,
Channel channel) throws UnknownPropertyException {
new PropertyToChannelLink(webThing, propertyName, channelHandler, channel);
}
private PropertyToChannelLink(ConsumedThing webThing, String propertyName, ChannelHandler channelHandler,
Channel channel) throws UnknownPropertyException {
this.channel = channel;
var optionalProperty = webThing.getThingDescription().getProperty(propertyName);
if (optionalProperty.isPresent()) {
var propertyType = optionalProperty.get().type;
var acceptedType = channel.getAcceptedItemType();
if (acceptedType == null) {
this.typeConverter = TypeConverters.create("String", propertyType);
} else {
this.typeConverter = TypeConverters.create(acceptedType, propertyType);
}
this.channelHandler = channelHandler;
webThing.observeProperty(propertyName, this);
} else {
throw new UnknownPropertyException("property " + propertyName + " does not exits");
}
}
@Override
public void accept(String propertyName, Object value) {
var stateCommand = typeConverter.toStateCommand(value);
channelHandler.updateItemState(channel.getUID(), stateCommand);
logger.debug("channel {} updated with {} ({})", channel.getUID().getAsString(), value,
channel.getAcceptedItemType());
}
}

View File

@ -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);
}

View File

@ -0,0 +1,179 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.webthing.internal.link;
import java.awt.*;
import java.math.BigDecimal;
import java.util.Collection;
import java.util.Locale;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.library.types.*;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
/**
* Helper class to create a TypeConverter
*
* @author Gregor Roth - Initial contribution
*/
@NonNullByDefault
class TypeConverters {
/**
* create a TypeConverter for a given Item type and property type
*
* @param itemType the item type
* @param propertyType the property type
* @return the type converter
*/
static TypeConverter create(String itemType, String propertyType) {
switch (itemType.toLowerCase(Locale.ENGLISH)) {
case "switch":
return new SwitchTypeConverter();
case "dimmer":
return new DimmerTypeConverter();
case "contact":
return new ContactTypeConverter();
case "color":
return new ColorTypeConverter();
case "number":
if (propertyType.toLowerCase(Locale.ENGLISH).equals("integer")) {
return new IntegerTypeConverter();
} else {
return new NumberTypeConverter();
}
default:
return new StringTypeConverter();
}
}
private static boolean toBoolean(Object propertyValue) {
return Boolean.parseBoolean(propertyValue.toString());
}
private static BigDecimal toDecimal(Object propertyValue) {
return new BigDecimal(propertyValue.toString());
}
private static final class ColorTypeConverter implements TypeConverter {
@Override
public Command toStateCommand(Object propertyValue) {
var value = propertyValue.toString();
if (!value.contains("#")) {
value = "#" + value;
}
Color rgb = Color.decode(value);
return HSBType.fromRGB(rgb.getRed(), rgb.getGreen(), rgb.getBlue());
}
@Override
public Object toPropertyValue(State state) {
var hsb = ((HSBType) state);
// Get HSB values
Float hue = hsb.getHue().floatValue();
Float saturation = hsb.getSaturation().floatValue();
Float brightness = hsb.getBrightness().floatValue();
// Convert HSB to RGB and then to HTML hex
Color rgb = Color.getHSBColor(hue / 360, saturation / 100, brightness / 100);
return String.format("#%02x%02x%02x", rgb.getRed(), rgb.getGreen(), rgb.getBlue());
}
}
private static final class SwitchTypeConverter implements TypeConverter {
@Override
public Command toStateCommand(Object propertyValue) {
return toBoolean(propertyValue) ? OnOffType.ON : OnOffType.OFF;
}
@Override
public Object toPropertyValue(State state) {
return state == OnOffType.ON;
}
}
private static final class ContactTypeConverter implements TypeConverter {
@Override
public Command toStateCommand(Object propertyValue) {
return toBoolean(propertyValue) ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
}
@Override
public Object toPropertyValue(State state) {
return state == OpenClosedType.OPEN;
}
}
private static final class DimmerTypeConverter implements TypeConverter {
@Override
public Command toStateCommand(Object propertyValue) {
return new PercentType(toDecimal(propertyValue));
}
@Override
public Object toPropertyValue(State state) {
return ((DecimalType) state).toBigDecimal().intValue();
}
}
private static final class NumberTypeConverter implements TypeConverter {
@Override
public Command toStateCommand(Object propertyValue) {
return new DecimalType(toDecimal(propertyValue));
}
@Override
public Object toPropertyValue(State state) {
return ((DecimalType) state).doubleValue();
}
}
private static final class IntegerTypeConverter implements TypeConverter {
@Override
public Command toStateCommand(Object propertyValue) {
return new DecimalType(toDecimal(propertyValue));
}
@Override
public Object toPropertyValue(State state) {
return ((DecimalType) state).intValue();
}
}
private static final class StringTypeConverter implements TypeConverter {
@SuppressWarnings("unchecked")
@Override
public Command toStateCommand(Object propertyValue) {
String textValue = propertyValue.toString();
if (propertyValue instanceof Collection) {
textValue = ((Collection<Object>) propertyValue).stream()
.reduce("", (entry1, entry2) -> entry1.toString() + "\n" + entry2.toString()).toString();
}
return StringType.valueOf(textValue);
}
@Override
public Object toPropertyValue(State state) {
return state.toString();
}
}
}

View File

@ -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;
}
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<binding:binding id="webthing" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:binding="https://openhab.org/schemas/binding/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/binding/v1.0.0 https://openhab.org/schemas/binding-1.0.0.xsd">
<name>WebThing Binding</name>
<description>The WebThing binding supports an interface to remote devices implementing the Web Thing API.</description>
</binding:binding>

View File

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="webthing"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<thing-type id="generic">
<label>WebThing</label>
<description>The WebThing to be connected</description>
<config-description>
<parameter name="webThingURI" type="text" required="true">
<context>url</context>
<label>URI</label>
<description>The URI of the WebThing to be connected. E.g. the URI of a web-connected MotionSensor or a URI of a
web-connected Display</description>
</parameter>
</config-description>
</thing-type>
<channel-type id="number">
<item-type>Number</item-type>
<label>Webthing Binding Channel</label>
<description>Number channel for Webthing Binding</description>
</channel-type>
<channel-type id="string">
<item-type>String</item-type>
<label>Webthing Binding Channel</label>
<description>String channel for Webthing Binding</description>
</channel-type>
<channel-type id="contact">
<item-type>Contact</item-type>
<label>Webthing Binding Channel</label>
<description>Contact channel for Webthing Binding</description>
</channel-type>
<channel-type id="switch">
<item-type>Switch</item-type>
<label>Webthing Binding Channel</label>
<description>Switch channel for Webthing Binding</description>
</channel-type>
<channel-type id="color">
<item-type>Color</item-type>
<label>Webthing Binding Channel</label>
<description>Color channel for Webthing Binding</description>
</channel-type>
<channel-type id="dimmer">
<item-type>Dimmer</item-type>
<label>Webthing Binding Channel</label>
<description>Dimmer channel for Webthing Binding</description>
</channel-type>
</thing:thing-descriptions>

View File

@ -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())));
}
}

View File

@ -0,0 +1,78 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.webthing.internal.client;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.*;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.api.ContentProvider;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
/**
* Mock helper
*
* @author Gregor Roth - Initial contribution
*/
@NonNullByDefault
public class Mocks {
public static Request mockRequest(@Nullable String requestContent, String responseContent) throws Exception {
return mockRequest(requestContent, responseContent, 200, 200);
}
public static Request mockRequest(@Nullable String requestContent, String responseContent, int getResponse,
int postResponse) throws Exception {
var request = mock(Request.class);
// GET request -> request.timeout(30, TimeUnit.SECONDS).send();
var getRequest = mock(Request.class);
var getContentResponse = mock(ContentResponse.class);
when(getContentResponse.getStatus()).thenReturn(getResponse);
when(getContentResponse.getContentAsString()).thenReturn(responseContent);
when(getRequest.send()).thenReturn(getContentResponse);
when(getRequest.accept("application/json")).thenReturn(getRequest);
when(request.timeout(30, TimeUnit.SECONDS)).thenReturn(getRequest);
// POST request -> request.method("PUT").content(new StringContentProvider(json)).timeout(30,
// TimeUnit.SECONDS).send();
if (requestContent != null) {
var postRequest = mock(Request.class);
when(postRequest.content(argThat((ContentProvider content) -> bufToString(content).equals(requestContent)),
eq("application/json"))).thenReturn(postRequest);
when(postRequest.timeout(30, TimeUnit.SECONDS)).thenReturn(postRequest);
var postContentResponse = mock(ContentResponse.class);
when(postContentResponse.getStatus()).thenReturn(postResponse);
when(postRequest.send()).thenReturn(postContentResponse);
when(request.method("PUT")).thenReturn(postRequest);
}
return request;
}
private static String bufToString(Iterable<ByteBuffer> data) {
var result = "";
for (var byteBuffer : data) {
result += StandardCharsets.UTF_8.decode(byteBuffer).toString();
}
return result;
}
}

View File

@ -0,0 +1,501 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.webthing.internal.client;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.WebSocket;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.websocket.api.BatchMode;
import org.eclipse.jetty.websocket.api.CloseStatus;
import org.eclipse.jetty.websocket.api.RemoteEndpoint;
import org.eclipse.jetty.websocket.api.Session;
import org.eclipse.jetty.websocket.api.SuspendToken;
import org.eclipse.jetty.websocket.api.UpgradeRequest;
import org.eclipse.jetty.websocket.api.UpgradeResponse;
import org.eclipse.jetty.websocket.api.WebSocketListener;
import org.eclipse.jetty.websocket.api.WebSocketPingPongListener;
import org.eclipse.jetty.websocket.api.WebSocketPolicy;
import org.eclipse.jetty.websocket.api.WriteCallback;
import org.junit.jupiter.api.Test;
import org.openhab.binding.webthing.internal.client.dto.PropertyStatusMessage;
import com.google.gson.Gson;
/**
*
*
* @author Gregor Roth - Initial contribution
*/
@NonNullByDefault
public class WebthingTest {
private static final Gson GSON = new Gson();
@Test
public void testWebthingDescription() throws Exception {
var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
var request = Mocks.mockRequest(null, load("/windsensor_response.json"));
when(httpClient.newRequest(URI.create("http://example.org:8090"))).thenReturn(request);
var request2 = Mocks.mockRequest(null, load("/windsensor_property.json"));
when(httpClient.newRequest(URI.create("http://example.org:8090/properties/windspeed"))).thenReturn(request2);
var webthing = createTestWebthing("http://example.org:8090", httpClient);
var metadata = webthing.getThingDescription();
assertEquals("Wind", metadata.title);
}
@Test
public void testWebthingDescriptionUnsetSchema() throws Exception {
var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
var request = Mocks.mockRequest(null, load("/unsetschema_response.json"));
when(httpClient.newRequest(URI.create("http://example.org:8090"))).thenReturn(request);
var request2 = Mocks.mockRequest(null, load("/windsensor_property.json"));
when(httpClient.newRequest(URI.create("http://example.org:8090/properties/windspeed"))).thenReturn(request2);
var webthing = createTestWebthing("http://example.org:8090", httpClient);
var metadata = webthing.getThingDescription();
assertEquals("Wind", metadata.title);
}
@Test
public void testWebthingDescriptionUNsupportedSchema() throws Exception {
var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
var request = Mocks.mockRequest(null, load("/unknownschema_response.json"));
when(httpClient.newRequest(URI.create("http://example.org:8090"))).thenReturn(request);
try {
createTestWebthing("http://example.org:8090", httpClient);
fail();
} catch (IOException e) {
assertEquals(
"unsupported schema (@context parameter) https://www.w3.org/2019/wot/td/v1 (Supported schemas are https://webthings.io/schemas and https://iot.mozilla.org/schemas)",
e.getMessage());
}
}
@Test
public void testReadReadOnlyProperty() throws Exception {
var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
var request = Mocks.mockRequest(null, load("/windsensor_response.json"));
when(httpClient.newRequest(URI.create("http://example.org:8090"))).thenReturn(request);
var request2 = Mocks.mockRequest(null, load("/windsensor_property.json"));
when(httpClient.newRequest(URI.create("http://example.org:8090/properties/windspeed"))).thenReturn(request2);
var webthing = createTestWebthing("http://example.org:8090", httpClient);
assertEquals(34.0, webthing.readProperty("windspeed"));
try {
webthing.writeProperty("windspeed", 23.0);
fail();
} catch (PropertyAccessException e) {
assertEquals(
"could not write windspeed (http://example.org:8090/properties/windspeed) with 23.0. Property is readOnly",
e.getMessage());
}
}
@Test
public void testReadPropertyTest() throws Exception {
var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
var request = Mocks.mockRequest(null, load("/awning_response.json"));
when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request);
var request2 = Mocks.mockRequest(null, load("/awning_property.json"));
when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position")))
.thenReturn(request2);
var webthing = createTestWebthing("http://example.org:8090/0", httpClient);
assertEquals(85.0, webthing.readProperty("target_position"));
}
@Test
public void testWriteProperty() throws Exception {
var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
var request = Mocks.mockRequest(null, load("/awning_response.json"));
when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request);
var request2 = Mocks.mockRequest("{\"target_position\":10}", load("/awning_property.json"));
when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position")))
.thenReturn(request2);
var webthing = createTestWebthing("http://example.org:8090/0", httpClient);
webthing.writeProperty("target_position", 10);
}
@Test
public void testWritePropertyError() throws Exception {
var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
var request = Mocks.mockRequest(null, load("/awning_response.json"));
when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request);
var request2 = Mocks.mockRequest("{\"target_position\":10}", load("/awning_property.json"), 200, 400);
when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position")))
.thenReturn(request2);
var webthing = createTestWebthing("http://example.org:8090/0", httpClient);
try {
webthing.writeProperty("target_position", 10);
fail();
} catch (PropertyAccessException e) {
assertEquals(
"could not write target_position (http://example.org:8090/0/properties/target_position) with 10",
e.getMessage());
}
}
@Test
public void testReadPropertyError() throws Exception {
var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
var request = Mocks.mockRequest(null, load("/windsensor_response.json"));
when(httpClient.newRequest(URI.create("http://example.org:8090"))).thenReturn(request);
var request2 = Mocks.mockRequest(null, load("/windsensor_response.json"), 500, 200);
when(httpClient.newRequest(URI.create("http://example.org:8090/properties/windspeed"))).thenReturn(request2);
var webthing = createTestWebthing("http://example.org:8090", httpClient);
try {
webthing.readProperty("windspeed");
fail();
} catch (PropertyAccessException e) {
assertEquals("could not read windspeed (http://example.org:8090/properties/windspeed)", e.getMessage());
}
}
@Test
public void testWebSocket() throws Exception {
var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
var request = Mocks.mockRequest(null, load("/awning_response.json"));
when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request);
var request2 = Mocks.mockRequest(null, load("/awning_property.json"));
when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position")))
.thenReturn(request2);
var errorHandler = new ErrorHandler();
var webSocketFactory = new TestWebsocketConnectionFactory();
var webthing = createTestWebthing("http://example.org:8090/0", httpClient, errorHandler, webSocketFactory);
var propertyChangedListenerImpl = new PropertyChangedListenerImpl();
webthing.observeProperty("target_position", propertyChangedListenerImpl);
var webSocketServerSide = webSocketFactory.webSocketRef.get();
var message = new PropertyStatusMessage();
message.messageType = "propertyStatus";
message.data = Map.of("target_position", 33);
webSocketServerSide.sendToClient(message);
while (propertyChangedListenerImpl.valueRef.get() == null) {
try {
Thread.sleep(100);
} catch (InterruptedException ignore) {
}
}
assertEquals(33.0, propertyChangedListenerImpl.valueRef.get());
webSocketServerSide.sendCloseToClient();
assertEquals("websocket closed by peer. ", errorHandler.errorRef.get());
}
@Test
public void testWebSocketReceiveTimout() throws Exception {
var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
var request = Mocks.mockRequest(null, load("/awning_response.json"));
when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request);
var request2 = Mocks.mockRequest(null, load("/awning_property.json"));
when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position")))
.thenReturn(request2);
var errorHandler = new ErrorHandler();
var webSocketFactory = new TestWebsocketConnectionFactory();
var pingPeriod = Duration.ofMillis(300);
var webthing = createTestWebthing("http://example.org:8090/0", httpClient, errorHandler, webSocketFactory,
pingPeriod);
var propertyChangedListenerImpl = new PropertyChangedListenerImpl();
webthing.observeProperty("target_position", propertyChangedListenerImpl);
webSocketFactory.webSocketRef.get().ignorePing.set(true);
try {
Thread.sleep(pingPeriod.dividedBy(2).toMillis());
} catch (InterruptedException ignore) {
}
assertNull(errorHandler.errorRef.get());
try {
Thread.sleep(pingPeriod.multipliedBy(4).toMillis());
} catch (InterruptedException ignore) {
}
assertTrue(errorHandler.errorRef.get().startsWith("connection seems to be broken (last message received at"));
}
public static String load(String name) throws Exception {
return new String(Files.readAllBytes(Paths.get(WebthingTest.class.getResource(name).toURI())));
}
public static ConsumedThingImpl createTestWebthing(String uri, HttpClient httpClient) throws IOException {
return createTestWebthing(uri, httpClient, (String) -> {
}, new TestWebsocketConnectionFactory());
}
public static ConsumedThingImpl createTestWebthing(String uri, HttpClient httpClient, Consumer<String> errorHandler,
WebSocketConnectionFactory websocketConnectionFactory, Duration pingPeriod) throws IOException {
return new ConsumedThingImpl(httpClient, URI.create(uri), Executors.newSingleThreadScheduledExecutor(),
errorHandler, websocketConnectionFactory, pingPeriod);
}
public static ConsumedThingImpl createTestWebthing(String uri, HttpClient httpClient, Consumer<String> errorHandler,
WebSocketConnectionFactory websocketConnectionFactory) throws IOException {
return createTestWebthing(uri, httpClient, errorHandler, websocketConnectionFactory, Duration.ofSeconds(100));
}
public static class TestWebsocketConnectionFactory implements WebSocketConnectionFactory {
public final AtomicReference<WebSocketImpl> webSocketRef = new AtomicReference<>();
@Override
public WebSocketConnection create(@NonNull URI webSocketURI, @NonNull ScheduledExecutorService executor,
@NonNull Consumer<String> errorHandler, @NonNull Duration pingPeriod) {
var webSocketConnection = new WebSocketConnectionImpl(executor, errorHandler, pingPeriod);
var webSocket = new WebSocketImpl(webSocketConnection);
webSocketRef.set(webSocket);
webSocketConnection.onWebSocketConnect(webSocket);
return webSocketConnection;
}
}
public static final class WebSocketImpl implements Session {
private final WebSocketListener listener;
private final WebSocketPingPongListener pongListener;
public AtomicBoolean ignorePing = new AtomicBoolean(false);
WebSocketImpl(WebSocketConnectionImpl connection) {
this.listener = connection;
this.pongListener = connection;
}
@Override
public void close() {
}
@Override
public void close(@Nullable CloseStatus closeStatus) {
}
@Override
public void close(int statusCode, @Nullable String reason) {
}
@Override
public void disconnect() throws IOException {
}
@Override
public long getIdleTimeout() {
return 0;
}
@Override
public InetSocketAddress getLocalAddress() {
return InetSocketAddress.createUnresolved("test", 23);
}
@Override
public WebSocketPolicy getPolicy() {
return WebSocketPolicy.newClientPolicy();
}
@Override
public String getProtocolVersion() {
return "1";
}
@Override
public RemoteEndpoint getRemote() {
return new RemoteEndpoint() {
@Override
public void sendBytes(@Nullable ByteBuffer data) throws IOException {
}
@Override
public Future sendBytesByFuture(@Nullable ByteBuffer data) {
throw new UnsupportedOperationException();
}
@Override
public void sendBytes(@Nullable ByteBuffer data, @Nullable WriteCallback callback) {
}
@Override
public void sendPartialBytes(@Nullable ByteBuffer fragment, boolean isLast) throws IOException {
}
@Override
public void sendPartialString(@Nullable String fragment, boolean isLast) throws IOException {
}
@Override
public void sendPing(@Nullable ByteBuffer applicationData) throws IOException {
if (!ignorePing.get()) {
pongListener.onWebSocketPong(applicationData);
}
}
@Override
public void sendPong(@Nullable ByteBuffer applicationData) throws IOException {
}
@Override
public void sendString(@Nullable String text) throws IOException {
}
@Override
public Future sendStringByFuture(@Nullable String text) {
throw new UnsupportedOperationException();
}
@Override
public void sendString(@Nullable String text, @Nullable WriteCallback callback) {
}
@Override
public BatchMode getBatchMode() {
return BatchMode.AUTO;
}
@Override
public void setBatchMode(@Nullable BatchMode mode) {
}
@Override
public InetSocketAddress getInetSocketAddress() {
throw new UnsupportedOperationException();
}
@Override
public void flush() throws IOException {
}
@Override
public int getMaxOutgoingFrames() {
return 0;
}
@Override
public void setMaxOutgoingFrames(int maxOutgoingFrames) {
}
};
}
@Override
public InetSocketAddress getRemoteAddress() {
return InetSocketAddress.createUnresolved("test", 12);
}
@Override
public UpgradeRequest getUpgradeRequest() {
throw new UnsupportedOperationException();
}
@Override
public UpgradeResponse getUpgradeResponse() {
throw new UnsupportedOperationException();
}
@Override
public boolean isOpen() {
return false;
}
@Override
public boolean isSecure() {
return false;
}
@Override
public void setIdleTimeout(long ms) {
}
@Override
public SuspendToken suspend() {
return new SuspendToken() {
@Override
public void resume() {
}
};
}
public void sendToClient(PropertyStatusMessage message) {
var data = GSON.toJson(message);
listener.onWebSocketText(data);
}
public void sendCloseToClient() {
listener.onWebSocketClose(200, "");
}
public CompletableFuture<WebSocket> sendPing(String message) {
if (!ignorePing.get()) {
var bytes = message.getBytes(StandardCharsets.UTF_8);
listener.onWebSocketBinary(bytes, 0, bytes.length);
}
return CompletableFuture.completedFuture(null);
}
}
private static final class PropertyChangedListenerImpl implements BiConsumer<String, Object> {
public final AtomicReference<String> propertyNameRef = new AtomicReference<>();
public final AtomicReference<Object> valueRef = new AtomicReference<>();
@Override
public void accept(String propertyName, Object value) {
propertyNameRef.set(propertyName);
valueRef.set(value);
}
}
public static class ErrorHandler implements Consumer<String> {
public final AtomicReference<String> errorRef = new AtomicReference<>();
@Override
public void accept(String error) {
errorRef.set(error);
}
}
}

View File

@ -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));
}
}

View File

@ -0,0 +1,206 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.webthing.internal.link;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.openhab.binding.webthing.internal.ChannelHandler;
import org.openhab.binding.webthing.internal.channel.Channels;
import org.openhab.binding.webthing.internal.client.Mocks;
import org.openhab.binding.webthing.internal.client.WebthingTest;
import org.openhab.binding.webthing.internal.client.dto.PropertyStatusMessage;
import org.openhab.core.library.types.*;
import org.openhab.core.thing.*;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import com.google.gson.Gson;
/**
* Mapping test.
*
* Please consider that changes of 'ItemType<->PropertyType mapping' validated by this test
* will break the compatibility to former releases.
*
* @author Gregor Roth - Initial contribution
*/
@NonNullByDefault
public class WebthingChannelLinkTest {
private final Gson gson = new Gson();
@Test
public void testChannelToProperty() throws Exception {
var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
var request = Mocks.mockRequest(null, load("/awning_response.json"));
when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request);
var request2 = Mocks.mockRequest("{\"target_position\":10}", load("/awning_property.json"));
when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position")))
.thenReturn(request2);
var thingUID = new ThingUID("webthing", "anwing");
var channelUID = Channels.createChannelUID(thingUID, "target_position");
var webthing = WebthingTest.createTestWebthing("http://example.org:8090/0", httpClient);
var channel = Channels.createChannel(thingUID, "target_position",
Objects.requireNonNull(webthing.getPropertyDescription("target_position")));
var testWebthingThingHandler = new TestWebthingThingHandler();
ChannelToPropertyLink.establish(testWebthingThingHandler, channel, webthing, "target_position");
testWebthingThingHandler.listeners.get(channelUID).onItemStateChanged(channelUID, new DecimalType(10));
}
@Test
public void testChannelToPropertyServerError() throws Exception {
var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
var request = Mocks.mockRequest(null, load("/awning_response.json"));
when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request);
var request2 = Mocks.mockRequest("{\"target_position\":130}", load("/awning_property.json"), 200, 500);
when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position")))
.thenReturn(request2);
var thingUID = new ThingUID("webthing", "anwing");
var channelUID = Channels.createChannelUID(thingUID, "target_position");
var webthing = WebthingTest.createTestWebthing("http://example.org:8090/0", httpClient);
var channel = Channels.createChannel(thingUID, "target_position",
Objects.requireNonNull(webthing.getPropertyDescription("target_position")));
var testWebthingThingHandler = new TestWebthingThingHandler();
ChannelToPropertyLink.establish(testWebthingThingHandler, channel, webthing, "target_position");
testWebthingThingHandler.listeners.get(channelUID).onItemStateChanged(channelUID, new DecimalType(130));
}
@Test
public void testPropertyToChannel() throws Exception {
var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
var request = Mocks.mockRequest(null, load("/awning_response.json"));
when(httpClient.newRequest(URI.create("http://example.org:8090/0"))).thenReturn(request);
var request2 = Mocks.mockRequest("{\"target_position\":10}", load("/awning_property.json"));
when(httpClient.newRequest(URI.create("http://example.org:8090/0/properties/target_position")))
.thenReturn(request2);
var thingUID = new ThingUID("webthing", "anwing");
var channelUID = Channels.createChannelUID(thingUID, "target_position");
var errorHandler = new WebthingTest.ErrorHandler();
var websocketConnectionFactory = new WebthingTest.TestWebsocketConnectionFactory();
var webthing = WebthingTest.createTestWebthing("http://example.org:8090/0", httpClient, errorHandler,
websocketConnectionFactory);
var channel = Channels.createChannel(thingUID, "target_position",
Objects.requireNonNull(webthing.getPropertyDescription("target_position")));
var testWebthingThingHandler = new TestWebthingThingHandler();
PropertyToChannelLink.establish(webthing, "target_position", testWebthingThingHandler, channel);
var message = new PropertyStatusMessage();
message.messageType = "propertyStatus";
message.data = Map.of("target_position", 77);
websocketConnectionFactory.webSocketRef.get().sendToClient(message);
assertEquals(new DecimalType(77), testWebthingThingHandler.itemState.get(channelUID));
}
@Test
public void testDataTypeMapping() throws Exception {
performDataTypeMappingTest("level_prop", 56.5, new DecimalType(56.5), 3.5, new DecimalType(3.5));
performDataTypeMappingTest("level_unit_prop", 10, new PercentType(10), 90, new PercentType(90));
performDataTypeMappingTest("thermo_prop", "off", new StringType("off"), "auto", new StringType("auto"));
performDataTypeMappingTest("temp_prop", 18.6, new DecimalType(18.6), 23.2, new DecimalType(23.2));
performDataTypeMappingTest("targettemp_prop", 18.6, new DecimalType(18.6), 23.2, new DecimalType(23.2));
performDataTypeMappingTest("open_prop", true, OpenClosedType.OPEN, false, OpenClosedType.CLOSED);
performDataTypeMappingTest("colortemp_prop", 10, new PercentType(10), 60, new PercentType(60));
performDataTypeMappingTest("color_prop", "#f2fe00", new HSBType("62,100,99"), "#ff0000",
new HSBType("0.0,100.0,100.0"));
performDataTypeMappingTest("colormode_prop", "color", new StringType("color"), "temperature",
new StringType("temperature"));
performDataTypeMappingTest("brightness_prop", 33, new PercentType(33), 65, new PercentType(65));
performDataTypeMappingTest("voltage_prop", 4.5, new DecimalType(4.5), 30.2, new DecimalType(30.2));
performDataTypeMappingTest("heating_prop", "off", new StringType("off"), "cooling", new StringType("cooling"));
performDataTypeMappingTest("onoff_prop", true, OnOffType.ON, false, OnOffType.OFF);
performDataTypeMappingTest("string_prop", "initial", new StringType("initial"), "updated",
new StringType("updated"));
performDataTypeMappingTest("number_prop", 80.5, new DecimalType(80.5), 60.9, new DecimalType(60.9));
performDataTypeMappingTest("integer_prop", 11, new DecimalType(11), 77, new DecimalType(77));
performDataTypeMappingTest("boolean_prop", true, OnOffType.ON, false, OnOffType.OFF);
}
private void performDataTypeMappingTest(String propertyName, Object initialValue, State initialState,
Object updatedValue, State updatedState) throws Exception {
var httpClient = mock(org.eclipse.jetty.client.HttpClient.class);
var request = Mocks.mockRequest(null, load("/datatypes_test_response.json"));
when(httpClient.newRequest(URI.create("http://example.org:8090/"))).thenReturn(request);
var request2 = Mocks.mockRequest(gson.toJson(Map.of(propertyName, updatedValue)),
gson.toJson(Map.of(propertyName, initialValue)));
when(httpClient.newRequest(URI.create("http://example.org:8090/properties/" + propertyName)))
.thenReturn(request2);
var thingUID = new ThingUID("webthing", "test");
var channelUID = Channels.createChannelUID(thingUID, propertyName);
var errorHandler = new WebthingTest.ErrorHandler();
var websocketConnectionFactory = new WebthingTest.TestWebsocketConnectionFactory();
var webthing = WebthingTest.createTestWebthing("http://example.org:8090/", httpClient, errorHandler,
websocketConnectionFactory);
var channel = Channels.createChannel(thingUID, propertyName,
Objects.requireNonNull(webthing.getPropertyDescription(propertyName)));
var testWebthingThingHandler = new TestWebthingThingHandler();
PropertyToChannelLink.establish(webthing, propertyName, testWebthingThingHandler, channel);
var message = new PropertyStatusMessage();
message.messageType = "propertyStatus";
message.data = Map.of(propertyName, initialValue);
websocketConnectionFactory.webSocketRef.get().sendToClient(message);
assertEquals(initialState, testWebthingThingHandler.itemState.get(channelUID));
ChannelToPropertyLink.establish(testWebthingThingHandler, channel, webthing, propertyName);
testWebthingThingHandler.listeners.get(channelUID).onItemStateChanged(channelUID, updatedState);
}
public static String load(String name) throws Exception {
return new String(Files.readAllBytes(Paths.get(WebthingTest.class.getResource(name).toURI())));
}
private static class TestWebthingThingHandler implements ChannelHandler {
public final Map<ChannelUID, ItemChangedListener> listeners = new ConcurrentHashMap<>();
public final Map<ChannelUID, Command> itemState = new ConcurrentHashMap<>();
@Override
public void observeChannel(ChannelUID channelUID, ItemChangedListener listener) {
listeners.put(channelUID, listener);
}
@Override
public void updateItemState(ChannelUID channelUID, Command command) {
itemState.put(channelUID, command);
}
}
}

View File

@ -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"
}
]

View File

@ -0,0 +1 @@
{"target_position":85}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -0,0 +1 @@
{"number_prop":80.5}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -0,0 +1,3 @@
{
"windspeed":34
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -326,6 +326,7 @@
<module>org.openhab.binding.volvooncall</module> <module>org.openhab.binding.volvooncall</module>
<module>org.openhab.binding.weathercompany</module> <module>org.openhab.binding.weathercompany</module>
<module>org.openhab.binding.weatherunderground</module> <module>org.openhab.binding.weatherunderground</module>
<module>org.openhab.binding.webthing</module>
<module>org.openhab.binding.wemo</module> <module>org.openhab.binding.wemo</module>
<module>org.openhab.binding.wifiled</module> <module>org.openhab.binding.wifiled</module>
<module>org.openhab.binding.windcentrale</module> <module>org.openhab.binding.windcentrale</module>