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;
15 import java.io.IOException;
17 import java.time.Duration;
18 import java.time.Instant;
20 import java.util.Optional;
21 import java.util.concurrent.ConcurrentHashMap;
22 import java.util.concurrent.ScheduledFuture;
23 import java.util.concurrent.TimeUnit;
24 import java.util.concurrent.atomic.AtomicBoolean;
25 import java.util.concurrent.atomic.AtomicReference;
27 import org.eclipse.jdt.annotation.NonNullByDefault;
28 import org.eclipse.jdt.annotation.Nullable;
29 import org.eclipse.jetty.client.HttpClient;
30 import org.eclipse.jetty.websocket.client.WebSocketClient;
31 import org.openhab.binding.webthing.internal.channel.Channels;
32 import org.openhab.binding.webthing.internal.client.ConsumedThing;
33 import org.openhab.binding.webthing.internal.client.ConsumedThingFactory;
34 import org.openhab.binding.webthing.internal.link.ChannelToPropertyLink;
35 import org.openhab.binding.webthing.internal.link.PropertyToChannelLink;
36 import org.openhab.binding.webthing.internal.link.UnknownPropertyException;
37 import org.openhab.core.thing.ChannelUID;
38 import org.openhab.core.thing.Thing;
39 import org.openhab.core.thing.ThingStatus;
40 import org.openhab.core.thing.ThingStatusDetail;
41 import org.openhab.core.thing.binding.BaseThingHandler;
42 import org.openhab.core.types.Command;
43 import org.openhab.core.types.RefreshType;
44 import org.openhab.core.types.State;
45 import org.slf4j.Logger;
46 import org.slf4j.LoggerFactory;
49 * The {@link WebThingHandler} is responsible for handling commands, which are
50 * sent to one of the channels.
52 * @author Gregor Roth - Initial contribution
55 public class WebThingHandler extends BaseThingHandler implements ChannelHandler {
56 private static final Duration RECONNECT_PERIOD = Duration.ofHours(23);
57 private static final Duration HEALTH_CHECK_PERIOD = Duration.ofSeconds(70);
58 private static final ItemChangedListener EMPTY_ITEM_CHANGED_LISTENER = (channelUID, stateCommand) -> {
61 private final Logger logger = LoggerFactory.getLogger(WebThingHandler.class);
62 private final HttpClient httpClient;
63 private final WebSocketClient webSocketClient;
64 private final AtomicBoolean isActivated = new AtomicBoolean(true);
65 private final Map<ChannelUID, ItemChangedListener> itemChangedListenerMap = new ConcurrentHashMap<>();
66 private final AtomicReference<Optional<ConsumedThing>> webThingConnectionRef = new AtomicReference<>(
68 private final AtomicReference<Instant> lastReconnect = new AtomicReference<>(Instant.now());
69 private final AtomicReference<Optional<ScheduledFuture<?>>> watchdogHandle = new AtomicReference<>(
71 private @Nullable URI webThingURI = null;
73 public WebThingHandler(Thing thing, HttpClient httpClient, WebSocketClient webSocketClient) {
75 this.httpClient = httpClient;
76 this.webSocketClient = webSocketClient;
79 private boolean isOnline() {
80 return getThing().getStatus() == ThingStatus.ONLINE;
83 private boolean isDisconnected() {
84 return (getThing().getStatus() == ThingStatus.OFFLINE) || (getThing().getStatus() == ThingStatus.UNKNOWN);
87 private boolean isAlive() {
88 return webThingConnectionRef.get().map(ConsumedThing::isAlive).orElse(false);
92 public void initialize() {
93 updateStatus(ThingStatus.UNKNOWN);
94 isActivated.set(true); // set with true, even though the connect may fail. In this case retries will be
97 // perform connect in background
98 scheduler.execute(() -> {
99 // WebThing URI present?
100 if (getWebThingURI() != null) {
101 logger.debug("try to connect WebThing {}", webThingURI);
102 var connected = tryReconnect();
104 logger.debug("WebThing {} connected", getWebThingLabel());
107 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
108 "webThing uri has not been set");
109 logger.warn("could not initialize WebThing. URI is not set or invalid. {}", this.webThingURI);
113 // starting watchdog that checks the healthiness of the WebThing connection, periodically
115 .getAndSet(Optional.of(scheduler.scheduleWithFixedDelay(this::checkWebThingConnection,
116 HEALTH_CHECK_PERIOD.getSeconds(), HEALTH_CHECK_PERIOD.getSeconds(), TimeUnit.SECONDS)))
117 .ifPresent(future -> future.cancel(true));
120 private @Nullable URI getWebThingURI() {
121 if (webThingURI == null) {
122 webThingURI = toUri(getConfigAs(WebThingConfiguration.class).webThingURI);
127 private @Nullable URI toUri(@Nullable String uri) {
130 return URI.create(uri);
132 } catch (IllegalArgumentException illegalURIException) {
139 public void dispose() {
141 isActivated.set(false); // set to false to avoid reconnecting
143 // terminate WebThing connection as well as the alive watchdog
144 webThingConnectionRef.getAndSet(Optional.empty()).ifPresent(ConsumedThing::close);
145 watchdogHandle.getAndSet(Optional.empty()).ifPresent(future -> future.cancel(true));
151 private boolean tryReconnect() {
152 if (isActivated.get()) { // will try reconnect only, if activated
154 // create the client-side WebThing representation
155 var uri = getWebThingURI();
157 var webThing = ConsumedThingFactory.instance().create(webSocketClient, httpClient, uri, scheduler,
159 this.webThingConnectionRef.getAndSet(Optional.of(webThing)).ifPresent(ConsumedThing::close);
161 // update the Thing structure based on the WebThing description
162 thingStructureChanged(webThing);
164 // link the Thing's channels with the WebThing properties to forward properties/item updates
165 establishWebThingChannelLinks(webThing);
167 lastReconnect.set(Instant.now());
168 updateStatus(ThingStatus.ONLINE);
171 } catch (IOException e) {
172 var msg = e.getMessage();
182 public void onError(String reason) {
183 var wasConnectedBefore = isOnline();
184 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, reason);
186 // close the WebThing connection. If the handler is still active, the WebThing connection
187 // will be re-established within the periodically watchdog task
188 webThingConnectionRef.getAndSet(Optional.empty()).ifPresent(ConsumedThing::close);
190 if (wasConnectedBefore) { // to reduce log messages, just log in case of connection state changed
191 logger.debug("WebThing {} disconnected {}. Try reconnect (each {} sec)", getWebThingLabel(), reason,
192 HEALTH_CHECK_PERIOD.getSeconds());
194 logger.debug("WebThing {} is offline {}. Try reconnect (each {} sec)", getWebThingLabel(), reason,
195 HEALTH_CHECK_PERIOD.getSeconds());
199 private String getWebThingLabel() {
200 if (getThing().getLabel() == null) {
201 return "" + webThingURI;
203 return "'" + getThing().getLabel() + "' (" + webThingURI + ")";
208 * updates the thing structure. Refer https://www.openhab.org/docs/developer/bindings/#updating-the-thing-structure
210 * @param webThing the WebThing that is used for the new structure
212 private void thingStructureChanged(ConsumedThing webThing) {
213 var thingBuilder = editThing().withLabel(webThing.getThingDescription().title);
215 // create a channel for each WebThing property
216 for (var entry : webThing.getThingDescription().properties.entrySet()) {
217 var channel = Channels.createChannel(thing.getUID(), entry.getKey(), entry.getValue());
218 // add channel (and remove a previous one, if exist)
219 thingBuilder.withoutChannel(channel.getUID()).withChannel(channel);
221 var thing = thingBuilder.build();
223 // and update the thing
228 * connects each WebThing property with a corresponding openHAB channel. After this changes will be synchronized
229 * between a WebThing property and the openHAB channel
231 * @param webThing the WebThing to be connected
232 * @throws IOException if the channels can not be connected
234 private void establishWebThingChannelLinks(ConsumedThing webThing) throws IOException {
235 // remove all registered listeners
236 itemChangedListenerMap.clear();
238 // create new links (listeners will be registered, implicitly)
239 for (var namePropertyPair : webThing.getThingDescription().properties.entrySet()) {
241 // determine the name of the associated channel
242 var channelUID = Channels.createChannelUID(getThing().getUID(), namePropertyPair.getKey());
244 // will try to establish a link, if channel is present
245 var channel = getThing().getChannel(channelUID);
246 if (channel != null) {
247 // establish downstream link
248 PropertyToChannelLink.establish(webThing, namePropertyPair.getKey(), this, channel);
250 // establish upstream link
251 if (!namePropertyPair.getValue().readOnly) {
252 ChannelToPropertyLink.establish(this, channel, webThing, namePropertyPair.getKey());
255 } catch (UnknownPropertyException upe) {
256 logger.warn("WebThing {} property {} could not be linked with a channel", getWebThingLabel(),
257 namePropertyPair.getKey(), upe);
263 public void handleCommand(ChannelUID channelUID, Command command) {
264 if (command instanceof State stateCommand) {
265 itemChangedListenerMap.getOrDefault(channelUID, EMPTY_ITEM_CHANGED_LISTENER).onItemStateChanged(channelUID,
267 } else if (command instanceof RefreshType) {
273 // ChannelHandler methods
275 public void observeChannel(ChannelUID channelUID, ItemChangedListener listener) {
276 itemChangedListenerMap.put(channelUID, listener);
280 public void updateItemState(ChannelUID channelUID, Command command) {
281 if (isActivated.get()) {
282 postCommand(channelUID, command);
288 private void checkWebThingConnection() {
289 // try reconnect, if necessary
290 if (isDisconnected() || (isOnline() && !isAlive())) {
291 logger.debug("try reconnecting WebThing {}", getWebThingLabel());
292 if (tryReconnect()) {
293 logger.debug("WebThing {} reconnected", getWebThingLabel());
297 // force reconnecting periodically, to fix erroneous states that occurs for unknown reasons
298 var elapsedSinceLastReconnect = Duration.between(lastReconnect.get(), Instant.now());
299 if (isOnline() && (elapsedSinceLastReconnect.getSeconds() > RECONNECT_PERIOD.getSeconds())) {
300 if (tryReconnect()) {
301 logger.debug("WebThing {} reconnected. Initiated by periodic reconnect", getWebThingLabel());
303 logger.debug("could not reconnect WebThing {} (periodic reconnect failed). Next trial in {} sec",
304 getWebThingLabel(), HEALTH_CHECK_PERIOD.getSeconds());