[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
54 changed files with 4545 additions and 0 deletions

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