2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.webthing.internal.client;
15 import java.io.IOException;
17 import java.time.Duration;
19 import java.util.Optional;
20 import java.util.concurrent.ExecutionException;
21 import java.util.concurrent.ScheduledExecutorService;
22 import java.util.concurrent.TimeUnit;
23 import java.util.concurrent.TimeoutException;
24 import java.util.concurrent.atomic.AtomicBoolean;
25 import java.util.function.BiConsumer;
26 import java.util.function.Consumer;
28 import org.eclipse.jdt.annotation.NonNullByDefault;
29 import org.eclipse.jdt.annotation.Nullable;
30 import org.eclipse.jetty.client.HttpClient;
31 import org.eclipse.jetty.client.util.StringContentProvider;
32 import org.eclipse.jetty.websocket.client.WebSocketClient;
33 import org.openhab.binding.webthing.internal.client.dto.Property;
34 import org.openhab.binding.webthing.internal.client.dto.WebThingDescription;
35 import org.slf4j.Logger;
36 import org.slf4j.LoggerFactory;
38 import com.google.gson.Gson;
41 * The implementation of the client-side Webthing representation. This is based on HTTP. Bindings to alternative
42 * application protocols such as CoAP may be defined in the future (which may be implemented by another class)
44 * @author Gregor Roth - Initial contribution
47 public class ConsumedThingImpl implements ConsumedThing {
48 private static final Duration DEFAULT_PING_PERIOD = Duration.ofSeconds(80);
49 private final Logger logger = LoggerFactory.getLogger(ConsumedThingImpl.class);
50 private final URI webThingURI;
51 private final Gson gson = new Gson();
52 private final HttpClient httpClient;
53 private final Consumer<String> errorHandler;
54 private final WebThingDescription description;
55 private final WebSocketConnection websocketDownstream;
56 private final AtomicBoolean isOpen = new AtomicBoolean(true);
61 * @param webSocketClient the web socket client to use
62 * @param httpClient the http client to use
63 * @param webThingURI the identifier of a WebThing resource
64 * @param executor executor to use
65 * @param errorHandler the error handler
66 * @throws IOException it the WebThing can not be connected
68 ConsumedThingImpl(WebSocketClient webSocketClient, HttpClient httpClient, URI webThingURI,
69 ScheduledExecutorService executor, Consumer<String> errorHandler) throws IOException {
70 this(httpClient, webThingURI, executor, errorHandler, WebSocketConnectionFactory.instance(webSocketClient));
76 * @param httpClient the http client to use
77 * @param webthingUrl the identifier of a WebThing resource
78 * @param executor executor to use
79 * @param errorHandler the error handler
80 * @param webSocketConnectionFactory the Websocket connectino fctory to be used
81 * @throws IOException if the WebThing can not be connected
83 ConsumedThingImpl(HttpClient httpClient, URI webthingUrl, ScheduledExecutorService executor,
84 Consumer<String> errorHandler, WebSocketConnectionFactory webSocketConnectionFactory) throws IOException {
85 this(httpClient, webthingUrl, executor, errorHandler, webSocketConnectionFactory, DEFAULT_PING_PERIOD);
91 * @param httpClient the http client to use
92 * @param webthingUrl the identifier of a WebThing resource
93 * @param executor executor to use
94 * @param errorHandler the error handler
95 * @param webSocketConnectionFactory the Websocket connectino fctory to be used
96 * @param pingPeriod the ping period tothe the healthiness of the connection
97 * @throws IOException if the WebThing can not be connected
99 ConsumedThingImpl(HttpClient httpClient, URI webthingUrl, ScheduledExecutorService executor,
100 Consumer<String> errorHandler, WebSocketConnectionFactory webSocketConnectionFactory, Duration pingPeriod)
102 this.webThingURI = webthingUrl;
103 this.httpClient = httpClient;
104 this.errorHandler = errorHandler;
105 this.description = new DescriptionLoader(httpClient).loadWebthingDescription(webThingURI,
106 Duration.ofSeconds(20));
108 // opens a websocket downstream to be notified if a property value will be changed
109 var optionalEventStreamUri = this.description.getEventStreamUri();
110 if (optionalEventStreamUri.isPresent()) {
111 this.websocketDownstream = webSocketConnectionFactory.create(optionalEventStreamUri.get(), executor,
112 this::onError, pingPeriod);
114 throw new IOException("WebThing " + webThingURI + " does not support websocket uri. WebThing description: "
119 private Optional<URI> getPropertyUri(String propertyName) {
120 var optionalProperty = description.getProperty(propertyName);
121 if (optionalProperty.isPresent()) {
122 var propertyDescription = optionalProperty.get();
123 for (var link : propertyDescription.links) {
124 if ((link.rel != null) && (link.href != null) && "property".equals(link.rel)) {
125 return Optional.of(webThingURI.resolve(link.href));
129 return Optional.empty();
133 public boolean isAlive() {
134 return isOpen.get() && this.websocketDownstream.isAlive();
138 public void close() {
140 this.websocketDownstream.close();
143 void onError(String reason) {
144 logger.debug("WebThing {} error occurred. {}", webThingURI, reason);
146 errorHandler.accept(reason);
152 public WebThingDescription getThingDescription() {
153 return this.description;
157 public void observeProperty(String propertyName, BiConsumer<String, Object> listener) {
158 this.websocketDownstream.observeProperty(propertyName, listener);
160 // it may take a long time before the observed property value will be changed. For this reason
161 // read and notify the current property value (as starting point)
163 var value = readProperty(propertyName);
164 listener.accept(propertyName, value);
165 } catch (PropertyAccessException pae) {
166 logger.warn("could not read WebThing {} property {}", webThingURI, propertyName, pae);
171 public Object readProperty(String propertyName) throws PropertyAccessException {
172 var optionalPropertyUri = getPropertyUri(propertyName);
173 if (optionalPropertyUri.isPresent()) {
174 var propertyUri = optionalPropertyUri.get();
176 var response = httpClient.newRequest(propertyUri).timeout(30, TimeUnit.SECONDS)
177 .accept("application/json").send();
178 if (response.getStatus() < 200 || response.getStatus() >= 300) {
179 onError("WebThing " + webThingURI + " disconnected");
180 throw new PropertyAccessException("could not read " + propertyName + " (" + propertyUri + ")");
182 var body = response.getContentAsString();
183 var properties = gson.fromJson(body, Map.class);
184 if (properties == null) {
185 onError("WebThing " + webThingURI + " erroneous");
186 throw new PropertyAccessException("could not read " + propertyName + " (" + propertyUri
187 + "). Response does not include any property (" + propertyUri + "): " + body);
189 var value = properties.get(propertyName);
193 onError("WebThing " + webThingURI + " erroneous");
194 throw new PropertyAccessException("could not read " + propertyName + " (" + propertyUri
195 + "). Response does not include " + propertyName + "(" + propertyUri + "): " + body);
198 } catch (ExecutionException | TimeoutException | InterruptedException e) {
199 onError("WebThing resource " + webThingURI + " disconnected");
200 throw new PropertyAccessException("could not read " + propertyName + " (" + propertyUri + ").", e);
203 onError("WebThing " + webThingURI + " does not support " + propertyName);
204 throw new PropertyAccessException("WebThing " + webThingURI + " does not support " + propertyName);
209 public void writeProperty(String propertyName, Object newValue) throws PropertyAccessException {
210 var optionalPropertyUri = getPropertyUri(propertyName);
211 if (optionalPropertyUri.isPresent()) {
212 var propertyUri = optionalPropertyUri.get();
213 var optionalProperty = description.getProperty(propertyName);
214 if (optionalProperty.isPresent()) {
216 if (optionalProperty.get().readOnly) {
217 throw new PropertyAccessException("could not write " + propertyName + " (" + propertyUri
218 + ") with " + newValue + ". Property is readOnly");
220 logger.debug("updating {} with {}", propertyName, newValue);
221 Map<String, Object> payload = Map.of(propertyName, newValue);
222 var json = gson.toJson(payload);
223 var response = httpClient.newRequest(propertyUri).method("PUT")
224 .content(new StringContentProvider(json), "application/json")
225 .timeout(30, TimeUnit.SECONDS).send();
226 if (response.getStatus() < 200 || response.getStatus() >= 300) {
227 onError("WebThing " + webThingURI + "could not write " + propertyName + " (" + propertyUri
228 + ") with " + newValue);
229 throw new PropertyAccessException(
230 "could not write " + propertyName + " (" + propertyUri + ") with " + newValue);
233 } catch (ExecutionException | TimeoutException | InterruptedException e) {
234 onError("WebThing resource " + webThingURI + " disconnected");
235 throw new PropertyAccessException(
236 "could not write " + propertyName + " (" + propertyUri + ") with " + newValue, e);
239 throw new PropertyAccessException("could not write " + propertyName + " (" + propertyUri + ") with "
240 + newValue + " WebTing does not support a property named " + propertyName);
243 onError("WebThing " + webThingURI + " does not support " + propertyName);
244 throw new PropertyAccessException("WebThing " + webThingURI + " does not support " + propertyName);
249 * Gets the property description
251 * @param propertyName the propertyName
252 * @return the description (meta data) of the property
254 public @Nullable Property getPropertyDescription(String propertyName) {
255 return description.properties.get(propertyName);
259 public String toString() {
260 return "WebThing " + description.title + " (" + webThingURI + ")";