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 != null && bridgeHandler instanceof VeSyncBridgeHandler) {
152 VeSyncBridgeHandler vesyncBridgeHandler = (VeSyncBridgeHandler) bridgeHandler;
154 VeSyncManagedDeviceBase metadata = vesyncBridgeHandler.api.getMacLookupMap().get(deviceLookupKey);
156 if (metadata == null) {
160 return ("online".equals(metadata.connectionStatus));
165 public void updateDeviceMetaData() {
166 Map<String, String> newProps = null;
168 BridgeHandler bridgeHandler = getBridgeHandler();
169 if (bridgeHandler != null && bridgeHandler instanceof VeSyncBridgeHandler) {
170 VeSyncBridgeHandler vesyncBridgeHandler = (VeSyncBridgeHandler) bridgeHandler;
172 VeSyncManagedDeviceBase metadata = vesyncBridgeHandler.api.getMacLookupMap().get(deviceLookupKey);
174 if (metadata == null) {
178 newProps = getMetadataProperities(metadata);
180 // Refresh the device -> protocol mapping
181 deviceLookupKey = getValidatedIdString();
183 if ("online".equals(metadata.connectionStatus)) {
184 updateStatus(ThingStatus.ONLINE);
185 } else if ("offline".equals(metadata.connectionStatus)) {
186 updateStatus(ThingStatus.OFFLINE);
190 if (newProps != null && !newProps.isEmpty()) {
191 this.updateProperties(newProps);
193 if (!isDeviceSupported()) {
194 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
195 "Device Model or Type not supported by this thing");
201 * Override this in classes that extend this, to
203 protected void customiseChannels() {
206 protected String[] getChannelsToRemove() {
207 return new String[] {};
210 private void removeChannels() {
211 final String[] channelsToRemove = getChannelsToRemove();
212 final List<Channel> channelsToBeRemoved = new ArrayList<>();
213 for (String name : channelsToRemove) {
214 Channel ch = getThing().getChannel(name);
216 channelsToBeRemoved.add(ch);
220 final ThingBuilder builder = editThing().withoutChannels(channelsToBeRemoved);
221 updateThing(builder.build());
225 * Extract the common properties for all devices, from the given meta-data of a device.
227 * @param metadata - the meta-data of a device
228 * @return - Map of common props
230 public Map<String, String> getMetadataProperities(final @Nullable VeSyncManagedDeviceBase metadata) {
231 if (metadata == null) {
234 final Map<String, String> newProps = new HashMap<>(4);
235 newProps.put(DEVICE_PROP_DEVICE_MAC_ID, metadata.getMacId());
236 newProps.put(DEVICE_PROP_DEVICE_NAME, metadata.getDeviceName());
237 newProps.put(DEVICE_PROP_DEVICE_TYPE, metadata.getDeviceType());
238 newProps.put(DEVICE_PROP_DEVICE_FAMILY,
239 getDeviceFamilyMetadata(metadata.getDeviceType()).getDeviceFamilyName());
240 newProps.put(DEVICE_PROP_DEVICE_UUID, metadata.getUuid());
244 protected synchronized @Nullable VeSyncClient getVeSyncClient() {
245 if (veSyncClient == null) {
246 Bridge bridge = getBridge();
247 if (bridge == null) {
250 ThingHandler handler = bridge.getHandler();
251 if (handler instanceof VeSyncClient) {
252 veSyncClient = (VeSyncClient) handler;
260 protected void requestBridgeFreqScanMetadataIfReq() {
261 if (requiresMetaDataFrequentUpdates()) {
262 BridgeHandler bridgeHandler = getBridgeHandler();
263 if (bridgeHandler != null && bridgeHandler instanceof VeSyncBridgeHandler) {
264 VeSyncBridgeHandler vesyncBridgeHandler = (VeSyncBridgeHandler) bridgeHandler;
265 vesyncBridgeHandler.checkIfIncreaseScanRateRequired();
271 public String getValidatedIdString() {
272 final VeSyncDeviceConfiguration config = getConfigAs(VeSyncDeviceConfiguration.class);
274 BridgeHandler bridgeHandler = getBridgeHandler();
275 if (bridgeHandler != null && bridgeHandler instanceof VeSyncBridgeHandler) {
276 VeSyncBridgeHandler vesyncBridgeHandler = (VeSyncBridgeHandler) bridgeHandler;
278 final String configMac = config.macId;
280 // Try to use the mac directly
281 if (configMac != null) {
282 logger.debug("Searching for device mac id : {}", configMac);
284 VeSyncManagedDeviceBase metadata = vesyncBridgeHandler.api.getMacLookupMap()
285 .get(configMac.toLowerCase());
287 if (metadata != null && metadata.macId != null) {
288 return metadata.macId;
292 final String deviceName = config.deviceName;
294 // Check if the device name can be matched to a single device
295 if (deviceName != null) {
296 final String[] matchedMacIds = vesyncBridgeHandler.api.getMacLookupMap().values().stream()
297 .filter(x -> deviceName.equals(x.deviceName)).map(x -> x.macId).toArray(String[]::new);
299 for (String val : matchedMacIds) {
300 logger.debug("Found MAC match on name with : {}", val);
303 if (matchedMacIds.length != 1) {
304 return MARKER_INVALID_DEVICE_KEY;
307 if (vesyncBridgeHandler.api.getMacLookupMap().get(matchedMacIds[0]) != null) {
308 return matchedMacIds[0];
313 return MARKER_INVALID_DEVICE_KEY;
317 public void initialize() {
318 intializeDeviceForUse();
321 private void intializeDeviceForUse() {
322 // Sanity check basic setup
323 final VeSyncBridgeHandler bridge = (VeSyncBridgeHandler) getBridgeHandler();
324 if (bridge == null) {
325 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED, "Missing bridge for API link");
328 updateStatus(ThingStatus.UNKNOWN);
331 deviceLookupKey = getValidatedIdString();
333 // Populate device props - this is required for polling, to cross-check the device model.
334 updateDeviceMetaData();
336 // If the base device class marks it as offline there is an issue that will prevent normal operation
337 if (getThing().getStatus().equals(ThingStatus.OFFLINE)) {
340 // This will force the bridge to push the configuration parameters for polling to the handler
341 bridge.updateThing(this);
343 // Give the bridge time to build the datamaps of the devices
344 scheduleInitialPoll();
347 private void scheduleInitialPoll() {
348 cancelInitialPoll(false);
349 initialPollingTask = scheduler.schedule(this::pollForUpdate, 10, TimeUnit.SECONDS);
352 private void cancelInitialPoll(final boolean interruptAllowed) {
353 final ScheduledFuture<?> pollJob = initialPollingTask;
354 if (pollJob != null && !pollJob.isCancelled()) {
355 pollJob.cancel(interruptAllowed);
356 initialPollingTask = null;
360 private void cancelReadbackPoll(final boolean interruptAllowed) {
361 final ScheduledFuture<?> pollJob = readbackPollTask;
362 if (pollJob != null && !pollJob.isCancelled()) {
363 pollJob.cancel(interruptAllowed);
364 readbackPollTask = null;
369 public void dispose() {
370 cancelReadbackPoll(true);
371 cancelInitialPoll(true);
374 public void pollForUpdate() {
375 pollForDeviceData(lastPollResultCache);
379 * This should be implemented by subclasses to provide the implementation for polling the specific
380 * data for the type the class is responsible for. (Excluding meta data).
382 * @param cachedResponse - An Expiring cache that can be utilised to store the responses, to prevent poll bursts by
383 * coalescing the requests.
385 protected abstract void pollForDeviceData(final ExpiringCache<String> cachedResponse);
388 * Send a BypassV2 command to the device. The body of the response is returned, a poll is done if the request
389 * should have been dispatched.
391 * @param method - the V2 bypass method
392 * @param payload - The payload to send in within the V2 bypass command
393 * @return - The body of the response, or EMPTY_STRING if the command could not be issued.
395 protected final String sendV2BypassControlCommand(final String method,
396 final VeSyncRequestManagedDeviceBypassV2.EmptyPayload payload) {
397 return sendV2BypassControlCommand(method, payload, true);
401 * Send a BypassV2 command to the device. The body of the response is returned.
403 * @param method - the V2 bypass method
404 * @param payload - The payload to send in within the V2 bypass command
405 * @param readbackDevice - if set to true after the command has been issued, whether a poll of the devices data
407 * @return - The body of the response, or EMPTY_STRING if the command could not be issued.
409 protected final String sendV2BypassControlCommand(final String method,
410 final VeSyncRequestManagedDeviceBypassV2.EmptyPayload payload, final boolean readbackDevice) {
411 final String result = sendV2BypassCommand(method, payload);
412 if (!result.equals(EMPTY_STRING) && readbackDevice) {
413 performReadbackPoll();
418 public final String sendV1Command(final String method, final String url, final VeSyncAuthenticatedRequest request) {
419 if (ThingStatus.OFFLINE.equals(this.thing.getStatus())) {
420 logger.debug("Command blocked as device is offline");
425 if (MARKER_INVALID_DEVICE_KEY.equals(deviceLookupKey)) {
426 deviceLookupKey = getValidatedIdString();
428 VeSyncClient client = getVeSyncClient();
429 if (client != null) {
430 return client.reqV2Authorized(url, deviceLookupKey, request);
432 throw new DeviceUnknownException("Missing client");
434 } catch (AuthenticationException e) {
435 logger.debug("Auth exception {}", e.getMessage());
437 } catch (final DeviceUnknownException e) {
438 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
439 "Check configuration details - " + e.getMessage());
440 // In case the name is updated server side - request the scan rate is increased
441 requestBridgeFreqScanMetadataIfReq();
447 * Send a BypassV2 command to the device. The body of the response is returned.
449 * @param method - the V2 bypass method
450 * @param payload - The payload to send in within the V2 bypass command
451 * @return - The body of the response, or EMPTY_STRING if the command could not be issued.
453 protected final String sendV2BypassCommand(final String method,
454 final VeSyncRequestManagedDeviceBypassV2.EmptyPayload payload) {
455 if (ThingStatus.OFFLINE.equals(this.thing.getStatus())) {
456 logger.debug("Command blocked as device is offline");
460 VeSyncRequestManagedDeviceBypassV2 readReq = new VeSyncRequestManagedDeviceBypassV2();
461 readReq.payload.method = method;
462 readReq.payload.data = payload;
465 if (MARKER_INVALID_DEVICE_KEY.equals(deviceLookupKey)) {
466 deviceLookupKey = getValidatedIdString();
468 VeSyncClient client = getVeSyncClient();
469 if (client != null) {
470 return client.reqV2Authorized(V2_BYPASS_ENDPOINT, deviceLookupKey, readReq);
472 throw new DeviceUnknownException("Missing client");
474 } catch (AuthenticationException e) {
475 logger.debug("Auth exception {}", e.getMessage());
477 } catch (final DeviceUnknownException e) {
478 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
479 "Check configuration details - " + e.getMessage());
480 // In case the name is updated server side - request the scan rate is increased
481 requestBridgeFreqScanMetadataIfReq();
486 // Given several changes may be done at the same time, or in close proximity, delay the read-back to catch
487 // multiple read-back's, so a single update can handle them.
488 public void performReadbackPoll() {
489 final long requestSystemMillis = System.currentTimeMillis();
490 latestReadBackMillis = requestSystemMillis;
491 cancelReadbackPoll(false);
492 readbackPollTask = scheduler.schedule(() -> {
493 // This is a historical poll, ignore it
494 if (requestSystemMillis != latestReadBackMillis) {
495 logger.trace("Poll read-back cancelled, another later one is scheduled to happen");
498 logger.trace("Read-back poll executing");
499 // Read-backs should never use the cached data - but may provide it for poll's that coincide with
500 // the caches alive duration.
501 lastPollResultCache.invalidateValue();
503 }, 1L, TimeUnit.SECONDS);
506 public void updateBridgeBasedPolls(VeSyncBridgeConfiguration config) {
509 protected boolean isDeviceSupported() {
510 final String deviceType = getThing().getProperties().get(DEVICE_PROP_DEVICE_TYPE);
511 return !getDeviceFamilyMetadata(deviceType).getDeviceFamilyName().equals(DEV_FAMILY_UNKNOWN);
515 * Subclasses should return the protocol prefix for the device type being modelled.
516 * E.g. LUH = The Humidifier Devices; LAP = The Air Purifiers;
517 * if irrelevant return a string that will not be used in the protocol e.g. __??__
519 * @return - The device type prefix for the device being modelled. E.g. LAP or LUH
521 public abstract String getDeviceFamilyProtocolPrefix();
524 * Subclasses should return list of VeSyncDeviceMetadata definitions that define the
525 * supported devices by their implementation.
527 * @return - List of VeSyncDeviceMetadata definitions, that defines groups of devices which
528 * are operationally the same device.
530 public abstract List<VeSyncDeviceMetadata> getSupportedDeviceMetadata();
532 public static VeSyncDeviceMetadata getDeviceFamilyMetadata(final @Nullable String deviceType,
533 final String deviceProtocolPrefix, final List<VeSyncDeviceMetadata> metadata) {
534 if (deviceType == null) {
537 final String[] idParts = deviceType.split("-");
538 if (idParts.length == 3) {
539 if (!deviceProtocolPrefix.equals(idParts[0])) {
543 List<VeSyncDeviceMetadata> foundMatch = metadata.stream()
544 .filter(x -> x.deviceTypeIdMatches(deviceType, idParts)).collect(Collectors.toList());
545 if (foundMatch.size() == 1) {
546 return foundMatch.get(0);
552 public VeSyncDeviceMetadata getDeviceFamilyMetadata(final @Nullable String deviceType) {
553 return getDeviceFamilyMetadata(deviceType, getDeviceFamilyProtocolPrefix(), getSupportedDeviceMetadata());