]> git.basschouten.com Git - openhab-addons.git/blob
bd7311ae312b59c9231fc8dc604f05735667a619
[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             final String configMac = config.macId;
274
275             // Try to use the mac directly
276             if (configMac != null) {
277                 logger.debug("Searching for device mac id : {}", configMac);
278                 @Nullable
279                 VeSyncManagedDeviceBase metadata = vesyncBridgeHandler.api.getMacLookupMap()
280                         .get(configMac.toLowerCase());
281
282                 if (metadata != null && metadata.macId != null) {
283                     return metadata.macId;
284                 }
285             }
286
287             final String deviceName = config.deviceName;
288
289             // Check if the device name can be matched to a single device
290             if (deviceName != null) {
291                 final String[] matchedMacIds = vesyncBridgeHandler.api.getMacLookupMap().values().stream()
292                         .filter(x -> deviceName.equals(x.deviceName)).map(x -> x.macId).toArray(String[]::new);
293
294                 for (String val : matchedMacIds) {
295                     logger.debug("Found MAC match on name with : {}", val);
296                 }
297
298                 if (matchedMacIds.length != 1) {
299                     return MARKER_INVALID_DEVICE_KEY;
300                 }
301
302                 if (vesyncBridgeHandler.api.getMacLookupMap().get(matchedMacIds[0]) != null) {
303                     return matchedMacIds[0];
304                 }
305             }
306         }
307
308         return MARKER_INVALID_DEVICE_KEY;
309     }
310
311     @Override
312     public void initialize() {
313         intializeDeviceForUse();
314     }
315
316     private void intializeDeviceForUse() {
317         // Sanity check basic setup
318         final VeSyncBridgeHandler bridge = (VeSyncBridgeHandler) getBridgeHandler();
319         if (bridge == null) {
320             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED, "Missing bridge for API link");
321             return;
322         } else {
323             updateStatus(ThingStatus.UNKNOWN);
324         }
325
326         deviceLookupKey = getValidatedIdString();
327
328         // Populate device props - this is required for polling, to cross-check the device model.
329         updateDeviceMetaData();
330
331         // If the base device class marks it as offline there is an issue that will prevent normal operation
332         if (getThing().getStatus().equals(ThingStatus.OFFLINE)) {
333             return;
334         }
335         // This will force the bridge to push the configuration parameters for polling to the handler
336         bridge.updateThing(this);
337
338         // Give the bridge time to build the datamaps of the devices
339         scheduleInitialPoll();
340     }
341
342     private void scheduleInitialPoll() {
343         cancelInitialPoll(false);
344         initialPollingTask = scheduler.schedule(this::pollForUpdate, 10, TimeUnit.SECONDS);
345     }
346
347     private void cancelInitialPoll(final boolean interruptAllowed) {
348         final ScheduledFuture<?> pollJob = initialPollingTask;
349         if (pollJob != null && !pollJob.isCancelled()) {
350             pollJob.cancel(interruptAllowed);
351             initialPollingTask = null;
352         }
353     }
354
355     private void cancelReadbackPoll(final boolean interruptAllowed) {
356         final ScheduledFuture<?> pollJob = readbackPollTask;
357         if (pollJob != null && !pollJob.isCancelled()) {
358             pollJob.cancel(interruptAllowed);
359             readbackPollTask = null;
360         }
361     }
362
363     @Override
364     public void dispose() {
365         cancelReadbackPoll(true);
366         cancelInitialPoll(true);
367     }
368
369     public void pollForUpdate() {
370         pollForDeviceData(lastPollResultCache);
371     }
372
373     /**
374      * This should be implemented by subclasses to provide the implementation for polling the specific
375      * data for the type the class is responsible for. (Excluding meta data).
376      *
377      * @param cachedResponse - An Expiring cache that can be utilised to store the responses, to prevent poll bursts by
378      *            coalescing the requests.
379      */
380     protected abstract void pollForDeviceData(final ExpiringCache<String> cachedResponse);
381
382     /**
383      * Send a BypassV2 command to the device. The body of the response is returned, a poll is done if the request
384      * should have been dispatched.
385      * 
386      * @param method - the V2 bypass method
387      * @param payload - The payload to send in within the V2 bypass command
388      * @return - The body of the response, or EMPTY_STRING if the command could not be issued.
389      */
390     protected final String sendV2BypassControlCommand(final String method,
391             final VeSyncRequestManagedDeviceBypassV2.EmptyPayload payload) {
392         return sendV2BypassControlCommand(method, payload, true);
393     }
394
395     /**
396      * Send a BypassV2 command to the device. The body of the response is returned.
397      * 
398      * @param method - the V2 bypass method
399      * @param payload - The payload to send in within the V2 bypass command
400      * @param readbackDevice - if set to true after the command has been issued, whether a poll of the devices data
401      *            should be run.
402      * @return - The body of the response, or EMPTY_STRING if the command could not be issued.
403      */
404     protected final String sendV2BypassControlCommand(final String method,
405             final VeSyncRequestManagedDeviceBypassV2.EmptyPayload payload, final boolean readbackDevice) {
406         final String result = sendV2BypassCommand(method, payload);
407         if (!result.equals(EMPTY_STRING) && readbackDevice) {
408             performReadbackPoll();
409         }
410         return result;
411     }
412
413     public final String sendV1Command(final String method, final String url, final VeSyncAuthenticatedRequest request) {
414         if (ThingStatus.OFFLINE.equals(this.thing.getStatus())) {
415             logger.debug("Command blocked as device is offline");
416             return EMPTY_STRING;
417         }
418
419         try {
420             if (MARKER_INVALID_DEVICE_KEY.equals(deviceLookupKey)) {
421                 deviceLookupKey = getValidatedIdString();
422             }
423             VeSyncClient client = getVeSyncClient();
424             if (client != null) {
425                 return client.reqV2Authorized(url, deviceLookupKey, request);
426             } else {
427                 throw new DeviceUnknownException("Missing client");
428             }
429         } catch (AuthenticationException e) {
430             logger.debug("Auth exception {}", e.getMessage());
431             return EMPTY_STRING;
432         } catch (final DeviceUnknownException e) {
433             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
434                     "Check configuration details - " + e.getMessage());
435             // In case the name is updated server side - request the scan rate is increased
436             requestBridgeFreqScanMetadataIfReq();
437             return EMPTY_STRING;
438         }
439     }
440
441     /**
442      * Send a BypassV2 command to the device. The body of the response is returned.
443      *
444      * @param method - the V2 bypass method
445      * @param payload - The payload to send in within the V2 bypass command
446      * @return - The body of the response, or EMPTY_STRING if the command could not be issued.
447      */
448     protected final String sendV2BypassCommand(final String method,
449             final VeSyncRequestManagedDeviceBypassV2.EmptyPayload payload) {
450         if (ThingStatus.OFFLINE.equals(this.thing.getStatus())) {
451             logger.debug("Command blocked as device is offline");
452             return EMPTY_STRING;
453         }
454
455         VeSyncRequestManagedDeviceBypassV2 readReq = new VeSyncRequestManagedDeviceBypassV2();
456         readReq.payload.method = method;
457         readReq.payload.data = payload;
458
459         try {
460             if (MARKER_INVALID_DEVICE_KEY.equals(deviceLookupKey)) {
461                 deviceLookupKey = getValidatedIdString();
462             }
463             VeSyncClient client = getVeSyncClient();
464             if (client != null) {
465                 return client.reqV2Authorized(V2_BYPASS_ENDPOINT, deviceLookupKey, readReq);
466             } else {
467                 throw new DeviceUnknownException("Missing client");
468             }
469         } catch (AuthenticationException e) {
470             logger.debug("Auth exception {}", e.getMessage());
471             return EMPTY_STRING;
472         } catch (final DeviceUnknownException e) {
473             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
474                     "Check configuration details - " + e.getMessage());
475             // In case the name is updated server side - request the scan rate is increased
476             requestBridgeFreqScanMetadataIfReq();
477             return EMPTY_STRING;
478         }
479     }
480
481     // Given several changes may be done at the same time, or in close proximity, delay the read-back to catch
482     // multiple read-back's, so a single update can handle them.
483     public void performReadbackPoll() {
484         final long requestSystemMillis = System.currentTimeMillis();
485         latestReadBackMillis = requestSystemMillis;
486         cancelReadbackPoll(false);
487         readbackPollTask = scheduler.schedule(() -> {
488             // This is a historical poll, ignore it
489             if (requestSystemMillis != latestReadBackMillis) {
490                 logger.trace("Poll read-back cancelled, another later one is scheduled to happen");
491                 return;
492             }
493             logger.trace("Read-back poll executing");
494             // Read-backs should never use the cached data - but may provide it for poll's that coincide with
495             // the caches alive duration.
496             lastPollResultCache.invalidateValue();
497             pollForUpdate();
498         }, 1L, TimeUnit.SECONDS);
499     }
500
501     public void updateBridgeBasedPolls(VeSyncBridgeConfiguration config) {
502     }
503
504     protected boolean isDeviceSupported() {
505         final String deviceType = getThing().getProperties().get(DEVICE_PROP_DEVICE_TYPE);
506         return !getDeviceFamilyMetadata(deviceType).getDeviceFamilyName().equals(DEV_FAMILY_UNKNOWN);
507     }
508
509     /**
510      * Subclasses should return the protocol prefix for the device type being modelled.
511      * E.g. LUH = The Humidifier Devices; LAP = The Air Purifiers;
512      * if irrelevant return a string that will not be used in the protocol e.g. __??__
513      *
514      * @return - The device type prefix for the device being modelled. E.g. LAP or LUH
515      */
516     public abstract String getDeviceFamilyProtocolPrefix();
517
518     /**
519      * Subclasses should return list of VeSyncDeviceMetadata definitions that define the
520      * supported devices by their implementation.
521      *
522      * @return - List of VeSyncDeviceMetadata definitions, that defines groups of devices which
523      *         are operationally the same device.
524      */
525     public abstract List<VeSyncDeviceMetadata> getSupportedDeviceMetadata();
526
527     public static VeSyncDeviceMetadata getDeviceFamilyMetadata(final @Nullable String deviceType,
528             final String deviceProtocolPrefix, final List<VeSyncDeviceMetadata> metadata) {
529         if (deviceType == null) {
530             return UNKNOWN;
531         }
532         final String[] idParts = deviceType.split("-");
533         if (idParts.length == 3) {
534             if (!deviceProtocolPrefix.equals(idParts[0])) {
535                 return UNKNOWN;
536             }
537         }
538         List<VeSyncDeviceMetadata> foundMatch = metadata.stream()
539                 .filter(x -> x.deviceTypeIdMatches(deviceType, idParts)).collect(Collectors.toList());
540         if (foundMatch.size() == 1) {
541             return foundMatch.get(0);
542         } else {
543             return UNKNOWN;
544         }
545     }
546
547     public VeSyncDeviceMetadata getDeviceFamilyMetadata(final @Nullable String deviceType) {
548         return getDeviceFamilyMetadata(deviceType, getDeviceFamilyProtocolPrefix(), getSupportedDeviceMetadata());
549     }
550 }