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.neohub.internal;
15 import static org.openhab.binding.neohub.internal.NeoHubBindingConstants.*;
17 import java.io.IOException;
18 import java.time.Duration;
19 import java.time.Instant;
20 import java.time.temporal.ChronoUnit;
21 import java.util.HashMap;
22 import java.util.List;
24 import java.util.concurrent.ScheduledFuture;
25 import java.util.concurrent.TimeUnit;
26 import java.util.concurrent.atomic.AtomicInteger;
28 import javax.measure.Unit;
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.openhab.binding.neohub.internal.NeoHubAbstractDeviceData.AbstractRecord;
33 import org.openhab.binding.neohub.internal.NeoHubBindingConstants.NeoHubReturnResult;
34 import org.openhab.core.io.net.http.WebSocketFactory;
35 import org.openhab.core.library.types.QuantityType;
36 import org.openhab.core.library.unit.SIUnits;
37 import org.openhab.core.library.unit.Units;
38 import org.openhab.core.thing.Bridge;
39 import org.openhab.core.thing.ChannelUID;
40 import org.openhab.core.thing.Thing;
41 import org.openhab.core.thing.ThingStatus;
42 import org.openhab.core.thing.ThingStatusDetail;
43 import org.openhab.core.thing.binding.BaseBridgeHandler;
44 import org.openhab.core.thing.binding.ThingHandler;
45 import org.openhab.core.types.Command;
46 import org.openhab.core.types.State;
47 import org.openhab.core.types.UnDefType;
48 import org.slf4j.Logger;
49 import org.slf4j.LoggerFactory;
51 import com.google.gson.JsonSyntaxException;
54 * The {@link NeoHubHandler} is the openHAB Handler for NeoHub devices
56 * @author Andrew Fiddian-Green - Initial contribution (v2.x binding code)
57 * @author Sebastian Prehn - Initial contribution (v1.x hub communication)
61 public class NeoHubHandler extends BaseBridgeHandler {
63 private static final String SEE_README = "See documentation chapter \"Connection Refused Errors\"";
64 private static final int MAX_FAILED_SEND_ATTEMPTS = 2;
65 private static final Duration MIN_RESTART_DELAY = Duration.ofSeconds(10);
66 private static final Duration MAX_RESTART_DELAY = Duration.ofHours(1);
68 private final Logger logger = LoggerFactory.getLogger(NeoHubHandler.class);
70 private final Map<String, Boolean> connectionStates = new HashMap<>();
72 private WebSocketFactory webSocketFactory;
74 private @Nullable NeoHubConfiguration config;
75 private @Nullable NeoHubSocketBase socket;
76 private @Nullable ScheduledFuture<?> lazyPollingScheduler;
77 private @Nullable ScheduledFuture<?> fastPollingScheduler;
79 private final AtomicInteger fastPollingCallsToGo = new AtomicInteger();
81 private @Nullable NeoHubReadDcbResponse systemData = null;
83 private enum ApiVersion {
87 public final String label;
89 private ApiVersion(String label) {
94 private ApiVersion apiVersion = ApiVersion.LEGACY;
95 private boolean isApiOnline = false;
96 private int failedSendAttempts = 0;
97 private Duration restartDelay = Duration.from(MIN_RESTART_DELAY);
98 private @Nullable ScheduledFuture<?> restartTask;
100 public NeoHubHandler(Bridge bridge, WebSocketFactory webSocketFactory) {
102 this.webSocketFactory = webSocketFactory;
106 public void handleCommand(ChannelUID channelUID, Command command) {
107 // future: currently there is nothing to do for a NeoHub
111 public void initialize() {
112 NeoHubConfiguration config = getConfigAs(NeoHubConfiguration.class);
114 if (logger.isDebugEnabled()) {
115 logger.debug("hub '{}' hostname={}", getThing().getUID(), config.hostName);
118 if (!MATCHER_IP_ADDRESS.matcher(config.hostName).matches()) {
119 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "parameter hostName must be set!");
123 if (logger.isDebugEnabled()) {
124 logger.debug("hub '{}' port={}", getThing().getUID(), config.portNumber);
127 if (config.portNumber < 0 || config.portNumber > 0xFFFF) {
128 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "portNumber is invalid!");
132 if (logger.isDebugEnabled()) {
133 logger.debug("hub '{}' polling interval={}", getThing().getUID(), config.pollingInterval);
136 if (config.pollingInterval < FAST_POLL_INTERVAL || config.pollingInterval > LAZY_POLL_INTERVAL) {
137 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String
138 .format("pollingInterval must be in range [%d..%d]!", FAST_POLL_INTERVAL, LAZY_POLL_INTERVAL));
142 if (logger.isDebugEnabled()) {
143 logger.debug("hub '{}' socketTimeout={}", getThing().getUID(), config.socketTimeout);
146 if (config.socketTimeout < 5 || config.socketTimeout > 20) {
147 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
148 String.format("socketTimeout must be in range [%d..%d]!", 5, 20));
152 if (logger.isDebugEnabled()) {
153 logger.debug("hub '{}' preferLegacyApi={}", getThing().getUID(), config.preferLegacyApi);
156 this.config = config;
157 NeoHubSocketBase socket = createSocket();
158 if (socket == null) {
161 this.socket = socket;
164 * Try to 'ping' the hub, and if there is a 'connection refused', it is probably due to the mobile App |
165 * Settings | Legacy API Enable switch not being On, so go offline and log a warning message.
168 socket.sendMessage(CMD_CODE_FIRMWARE);
169 } catch (IOException e) {
170 String error = e.getMessage();
171 if (error != null && error.toLowerCase().startsWith("connection refused")) {
172 logger.warn("CONNECTION REFUSED!! (hub '{}') => {}", getThing().getUID(), SEE_README);
173 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, SEE_README);
176 } catch (NeoHubException e) {
177 // NeoHubException won't actually occur here
180 if (logger.isDebugEnabled()) {
181 logger.debug("hub '{}' start background polling..", getThing().getUID());
184 // create a "lazy" polling scheduler
185 ScheduledFuture<?> lazy = this.lazyPollingScheduler;
186 if (lazy == null || lazy.isCancelled()) {
187 this.lazyPollingScheduler = scheduler.scheduleWithFixedDelay(this::lazyPollingSchedulerExecute,
188 config.pollingInterval, config.pollingInterval, TimeUnit.SECONDS);
191 // create a "fast" polling scheduler
192 fastPollingCallsToGo.set(FAST_POLL_CYCLES);
193 ScheduledFuture<?> fast = this.fastPollingScheduler;
194 if (fast == null || fast.isCancelled()) {
195 this.fastPollingScheduler = scheduler.scheduleWithFixedDelay(this::fastPollingSchedulerExecute,
196 FAST_POLL_INTERVAL, FAST_POLL_INTERVAL, TimeUnit.SECONDS);
199 updateStatus(ThingStatus.UNKNOWN);
201 // start a fast polling burst to ensure the NeHub is initialized quickly
202 startFastPollingBurst();
206 * Create a web or TCP socket based on the configuration setting
208 private @Nullable NeoHubSocketBase createSocket() {
209 NeoHubConfiguration config = this.config;
210 if (config == null) {
211 logger.debug("\"hub '{}' configuration is null", getThing().getUID());
212 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR);
215 if (config.useWebSocket) {
216 return new NeoHubWebSocket(config, webSocketFactory, thing.getUID());
218 return new NeoHubSocket(config, thing.getUID().getAsString());
220 } catch (IOException e) {
221 logger.debug("\"hub '{}' error creating web/tcp socket: '{}'", getThing().getUID(), e.getMessage());
222 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
229 public void dispose() {
230 if (logger.isDebugEnabled()) {
231 logger.debug("hub '{}' shutting down..", getThing().getUID());
235 ScheduledFuture<?> restartTask = this.restartTask;
236 if (restartTask != null) {
237 restartTask.cancel(true);
240 // clean up the lazy polling scheduler
241 ScheduledFuture<?> lazy = this.lazyPollingScheduler;
242 if (lazy != null && !lazy.isCancelled()) {
244 this.lazyPollingScheduler = null;
247 // clean up the fast polling scheduler
248 ScheduledFuture<?> fast = this.fastPollingScheduler;
249 if (fast != null && !fast.isCancelled()) {
251 this.fastPollingScheduler = null;
255 private void closeSocket() {
256 NeoHubSocketBase socket = this.socket;
258 if (socket != null) {
261 } catch (IOException e) {
267 * device handlers call this to initiate a burst of fast polling requests (
268 * improves response time to users when openHAB changes a channel value )
270 public void startFastPollingBurst() {
271 fastPollingCallsToGo.set(FAST_POLL_CYCLES);
275 * device handlers call this method to issue commands to the NeoHub
277 public synchronized NeoHubReturnResult toNeoHubSendChannelValue(String commandStr) {
278 NeoHubSocketBase socket = this.socket;
280 if (socket == null || config == null) {
281 return NeoHubReturnResult.ERR_INITIALIZATION;
285 socket.sendMessage(commandStr);
287 // start a fast polling burst (to confirm the status change)
288 startFastPollingBurst();
290 return NeoHubReturnResult.SUCCEEDED;
291 } catch (IOException | NeoHubException e) {
292 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
293 logger.warn(MSG_FMT_SET_VALUE_ERR, getThing().getUID(), commandStr, e.getMessage());
294 return NeoHubReturnResult.ERR_COMMUNICATION;
299 * sends a JSON request to the NeoHub to read the device data
301 * @return a class that contains the full status of all devices
303 protected @Nullable NeoHubAbstractDeviceData fromNeoHubGetDeviceData() {
304 NeoHubSocketBase socket = this.socket;
306 if (socket == null) {
312 NeoHubAbstractDeviceData deviceData;
314 if (apiVersion == ApiVersion.LEGACY) {
315 responseJson = socket.sendMessage(CMD_CODE_INFO);
316 deviceData = NeoHubInfoResponse.createDeviceData(responseJson);
318 responseJson = socket.sendMessage(CMD_CODE_GET_LIVE_DATA);
319 deviceData = NeoHubLiveDeviceData.createDeviceData(responseJson);
322 if (deviceData == null) {
323 logger.warn(MSG_FMT_DEVICE_POLL_ERR, getThing().getUID(), "failed to create device data response");
324 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
329 List<? extends AbstractRecord> devices = deviceData.getDevices();
330 if (devices == null || devices.isEmpty()) {
331 logger.warn(MSG_FMT_DEVICE_POLL_ERR, getThing().getUID(), "no devices found");
332 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
336 if (getThing().getStatus() != ThingStatus.ONLINE) {
337 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
338 restartDelay = Duration.from(MIN_RESTART_DELAY);
341 // check if we also need to discard and update systemData
342 NeoHubReadDcbResponse systemData = this.systemData;
343 if (systemData != null) {
344 if (deviceData instanceof NeoHubLiveDeviceData liveDeviceData) {
346 * note: time-stamps are measured in seconds from 1970-01-01T00:00:00Z
348 * new API: discard systemData if its time-stamp is older than the system
349 * time-stamp on the hub
351 if (systemData.timeStamp < liveDeviceData.getTimestampSystem()) {
352 this.systemData = null;
356 * note: time-stamps are measured in seconds from 1970-01-01T00:00:00Z
358 * legacy API: discard systemData if its time-stamp is older than one hour
360 if (systemData.timeStamp < Instant.now().minus(1, ChronoUnit.HOURS).getEpochSecond()) {
361 this.systemData = null;
367 } catch (IOException | NeoHubException e) {
368 logger.warn(MSG_FMT_DEVICE_POLL_ERR, getThing().getUID(), e.getMessage());
369 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
375 private synchronized void scheduleRestart() {
377 restartTask = scheduler.schedule(() -> {
378 NeoHubSocketBase socket = createSocket();
379 this.socket = socket;
380 if (!Thread.interrupted() && socket == null) { // keep trying..
381 restartDelay = restartDelay.plus(restartDelay);
382 if (restartDelay.compareTo(MAX_RESTART_DELAY) > 0) {
383 restartDelay = Duration.from(MAX_RESTART_DELAY);
387 }, restartDelay.toSeconds(), TimeUnit.SECONDS);
391 * sends a JSON request to the NeoHub to read the system data
393 * @return a class that contains the status of the system
395 protected @Nullable NeoHubReadDcbResponse fromNeoHubReadSystemData() {
396 NeoHubSocketBase socket = this.socket;
398 if (socket == null) {
404 NeoHubReadDcbResponse systemData;
406 if (apiVersion == ApiVersion.LEGACY) {
407 responseJson = socket.sendMessage(CMD_CODE_READ_DCB);
408 systemData = NeoHubReadDcbResponse.createSystemData(responseJson);
410 responseJson = socket.sendMessage(CMD_CODE_GET_SYSTEM);
411 systemData = NeoHubReadDcbResponse.createSystemData(responseJson);
414 if (systemData == null) {
415 logger.warn(MSG_FMT_SYSTEM_POLL_ERR, getThing().getUID(), "failed to create system data response");
419 String physicalFirmware = systemData.getFirmwareVersion();
420 if (physicalFirmware != null) {
421 String thingFirmware = getThing().getProperties().get(PROPERTY_FIRMWARE_VERSION);
422 if (!physicalFirmware.equals(thingFirmware)) {
423 getThing().setProperty(PROPERTY_FIRMWARE_VERSION, physicalFirmware);
428 } catch (IOException | NeoHubException e) {
429 logger.warn(MSG_FMT_SYSTEM_POLL_ERR, getThing().getUID(), e.getMessage());
435 * this is the callback used by the lazy polling scheduler.. fetches the info
436 * for all devices from the NeoHub, and passes the results the respective device
439 private synchronized void lazyPollingSchedulerExecute() {
440 // check which API is supported
445 NeoHubAbstractDeviceData deviceData = fromNeoHubGetDeviceData();
446 if (deviceData == null) {
447 if (fastPollingCallsToGo.get() == 0) {
448 failedSendAttempts++;
449 if (failedSendAttempts < MAX_FAILED_SEND_ATTEMPTS) {
450 logger.debug("lazyPollingSchedulerExecute() deviceData:null, running again");
451 scheduler.submit(() -> lazyPollingSchedulerExecute());
456 failedSendAttempts = 0;
458 // dispatch deviceData to each of the hub's owned devices ..
459 List<Thing> children = getThing().getThings();
460 for (Thing child : children) {
461 ThingHandler device = child.getHandler();
462 if (device instanceof NeoBaseHandler neoBaseHandler) {
463 neoBaseHandler.toBaseSendPollResponse(deviceData);
467 // evaluate and update the state of our RF mesh QoS channel
468 List<? extends AbstractRecord> devices = deviceData.getDevices();
472 if (devices == null || devices.isEmpty()) {
473 state = UnDefType.UNDEF;
476 int totalDeviceCount = devices.size();
477 int onlineDeviceCount = 0;
479 for (AbstractRecord device : devices) {
480 String deviceName = device.getDeviceName();
481 Boolean online = !device.offline();
484 Boolean onlineBefore = connectionStates.put(deviceName, online);
486 * note: we use logger.info() here to log changes; reason is that the average user does really need
487 * to know if a device (very occasionally) drops out of the normally reliable RF mesh; however we
488 * only log it if 1) the state has changed, and 2) either 2a) the device has already been discovered
489 * by the bridge handler, or 2b) logger debug mode is set
491 if (!online.equals(onlineBefore) && ((onlineBefore != null) || logger.isDebugEnabled())) {
492 logger.info("hub '{}' device \"{}\" has {} the RF mesh network", getThing().getUID(),
493 deviceName, online.booleanValue() ? "joined" : "left");
496 if (online.booleanValue()) {
500 property = String.format("[%d/%d]", onlineDeviceCount, totalDeviceCount);
501 state = new QuantityType<>((100.0 * onlineDeviceCount) / totalDeviceCount, Units.PERCENT);
503 getThing().setProperty(PROPERTY_API_DEVICEINFO, property);
504 updateState(CHAN_MESH_NETWORK_QOS, state);
506 if (fastPollingCallsToGo.get() > 0) {
507 fastPollingCallsToGo.decrementAndGet();
512 * this is the callback used by the fast polling scheduler.. checks if a fast
513 * polling burst is scheduled, and if so calls lazyPollingSchedulerExecute
515 private void fastPollingSchedulerExecute() {
516 if (fastPollingCallsToGo.get() > 0) {
517 lazyPollingSchedulerExecute();
522 * select whether to use the old "deprecated" API or the new API
524 private void selectApi() {
525 boolean supportsLegacyApi = false;
526 boolean supportsFutureApi = false;
528 NeoHubSocketBase socket = this.socket;
529 if (socket != null) {
531 NeoHubReadDcbResponse systemData;
534 responseJson = socket.sendMessage(CMD_CODE_READ_DCB);
535 systemData = NeoHubReadDcbResponse.createSystemData(responseJson);
536 supportsLegacyApi = systemData != null;
537 if (!supportsLegacyApi) {
538 throw new NeoHubException("legacy API not supported");
540 } catch (JsonSyntaxException | NeoHubException | IOException e) {
541 // we learned that this API is not currently supported; no big deal
542 logger.debug("hub '{}' legacy API is not supported!", getThing().getUID());
545 responseJson = socket.sendMessage(CMD_CODE_GET_SYSTEM);
546 systemData = NeoHubReadDcbResponse.createSystemData(responseJson);
547 supportsFutureApi = systemData != null;
548 if (!supportsFutureApi) {
549 throw new NeoHubException(String.format("hub '%s' new API not supported", getThing().getUID()));
551 } catch (JsonSyntaxException | NeoHubException | IOException e) {
552 // we learned that this API is not currently supported; no big deal
553 logger.debug("hub '{}' new API is not supported!", getThing().getUID());
557 if (!supportsLegacyApi && !supportsFutureApi) {
558 logger.warn("hub '{}' currently neither legacy nor new API are supported!", getThing().getUID());
563 NeoHubConfiguration config = this.config;
564 ApiVersion apiVersion = (supportsLegacyApi && config != null && config.preferLegacyApi) ? ApiVersion.LEGACY
566 if (apiVersion != this.apiVersion) {
567 logger.debug("hub '{}' changing API version: '{}' => '{}'", getThing().getUID(), this.apiVersion.label,
569 this.apiVersion = apiVersion;
572 if (!apiVersion.label.equals(getThing().getProperties().get(PROPERTY_API_VERSION))) {
573 getThing().setProperty(PROPERTY_API_VERSION, apiVersion.label);
576 this.isApiOnline = true;
580 * get the Engineers data
582 public @Nullable NeoHubGetEngineersData fromNeoHubGetEngineersData() {
583 NeoHubSocketBase socket = this.socket;
584 if (socket != null) {
587 responseJson = socket.sendMessage(CMD_CODE_GET_ENGINEERS);
588 return NeoHubGetEngineersData.createEngineersData(responseJson);
589 } catch (JsonSyntaxException | IOException | NeoHubException e) {
590 logger.warn(MSG_FMT_ENGINEERS_POLL_ERR, getThing().getUID(), e.getMessage());
596 public boolean isLegacyApiSelected() {
597 return apiVersion == ApiVersion.LEGACY;
600 public Unit<?> getTemperatureUnit() {
601 NeoHubReadDcbResponse systemData = this.systemData;
602 if (systemData == null) {
603 this.systemData = systemData = fromNeoHubReadSystemData();
605 if (systemData != null) {
606 return systemData.getTemperatureUnit();
608 return SIUnits.CELSIUS;