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) {
273 final String configMac = config.macId;
275 // Try to use the mac directly
276 if (configMac != null) {
277 logger.debug("Searching for device mac id : {}", configMac);
279 VeSyncManagedDeviceBase metadata = vesyncBridgeHandler.api.getMacLookupMap()
280 .get(configMac.toLowerCase());
282 if (metadata != null && metadata.macId != null) {
283 return metadata.macId;
287 final String deviceName = config.deviceName;
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);
294 for (String val : matchedMacIds) {
295 logger.debug("Found MAC match on name with : {}", val);
298 if (matchedMacIds.length != 1) {
299 return MARKER_INVALID_DEVICE_KEY;
302 if (vesyncBridgeHandler.api.getMacLookupMap().get(matchedMacIds[0]) != null) {
303 return matchedMacIds[0];
308 return MARKER_INVALID_DEVICE_KEY;
312 public void initialize() {
313 intializeDeviceForUse();
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");
323 updateStatus(ThingStatus.UNKNOWN);
326 deviceLookupKey = getValidatedIdString();
328 // Populate device props - this is required for polling, to cross-check the device model.
329 updateDeviceMetaData();
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)) {
335 // This will force the bridge to push the configuration parameters for polling to the handler
336 bridge.updateThing(this);
338 // Give the bridge time to build the datamaps of the devices
339 scheduleInitialPoll();
342 private void scheduleInitialPoll() {
343 cancelInitialPoll(false);
344 initialPollingTask = scheduler.schedule(this::pollForUpdate, 10, TimeUnit.SECONDS);
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;
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;
364 public void dispose() {
365 cancelReadbackPoll(true);
366 cancelInitialPoll(true);
369 public void pollForUpdate() {
370 pollForDeviceData(lastPollResultCache);
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).
377 * @param cachedResponse - An Expiring cache that can be utilised to store the responses, to prevent poll bursts by
378 * coalescing the requests.
380 protected abstract void pollForDeviceData(final ExpiringCache<String> cachedResponse);
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.
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.
390 protected final String sendV2BypassControlCommand(final String method,
391 final VeSyncRequestManagedDeviceBypassV2.EmptyPayload payload) {
392 return sendV2BypassControlCommand(method, payload, true);
396 * Send a BypassV2 command to the device. The body of the response is returned.
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
402 * @return - The body of the response, or EMPTY_STRING if the command could not be issued.
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();
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");
420 if (MARKER_INVALID_DEVICE_KEY.equals(deviceLookupKey)) {
421 deviceLookupKey = getValidatedIdString();
423 VeSyncClient client = getVeSyncClient();
424 if (client != null) {
425 return client.reqV2Authorized(url, deviceLookupKey, request);
427 throw new DeviceUnknownException("Missing client");
429 } catch (AuthenticationException e) {
430 logger.debug("Auth exception {}", e.getMessage());
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();
442 * Send a BypassV2 command to the device. The body of the response is returned.
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.
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");
455 VeSyncRequestManagedDeviceBypassV2 readReq = new VeSyncRequestManagedDeviceBypassV2();
456 readReq.payload.method = method;
457 readReq.payload.data = payload;
460 if (MARKER_INVALID_DEVICE_KEY.equals(deviceLookupKey)) {
461 deviceLookupKey = getValidatedIdString();
463 VeSyncClient client = getVeSyncClient();
464 if (client != null) {
465 return client.reqV2Authorized(V2_BYPASS_ENDPOINT, deviceLookupKey, readReq);
467 throw new DeviceUnknownException("Missing client");
469 } catch (AuthenticationException e) {
470 logger.debug("Auth exception {}", e.getMessage());
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();
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");
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();
498 }, 1L, TimeUnit.SECONDS);
501 public void updateBridgeBasedPolls(VeSyncBridgeConfiguration config) {
504 protected boolean isDeviceSupported() {
505 final String deviceType = getThing().getProperties().get(DEVICE_PROP_DEVICE_TYPE);
506 return !getDeviceFamilyMetadata(deviceType).getDeviceFamilyName().equals(DEV_FAMILY_UNKNOWN);
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. __??__
514 * @return - The device type prefix for the device being modelled. E.g. LAP or LUH
516 public abstract String getDeviceFamilyProtocolPrefix();
519 * Subclasses should return list of VeSyncDeviceMetadata definitions that define the
520 * supported devices by their implementation.
522 * @return - List of VeSyncDeviceMetadata definitions, that defines groups of devices which
523 * are operationally the same device.
525 public abstract List<VeSyncDeviceMetadata> getSupportedDeviceMetadata();
527 public static VeSyncDeviceMetadata getDeviceFamilyMetadata(final @Nullable String deviceType,
528 final String deviceProtocolPrefix, final List<VeSyncDeviceMetadata> metadata) {
529 if (deviceType == null) {
532 final String[] idParts = deviceType.split("-");
533 if (idParts.length == 3) {
534 if (!deviceProtocolPrefix.equals(idParts[0])) {
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);
547 public VeSyncDeviceMetadata getDeviceFamilyMetadata(final @Nullable String deviceType) {
548 return getDeviceFamilyMetadata(deviceType, getDeviceFamilyProtocolPrefix(), getSupportedDeviceMetadata());