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.library.types.QuantityType;
34 import org.openhab.core.library.unit.SIUnits;
35 import org.openhab.core.library.unit.Units;
36 import org.openhab.core.thing.Bridge;
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.BaseBridgeHandler;
42 import org.openhab.core.thing.binding.ThingHandler;
43 import org.openhab.core.types.Command;
44 import org.openhab.core.types.State;
45 import org.openhab.core.types.UnDefType;
46 import org.slf4j.Logger;
47 import org.slf4j.LoggerFactory;
49 import com.google.gson.JsonSyntaxException;
52 * The {@link NeoHubHandler} is the openHAB Handler for NeoHub devices
54 * @author Andrew Fiddian-Green - Initial contribution (v2.x binding code)
55 * @author Sebastian Prehn - Initial contribution (v1.x hub communication)
59 public class NeoHubHandler extends BaseBridgeHandler {
61 private static final String SEE_README = "See documentation chapter \"Connection Refused Errors\"";
62 private static final int MAX_FAILED_SEND_ATTEMPTS = 2;
64 private final Logger logger = LoggerFactory.getLogger(NeoHubHandler.class);
66 private final Map<String, Boolean> connectionStates = new HashMap<>();
68 private @Nullable NeoHubConfiguration config;
69 private @Nullable NeoHubSocketBase socket;
70 private @Nullable ScheduledFuture<?> lazyPollingScheduler;
71 private @Nullable ScheduledFuture<?> fastPollingScheduler;
73 private final AtomicInteger fastPollingCallsToGo = new AtomicInteger();
75 private @Nullable NeoHubReadDcbResponse systemData = null;
77 private enum ApiVersion {
81 public final String label;
83 private ApiVersion(String label) {
88 private ApiVersion apiVersion = ApiVersion.LEGACY;
89 private boolean isApiOnline = false;
90 private int failedSendAttempts = 0;
92 public NeoHubHandler(Bridge bridge) {
97 public void handleCommand(ChannelUID channelUID, Command command) {
98 // future: currently there is nothing to do for a NeoHub
102 public void initialize() {
103 NeoHubConfiguration config = getConfigAs(NeoHubConfiguration.class);
105 if (logger.isDebugEnabled()) {
106 logger.debug("hub '{}' hostname={}", getThing().getUID(), config.hostName);
109 if (!MATCHER_IP_ADDRESS.matcher(config.hostName).matches()) {
110 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "parameter hostName must be set!");
114 if (logger.isDebugEnabled()) {
115 logger.debug("hub '{}' port={}", getThing().getUID(), config.portNumber);
118 if (config.portNumber < 0 || config.portNumber > 0xFFFF) {
119 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "portNumber is invalid!");
123 if (logger.isDebugEnabled()) {
124 logger.debug("hub '{}' polling interval={}", getThing().getUID(), config.pollingInterval);
127 if (config.pollingInterval < FAST_POLL_INTERVAL || config.pollingInterval > LAZY_POLL_INTERVAL) {
128 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String
129 .format("pollingInterval must be in range [%d..%d]!", FAST_POLL_INTERVAL, LAZY_POLL_INTERVAL));
133 if (logger.isDebugEnabled()) {
134 logger.debug("hub '{}' socketTimeout={}", getThing().getUID(), config.socketTimeout);
137 if (config.socketTimeout < 5 || config.socketTimeout > 20) {
138 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
139 String.format("socketTimeout must be in range [%d..%d]!", 5, 20));
143 if (logger.isDebugEnabled()) {
144 logger.debug("hub '{}' preferLegacyApi={}", getThing().getUID(), config.preferLegacyApi);
147 // create a web or TCP socket based on the port number in the configuration
148 NeoHubSocketBase socket;
150 if (config.useWebSocket) {
151 socket = new NeoHubWebSocket(config, thing.getUID().getAsString());
153 socket = new NeoHubSocket(config, thing.getUID().getAsString());
155 } catch (IOException e) {
156 logger.debug("\"hub '{}' error creating web/tcp socket: '{}'", getThing().getUID(), e.getMessage());
160 this.socket = socket;
161 this.config = config;
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 public void dispose() {
207 if (logger.isDebugEnabled()) {
208 logger.debug("hub '{}' stop background polling..", getThing().getUID());
211 // clean up the lazy polling scheduler
212 ScheduledFuture<?> lazy = this.lazyPollingScheduler;
213 if (lazy != null && !lazy.isCancelled()) {
215 this.lazyPollingScheduler = null;
218 // clean up the fast polling scheduler
219 ScheduledFuture<?> fast = this.fastPollingScheduler;
220 if (fast != null && !fast.isCancelled()) {
222 this.fastPollingScheduler = null;
225 NeoHubSocketBase socket = this.socket;
226 if (socket != null) {
229 } catch (IOException e) {
236 * device handlers call this to initiate a burst of fast polling requests (
237 * improves response time to users when openHAB changes a channel value )
239 public void startFastPollingBurst() {
240 fastPollingCallsToGo.set(FAST_POLL_CYCLES);
244 * device handlers call this method to issue commands to the NeoHub
246 public synchronized NeoHubReturnResult toNeoHubSendChannelValue(String commandStr) {
247 NeoHubSocketBase socket = this.socket;
249 if (socket == null || config == null) {
250 return NeoHubReturnResult.ERR_INITIALIZATION;
254 socket.sendMessage(commandStr);
256 // start a fast polling burst (to confirm the status change)
257 startFastPollingBurst();
259 return NeoHubReturnResult.SUCCEEDED;
260 } catch (IOException | NeoHubException e) {
261 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
262 logger.warn(MSG_FMT_SET_VALUE_ERR, getThing().getUID(), commandStr, e.getMessage());
263 return NeoHubReturnResult.ERR_COMMUNICATION;
268 * sends a JSON request to the NeoHub to read the device data
270 * @return a class that contains the full status of all devices
272 protected @Nullable NeoHubAbstractDeviceData fromNeoHubGetDeviceData() {
273 NeoHubSocketBase socket = this.socket;
275 if (socket == null || config == null) {
276 logger.warn(MSG_HUB_CONFIG, getThing().getUID());
282 NeoHubAbstractDeviceData deviceData;
284 if (apiVersion == ApiVersion.LEGACY) {
285 responseJson = socket.sendMessage(CMD_CODE_INFO);
286 deviceData = NeoHubInfoResponse.createDeviceData(responseJson);
288 responseJson = socket.sendMessage(CMD_CODE_GET_LIVE_DATA);
289 deviceData = NeoHubLiveDeviceData.createDeviceData(responseJson);
292 if (deviceData == null) {
293 logger.warn(MSG_FMT_DEVICE_POLL_ERR, getThing().getUID(), "failed to create device data response");
294 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
299 List<? extends AbstractRecord> devices = deviceData.getDevices();
300 if (devices == null || devices.isEmpty()) {
301 logger.warn(MSG_FMT_DEVICE_POLL_ERR, getThing().getUID(), "no devices found");
302 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
306 if (getThing().getStatus() != ThingStatus.ONLINE) {
307 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
310 // check if we also need to discard and update systemData
311 NeoHubReadDcbResponse systemData = this.systemData;
312 if (systemData != null) {
313 if (deviceData instanceof NeoHubLiveDeviceData) {
315 * note: time-stamps are measured in seconds from 1970-01-01T00:00:00Z
317 * new API: discard systemData if its time-stamp is older than the system
318 * time-stamp on the hub
320 if (systemData.timeStamp < ((NeoHubLiveDeviceData) deviceData).getTimestampSystem()) {
321 this.systemData = null;
325 * note: time-stamps are measured in seconds from 1970-01-01T00:00:00Z
327 * legacy API: discard systemData if its time-stamp is older than one hour
329 if (systemData.timeStamp < Instant.now().minus(1, ChronoUnit.HOURS).getEpochSecond()) {
330 this.systemData = null;
336 } catch (IOException | NeoHubException e) {
337 logger.warn(MSG_FMT_DEVICE_POLL_ERR, getThing().getUID(), e.getMessage());
338 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
344 * sends a JSON request to the NeoHub to read the system data
346 * @return a class that contains the status of the system
348 protected @Nullable NeoHubReadDcbResponse fromNeoHubReadSystemData() {
349 NeoHubSocketBase socket = this.socket;
351 if (socket == null) {
357 NeoHubReadDcbResponse systemData;
359 if (apiVersion == ApiVersion.LEGACY) {
360 responseJson = socket.sendMessage(CMD_CODE_READ_DCB);
361 systemData = NeoHubReadDcbResponse.createSystemData(responseJson);
363 responseJson = socket.sendMessage(CMD_CODE_GET_SYSTEM);
364 systemData = NeoHubReadDcbResponse.createSystemData(responseJson);
367 if (systemData == null) {
368 logger.warn(MSG_FMT_SYSTEM_POLL_ERR, getThing().getUID(), "failed to create system data response");
372 String physicalFirmware = systemData.getFirmwareVersion();
373 if (physicalFirmware != null) {
374 String thingFirmware = getThing().getProperties().get(PROPERTY_FIRMWARE_VERSION);
375 if (!physicalFirmware.equals(thingFirmware)) {
376 getThing().setProperty(PROPERTY_FIRMWARE_VERSION, physicalFirmware);
381 } catch (IOException | NeoHubException e) {
382 logger.warn(MSG_FMT_SYSTEM_POLL_ERR, getThing().getUID(), e.getMessage());
388 * this is the callback used by the lazy polling scheduler.. fetches the info
389 * for all devices from the NeoHub, and passes the results the respective device
392 private synchronized void lazyPollingSchedulerExecute() {
393 // check which API is supported
398 NeoHubAbstractDeviceData deviceData = fromNeoHubGetDeviceData();
399 if (deviceData == null) {
400 if (fastPollingCallsToGo.get() == 0) {
401 failedSendAttempts++;
402 if (failedSendAttempts < MAX_FAILED_SEND_ATTEMPTS) {
403 logger.debug("lazyPollingSchedulerExecute() deviceData:null, running again");
404 scheduler.submit(() -> lazyPollingSchedulerExecute());
409 failedSendAttempts = 0;
411 // dispatch deviceData to each of the hub's owned devices ..
412 List<Thing> children = getThing().getThings();
413 for (Thing child : children) {
414 ThingHandler device = child.getHandler();
415 if (device instanceof NeoBaseHandler) {
416 ((NeoBaseHandler) device).toBaseSendPollResponse(deviceData);
420 // evaluate and update the state of our RF mesh QoS channel
421 List<? extends AbstractRecord> devices = deviceData.getDevices();
425 if (devices == null || devices.isEmpty()) {
426 state = UnDefType.UNDEF;
429 int totalDeviceCount = devices.size();
430 int onlineDeviceCount = 0;
432 for (AbstractRecord device : devices) {
433 String deviceName = device.getDeviceName();
434 Boolean online = !device.offline();
437 Boolean onlineBefore = connectionStates.put(deviceName, online);
439 * note: we use logger.info() here to log changes; reason is that the average user does really need
440 * to know if a device (very occasionally) drops out of the normally reliable RF mesh; however we
441 * only log it if 1) the state has changed, and 2) either 2a) the device has already been discovered
442 * by the bridge handler, or 2b) logger debug mode is set
444 if (!online.equals(onlineBefore) && ((onlineBefore != null) || logger.isDebugEnabled())) {
445 logger.info("hub '{}' device \"{}\" has {} the RF mesh network", getThing().getUID(),
446 deviceName, online.booleanValue() ? "joined" : "left");
449 if (online.booleanValue()) {
453 property = String.format("[%d/%d]", onlineDeviceCount, totalDeviceCount);
454 state = new QuantityType<>((100.0 * onlineDeviceCount) / totalDeviceCount, Units.PERCENT);
456 getThing().setProperty(PROPERTY_API_DEVICEINFO, property);
457 updateState(CHAN_MESH_NETWORK_QOS, state);
459 if (fastPollingCallsToGo.get() > 0) {
460 fastPollingCallsToGo.decrementAndGet();
465 * this is the callback used by the fast polling scheduler.. checks if a fast
466 * polling burst is scheduled, and if so calls lazyPollingSchedulerExecute
468 private void fastPollingSchedulerExecute() {
469 if (fastPollingCallsToGo.get() > 0) {
470 lazyPollingSchedulerExecute();
475 * select whether to use the old "deprecated" API or the new API
477 private void selectApi() {
478 boolean supportsLegacyApi = false;
479 boolean supportsFutureApi = false;
481 NeoHubSocketBase socket = this.socket;
482 if (socket != null) {
484 NeoHubReadDcbResponse systemData;
487 responseJson = socket.sendMessage(CMD_CODE_READ_DCB);
488 systemData = NeoHubReadDcbResponse.createSystemData(responseJson);
489 supportsLegacyApi = systemData != null;
490 if (!supportsLegacyApi) {
491 throw new NeoHubException("legacy API not supported");
493 } catch (JsonSyntaxException | NeoHubException | IOException e) {
494 // we learned that this API is not currently supported; no big deal
495 logger.debug("hub '{}' legacy API is not supported!", getThing().getUID());
498 responseJson = socket.sendMessage(CMD_CODE_GET_SYSTEM);
499 systemData = NeoHubReadDcbResponse.createSystemData(responseJson);
500 supportsFutureApi = systemData != null;
501 if (!supportsFutureApi) {
502 throw new NeoHubException(String.format("hub '%s' new API not supported", getThing().getUID()));
504 } catch (JsonSyntaxException | NeoHubException | IOException e) {
505 // we learned that this API is not currently supported; no big deal
506 logger.debug("hub '{}' new API is not supported!", getThing().getUID());
510 if (!supportsLegacyApi && !supportsFutureApi) {
511 logger.warn("hub '{}' currently neither legacy nor new API are supported!", getThing().getUID());
516 NeoHubConfiguration config = this.config;
517 ApiVersion apiVersion = (supportsLegacyApi && config != null && config.preferLegacyApi) ? ApiVersion.LEGACY
519 if (apiVersion != this.apiVersion) {
520 logger.debug("hub '{}' changing API version: '{}' => '{}'", getThing().getUID(), this.apiVersion.label,
522 this.apiVersion = apiVersion;
525 if (!apiVersion.label.equals(getThing().getProperties().get(PROPERTY_API_VERSION))) {
526 getThing().setProperty(PROPERTY_API_VERSION, apiVersion.label);
529 this.isApiOnline = true;
533 * get the Engineers data
535 public @Nullable NeoHubGetEngineersData fromNeoHubGetEngineersData() {
536 NeoHubSocketBase socket = this.socket;
537 if (socket != null) {
540 responseJson = socket.sendMessage(CMD_CODE_GET_ENGINEERS);
541 return NeoHubGetEngineersData.createEngineersData(responseJson);
542 } catch (JsonSyntaxException | IOException | NeoHubException e) {
543 logger.warn(MSG_FMT_ENGINEERS_POLL_ERR, getThing().getUID(), e.getMessage());
549 public boolean isLegacyApiSelected() {
550 return apiVersion == ApiVersion.LEGACY;
553 public Unit<?> getTemperatureUnit() {
554 NeoHubReadDcbResponse systemData = this.systemData;
555 if (systemData == null) {
556 this.systemData = systemData = fromNeoHubReadSystemData();
558 if (systemData != null) {
559 return systemData.getTemperatureUnit();
561 return SIUnits.CELSIUS;