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.Instant;
19 import java.time.temporal.ChronoUnit;
20 import java.util.HashMap;
21 import java.util.List;
23 import java.util.concurrent.ScheduledFuture;
24 import java.util.concurrent.TimeUnit;
25 import java.util.concurrent.atomic.AtomicInteger;
27 import javax.measure.Unit;
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.openhab.binding.neohub.internal.NeoHubAbstractDeviceData.AbstractRecord;
32 import org.openhab.binding.neohub.internal.NeoHubBindingConstants.NeoHubReturnResult;
33 import org.openhab.core.io.net.http.WebSocketFactory;
34 import org.openhab.core.library.types.QuantityType;
35 import org.openhab.core.library.unit.SIUnits;
36 import org.openhab.core.library.unit.Units;
37 import org.openhab.core.thing.Bridge;
38 import org.openhab.core.thing.ChannelUID;
39 import org.openhab.core.thing.Thing;
40 import org.openhab.core.thing.ThingStatus;
41 import org.openhab.core.thing.ThingStatusDetail;
42 import org.openhab.core.thing.binding.BaseBridgeHandler;
43 import org.openhab.core.thing.binding.ThingHandler;
44 import org.openhab.core.types.Command;
45 import org.openhab.core.types.State;
46 import org.openhab.core.types.UnDefType;
47 import org.slf4j.Logger;
48 import org.slf4j.LoggerFactory;
50 import com.google.gson.JsonSyntaxException;
53 * The {@link NeoHubHandler} is the openHAB Handler for NeoHub devices
55 * @author Andrew Fiddian-Green - Initial contribution (v2.x binding code)
56 * @author Sebastian Prehn - Initial contribution (v1.x hub communication)
60 public class NeoHubHandler extends BaseBridgeHandler {
62 private static final String SEE_README = "See documentation chapter \"Connection Refused Errors\"";
63 private static final int MAX_FAILED_SEND_ATTEMPTS = 2;
65 private final Logger logger = LoggerFactory.getLogger(NeoHubHandler.class);
67 private final Map<String, Boolean> connectionStates = new HashMap<>();
69 private WebSocketFactory webSocketFactory;
71 private @Nullable NeoHubConfiguration config;
72 private @Nullable NeoHubSocketBase socket;
73 private @Nullable ScheduledFuture<?> lazyPollingScheduler;
74 private @Nullable ScheduledFuture<?> fastPollingScheduler;
76 private final AtomicInteger fastPollingCallsToGo = new AtomicInteger();
78 private @Nullable NeoHubReadDcbResponse systemData = null;
80 private enum ApiVersion {
84 public final String label;
86 private ApiVersion(String label) {
91 private ApiVersion apiVersion = ApiVersion.LEGACY;
92 private boolean isApiOnline = false;
93 private int failedSendAttempts = 0;
95 public NeoHubHandler(Bridge bridge, WebSocketFactory webSocketFactory) {
97 this.webSocketFactory = webSocketFactory;
101 public void handleCommand(ChannelUID channelUID, Command command) {
102 // future: currently there is nothing to do for a NeoHub
106 public void initialize() {
107 NeoHubConfiguration config = getConfigAs(NeoHubConfiguration.class);
109 if (logger.isDebugEnabled()) {
110 logger.debug("hub '{}' hostname={}", getThing().getUID(), config.hostName);
113 if (!MATCHER_IP_ADDRESS.matcher(config.hostName).matches()) {
114 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "parameter hostName must be set!");
118 if (logger.isDebugEnabled()) {
119 logger.debug("hub '{}' port={}", getThing().getUID(), config.portNumber);
122 if (config.portNumber < 0 || config.portNumber > 0xFFFF) {
123 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "portNumber is invalid!");
127 if (logger.isDebugEnabled()) {
128 logger.debug("hub '{}' polling interval={}", getThing().getUID(), config.pollingInterval);
131 if (config.pollingInterval < FAST_POLL_INTERVAL || config.pollingInterval > LAZY_POLL_INTERVAL) {
132 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String
133 .format("pollingInterval must be in range [%d..%d]!", FAST_POLL_INTERVAL, LAZY_POLL_INTERVAL));
137 if (logger.isDebugEnabled()) {
138 logger.debug("hub '{}' socketTimeout={}", getThing().getUID(), config.socketTimeout);
141 if (config.socketTimeout < 5 || config.socketTimeout > 20) {
142 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
143 String.format("socketTimeout must be in range [%d..%d]!", 5, 20));
147 if (logger.isDebugEnabled()) {
148 logger.debug("hub '{}' preferLegacyApi={}", getThing().getUID(), config.preferLegacyApi);
151 // create a web or TCP socket based on the port number in the configuration
152 NeoHubSocketBase socket;
154 if (config.useWebSocket) {
155 socket = new NeoHubWebSocket(config, webSocketFactory, thing.getUID());
157 socket = new NeoHubSocket(config, thing.getUID().getAsString());
159 } catch (IOException e) {
160 logger.debug("\"hub '{}' error creating web/tcp socket: '{}'", getThing().getUID(), e.getMessage());
164 this.socket = socket;
165 this.config = config;
168 * Try to 'ping' the hub, and if there is a 'connection refused', it is probably due to the mobile App |
169 * Settings | Legacy API Enable switch not being On, so go offline and log a warning message.
172 socket.sendMessage(CMD_CODE_FIRMWARE);
173 } catch (IOException e) {
174 String error = e.getMessage();
175 if (error != null && error.toLowerCase().startsWith("connection refused")) {
176 logger.warn("CONNECTION REFUSED!! (hub '{}') => {}", getThing().getUID(), SEE_README);
177 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, SEE_README);
180 } catch (NeoHubException e) {
181 // NeoHubException won't actually occur here
184 if (logger.isDebugEnabled()) {
185 logger.debug("hub '{}' start background polling..", getThing().getUID());
188 // create a "lazy" polling scheduler
189 ScheduledFuture<?> lazy = this.lazyPollingScheduler;
190 if (lazy == null || lazy.isCancelled()) {
191 this.lazyPollingScheduler = scheduler.scheduleWithFixedDelay(this::lazyPollingSchedulerExecute,
192 config.pollingInterval, config.pollingInterval, TimeUnit.SECONDS);
195 // create a "fast" polling scheduler
196 fastPollingCallsToGo.set(FAST_POLL_CYCLES);
197 ScheduledFuture<?> fast = this.fastPollingScheduler;
198 if (fast == null || fast.isCancelled()) {
199 this.fastPollingScheduler = scheduler.scheduleWithFixedDelay(this::fastPollingSchedulerExecute,
200 FAST_POLL_INTERVAL, FAST_POLL_INTERVAL, TimeUnit.SECONDS);
203 updateStatus(ThingStatus.UNKNOWN);
205 // start a fast polling burst to ensure the NeHub is initialized quickly
206 startFastPollingBurst();
210 public void dispose() {
211 if (logger.isDebugEnabled()) {
212 logger.debug("hub '{}' stop background polling..", getThing().getUID());
215 // clean up the lazy polling scheduler
216 ScheduledFuture<?> lazy = this.lazyPollingScheduler;
217 if (lazy != null && !lazy.isCancelled()) {
219 this.lazyPollingScheduler = null;
222 // clean up the fast polling scheduler
223 ScheduledFuture<?> fast = this.fastPollingScheduler;
224 if (fast != null && !fast.isCancelled()) {
226 this.fastPollingScheduler = null;
229 NeoHubSocketBase socket = this.socket;
230 if (socket != null) {
233 } catch (IOException e) {
240 * device handlers call this to initiate a burst of fast polling requests (
241 * improves response time to users when openHAB changes a channel value )
243 public void startFastPollingBurst() {
244 fastPollingCallsToGo.set(FAST_POLL_CYCLES);
248 * device handlers call this method to issue commands to the NeoHub
250 public synchronized NeoHubReturnResult toNeoHubSendChannelValue(String commandStr) {
251 NeoHubSocketBase socket = this.socket;
253 if (socket == null || config == null) {
254 return NeoHubReturnResult.ERR_INITIALIZATION;
258 socket.sendMessage(commandStr);
260 // start a fast polling burst (to confirm the status change)
261 startFastPollingBurst();
263 return NeoHubReturnResult.SUCCEEDED;
264 } catch (IOException | NeoHubException e) {
265 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
266 logger.warn(MSG_FMT_SET_VALUE_ERR, getThing().getUID(), commandStr, e.getMessage());
267 return NeoHubReturnResult.ERR_COMMUNICATION;
272 * sends a JSON request to the NeoHub to read the device data
274 * @return a class that contains the full status of all devices
276 protected @Nullable NeoHubAbstractDeviceData fromNeoHubGetDeviceData() {
277 NeoHubSocketBase socket = this.socket;
279 if (socket == null || config == null) {
280 logger.warn(MSG_HUB_CONFIG, getThing().getUID());
286 NeoHubAbstractDeviceData deviceData;
288 if (apiVersion == ApiVersion.LEGACY) {
289 responseJson = socket.sendMessage(CMD_CODE_INFO);
290 deviceData = NeoHubInfoResponse.createDeviceData(responseJson);
292 responseJson = socket.sendMessage(CMD_CODE_GET_LIVE_DATA);
293 deviceData = NeoHubLiveDeviceData.createDeviceData(responseJson);
296 if (deviceData == null) {
297 logger.warn(MSG_FMT_DEVICE_POLL_ERR, getThing().getUID(), "failed to create device data response");
298 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
303 List<? extends AbstractRecord> devices = deviceData.getDevices();
304 if (devices == null || devices.isEmpty()) {
305 logger.warn(MSG_FMT_DEVICE_POLL_ERR, getThing().getUID(), "no devices found");
306 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
310 if (getThing().getStatus() != ThingStatus.ONLINE) {
311 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
314 // check if we also need to discard and update systemData
315 NeoHubReadDcbResponse systemData = this.systemData;
316 if (systemData != null) {
317 if (deviceData instanceof NeoHubLiveDeviceData liveDeviceData) {
319 * note: time-stamps are measured in seconds from 1970-01-01T00:00:00Z
321 * new API: discard systemData if its time-stamp is older than the system
322 * time-stamp on the hub
324 if (systemData.timeStamp < liveDeviceData.getTimestampSystem()) {
325 this.systemData = null;
329 * note: time-stamps are measured in seconds from 1970-01-01T00:00:00Z
331 * legacy API: discard systemData if its time-stamp is older than one hour
333 if (systemData.timeStamp < Instant.now().minus(1, ChronoUnit.HOURS).getEpochSecond()) {
334 this.systemData = null;
340 } catch (IOException | NeoHubException e) {
341 logger.warn(MSG_FMT_DEVICE_POLL_ERR, getThing().getUID(), e.getMessage());
342 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
348 * sends a JSON request to the NeoHub to read the system data
350 * @return a class that contains the status of the system
352 protected @Nullable NeoHubReadDcbResponse fromNeoHubReadSystemData() {
353 NeoHubSocketBase socket = this.socket;
355 if (socket == null) {
361 NeoHubReadDcbResponse systemData;
363 if (apiVersion == ApiVersion.LEGACY) {
364 responseJson = socket.sendMessage(CMD_CODE_READ_DCB);
365 systemData = NeoHubReadDcbResponse.createSystemData(responseJson);
367 responseJson = socket.sendMessage(CMD_CODE_GET_SYSTEM);
368 systemData = NeoHubReadDcbResponse.createSystemData(responseJson);
371 if (systemData == null) {
372 logger.warn(MSG_FMT_SYSTEM_POLL_ERR, getThing().getUID(), "failed to create system data response");
376 String physicalFirmware = systemData.getFirmwareVersion();
377 if (physicalFirmware != null) {
378 String thingFirmware = getThing().getProperties().get(PROPERTY_FIRMWARE_VERSION);
379 if (!physicalFirmware.equals(thingFirmware)) {
380 getThing().setProperty(PROPERTY_FIRMWARE_VERSION, physicalFirmware);
385 } catch (IOException | NeoHubException e) {
386 logger.warn(MSG_FMT_SYSTEM_POLL_ERR, getThing().getUID(), e.getMessage());
392 * this is the callback used by the lazy polling scheduler.. fetches the info
393 * for all devices from the NeoHub, and passes the results the respective device
396 private synchronized void lazyPollingSchedulerExecute() {
397 // check which API is supported
402 NeoHubAbstractDeviceData deviceData = fromNeoHubGetDeviceData();
403 if (deviceData == null) {
404 if (fastPollingCallsToGo.get() == 0) {
405 failedSendAttempts++;
406 if (failedSendAttempts < MAX_FAILED_SEND_ATTEMPTS) {
407 logger.debug("lazyPollingSchedulerExecute() deviceData:null, running again");
408 scheduler.submit(() -> lazyPollingSchedulerExecute());
413 failedSendAttempts = 0;
415 // dispatch deviceData to each of the hub's owned devices ..
416 List<Thing> children = getThing().getThings();
417 for (Thing child : children) {
418 ThingHandler device = child.getHandler();
419 if (device instanceof NeoBaseHandler neoBaseHandler) {
420 neoBaseHandler.toBaseSendPollResponse(deviceData);
424 // evaluate and update the state of our RF mesh QoS channel
425 List<? extends AbstractRecord> devices = deviceData.getDevices();
429 if (devices == null || devices.isEmpty()) {
430 state = UnDefType.UNDEF;
433 int totalDeviceCount = devices.size();
434 int onlineDeviceCount = 0;
436 for (AbstractRecord device : devices) {
437 String deviceName = device.getDeviceName();
438 Boolean online = !device.offline();
441 Boolean onlineBefore = connectionStates.put(deviceName, online);
443 * note: we use logger.info() here to log changes; reason is that the average user does really need
444 * to know if a device (very occasionally) drops out of the normally reliable RF mesh; however we
445 * only log it if 1) the state has changed, and 2) either 2a) the device has already been discovered
446 * by the bridge handler, or 2b) logger debug mode is set
448 if (!online.equals(onlineBefore) && ((onlineBefore != null) || logger.isDebugEnabled())) {
449 logger.info("hub '{}' device \"{}\" has {} the RF mesh network", getThing().getUID(),
450 deviceName, online.booleanValue() ? "joined" : "left");
453 if (online.booleanValue()) {
457 property = String.format("[%d/%d]", onlineDeviceCount, totalDeviceCount);
458 state = new QuantityType<>((100.0 * onlineDeviceCount) / totalDeviceCount, Units.PERCENT);
460 getThing().setProperty(PROPERTY_API_DEVICEINFO, property);
461 updateState(CHAN_MESH_NETWORK_QOS, state);
463 if (fastPollingCallsToGo.get() > 0) {
464 fastPollingCallsToGo.decrementAndGet();
469 * this is the callback used by the fast polling scheduler.. checks if a fast
470 * polling burst is scheduled, and if so calls lazyPollingSchedulerExecute
472 private void fastPollingSchedulerExecute() {
473 if (fastPollingCallsToGo.get() > 0) {
474 lazyPollingSchedulerExecute();
479 * select whether to use the old "deprecated" API or the new API
481 private void selectApi() {
482 boolean supportsLegacyApi = false;
483 boolean supportsFutureApi = false;
485 NeoHubSocketBase socket = this.socket;
486 if (socket != null) {
488 NeoHubReadDcbResponse systemData;
491 responseJson = socket.sendMessage(CMD_CODE_READ_DCB);
492 systemData = NeoHubReadDcbResponse.createSystemData(responseJson);
493 supportsLegacyApi = systemData != null;
494 if (!supportsLegacyApi) {
495 throw new NeoHubException("legacy API not supported");
497 } catch (JsonSyntaxException | NeoHubException | IOException e) {
498 // we learned that this API is not currently supported; no big deal
499 logger.debug("hub '{}' legacy API is not supported!", getThing().getUID());
502 responseJson = socket.sendMessage(CMD_CODE_GET_SYSTEM);
503 systemData = NeoHubReadDcbResponse.createSystemData(responseJson);
504 supportsFutureApi = systemData != null;
505 if (!supportsFutureApi) {
506 throw new NeoHubException(String.format("hub '%s' new API not supported", getThing().getUID()));
508 } catch (JsonSyntaxException | NeoHubException | IOException e) {
509 // we learned that this API is not currently supported; no big deal
510 logger.debug("hub '{}' new API is not supported!", getThing().getUID());
514 if (!supportsLegacyApi && !supportsFutureApi) {
515 logger.warn("hub '{}' currently neither legacy nor new API are supported!", getThing().getUID());
520 NeoHubConfiguration config = this.config;
521 ApiVersion apiVersion = (supportsLegacyApi && config != null && config.preferLegacyApi) ? ApiVersion.LEGACY
523 if (apiVersion != this.apiVersion) {
524 logger.debug("hub '{}' changing API version: '{}' => '{}'", getThing().getUID(), this.apiVersion.label,
526 this.apiVersion = apiVersion;
529 if (!apiVersion.label.equals(getThing().getProperties().get(PROPERTY_API_VERSION))) {
530 getThing().setProperty(PROPERTY_API_VERSION, apiVersion.label);
533 this.isApiOnline = true;
537 * get the Engineers data
539 public @Nullable NeoHubGetEngineersData fromNeoHubGetEngineersData() {
540 NeoHubSocketBase socket = this.socket;
541 if (socket != null) {
544 responseJson = socket.sendMessage(CMD_CODE_GET_ENGINEERS);
545 return NeoHubGetEngineersData.createEngineersData(responseJson);
546 } catch (JsonSyntaxException | IOException | NeoHubException e) {
547 logger.warn(MSG_FMT_ENGINEERS_POLL_ERR, getThing().getUID(), e.getMessage());
553 public boolean isLegacyApiSelected() {
554 return apiVersion == ApiVersion.LEGACY;
557 public Unit<?> getTemperatureUnit() {
558 NeoHubReadDcbResponse systemData = this.systemData;
559 if (systemData == null) {
560 this.systemData = systemData = fromNeoHubReadSystemData();
562 if (systemData != null) {
563 return systemData.getTemperatureUnit();
565 return SIUnits.CELSIUS;