2 * Copyright (c) 2010-2024 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.linktap.internal;
15 import static org.openhab.binding.linktap.internal.LinkTapBindingConstants.*;
16 import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.*;
17 import static org.openhab.binding.linktap.protocol.frames.ValidationError.Cause.BUG;
18 import static org.openhab.binding.linktap.protocol.frames.ValidationError.Cause.USER;
20 import java.io.IOException;
21 import java.net.InetAddress;
22 import java.net.InetSocketAddress;
23 import java.net.Socket;
24 import java.net.UnknownHostException;
25 import java.time.Duration;
26 import java.util.Arrays;
27 import java.util.Collection;
29 import java.util.Objects;
30 import java.util.Optional;
32 import java.util.UUID;
33 import java.util.concurrent.ConcurrentHashMap;
34 import java.util.concurrent.ConcurrentMap;
35 import java.util.concurrent.CopyOnWriteArrayList;
36 import java.util.concurrent.ScheduledFuture;
37 import java.util.concurrent.TimeUnit;
38 import java.util.stream.Collectors;
39 import java.util.stream.Stream;
41 import javax.validation.constraints.NotNull;
43 import org.eclipse.jdt.annotation.NonNullByDefault;
44 import org.eclipse.jdt.annotation.Nullable;
45 import org.openhab.binding.linktap.configuration.LinkTapBridgeConfiguration;
46 import org.openhab.binding.linktap.protocol.frames.GatewayConfigResp;
47 import org.openhab.binding.linktap.protocol.frames.GatewayDeviceResponse;
48 import org.openhab.binding.linktap.protocol.frames.TLGatewayFrame;
49 import org.openhab.binding.linktap.protocol.frames.ValidationError;
50 import org.openhab.binding.linktap.protocol.http.CommandNotSupportedException;
51 import org.openhab.binding.linktap.protocol.http.DeviceIdException;
52 import org.openhab.binding.linktap.protocol.http.GatewayIdException;
53 import org.openhab.binding.linktap.protocol.http.InvalidParameterException;
54 import org.openhab.binding.linktap.protocol.http.LinkTapException;
55 import org.openhab.binding.linktap.protocol.http.NotTapLinkGatewayException;
56 import org.openhab.binding.linktap.protocol.http.TransientCommunicationIssueException;
57 import org.openhab.binding.linktap.protocol.http.WebServerApi;
58 import org.openhab.binding.linktap.protocol.servers.BindingServlet;
59 import org.openhab.binding.linktap.protocol.servers.IHttpClientProvider;
60 import org.openhab.core.cache.ExpiringCache;
61 import org.openhab.core.config.discovery.DiscoveryServiceRegistry;
62 import org.openhab.core.i18n.LocaleProvider;
63 import org.openhab.core.i18n.TranslationProvider;
64 import org.openhab.core.thing.Bridge;
65 import org.openhab.core.thing.ChannelUID;
66 import org.openhab.core.thing.Thing;
67 import org.openhab.core.thing.ThingStatus;
68 import org.openhab.core.thing.ThingStatusDetail;
69 import org.openhab.core.thing.ThingUID;
70 import org.openhab.core.thing.binding.BaseBridgeHandler;
71 import org.openhab.core.thing.binding.ThingHandler;
72 import org.openhab.core.thing.binding.ThingHandlerService;
73 import org.openhab.core.types.Command;
74 import org.osgi.framework.Bundle;
75 import org.osgi.framework.FrameworkUtil;
76 import org.osgi.service.component.annotations.Activate;
77 import org.osgi.service.component.annotations.Reference;
78 import org.slf4j.Logger;
79 import org.slf4j.LoggerFactory;
82 * The {@link LinkTapBridgeHandler} class defines the handler for a LinkTapHandler
84 * @author David Goodyear - Initial contribution
87 public class LinkTapBridgeHandler extends BaseBridgeHandler {
89 public static final LookupWrapper<@Nullable LinkTapBridgeHandler> ADDR_LOOKUP = new LookupWrapper<>();
90 public static final LookupWrapper<@Nullable LinkTapBridgeHandler> GW_ID_LOOKUP = new LookupWrapper<>();
91 public static final LookupWrapper<@Nullable LinkTapHandler> DEV_ID_LOOKUP = new LookupWrapper<>();
92 public static final LookupWrapper<@Nullable String> MDNS_LOOKUP = new LookupWrapper<>();
93 private static final long MIN_TIME_BETWEEN_MDNS_SCANS_MS = 600000;
95 private final DiscoveryServiceRegistry discoverySrvReg;
96 private final TranslationProvider translationProvider;
97 private final LocaleProvider localeProvider;
98 private final Bundle bundle;
99 private final Logger logger = LoggerFactory.getLogger(LinkTapBridgeHandler.class);
100 private final Object schedulerLock = new Object();
101 private final Object reconnectFutureLock = new Object();
102 private final Object getConfigLock = new Object();
104 private volatile String currentGwId = "";
105 private volatile LinkTapBridgeConfiguration config = new LinkTapBridgeConfiguration();
106 private volatile long lastGwCommandRecvTs = 0L;
107 private volatile long lastMdnsScanMillis = -1L;
109 private String bridgeKey = "";
110 private IHttpClientProvider httpClientProvider;
111 private @Nullable ScheduledFuture<?> backgroundGwPollingScheduler;
112 private @Nullable ScheduledFuture<?> connectRepair = null;
114 protected ExpiringCache<String> lastGetConfigCache = new ExpiringCache<>(Duration.ofSeconds(10),
115 LinkTapBridgeHandler::expireCacheContents);
117 private static @Nullable String expireCacheContents() {
122 public LinkTapBridgeHandler(final Bridge bridge, IHttpClientProvider httpClientProvider,
123 @Reference DiscoveryServiceRegistry discoveryService, @Reference TranslationProvider translationProvider,
124 @Reference LocaleProvider localeProvider) {
126 this.httpClientProvider = httpClientProvider;
127 this.discoverySrvReg = discoveryService;
128 this.translationProvider = translationProvider;
129 this.localeProvider = localeProvider;
130 this.bundle = FrameworkUtil.getBundle(getClass());
131 TransactionProcessor.getInstance().setTranslationProviderInfo(translationProvider, localeProvider, bundle);
132 WebServerApi.getInstance().setTranslationProviderInfo(translationProvider, localeProvider, bundle);
133 BindingServlet.getInstance().setTranslationProviderInfo(translationProvider, localeProvider, bundle);
136 public String getLocalizedText(String key, @Nullable Object @Nullable... arguments) {
137 String result = translationProvider.getText(bundle, key, key, localeProvider.getLocale(), arguments);
138 return Objects.nonNull(result) ? result : key;
141 private void startGwPolling() {
142 synchronized (schedulerLock) {
144 backgroundGwPollingScheduler = scheduler.scheduleWithFixedDelay(() -> {
145 if (lastGwCommandRecvTs + 120000 < System.currentTimeMillis()) {
146 getGatewayConfiguration();
148 }, 5000, 120000, TimeUnit.MILLISECONDS);
152 private void cancelGwPolling() {
153 synchronized (schedulerLock) {
154 final ScheduledFuture<?> ref = backgroundGwPollingScheduler;
157 backgroundGwPollingScheduler = null;
162 private void requestMdnsScan() {
163 final long sysMillis = System.currentTimeMillis();
164 if (lastMdnsScanMillis + MIN_TIME_BETWEEN_MDNS_SCANS_MS < sysMillis) {
165 logger.debug("Requesting MDNS Scan");
166 discoverySrvReg.startScan(THING_TYPE_GATEWAY, null);
167 lastMdnsScanMillis = sysMillis;
169 logger.trace("Not requesting MDNS Scan last ran under 10 min's ago");
174 public void initialize() {
175 updateStatus(ThingStatus.UNKNOWN);
176 config = getConfigAs(LinkTapBridgeConfiguration.class);
177 scheduleReconnect(0);
181 public void dispose() {
184 deregisterBridge(this);
185 GW_ID_LOOKUP.deregisterItem(currentGwId, this, () -> {
190 public Collection<Class<? extends ThingHandlerService>> getServices() {
191 return Set.of(LinkTapDeviceDiscoveryService.class);
194 public @Nullable String getGatewayId() {
198 private void deregisterBridge(final LinkTapBridgeHandler ref) {
199 if (!bridgeKey.isEmpty()) {
200 ADDR_LOOKUP.deregisterItem(bridgeKey, ref, () -> {
201 BindingServlet.getInstance().unregisterServlet();
207 private boolean registerBridge(final LinkTapBridgeHandler ref) {
208 final WebServerApi api = WebServerApi.getInstance();
209 api.setHttpClient(httpClientProvider.getHttpClient());
211 final String host = getHostname();
213 if (!bridgeKey.equals(host)) {
214 deregisterBridge(this);
218 if (!ADDR_LOOKUP.registerItem(bridgeKey, this, () -> {
219 BindingServlet.getInstance().registerServlet();
223 } catch (UnknownHostException e) {
224 deregisterBridge(this);
230 public void getGatewayConfiguration() {
232 synchronized (getConfigLock) {
233 resp = lastGetConfigCache.getValue();
234 if (lastGetConfigCache.isExpired() || resp == null || resp.isBlank()) {
235 TLGatewayFrame req = new TLGatewayFrame(CMD_GET_CONFIGURATION);
236 resp = sendApiRequest(req);
237 GatewayDeviceResponse respFrame = LinkTapBindingConstants.GSON.fromJson(resp,
238 GatewayDeviceResponse.class);
239 // The system may not have picked up the ID before in which case - extract it from the error response
240 // and re-run the request to ensure a full configuration data-set is retrieved.
241 // This is normally populated as part of the sendApiRequest sequencing where the gateway id is
244 if (req.gatewayId.isEmpty() && respFrame != null
245 && respFrame.getRes() == GatewayDeviceResponse.ResultStatus.RET_GATEWAY_ID_NOT_MATCHED) {
246 // Use the response GW_ID from the error response - to re-request with the correct ID
247 // This only happens in occasional startup race conditions, but this removes a low change
249 req.gatewayId = respFrame.gatewayId;
250 resp = sendApiRequest(req);
252 lastGetConfigCache.putValue(resp);
257 final GatewayConfigResp gwConfig = LinkTapBindingConstants.GSON.fromJson(resp, GatewayConfigResp.class);
258 if (gwConfig == null) {
261 currentGwId = gwConfig.gatewayId;
263 final String version = gwConfig.version;
264 final String volUnit = gwConfig.volumeUnit;
265 final String[] devIds = gwConfig.endDevices;
266 final String[] devNames = gwConfig.deviceNames;
267 final Integer utcOffset = gwConfig.utfOfs;
268 if (!version.equals(editProperties().get(BRIDGE_PROP_GW_VER))) {
269 final Map<String, String> props = editProperties();
270 props.put(BRIDGE_PROP_GW_VER, version);
271 updateProperties(props);
274 if (!volUnit.equals(editProperties().get(BRIDGE_PROP_VOL_UNIT))) {
275 final Map<String, String> props = editProperties();
276 props.put(BRIDGE_PROP_VOL_UNIT, volUnit);
277 updateProperties(props);
279 if (utcOffset != DEFAULT_INT) { // This is only in later firmwares
280 final String strVal = String.valueOf(utcOffset);
281 if (!strVal.equals(editProperties().get(BRIDGE_PROP_UTC_OFFSET))) {
282 final Map<String, String> props = editProperties();
283 props.put(BRIDGE_PROP_UTC_OFFSET, strVal);
284 updateProperties(props);
288 boolean updatedDeviceInfo = devIds.length != discoveredDevices.size();
290 for (int i = 0; i < devIds.length; ++i) {
291 LinkTapDeviceMetadata deviceInfo = new LinkTapDeviceMetadata(devIds[i], devNames[i]);
292 LinkTapDeviceMetadata replaced = discoveredDevices.put(deviceInfo.deviceId, deviceInfo);
294 && (!replaced.deviceId.equals(devIds[i]) || !replaced.deviceName.equals(devNames[i]))) {
295 updatedDeviceInfo = true;
299 handlers.forEach(x -> x.handleMetadataRetrieved(this));
301 if (updatedDeviceInfo) {
302 this.scheduler.execute(() -> {
303 for (Thing el : getThing().getThings()) {
304 final ThingHandler th = el.getHandler();
305 if (th instanceof IBridgeData bridgeData) {
306 bridgeData.handleBridgeDataUpdated();
313 public String sendApiRequest(final TLGatewayFrame req) {
314 final UUID uid = UUID.randomUUID();
316 final WebServerApi api = WebServerApi.getInstance();
317 String host = "Unresolved";
319 host = getHostname();
320 final boolean confirmGateway = req.command != TLGatewayFrame.CMD_GET_CONFIGURATION;
321 if (confirmGateway && (host.isEmpty() || currentGwId.isEmpty())) {
322 logger.warn("{}", getLocalizedText("warning.host-gw-unknown-for-cmd", host, currentGwId, req.command));
325 if (req.gatewayId.isEmpty()) {
326 req.gatewayId = currentGwId;
328 final String reqData = LinkTapBindingConstants.GSON.toJson(req);
329 logger.debug("{} = APP BRIDGE -> GW -> Request {}", uid, reqData);
330 final String respData = api.sendRequest(host, reqData);
331 logger.debug("{} = APP BRIDGE -> GW -> Response {}", uid, respData);
332 final TLGatewayFrame gwResponseFrame = LinkTapBindingConstants.GSON.fromJson(respData,
333 TLGatewayFrame.class);
334 if (confirmGateway && gwResponseFrame != null && !gwResponseFrame.gatewayId.equals(req.gatewayId)) {
335 logger.warn("{}", getLocalizedText("warning.response-from-wrong-gw-id", uid, req.gatewayId,
336 gwResponseFrame.gatewayId));
339 if (gwResponseFrame != null && req.command != gwResponseFrame.command) {
341 getLocalizedText("warning.incorrect-cmd-resp", uid, req.command, gwResponseFrame.command));
345 } catch (NotTapLinkGatewayException e) {
346 logger.warn("{}", getLocalizedText("warning.not-taplink-gw", uid, host));
347 } catch (UnknownHostException e) {
348 logger.warn("{}", getLocalizedText("warning.comms-issue-auto-retry", uid, e.getMessage()));
350 } catch (TransientCommunicationIssueException e) {
351 logger.warn("{}", getLocalizedText("warning.comms-issue-auto-retry", uid, getLocalizedText(e.getI18Key())));
357 private void connect() {
358 // Check if we can resolve the remote host, if so then it can be mapped back to a bridge handler.
359 // If not further communications would fail - so it's offline.
360 if (!registerBridge(this)) {
363 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
364 getLocalizedText("bridge.error.host-not-found"));
368 final WebServerApi api = WebServerApi.getInstance();
369 api.setHttpClient(httpClientProvider.getHttpClient());
371 final Map<String, String> bridgeProps = api.getBridgeProperities(bridgeKey);
372 if (!bridgeProps.isEmpty()) {
373 final String readGwId = bridgeProps.get(BRIDGE_PROP_GW_ID);
374 if (readGwId != null) {
375 currentGwId = readGwId;
377 final Map<String, String> currentProps = editProperties();
378 currentProps.putAll(bridgeProps);
379 updateProperties(currentProps);
381 if (!api.unlockWebInterface(bridgeKey, config.username, config.password)) {
382 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
383 getLocalizedText("bridge.error.check-credentials"));
388 getGatewayConfiguration();
390 // Update the GW ID -> this bridge lookup
391 GW_ID_LOOKUP.registerItem(currentGwId, this, () -> {
394 if (Thread.currentThread().isInterrupted()) {
399 final String hostname = getHostname(config);
401 String localServerAddr = "";
402 try (Socket socket = new Socket()) {
404 socket.connect(new InetSocketAddress(hostname, 80), 1500);
405 } catch (IOException e) {
406 logger.warn("{}", getLocalizedText("warning.failed-local-address-detection", e.getMessage()));
407 throw new TransientCommunicationIssueException("Local address lookup failure",
408 "exception.local-addr-lookup-failure");
410 localServerAddr = socket.getLocalAddress().getHostAddress();
411 logger.trace("Local address for connectivity is {}", socket.getLocalAddress().getHostAddress());
412 } catch (IOException e) {
413 logger.trace("Failed to connect to remote device due to exception", e);
416 final String servletEp = BindingServlet.getServletAddress(localServerAddr,
417 getLocalizedText("warning.no-http-server-port"));
418 final Optional<String> servletEpOpt = (!servletEp.isEmpty()) ? Optional.of(servletEp) : Optional.empty();
419 api.configureBridge(hostname, Optional.of(config.enableMDNS), Optional.of(config.enableJSONComms),
421 updateStatus(ThingStatus.ONLINE);
422 if (Thread.currentThread().isInterrupted()) {
426 connectRepair = null;
428 final Firmware firmware = new Firmware(getThing().getProperties().get(BRIDGE_PROP_GW_VER));
429 if (!firmware.supportsLocalConfig()) {
430 logger.warn("{}", getLocalizedText("warning.fw-update-local-config", getThing().getLabel(),
431 firmware.getRecommendedMinVer()));
433 } catch (InterruptedException ignored) {
434 } catch (LinkTapException | NotTapLinkGatewayException e) {
435 deregisterBridge(this);
436 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
437 getLocalizedText("bridge.error.target-is-not-gateway"));
438 } catch (TransientCommunicationIssueException e) {
440 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
441 getLocalizedText("bridge.error.cannot-connect"));
442 } catch (UnknownHostException e) {
444 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
445 getLocalizedText("bridge.error.unknown-host"));
449 private void scheduleReconnect() {
450 scheduleReconnect(15);
453 public void attemptReconnectIfNeeded() {
454 if (ThingStatus.OFFLINE.equals(getThing().getStatus())) {
455 synchronized (reconnectFutureLock) {
456 if (connectRepair != null) {
457 scheduleReconnect(0);
463 private void scheduleReconnect(int seconds) {
467 logger.trace("Scheduling connection re-attempt in {} seconds", seconds);
468 synchronized (reconnectFutureLock) {
470 connectRepair = scheduler.schedule(this::connect, seconds, TimeUnit.SECONDS); // Schedule a retry
474 private void cancelReconnect() {
475 synchronized (reconnectFutureLock) {
476 final @Nullable ScheduledFuture<?> ref = connectRepair;
479 connectRepair = null;
485 public void handleCommand(final ChannelUID channelUID, final Command command) {
488 protected @NotNull String getHostname() throws UnknownHostException {
489 return getHostname(config);
492 private @NotNull String getHostname(final LinkTapBridgeConfiguration config) throws UnknownHostException {
494 String hostname = config.host;
495 final String mdnsLookup = MDNS_LOOKUP.getItem(hostname);
496 if (mdnsLookup != null) {
497 hostname = mdnsLookup;
499 return InetAddress.getByName(hostname).getHostAddress();
502 private final Object singleCommLock = new Object();
504 public String sendRequest(final TLGatewayFrame frame) throws DeviceIdException, InvalidParameterException {
505 // Validate the payload is within the expected limits for the device its being sent to
506 if (config.enforceProtocolLimits) {
507 final Collection<ValidationError> errors = frame.getValidationErrors();
508 if (!errors.isEmpty()) {
509 final String bugs = errors.stream().filter(x -> x.getCause() == BUG).map(ValidationError::toString)
510 .collect(Collectors.joining(","));
511 final String userDataIssues = errors.stream().filter(x -> x.getCause() == USER)
512 .map(ValidationError::toString).collect(Collectors.joining(","));
513 if (!bugs.isEmpty()) {
515 getLocalizedText("bug-report.unexpected-payload-failure", getThing().getLabel(), bugs));
517 if (!userDataIssues.isEmpty()) {
518 logger.warn("{}", getLocalizedText("warning.user-data-payload-failure", getThing().getLabel(),
524 final TransactionProcessor tp = TransactionProcessor.getInstance();
525 final String gatewayId = getGatewayId();
526 if (gatewayId == null) {
527 logger.warn("{}", getLocalizedText("warning.error-with-gw-id"));
530 frame.gatewayId = gatewayId;
531 // The gateway is a single device that may have to do RF, limit the comm's to ensure
532 // it can maintain a good QoS. Responses for most commands are very fast on a reasonable network.
534 synchronized (singleCommLock) {
536 return tp.sendRequest(this, frame);
537 } catch (final CommandNotSupportedException cnse) {
539 getLocalizedText("warning.device-no-accept", getThing().getLabel(), cnse.getMessage()));
542 } catch (final GatewayIdException gide) {
543 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, gide.getI18Key());
548 public ThingUID getUID() {
549 return thing.getUID();
553 * Discovery handling of Gateway owned Devices
556 public void registerMetaDataUpdatedHandler(DeviceMetaDataUpdatedHandler dmduh) {
560 public void unregisterMetaDataUpdatedHandler(DeviceMetaDataUpdatedHandler dmduh) {
561 handlers.remove(dmduh);
564 private final CopyOnWriteArrayList<DeviceMetaDataUpdatedHandler> handlers = new CopyOnWriteArrayList<>();
566 private ConcurrentMap<String, LinkTapDeviceMetadata> discoveredDevices = new ConcurrentHashMap<>();
568 public final Stream<LinkTapDeviceMetadata> getDiscoveredDevices() {
569 return discoveredDevices.values().stream();
572 public final Map<String, LinkTapDeviceMetadata> getDeviceLookup() {
573 return discoveredDevices;
576 public void processGatewayCommand(final int commandId, final String frame) {
577 logger.debug("{} processing gateway request with command {}", this.getThing().getLabel(), commandId);
578 // Store this so that the only when necessary can polls be done - aka
579 // no direct from Gateway communications.
580 lastGwCommandRecvTs = System.currentTimeMillis();
583 lastGetConfigCache.invalidateValue();
584 processCommand0(frame);
586 case CMD_RAINFALL_DATA:
587 case CMD_NOTIFICATION_WATERING_SKIPPED:
588 case CMD_DATETIME_SYNC:
589 logger.debug("No implementation for command {} for processing the GW request", commandId);
593 private void processCommand0(final String request) {
594 final GatewayConfigResp decoded = LinkTapBindingConstants.GSON.fromJson(request, GatewayConfigResp.class);
596 // Check the current version property matches and if not update it
597 final String currentVerKnown = editProperties().get(BRIDGE_PROP_GW_VER);
598 if (decoded != null && currentVerKnown != null && !decoded.version.isEmpty()) {
599 if (!currentVerKnown.equals(decoded.version)) {
600 final Map<String, String> currentProps = editProperties();
601 currentProps.put(BRIDGE_PROP_GW_VER, decoded.version);
602 updateProperties(currentProps);
605 final String currentVolUnit = editProperties().get(BRIDGE_PROP_VOL_UNIT);
606 if (decoded != null && currentVolUnit != null && !decoded.volumeUnit.isEmpty()) {
607 if (!currentVolUnit.equals(decoded.volumeUnit)) {
608 final Map<String, String> currentProps = editProperties();
609 currentProps.put(BRIDGE_PROP_VOL_UNIT, decoded.volumeUnit);
610 updateProperties(currentProps);
613 final String[] devices = decoded != null ? decoded.endDevices : EMPTY_STRING_ARRAY;
614 // Go through all the device ID's returned check we know about them.
615 // If not a background scan should be done
616 boolean fullScanRequired = false;
617 if (discoveredDevices.size() != devices.length) {
618 fullScanRequired = true;
620 if (!discoveredDevices.keySet().containsAll(Arrays.stream(devices).toList())) {
621 fullScanRequired = true;
623 if (fullScanRequired) {
624 logger.trace("The configured devices have changed a full scan should be run");
625 scheduler.execute(this::getGatewayConfiguration);
630 public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) {
631 scheduler.execute(this::getGatewayConfiguration);
632 super.childHandlerDisposed(childHandler, childThing);