]> git.basschouten.com Git - openhab-addons.git/blob
53073b579c1c12e974d357fd1ad906c224ca8f92
[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.vesync.internal.handlers;
14
15 import static org.openhab.binding.vesync.internal.VeSyncConstants.*;
16 import static org.openhab.binding.vesync.internal.dto.requests.VeSyncProtocolConstants.V2_BYPASS_ENDPOINT;
17
18 import java.time.Duration;
19 import java.util.ArrayList;
20 import java.util.Collections;
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.stream.Collectors;
27
28 import javax.validation.constraints.NotNull;
29
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.openhab.binding.vesync.internal.VeSyncBridgeConfiguration;
33 import org.openhab.binding.vesync.internal.VeSyncDeviceConfiguration;
34 import org.openhab.binding.vesync.internal.dto.requests.VeSyncAuthenticatedRequest;
35 import org.openhab.binding.vesync.internal.dto.requests.VeSyncRequestManagedDeviceBypassV2;
36 import org.openhab.binding.vesync.internal.dto.responses.VeSyncManagedDeviceBase;
37 import org.openhab.binding.vesync.internal.exceptions.AuthenticationException;
38 import org.openhab.binding.vesync.internal.exceptions.DeviceUnknownException;
39 import org.openhab.core.cache.ExpiringCache;
40 import org.openhab.core.thing.Bridge;
41 import org.openhab.core.thing.Channel;
42 import org.openhab.core.thing.ChannelUID;
43 import org.openhab.core.thing.Thing;
44 import org.openhab.core.thing.ThingStatus;
45 import org.openhab.core.thing.ThingStatusDetail;
46 import org.openhab.core.thing.binding.BaseThingHandler;
47 import org.openhab.core.thing.binding.BridgeHandler;
48 import org.openhab.core.thing.binding.ThingHandler;
49 import org.openhab.core.thing.binding.builder.ThingBuilder;
50 import org.slf4j.Logger;
51 import org.slf4j.LoggerFactory;
52
53 /**
54  * The {@link VeSyncBaseDeviceHandler} is responsible for handling commands, which are
55  * sent to one of the channels.
56  *
57  * @author David Goodyear - Initial contribution
58  */
59 @NonNullByDefault
60 public abstract class VeSyncBaseDeviceHandler extends BaseThingHandler {
61
62     public static final String DEV_FAMILY_UNKNOWN = "UNKNOWN";
63
64     public static final VeSyncDeviceMetadata UNKNOWN = new VeSyncDeviceMetadata(DEV_FAMILY_UNKNOWN,
65             Collections.emptyList(), Collections.emptyList());
66
67     private final Logger logger = LoggerFactory.getLogger(VeSyncBaseDeviceHandler.class);
68
69     private static final String MARKER_INVALID_DEVICE_KEY = "---INVALID---";
70
71     @NotNull
72     protected String deviceLookupKey = MARKER_INVALID_DEVICE_KEY;
73
74     private static final int CACHE_TIMEOUT_SECOND = 5;
75
76     private int activePollRate = -2; // -1 is used to deactivate the poll, so default to a different value
77
78     private @Nullable ScheduledFuture<?> backgroundPollingScheduler;
79     private final Object pollConfigLock = new Object();
80
81     protected @Nullable VeSyncClient veSyncClient;
82
83     private volatile long latestReadBackMillis = 0;
84
85     @Nullable
86     ScheduledFuture<?> initialPollingTask = null;
87
88     @Nullable
89     ScheduledFuture<?> readbackPollTask = null;
90
91     public VeSyncBaseDeviceHandler(Thing thing) {
92         super(thing);
93     }
94
95     protected @Nullable Channel findChannelById(final String channelGroupId) {
96         return getThing().getChannel(channelGroupId);
97     }
98
99     protected ExpiringCache<String> lastPollResultCache = new ExpiringCache<>(Duration.ofSeconds(CACHE_TIMEOUT_SECOND),
100             VeSyncBaseDeviceHandler::expireCacheContents);
101
102     private static @Nullable String expireCacheContents() {
103         return null;
104     }
105
106     @Override
107     public void channelLinked(ChannelUID channelUID) {
108         super.channelLinked(channelUID);
109
110         scheduler.execute(this::pollForUpdate);
111     }
112
113     protected void setBackgroundPollInterval(final int seconds) {
114         if (activePollRate == seconds) {
115             return;
116         }
117         logger.debug("Reconfiguring devices background polling to {} seconds", seconds);
118
119         synchronized (pollConfigLock) {
120             final ScheduledFuture<?> job = backgroundPollingScheduler;
121
122             // Cancel the current scan's and re-schedule as required
123             if (job != null && !job.isCancelled()) {
124                 job.cancel(true);
125                 backgroundPollingScheduler = null;
126             }
127             if (seconds > 0) {
128                 logger.trace("Device data is polling every {} seconds", seconds);
129                 backgroundPollingScheduler = scheduler.scheduleWithFixedDelay(this::pollForUpdate, seconds, seconds,
130                         TimeUnit.SECONDS);
131             }
132             activePollRate = seconds;
133         }
134     }
135
136     public boolean requiresMetaDataFrequentUpdates() {
137         return (MARKER_INVALID_DEVICE_KEY.equals(deviceLookupKey));
138     }
139
140     private @Nullable BridgeHandler getBridgeHandler() {
141         Bridge bridgeRef = getBridge();
142         if (bridgeRef == null) {
143             return null;
144         } else {
145             return bridgeRef.getHandler();
146         }
147     }
148
149     protected boolean isDeviceOnline() {
150         BridgeHandler bridgeHandler = getBridgeHandler();
151         if (bridgeHandler instanceof VeSyncBridgeHandler veSyncBridgeHandler) {
152             @Nullable
153             VeSyncManagedDeviceBase metadata = veSyncBridgeHandler.api.getMacLookupMap().get(deviceLookupKey);
154
155             if (metadata == null) {
156                 return false;
157             }
158
159             return ("online".equals(metadata.connectionStatus));
160         }
161         return false;
162     }
163
164     public void updateDeviceMetaData() {
165         Map<String, String> newProps = null;
166
167         BridgeHandler bridgeHandler = getBridgeHandler();
168         if (bridgeHandler instanceof VeSyncBridgeHandler veSyncBridgeHandler) {
169             @Nullable
170             VeSyncManagedDeviceBase metadata = veSyncBridgeHandler.api.getMacLookupMap().get(deviceLookupKey);
171
172             if (metadata == null) {
173                 return;
174             }
175
176             newProps = getMetadataProperities(metadata);
177
178             // Refresh the device -> protocol mapping
179             deviceLookupKey = getValidatedIdString();
180
181             if ("online".equals(metadata.connectionStatus)) {
182                 updateStatus(ThingStatus.ONLINE);
183             } else if ("offline".equals(metadata.connectionStatus)) {
184                 updateStatus(ThingStatus.OFFLINE);
185             }
186         }
187
188         if (newProps != null && !newProps.isEmpty()) {
189             this.updateProperties(newProps);
190             removeChannels();
191             if (!isDeviceSupported()) {
192                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
193                         "Device Model or Type not supported by this thing");
194             }
195         }
196     }
197
198     /**
199      * Override this in classes that extend this, to
200      */
201     protected void customiseChannels() {
202     }
203
204     protected String[] getChannelsToRemove() {
205         return new String[] {};
206     }
207
208     private void removeChannels() {
209         final String[] channelsToRemove = getChannelsToRemove();
210         final List<Channel> channelsToBeRemoved = new ArrayList<>();
211         for (String name : channelsToRemove) {
212             Channel ch = getThing().getChannel(name);
213             if (ch != null) {
214                 channelsToBeRemoved.add(ch);
215             }
216         }
217
218         final ThingBuilder builder = editThing().withoutChannels(channelsToBeRemoved);
219         updateThing(builder.build());
220     }
221
222     /**
223      * Extract the common properties for all devices, from the given meta-data of a device.
224      * 
225      * @param metadata - the meta-data of a device
226      * @return - Map of common props
227      */
228     public Map<String, String> getMetadataProperities(final @Nullable VeSyncManagedDeviceBase metadata) {
229         if (metadata == null) {
230             return Map.of();
231         }
232         final Map<String, String> newProps = new HashMap<>(4);
233         newProps.put(DEVICE_PROP_DEVICE_MAC_ID, metadata.getMacId());
234         newProps.put(DEVICE_PROP_DEVICE_NAME, metadata.getDeviceName());
235         newProps.put(DEVICE_PROP_DEVICE_TYPE, metadata.getDeviceType());
236         newProps.put(DEVICE_PROP_DEVICE_FAMILY,
237                 getDeviceFamilyMetadata(metadata.getDeviceType()).getDeviceFamilyName());
238         newProps.put(DEVICE_PROP_DEVICE_UUID, metadata.getUuid());
239         return newProps;
240     }
241
242     protected synchronized @Nullable VeSyncClient getVeSyncClient() {
243         if (veSyncClient == null) {
244             Bridge bridge = getBridge();
245             if (bridge == null) {
246                 return null;
247             }
248             ThingHandler handler = bridge.getHandler();
249             if (handler instanceof VeSyncClient client) {
250                 veSyncClient = client;
251             } else {
252                 return null;
253             }
254         }
255         return veSyncClient;
256     }
257
258     protected void requestBridgeFreqScanMetadataIfReq() {
259         if (requiresMetaDataFrequentUpdates()) {
260             BridgeHandler bridgeHandler = getBridgeHandler();
261             if (bridgeHandler instanceof VeSyncBridgeHandler vesyncBridgeHandler) {
262                 vesyncBridgeHandler.checkIfIncreaseScanRateRequired();
263             }
264         }
265     }
266
267     @NotNull
268     public String getValidatedIdString() {
269         final VeSyncDeviceConfiguration config = getConfigAs(VeSyncDeviceConfiguration.class);
270
271         BridgeHandler bridgeHandler = getBridgeHandler();
272         if (bridgeHandler instanceof VeSyncBridgeHandler vesyncBridgeHandler) {
273
274             final String configMac = config.macId;
275
276             // Try to use the mac directly
277             if (configMac != null) {
278                 logger.debug("Searching for device mac id : {}", configMac);
279                 @Nullable
280                 VeSyncManagedDeviceBase metadata = vesyncBridgeHandler.api.getMacLookupMap()
281                         .get(configMac.toLowerCase());
282
283                 if (metadata != null && metadata.macId != null) {
284                     return metadata.macId;
285                 }
286             }
287
288             final String deviceName = config.deviceName;
289
290             // Check if the device name can be matched to a single device
291             if (deviceName != null) {
292                 final String[] matchedMacIds = vesyncBridgeHandler.api.getMacLookupMap().values().stream()
293                         .filter(x -> deviceName.equals(x.deviceName)).map(x -> x.macId).toArray(String[]::new);
294
295                 for (String val : matchedMacIds) {
296                     logger.debug("Found MAC match on name with : {}", val);
297                 }
298
299                 if (matchedMacIds.length != 1) {
300                     return MARKER_INVALID_DEVICE_KEY;
301                 }
302
303                 if (vesyncBridgeHandler.api.getMacLookupMap().get(matchedMacIds[0]) != null) {
304                     return matchedMacIds[0];
305                 }
306             }
307         }
308
309         return MARKER_INVALID_DEVICE_KEY;
310     }
311
312     @Override
313     public void initialize() {
314         intializeDeviceForUse();
315     }
316
317     private void intializeDeviceForUse() {
318         // Sanity check basic setup
319         final VeSyncBridgeHandler bridge = (VeSyncBridgeHandler) getBridgeHandler();
320         if (bridge == null) {
321             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED, "Missing bridge for API link");
322             return;
323         } else {
324             updateStatus(ThingStatus.UNKNOWN);
325         }
326
327         deviceLookupKey = getValidatedIdString();
328
329         // Populate device props - this is required for polling, to cross-check the device model.
330         updateDeviceMetaData();
331
332         // If the base device class marks it as offline there is an issue that will prevent normal operation
333         if (getThing().getStatus().equals(ThingStatus.OFFLINE)) {
334             return;
335         }
336         // This will force the bridge to push the configuration parameters for polling to the handler
337         bridge.updateThing(this);
338
339         // Give the bridge time to build the datamaps of the devices
340         scheduleInitialPoll();
341     }
342
343     private void scheduleInitialPoll() {
344         cancelInitialPoll(false);
345         initialPollingTask = scheduler.schedule(this::pollForUpdate, 10, TimeUnit.SECONDS);
346     }
347
348     private void cancelInitialPoll(final boolean interruptAllowed) {
349         final ScheduledFuture<?> pollJob = initialPollingTask;
350         if (pollJob != null && !pollJob.isCancelled()) {
351             pollJob.cancel(interruptAllowed);
352             initialPollingTask = null;
353         }
354     }
355
356     private void cancelReadbackPoll(final boolean interruptAllowed) {
357         final ScheduledFuture<?> pollJob = readbackPollTask;
358         if (pollJob != null && !pollJob.isCancelled()) {
359             pollJob.cancel(interruptAllowed);
360             readbackPollTask = null;
361         }
362     }
363
364     @Override
365     public void dispose() {
366         cancelReadbackPoll(true);
367         cancelInitialPoll(true);
368     }
369
370     public void pollForUpdate() {
371         pollForDeviceData(lastPollResultCache);
372     }
373
374     /**
375      * This should be implemented by subclasses to provide the implementation for polling the specific
376      * data for the type the class is responsible for. (Excluding meta data).
377      *
378      * @param cachedResponse - An Expiring cache that can be utilised to store the responses, to prevent poll bursts by
379      *            coalescing the requests.
380      */
381     protected abstract void pollForDeviceData(final ExpiringCache<String> cachedResponse);
382
383     /**
384      * Send a BypassV2 command to the device. The body of the response is returned, a poll is done if the request
385      * should have been dispatched.
386      * 
387      * @param method - the V2 bypass method
388      * @param payload - The payload to send in within the V2 bypass command
389      * @return - The body of the response, or EMPTY_STRING if the command could not be issued.
390      */
391     protected final String sendV2BypassControlCommand(final String method,
392             final VeSyncRequestManagedDeviceBypassV2.EmptyPayload payload) {
393         return sendV2BypassControlCommand(method, payload, true);
394     }
395
396     /**
397      * Send a BypassV2 command to the device. The body of the response is returned.
398      * 
399      * @param method - the V2 bypass method
400      * @param payload - The payload to send in within the V2 bypass command
401      * @param readbackDevice - if set to true after the command has been issued, whether a poll of the devices data
402      *            should be run.
403      * @return - The body of the response, or EMPTY_STRING if the command could not be issued.
404      */
405     protected final String sendV2BypassControlCommand(final String method,
406             final VeSyncRequestManagedDeviceBypassV2.EmptyPayload payload, final boolean readbackDevice) {
407         final String result = sendV2BypassCommand(method, payload);
408         if (!result.equals(EMPTY_STRING) && readbackDevice) {
409             performReadbackPoll();
410         }
411         return result;
412     }
413
414     public final String sendV1Command(final String method, final String url, final VeSyncAuthenticatedRequest request) {
415         if (ThingStatus.OFFLINE.equals(this.thing.getStatus())) {
416             logger.debug("Command blocked as device is offline");
417             return EMPTY_STRING;
418         }
419
420         try {
421             if (MARKER_INVALID_DEVICE_KEY.equals(deviceLookupKey)) {
422                 deviceLookupKey = getValidatedIdString();
423             }
424             VeSyncClient client = getVeSyncClient();
425             if (client != null) {
426                 return client.reqV2Authorized(url, deviceLookupKey, request);
427             } else {
428                 throw new DeviceUnknownException("Missing client");
429             }
430         } catch (AuthenticationException e) {
431             logger.debug("Auth exception {}", e.getMessage());
432             return EMPTY_STRING;
433         } catch (final DeviceUnknownException e) {
434             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
435                     "Check configuration details - " + e.getMessage());
436             // In case the name is updated server side - request the scan rate is increased
437             requestBridgeFreqScanMetadataIfReq();
438             return EMPTY_STRING;
439         }
440     }
441
442     /**
443      * Send a BypassV2 command to the device. The body of the response is returned.
444      *
445      * @param method - the V2 bypass method
446      * @param payload - The payload to send in within the V2 bypass command
447      * @return - The body of the response, or EMPTY_STRING if the command could not be issued.
448      */
449     protected final String sendV2BypassCommand(final String method,
450             final VeSyncRequestManagedDeviceBypassV2.EmptyPayload payload) {
451         if (ThingStatus.OFFLINE.equals(this.thing.getStatus())) {
452             logger.debug("Command blocked as device is offline");
453             return EMPTY_STRING;
454         }
455
456         VeSyncRequestManagedDeviceBypassV2 readReq = new VeSyncRequestManagedDeviceBypassV2();
457         readReq.payload.method = method;
458         readReq.payload.data = payload;
459
460         try {
461             if (MARKER_INVALID_DEVICE_KEY.equals(deviceLookupKey)) {
462                 deviceLookupKey = getValidatedIdString();
463             }
464             VeSyncClient client = getVeSyncClient();
465             if (client != null) {
466                 return client.reqV2Authorized(V2_BYPASS_ENDPOINT, deviceLookupKey, readReq);
467             } else {
468                 throw new DeviceUnknownException("Missing client");
469             }
470         } catch (AuthenticationException e) {
471             logger.debug("Auth exception {}", e.getMessage());
472             return EMPTY_STRING;
473         } catch (final DeviceUnknownException e) {
474             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
475                     "Check configuration details - " + e.getMessage());
476             // In case the name is updated server side - request the scan rate is increased
477             requestBridgeFreqScanMetadataIfReq();
478             return EMPTY_STRING;
479         }
480     }
481
482     // Given several changes may be done at the same time, or in close proximity, delay the read-back to catch
483     // multiple read-back's, so a single update can handle them.
484     public void performReadbackPoll() {
485         final long requestSystemMillis = System.currentTimeMillis();
486         latestReadBackMillis = requestSystemMillis;
487         cancelReadbackPoll(false);
488         readbackPollTask = scheduler.schedule(() -> {
489             // This is a historical poll, ignore it
490             if (requestSystemMillis != latestReadBackMillis) {
491                 logger.trace("Poll read-back cancelled, another later one is scheduled to happen");
492                 return;
493             }
494             logger.trace("Read-back poll executing");
495             // Read-backs should never use the cached data - but may provide it for poll's that coincide with
496             // the caches alive duration.
497             lastPollResultCache.invalidateValue();
498             pollForUpdate();
499         }, 1L, TimeUnit.SECONDS);
500     }
501
502     public void updateBridgeBasedPolls(VeSyncBridgeConfiguration config) {
503     }
504
505     protected boolean isDeviceSupported() {
506         final String deviceType = getThing().getProperties().get(DEVICE_PROP_DEVICE_TYPE);
507         return !getDeviceFamilyMetadata(deviceType).getDeviceFamilyName().equals(DEV_FAMILY_UNKNOWN);
508     }
509
510     /**
511      * Subclasses should return the protocol prefix for the device type being modelled.
512      * E.g. LUH = The Humidifier Devices; LAP = The Air Purifiers;
513      * if irrelevant return a string that will not be used in the protocol e.g. __??__
514      *
515      * @return - The device type prefix for the device being modelled. E.g. LAP or LUH
516      */
517     public abstract String getDeviceFamilyProtocolPrefix();
518
519     /**
520      * Subclasses should return list of VeSyncDeviceMetadata definitions that define the
521      * supported devices by their implementation.
522      *
523      * @return - List of VeSyncDeviceMetadata definitions, that defines groups of devices which
524      *         are operationally the same device.
525      */
526     public abstract List<VeSyncDeviceMetadata> getSupportedDeviceMetadata();
527
528     public static VeSyncDeviceMetadata getDeviceFamilyMetadata(final @Nullable String deviceType,
529             final String deviceProtocolPrefix, final List<VeSyncDeviceMetadata> metadata) {
530         if (deviceType == null) {
531             return UNKNOWN;
532         }
533         final String[] idParts = deviceType.split("-");
534         if (idParts.length == 3) {
535             if (!deviceProtocolPrefix.equals(idParts[0])) {
536                 return UNKNOWN;
537             }
538         }
539         List<VeSyncDeviceMetadata> foundMatch = metadata.stream()
540                 .filter(x -> x.deviceTypeIdMatches(deviceType, idParts)).collect(Collectors.toList());
541         if (foundMatch.size() == 1) {
542             return foundMatch.get(0);
543         } else {
544             return UNKNOWN;
545         }
546     }
547
548     public VeSyncDeviceMetadata getDeviceFamilyMetadata(final @Nullable String deviceType) {
549         return getDeviceFamilyMetadata(deviceType, getDeviceFamilyProtocolPrefix(), getSupportedDeviceMetadata());
550     }
551 }