]> git.basschouten.com Git - openhab-addons.git/blob
3659ffc72f36e34b9018e610ca4824969126059f
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.neohub.internal;
14
15 import static org.openhab.binding.neohub.internal.NeoHubBindingConstants.*;
16
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;
23 import java.util.Map;
24 import java.util.concurrent.ScheduledFuture;
25 import java.util.concurrent.TimeUnit;
26 import java.util.concurrent.atomic.AtomicInteger;
27
28 import javax.measure.Unit;
29
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;
50
51 import com.google.gson.JsonSyntaxException;
52
53 /**
54  * The {@link NeoHubHandler} is the openHAB Handler for NeoHub devices
55  *
56  * @author Andrew Fiddian-Green - Initial contribution (v2.x binding code)
57  * @author Sebastian Prehn - Initial contribution (v1.x hub communication)
58  *
59  */
60 @NonNullByDefault
61 public class NeoHubHandler extends BaseBridgeHandler {
62
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);
67
68     private final Logger logger = LoggerFactory.getLogger(NeoHubHandler.class);
69
70     private final Map<String, Boolean> connectionStates = new HashMap<>();
71
72     private WebSocketFactory webSocketFactory;
73
74     private @Nullable NeoHubConfiguration config;
75     private @Nullable NeoHubSocketBase socket;
76     private @Nullable ScheduledFuture<?> lazyPollingScheduler;
77     private @Nullable ScheduledFuture<?> fastPollingScheduler;
78
79     private final AtomicInteger fastPollingCallsToGo = new AtomicInteger();
80
81     private @Nullable NeoHubReadDcbResponse systemData = null;
82
83     private enum ApiVersion {
84         LEGACY("legacy"),
85         NEW("new");
86
87         public final String label;
88
89         private ApiVersion(String label) {
90             this.label = label;
91         }
92     }
93
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;
99
100     public NeoHubHandler(Bridge bridge, WebSocketFactory webSocketFactory) {
101         super(bridge);
102         this.webSocketFactory = webSocketFactory;
103     }
104
105     @Override
106     public void handleCommand(ChannelUID channelUID, Command command) {
107         // future: currently there is nothing to do for a NeoHub
108     }
109
110     @Override
111     public void initialize() {
112         NeoHubConfiguration config = getConfigAs(NeoHubConfiguration.class);
113
114         if (logger.isDebugEnabled()) {
115             logger.debug("hub '{}' hostname={}", getThing().getUID(), config.hostName);
116         }
117
118         if (!MATCHER_IP_ADDRESS.matcher(config.hostName).matches()) {
119             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "parameter hostName must be set!");
120             return;
121         }
122
123         if (logger.isDebugEnabled()) {
124             logger.debug("hub '{}' port={}", getThing().getUID(), config.portNumber);
125         }
126
127         if (config.portNumber < 0 || config.portNumber > 0xFFFF) {
128             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "portNumber is invalid!");
129             return;
130         }
131
132         if (logger.isDebugEnabled()) {
133             logger.debug("hub '{}' polling interval={}", getThing().getUID(), config.pollingInterval);
134         }
135
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));
139             return;
140         }
141
142         if (logger.isDebugEnabled()) {
143             logger.debug("hub '{}' socketTimeout={}", getThing().getUID(), config.socketTimeout);
144         }
145
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));
149             return;
150         }
151
152         if (logger.isDebugEnabled()) {
153             logger.debug("hub '{}' preferLegacyApi={}", getThing().getUID(), config.preferLegacyApi);
154         }
155
156         this.config = config;
157         NeoHubSocketBase socket = createSocket();
158         if (socket == null) {
159             return;
160         }
161         this.socket = socket;
162
163         /*
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.
166          */
167         try {
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);
174                 return;
175             }
176         } catch (NeoHubException e) {
177             // NeoHubException won't actually occur here
178         }
179
180         if (logger.isDebugEnabled()) {
181             logger.debug("hub '{}' start background polling..", getThing().getUID());
182         }
183
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);
189         }
190
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);
197         }
198
199         updateStatus(ThingStatus.UNKNOWN);
200
201         // start a fast polling burst to ensure the NeHub is initialized quickly
202         startFastPollingBurst();
203     }
204
205     /**
206      * Create a web or TCP socket based on the configuration setting
207      */
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);
213         } else {
214             try {
215                 if (config.useWebSocket) {
216                     return new NeoHubWebSocket(config, webSocketFactory, thing.getUID());
217                 } else {
218                     return new NeoHubSocket(config, thing.getUID().getAsString());
219                 }
220             } catch (IOException e) {
221                 logger.debug("\"hub '{}' error creating web/tcp socket: '{}'", getThing().getUID(), e.getMessage());
222                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
223             }
224         }
225         return null;
226     }
227
228     @Override
229     public void dispose() {
230         if (logger.isDebugEnabled()) {
231             logger.debug("hub '{}' shutting down..", getThing().getUID());
232         }
233
234         closeSocket();
235         ScheduledFuture<?> restartTask = this.restartTask;
236         if (restartTask != null) {
237             restartTask.cancel(true);
238         }
239
240         // clean up the lazy polling scheduler
241         ScheduledFuture<?> lazy = this.lazyPollingScheduler;
242         if (lazy != null && !lazy.isCancelled()) {
243             lazy.cancel(true);
244             this.lazyPollingScheduler = null;
245         }
246
247         // clean up the fast polling scheduler
248         ScheduledFuture<?> fast = this.fastPollingScheduler;
249         if (fast != null && !fast.isCancelled()) {
250             fast.cancel(true);
251             this.fastPollingScheduler = null;
252         }
253     }
254
255     private void closeSocket() {
256         NeoHubSocketBase socket = this.socket;
257         this.socket = null;
258         if (socket != null) {
259             try {
260                 socket.close();
261             } catch (IOException e) {
262             }
263         }
264     }
265
266     /**
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 )
269      */
270     public void startFastPollingBurst() {
271         fastPollingCallsToGo.set(FAST_POLL_CYCLES);
272     }
273
274     /*
275      * device handlers call this method to issue commands to the NeoHub
276      */
277     public synchronized NeoHubReturnResult toNeoHubSendChannelValue(String commandStr) {
278         NeoHubSocketBase socket = this.socket;
279
280         if (socket == null || config == null) {
281             return NeoHubReturnResult.ERR_INITIALIZATION;
282         }
283
284         try {
285             socket.sendMessage(commandStr);
286
287             // start a fast polling burst (to confirm the status change)
288             startFastPollingBurst();
289
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;
295         }
296     }
297
298     /**
299      * sends a JSON request to the NeoHub to read the device data
300      *
301      * @return a class that contains the full status of all devices
302      */
303     protected @Nullable NeoHubAbstractDeviceData fromNeoHubGetDeviceData() {
304         NeoHubSocketBase socket = this.socket;
305
306         if (socket == null) {
307             return null;
308         }
309
310         try {
311             String responseJson;
312             NeoHubAbstractDeviceData deviceData;
313
314             if (apiVersion == ApiVersion.LEGACY) {
315                 responseJson = socket.sendMessage(CMD_CODE_INFO);
316                 deviceData = NeoHubInfoResponse.createDeviceData(responseJson);
317             } else {
318                 responseJson = socket.sendMessage(CMD_CODE_GET_LIVE_DATA);
319                 deviceData = NeoHubLiveDeviceData.createDeviceData(responseJson);
320             }
321
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);
325                 return null;
326             }
327
328             @Nullable
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);
333                 return null;
334             }
335
336             if (getThing().getStatus() != ThingStatus.ONLINE) {
337                 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
338                 restartDelay = Duration.from(MIN_RESTART_DELAY);
339             }
340
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) {
345                     /*
346                      * note: time-stamps are measured in seconds from 1970-01-01T00:00:00Z
347                      *
348                      * new API: discard systemData if its time-stamp is older than the system
349                      * time-stamp on the hub
350                      */
351                     if (systemData.timeStamp < liveDeviceData.getTimestampSystem()) {
352                         this.systemData = null;
353                     }
354                 } else {
355                     /*
356                      * note: time-stamps are measured in seconds from 1970-01-01T00:00:00Z
357                      *
358                      * legacy API: discard systemData if its time-stamp is older than one hour
359                      */
360                     if (systemData.timeStamp < Instant.now().minus(1, ChronoUnit.HOURS).getEpochSecond()) {
361                         this.systemData = null;
362                     }
363                 }
364             }
365
366             return deviceData;
367         } catch (IOException | NeoHubException e) {
368             logger.warn(MSG_FMT_DEVICE_POLL_ERR, getThing().getUID(), e.getMessage());
369             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
370             scheduleRestart();
371         }
372         return null;
373     }
374
375     private synchronized void scheduleRestart() {
376         closeSocket();
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);
384                 }
385                 scheduleRestart();
386             }
387         }, restartDelay.toSeconds(), TimeUnit.SECONDS);
388     }
389
390     /**
391      * sends a JSON request to the NeoHub to read the system data
392      *
393      * @return a class that contains the status of the system
394      */
395     protected @Nullable NeoHubReadDcbResponse fromNeoHubReadSystemData() {
396         NeoHubSocketBase socket = this.socket;
397
398         if (socket == null) {
399             return null;
400         }
401
402         try {
403             String responseJson;
404             NeoHubReadDcbResponse systemData;
405
406             if (apiVersion == ApiVersion.LEGACY) {
407                 responseJson = socket.sendMessage(CMD_CODE_READ_DCB);
408                 systemData = NeoHubReadDcbResponse.createSystemData(responseJson);
409             } else {
410                 responseJson = socket.sendMessage(CMD_CODE_GET_SYSTEM);
411                 systemData = NeoHubReadDcbResponse.createSystemData(responseJson);
412             }
413
414             if (systemData == null) {
415                 logger.warn(MSG_FMT_SYSTEM_POLL_ERR, getThing().getUID(), "failed to create system data response");
416                 return null;
417             }
418
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);
424                 }
425             }
426
427             return systemData;
428         } catch (IOException | NeoHubException e) {
429             logger.warn(MSG_FMT_SYSTEM_POLL_ERR, getThing().getUID(), e.getMessage());
430             return null;
431         }
432     }
433
434     /**
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
437      * handlers
438      */
439     private synchronized void lazyPollingSchedulerExecute() {
440         // check which API is supported
441         if (!isApiOnline) {
442             selectApi();
443         }
444
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());
452                 }
453             }
454             return;
455         } else {
456             failedSendAttempts = 0;
457
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);
464                 }
465             }
466
467             // evaluate and update the state of our RF mesh QoS channel
468             List<? extends AbstractRecord> devices = deviceData.getDevices();
469             State state;
470             String property;
471
472             if (devices == null || devices.isEmpty()) {
473                 state = UnDefType.UNDEF;
474                 property = "[?/?]";
475             } else {
476                 int totalDeviceCount = devices.size();
477                 int onlineDeviceCount = 0;
478
479                 for (AbstractRecord device : devices) {
480                     String deviceName = device.getDeviceName();
481                     Boolean online = !device.offline();
482
483                     @Nullable
484                     Boolean onlineBefore = connectionStates.put(deviceName, online);
485                     /*
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
490                      */
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");
494                     }
495
496                     if (online.booleanValue()) {
497                         onlineDeviceCount++;
498                     }
499                 }
500                 property = String.format("[%d/%d]", onlineDeviceCount, totalDeviceCount);
501                 state = new QuantityType<>((100.0 * onlineDeviceCount) / totalDeviceCount, Units.PERCENT);
502             }
503             getThing().setProperty(PROPERTY_API_DEVICEINFO, property);
504             updateState(CHAN_MESH_NETWORK_QOS, state);
505         }
506         if (fastPollingCallsToGo.get() > 0) {
507             fastPollingCallsToGo.decrementAndGet();
508         }
509     }
510
511     /**
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
514      */
515     private void fastPollingSchedulerExecute() {
516         if (fastPollingCallsToGo.get() > 0) {
517             lazyPollingSchedulerExecute();
518         }
519     }
520
521     /**
522      * select whether to use the old "deprecated" API or the new API
523      */
524     private void selectApi() {
525         boolean supportsLegacyApi = false;
526         boolean supportsFutureApi = false;
527
528         NeoHubSocketBase socket = this.socket;
529         if (socket != null) {
530             String responseJson;
531             NeoHubReadDcbResponse systemData;
532
533             try {
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");
539                 }
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());
543             }
544             try {
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()));
550                 }
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());
554             }
555         }
556
557         if (!supportsLegacyApi && !supportsFutureApi) {
558             logger.warn("hub '{}' currently neither legacy nor new API are supported!", getThing().getUID());
559             isApiOnline = false;
560             return;
561         }
562
563         NeoHubConfiguration config = this.config;
564         ApiVersion apiVersion = (supportsLegacyApi && config != null && config.preferLegacyApi) ? ApiVersion.LEGACY
565                 : ApiVersion.NEW;
566         if (apiVersion != this.apiVersion) {
567             logger.debug("hub '{}' changing API version: '{}' => '{}'", getThing().getUID(), this.apiVersion.label,
568                     apiVersion.label);
569             this.apiVersion = apiVersion;
570         }
571
572         if (!apiVersion.label.equals(getThing().getProperties().get(PROPERTY_API_VERSION))) {
573             getThing().setProperty(PROPERTY_API_VERSION, apiVersion.label);
574         }
575
576         this.isApiOnline = true;
577     }
578
579     /**
580      * get the Engineers data
581      */
582     public @Nullable NeoHubGetEngineersData fromNeoHubGetEngineersData() {
583         NeoHubSocketBase socket = this.socket;
584         if (socket != null) {
585             String responseJson;
586             try {
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());
591             }
592         }
593         return null;
594     }
595
596     public boolean isLegacyApiSelected() {
597         return apiVersion == ApiVersion.LEGACY;
598     }
599
600     public Unit<?> getTemperatureUnit() {
601         NeoHubReadDcbResponse systemData = this.systemData;
602         if (systemData == null) {
603             this.systemData = systemData = fromNeoHubReadSystemData();
604         }
605         if (systemData != null) {
606             return systemData.getTemperatureUnit();
607         }
608         return SIUnits.CELSIUS;
609     }
610 }