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.vesync.internal.handlers;
15 import static org.openhab.binding.vesync.internal.VeSyncConstants.*;
16 import static org.openhab.binding.vesync.internal.dto.requests.VeSyncProtocolConstants.V2_BYPASS_ENDPOINT;
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;
24 import java.util.concurrent.ScheduledFuture;
25 import java.util.concurrent.TimeUnit;
26 import java.util.stream.Collectors;
28 import javax.validation.constraints.NotNull;
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;
54 * The {@link VeSyncBaseDeviceHandler} is responsible for handling commands, which are
55 * sent to one of the channels.
57 * @author David Goodyear - Initial contribution
60 public abstract class VeSyncBaseDeviceHandler extends BaseThingHandler {
62 public static final String DEV_FAMILY_UNKNOWN = "UNKNOWN";
64 public static final VeSyncDeviceMetadata UNKNOWN = new VeSyncDeviceMetadata(DEV_FAMILY_UNKNOWN,
65 Collections.emptyList(), Collections.emptyList());
67 private final Logger logger = LoggerFactory.getLogger(VeSyncBaseDeviceHandler.class);
69 private static final String MARKER_INVALID_DEVICE_KEY = "---INVALID---";
72 protected String deviceLookupKey = MARKER_INVALID_DEVICE_KEY;
74 private static final int CACHE_TIMEOUT_SECOND = 5;
76 private int activePollRate = -2; // -1 is used to deactivate the poll, so default to a different value
78 private @Nullable ScheduledFuture<?> backgroundPollingScheduler;
79 private final Object pollConfigLock = new Object();
81 protected @Nullable VeSyncClient veSyncClient;
83 private volatile long latestReadBackMillis = 0;
86 ScheduledFuture<?> initialPollingTask = null;
89 ScheduledFuture<?> readbackPollTask = null;
91 public VeSyncBaseDeviceHandler(Thing thing) {
95 protected @Nullable Channel findChannelById(final String channelGroupId) {
96 return getThing().getChannel(channelGroupId);
99 protected ExpiringCache<String> lastPollResultCache = new ExpiringCache<>(Duration.ofSeconds(CACHE_TIMEOUT_SECOND),
100 VeSyncBaseDeviceHandler::expireCacheContents);
102 private static @Nullable String expireCacheContents() {
107 public void channelLinked(ChannelUID channelUID) {
108 super.channelLinked(channelUID);
110 scheduler.execute(this::pollForUpdate);
113 protected void setBackgroundPollInterval(final int seconds) {
114 if (activePollRate == seconds) {
117 logger.debug("Reconfiguring devices background polling to {} seconds", seconds);
119 synchronized (pollConfigLock) {
120 final ScheduledFuture<?> job = backgroundPollingScheduler;
122 // Cancel the current scan's and re-schedule as required
123 if (job != null && !job.isCancelled()) {
125 backgroundPollingScheduler = null;
128 logger.trace("Device data is polling every {} seconds", seconds);
129 backgroundPollingScheduler = scheduler.scheduleWithFixedDelay(this::pollForUpdate, seconds, seconds,
132 activePollRate = seconds;
136 public boolean requiresMetaDataFrequentUpdates() {
137 return (MARKER_INVALID_DEVICE_KEY.equals(deviceLookupKey));
140 private @Nullable BridgeHandler getBridgeHandler() {
141 Bridge bridgeRef = getBridge();
142 if (bridgeRef == null) {
145 return bridgeRef.getHandler();
149 protected boolean isDeviceOnline() {
150 BridgeHandler bridgeHandler = getBridgeHandler();
151 if (bridgeHandler instanceof VeSyncBridgeHandler veSyncBridgeHandler) {
153 VeSyncManagedDeviceBase metadata = veSyncBridgeHandler.api.getMacLookupMap().get(deviceLookupKey);
155 if (metadata == null) {
159 return ("online".equals(metadata.connectionStatus));
164 public void updateDeviceMetaData() {
165 Map<String, String> newProps = null;
167 BridgeHandler bridgeHandler = getBridgeHandler();
168 if (bridgeHandler instanceof VeSyncBridgeHandler veSyncBridgeHandler) {
170 VeSyncManagedDeviceBase metadata = veSyncBridgeHandler.api.getMacLookupMap().get(deviceLookupKey);
172 if (metadata == null) {
176 newProps = getMetadataProperities(metadata);
178 // Refresh the device -> protocol mapping
179 deviceLookupKey = getValidatedIdString();
181 if ("online".equals(metadata.connectionStatus)) {
182 updateStatus(ThingStatus.ONLINE);
183 } else if ("offline".equals(metadata.connectionStatus)) {
184 updateStatus(ThingStatus.OFFLINE);
188 if (newProps != null && !newProps.isEmpty()) {
189 this.updateProperties(newProps);
191 if (!isDeviceSupported()) {
192 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
193 "Device Model or Type not supported by this thing");
199 * Override this in classes that extend this, to
201 protected void customiseChannels() {
204 protected String[] getChannelsToRemove() {
205 return new String[] {};
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);
214 channelsToBeRemoved.add(ch);
218 final ThingBuilder builder = editThing().withoutChannels(channelsToBeRemoved);
219 updateThing(builder.build());
223 * Extract the common properties for all devices, from the given meta-data of a device.
225 * @param metadata - the meta-data of a device
226 * @return - Map of common props
228 public Map<String, String> getMetadataProperities(final @Nullable VeSyncManagedDeviceBase metadata) {
229 if (metadata == null) {
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());
242 protected synchronized @Nullable VeSyncClient getVeSyncClient() {
243 if (veSyncClient == null) {
244 Bridge bridge = getBridge();
245 if (bridge == null) {
248 ThingHandler handler = bridge.getHandler();
249 if (handler instanceof VeSyncClient client) {
250 veSyncClient = client;
258 protected void requestBridgeFreqScanMetadataIfReq() {
259 if (requiresMetaDataFrequentUpdates()) {
260 BridgeHandler bridgeHandler = getBridgeHandler();
261 if (bridgeHandler instanceof VeSyncBridgeHandler vesyncBridgeHandler) {
262 vesyncBridgeHandler.checkIfIncreaseScanRateRequired();
268 public String getValidatedIdString() {
269 final VeSyncDeviceConfiguration config = getConfigAs(VeSyncDeviceConfiguration.class);
271 BridgeHandler bridgeHandler = getBridgeHandler();
272 if (bridgeHandler instanceof VeSyncBridgeHandler vesyncBridgeHandler) {
274 final String configMac = config.macId;
276 // Try to use the mac directly
277 if (configMac != null) {
278 logger.debug("Searching for device mac id : {}", configMac);
280 VeSyncManagedDeviceBase metadata = vesyncBridgeHandler.api.getMacLookupMap()
281 .get(configMac.toLowerCase());
283 if (metadata != null && metadata.macId != null) {
284 return metadata.macId;
288 final String deviceName = config.deviceName;
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);
295 for (String val : matchedMacIds) {
296 logger.debug("Found MAC match on name with : {}", val);
299 if (matchedMacIds.length != 1) {
300 return MARKER_INVALID_DEVICE_KEY;
303 if (vesyncBridgeHandler.api.getMacLookupMap().get(matchedMacIds[0]) != null) {
304 return matchedMacIds[0];
309 return MARKER_INVALID_DEVICE_KEY;
313 public void initialize() {
314 intializeDeviceForUse();
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");
324 updateStatus(ThingStatus.UNKNOWN);
327 deviceLookupKey = getValidatedIdString();
329 // Populate device props - this is required for polling, to cross-check the device model.
330 updateDeviceMetaData();
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)) {
336 // This will force the bridge to push the configuration parameters for polling to the handler
337 bridge.updateThing(this);
339 // Give the bridge time to build the datamaps of the devices
340 scheduleInitialPoll();
343 private void scheduleInitialPoll() {
344 cancelInitialPoll(false);
345 initialPollingTask = scheduler.schedule(this::pollForUpdate, 10, TimeUnit.SECONDS);
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;
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;
365 public void dispose() {
366 cancelReadbackPoll(true);
367 cancelInitialPoll(true);
370 public void pollForUpdate() {
371 pollForDeviceData(lastPollResultCache);
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).
378 * @param cachedResponse - An Expiring cache that can be utilised to store the responses, to prevent poll bursts by
379 * coalescing the requests.
381 protected abstract void pollForDeviceData(final ExpiringCache<String> cachedResponse);
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.
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.
391 protected final String sendV2BypassControlCommand(final String method,
392 final VeSyncRequestManagedDeviceBypassV2.EmptyPayload payload) {
393 return sendV2BypassControlCommand(method, payload, true);
397 * Send a BypassV2 command to the device. The body of the response is returned.
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
403 * @return - The body of the response, or EMPTY_STRING if the command could not be issued.
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();
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");
421 if (MARKER_INVALID_DEVICE_KEY.equals(deviceLookupKey)) {
422 deviceLookupKey = getValidatedIdString();
424 VeSyncClient client = getVeSyncClient();
425 if (client != null) {
426 return client.reqV2Authorized(url, deviceLookupKey, request);
428 throw new DeviceUnknownException("Missing client");
430 } catch (AuthenticationException e) {
431 logger.debug("Auth exception {}", e.getMessage());
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();
443 * Send a BypassV2 command to the device. The body of the response is returned.
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.
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");
456 VeSyncRequestManagedDeviceBypassV2 readReq = new VeSyncRequestManagedDeviceBypassV2();
457 readReq.payload.method = method;
458 readReq.payload.data = payload;
461 if (MARKER_INVALID_DEVICE_KEY.equals(deviceLookupKey)) {
462 deviceLookupKey = getValidatedIdString();
464 VeSyncClient client = getVeSyncClient();
465 if (client != null) {
466 return client.reqV2Authorized(V2_BYPASS_ENDPOINT, deviceLookupKey, readReq);
468 throw new DeviceUnknownException("Missing client");
470 } catch (AuthenticationException e) {
471 logger.debug("Auth exception {}", e.getMessage());
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();
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");
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();
499 }, 1L, TimeUnit.SECONDS);
502 public void updateBridgeBasedPolls(VeSyncBridgeConfiguration config) {
505 protected boolean isDeviceSupported() {
506 final String deviceType = getThing().getProperties().get(DEVICE_PROP_DEVICE_TYPE);
507 return !getDeviceFamilyMetadata(deviceType).getDeviceFamilyName().equals(DEV_FAMILY_UNKNOWN);
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. __??__
515 * @return - The device type prefix for the device being modelled. E.g. LAP or LUH
517 public abstract String getDeviceFamilyProtocolPrefix();
520 * Subclasses should return list of VeSyncDeviceMetadata definitions that define the
521 * supported devices by their implementation.
523 * @return - List of VeSyncDeviceMetadata definitions, that defines groups of devices which
524 * are operationally the same device.
526 public abstract List<VeSyncDeviceMetadata> getSupportedDeviceMetadata();
528 public static VeSyncDeviceMetadata getDeviceFamilyMetadata(final @Nullable String deviceType,
529 final String deviceProtocolPrefix, final List<VeSyncDeviceMetadata> metadata) {
530 if (deviceType == null) {
533 final String[] idParts = deviceType.split("-");
534 if (idParts.length == 3) {
535 if (!deviceProtocolPrefix.equals(idParts[0])) {
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);
548 public VeSyncDeviceMetadata getDeviceFamilyMetadata(final @Nullable String deviceType) {
549 return getDeviceFamilyMetadata(deviceType, getDeviceFamilyProtocolPrefix(), getSupportedDeviceMetadata());