2 * Copyright (c) 2010-2022 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.hdpowerview.internal.handler;
15 import static org.openhab.binding.hdpowerview.internal.HDPowerViewBindingConstants.*;
16 import static org.openhab.binding.hdpowerview.internal.api.CoordinateSystem.*;
18 import java.util.HashMap;
19 import java.util.List;
21 import java.util.StringJoiner;
22 import java.util.concurrent.ScheduledFuture;
23 import java.util.concurrent.TimeUnit;
25 import javax.ws.rs.NotSupportedException;
27 import org.eclipse.jdt.annotation.NonNullByDefault;
28 import org.eclipse.jdt.annotation.Nullable;
29 import org.openhab.binding.hdpowerview.internal.HDPowerViewBindingConstants;
30 import org.openhab.binding.hdpowerview.internal.HDPowerViewWebTargets;
31 import org.openhab.binding.hdpowerview.internal.api.CoordinateSystem;
32 import org.openhab.binding.hdpowerview.internal.api.Firmware;
33 import org.openhab.binding.hdpowerview.internal.api.ShadePosition;
34 import org.openhab.binding.hdpowerview.internal.api.SurveyData;
35 import org.openhab.binding.hdpowerview.internal.api.responses.Shades.ShadeData;
36 import org.openhab.binding.hdpowerview.internal.config.HDPowerViewShadeConfiguration;
37 import org.openhab.binding.hdpowerview.internal.database.ShadeCapabilitiesDatabase;
38 import org.openhab.binding.hdpowerview.internal.database.ShadeCapabilitiesDatabase.Capabilities;
39 import org.openhab.binding.hdpowerview.internal.exceptions.HubException;
40 import org.openhab.binding.hdpowerview.internal.exceptions.HubInvalidResponseException;
41 import org.openhab.binding.hdpowerview.internal.exceptions.HubMaintenanceException;
42 import org.openhab.binding.hdpowerview.internal.exceptions.HubProcessingException;
43 import org.openhab.binding.hdpowerview.internal.exceptions.HubShadeTimeoutException;
44 import org.openhab.core.library.types.DecimalType;
45 import org.openhab.core.library.types.OnOffType;
46 import org.openhab.core.library.types.PercentType;
47 import org.openhab.core.library.types.QuantityType;
48 import org.openhab.core.library.types.StopMoveType;
49 import org.openhab.core.library.types.StringType;
50 import org.openhab.core.library.types.UpDownType;
51 import org.openhab.core.library.unit.Units;
52 import org.openhab.core.thing.Bridge;
53 import org.openhab.core.thing.ChannelUID;
54 import org.openhab.core.thing.Thing;
55 import org.openhab.core.thing.ThingStatus;
56 import org.openhab.core.thing.ThingStatusDetail;
57 import org.openhab.core.types.Command;
58 import org.openhab.core.types.RefreshType;
59 import org.openhab.core.types.UnDefType;
60 import org.slf4j.Logger;
61 import org.slf4j.LoggerFactory;
64 * Handles commands for an HD PowerView Shade
66 * @author Andy Lintner - Initial contribution
67 * @author Andrew Fiddian-Green - Added support for secondary rail positions
70 public class HDPowerViewShadeHandler extends AbstractHubbedThingHandler {
72 private enum RefreshKind {
78 private static final String COMMAND_CALIBRATE = "CALIBRATE";
79 private static final String COMMAND_IDENTIFY = "IDENTIFY";
81 private static final String DETECTED_SECONDARY_RAIL = "secondaryRailDetected";
82 private static final String DETECTED_TILT_ANYWHERE = "tiltAnywhereDetected";
83 private final Map<String, String> detectedCapabilities = new HashMap<>();
85 private final Logger logger = LoggerFactory.getLogger(HDPowerViewShadeHandler.class);
86 private final ShadeCapabilitiesDatabase db = new ShadeCapabilitiesDatabase();
88 private @Nullable ScheduledFuture<?> refreshPositionFuture = null;
89 private @Nullable ScheduledFuture<?> refreshSignalFuture = null;
90 private @Nullable ScheduledFuture<?> refreshBatteryLevelFuture = null;
91 private @Nullable Capabilities capabilities;
93 private boolean isDisposing;
95 public HDPowerViewShadeHandler(Thing thing) {
100 public void initialize() {
102 shadeId = getConfigAs(HDPowerViewShadeConfiguration.class).id;
103 logger.debug("Initializing shade handler for shade {}", shadeId);
104 Bridge bridge = getBridge();
105 if (bridge == null) {
106 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
107 "@text/offline.conf-error.invalid-bridge-handler");
111 updateStatus(ThingStatus.UNKNOWN);
115 public void dispose() {
116 logger.debug("Disposing shade handler for shade {}", shadeId);
118 ScheduledFuture<?> future = refreshPositionFuture;
119 if (future != null) {
122 refreshPositionFuture = null;
123 future = refreshSignalFuture;
124 if (future != null) {
127 refreshSignalFuture = null;
128 future = refreshBatteryLevelFuture;
129 if (future != null) {
132 refreshBatteryLevelFuture = null;
137 public void handleCommand(ChannelUID channelUID, Command command) {
138 String channelId = channelUID.getId();
140 if (RefreshType.REFRESH == command) {
142 case CHANNEL_SHADE_POSITION:
143 case CHANNEL_SHADE_SECONDARY_POSITION:
144 case CHANNEL_SHADE_VANE:
145 requestRefreshShadePosition();
147 case CHANNEL_SHADE_LOW_BATTERY:
148 case CHANNEL_SHADE_BATTERY_LEVEL:
149 case CHANNEL_SHADE_BATTERY_VOLTAGE:
150 requestRefreshShadeBatteryLevel();
152 case CHANNEL_SHADE_SIGNAL_STRENGTH:
153 requestRefreshShadeSurvey();
159 HDPowerViewHubHandler bridge = getBridgeHandler();
160 if (bridge == null) {
161 logger.warn("Missing bridge handler");
164 HDPowerViewWebTargets webTargets = bridge.getWebTargets();
166 handleShadeCommand(channelId, command, webTargets, shadeId);
167 } catch (HubInvalidResponseException e) {
168 Throwable cause = e.getCause();
170 logger.warn("Bridge returned a bad JSON response: {}", e.getMessage());
172 logger.warn("Bridge returned a bad JSON response: {} -> {}", e.getMessage(), cause.getMessage());
174 } catch (HubMaintenanceException e) {
175 // exceptions are logged in HDPowerViewWebTargets
176 } catch (HubShadeTimeoutException e) {
177 logger.warn("Shade {} timeout when sending command {}", shadeId, command);
178 } catch (HubException e) {
179 // ScheduledFutures will be cancelled by dispose(), naturally causing InterruptedException in invoke()
180 // for any ongoing requests. Logging this would only cause confusion.
182 logger.warn("Unexpected error: {}", e.getMessage());
187 private void handleShadeCommand(String channelId, Command command, HDPowerViewWebTargets webTargets, int shadeId)
188 throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException,
189 HubShadeTimeoutException {
191 case CHANNEL_SHADE_POSITION:
192 if (command instanceof PercentType) {
193 moveShade(PRIMARY_POSITION, ((PercentType) command).intValue(), webTargets, shadeId);
194 } else if (command instanceof UpDownType) {
195 moveShade(PRIMARY_POSITION, UpDownType.UP == command ? 0 : 100, webTargets, shadeId);
196 } else if (command instanceof StopMoveType) {
197 if (StopMoveType.STOP == command) {
198 stopShade(webTargets, shadeId);
200 logger.warn("Unexpected StopMoveType command");
205 case CHANNEL_SHADE_VANE:
206 if (command instanceof PercentType) {
207 moveShade(VANE_TILT_POSITION, ((PercentType) command).intValue(), webTargets, shadeId);
208 } else if (command instanceof OnOffType) {
209 moveShade(VANE_TILT_POSITION, OnOffType.ON == command ? 100 : 0, webTargets, shadeId);
213 case CHANNEL_SHADE_SECONDARY_POSITION:
214 if (command instanceof PercentType) {
215 moveShade(SECONDARY_POSITION, ((PercentType) command).intValue(), webTargets, shadeId);
216 } else if (command instanceof UpDownType) {
217 moveShade(SECONDARY_POSITION, UpDownType.UP == command ? 0 : 100, webTargets, shadeId);
218 } else if (command instanceof StopMoveType) {
219 if (StopMoveType.STOP == command) {
220 stopShade(webTargets, shadeId);
222 logger.warn("Unexpected StopMoveType command");
227 case CHANNEL_SHADE_COMMAND:
228 if (command instanceof StringType) {
229 if (COMMAND_IDENTIFY.equals(((StringType) command).toString())) {
230 logger.debug("Identify shade {}", shadeId);
231 identifyShade(webTargets, shadeId);
232 } else if (COMMAND_CALIBRATE.equals(((StringType) command).toString())) {
233 logger.debug("Calibrate shade {}", shadeId);
234 calibrateShade(webTargets, shadeId);
237 logger.warn("Unsupported command: {}. Supported commands are: " + COMMAND_CALIBRATE, command);
244 * Update the state of the channels based on the ShadeData provided.
246 * @param shadeData the ShadeData to be used.
248 protected void onReceiveUpdate(ShadeData shadeData) {
249 updateStatus(ThingStatus.ONLINE);
250 updateCapabilities(shadeData);
251 updateSoftProperties(shadeData);
252 updateFirmwareProperties(shadeData);
253 ShadePosition shadePosition = shadeData.positions;
254 if (shadePosition != null) {
255 updatePositionStates(shadePosition);
257 updateBatteryStates(shadeData.batteryStatus, shadeData.batteryStrength);
258 updateSignalStrengthState(shadeData.signalStrength);
261 private void updateCapabilities(ShadeData shade) {
262 if (capabilities != null) {
266 Capabilities capabilities = db.getCapabilities(shade.type, shade.capabilities);
267 if (capabilities.getValue() < 0) {
268 logger.debug("Unable to set capabilities for shade {}", shade.id);
271 logger.debug("Caching capabilities {} for shade {}", capabilities.getValue(), shade.id);
272 this.capabilities = capabilities;
275 private Capabilities getCapabilitiesOrDefault() {
276 Capabilities capabilities = this.capabilities;
277 if (capabilities == null) {
278 return new Capabilities();
284 * Update the Thing's properties based on the contents of the provided ShadeData.
286 * Checks the database of known Shade 'types' and 'capabilities' and logs any unknown or incompatible values, so
287 * that developers can be kept updated about the potential need to add support for that type resp. capabilities.
291 private void updateSoftProperties(ShadeData shadeData) {
292 final Map<String, String> properties = getThing().getProperties();
293 boolean propChanged = false;
295 // update 'type' property
296 final int type = shadeData.type;
297 String propKey = HDPowerViewBindingConstants.PROPERTY_SHADE_TYPE;
298 String propOldVal = properties.getOrDefault(propKey, "");
299 String propNewVal = db.getType(type).toString();
300 if (!propNewVal.equals(propOldVal)) {
302 getThing().setProperty(propKey, propNewVal);
303 if ((type > 0) && !db.isTypeInDatabase(type)) {
304 db.logTypeNotInDatabase(type);
308 // update 'capabilities' property
309 Capabilities capabilities = db.getCapabilities(shadeData.capabilities);
310 final int capabilitiesVal = capabilities.getValue();
311 propKey = HDPowerViewBindingConstants.PROPERTY_SHADE_CAPABILITIES;
312 propOldVal = properties.getOrDefault(propKey, "");
313 propNewVal = capabilities.toString();
314 if (!propNewVal.equals(propOldVal)) {
316 getThing().setProperty(propKey, propNewVal);
317 if ((capabilitiesVal >= 0) && !db.isCapabilitiesInDatabase(capabilitiesVal)) {
318 db.logCapabilitiesNotInDatabase(type, capabilitiesVal);
322 if (propChanged && db.isCapabilitiesInDatabase(capabilitiesVal) && db.isTypeInDatabase(type)
323 && (capabilitiesVal != db.getType(type).getCapabilities()) && (shadeData.capabilities != null)) {
324 db.logCapabilitiesMismatch(type, capabilitiesVal);
328 private void updateFirmwareProperties(ShadeData shadeData) {
329 Map<String, String> properties = editProperties();
330 Firmware shadeFirmware = shadeData.firmware;
331 Firmware motorFirmware = shadeData.motor;
332 if (shadeFirmware != null) {
333 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, shadeFirmware.toString());
335 if (motorFirmware != null) {
336 properties.put(PROPERTY_MOTOR_FIRMWARE_VERSION, motorFirmware.toString());
338 updateProperties(properties);
342 * After a hard refresh, update the Thing's detected capabilities based on the contents of the provided ShadeData.
344 * Checks if the secondary support capabilities in the database of known Shade 'types' and 'capabilities' matches
345 * that implied by the ShadeData and logs any incompatible values, so that developers can be kept updated about the
346 * potential need to add support for that type resp. capabilities.
350 private void updateDetectedCapabilities(ShadeData shadeData) {
351 final ShadePosition positions = shadeData.positions;
352 if (positions == null) {
355 Capabilities capabilities = getCapabilitiesOrDefault();
357 // update 'secondary rail' detected capability
358 String capsKey = DETECTED_SECONDARY_RAIL;
359 String capsOldVal = detectedCapabilities.getOrDefault(capsKey, "");
360 boolean capsNewBool = positions.secondaryRailDetected();
361 String capsNewVal = String.valueOf(capsNewBool);
362 if (!capsNewVal.equals(capsOldVal)) {
363 detectedCapabilities.put(capsKey, capsNewVal);
364 if (capsNewBool != capabilities.supportsSecondary()) {
365 db.logPropertyMismatch(capsKey, shadeData.type, capabilities.getValue(), capsNewBool);
369 // update 'tilt anywhere' detected capability
370 capsKey = DETECTED_TILT_ANYWHERE;
371 capsOldVal = detectedCapabilities.getOrDefault(capsKey, "");
372 capsNewBool = positions.tiltAnywhereDetected();
373 capsNewVal = String.valueOf(capsNewBool);
374 if (!capsNewVal.equals(capsOldVal)) {
375 detectedCapabilities.put(capsKey, capsNewVal);
376 if (capsNewBool != capabilities.supportsTiltAnywhere()) {
377 db.logPropertyMismatch(capsKey, shadeData.type, capabilities.getValue(), capsNewBool);
382 private void updatePositionStates(ShadePosition shadePos) {
383 Capabilities capabilities = this.capabilities;
384 if (capabilities == null) {
385 logger.debug("The 'shadeCapabilities' field has not yet been initialized");
386 updateState(CHANNEL_SHADE_POSITION, UnDefType.UNDEF);
387 updateState(CHANNEL_SHADE_VANE, UnDefType.UNDEF);
388 updateState(CHANNEL_SHADE_SECONDARY_POSITION, UnDefType.UNDEF);
391 updateState(CHANNEL_SHADE_POSITION, shadePos.getState(capabilities, PRIMARY_POSITION));
392 updateState(CHANNEL_SHADE_VANE, shadePos.getState(capabilities, VANE_TILT_POSITION));
393 updateState(CHANNEL_SHADE_SECONDARY_POSITION, shadePos.getState(capabilities, SECONDARY_POSITION));
396 private void updateBatteryStates(int batteryStatus, double batteryStrength) {
397 updateBatteryLevelStates(batteryStatus);
398 updateState(CHANNEL_SHADE_BATTERY_VOLTAGE,
399 batteryStrength > 0 ? new QuantityType<>(batteryStrength / 10, Units.VOLT) : UnDefType.UNDEF);
402 private void updateBatteryLevelStates(int batteryStatus) {
404 switch (batteryStatus) {
412 case 4: // Plugged in
415 default: // No status available (0) or invalid
416 updateState(CHANNEL_SHADE_LOW_BATTERY, UnDefType.UNDEF);
417 updateState(CHANNEL_SHADE_BATTERY_LEVEL, UnDefType.UNDEF);
420 updateState(CHANNEL_SHADE_LOW_BATTERY, batteryStatus == 1 ? OnOffType.ON : OnOffType.OFF);
421 updateState(CHANNEL_SHADE_BATTERY_LEVEL, new DecimalType(mappedValue));
424 private void updateSignalStrengthState(int signalStrength) {
425 updateState(CHANNEL_SHADE_SIGNAL_STRENGTH, new DecimalType(signalStrength));
428 private void moveShade(CoordinateSystem coordSys, int newPercent, HDPowerViewWebTargets webTargets, int shadeId)
429 throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException,
430 HubShadeTimeoutException {
431 ShadePosition newPosition = null;
432 // (try to) read the positions from the hub
433 ShadeData shadeData = webTargets.getShade(shadeId);
434 updateCapabilities(shadeData);
435 newPosition = shadeData.positions;
436 // if no positions returned, then create a new position
437 if (newPosition == null) {
438 newPosition = new ShadePosition();
440 Capabilities capabilities = getCapabilitiesOrDefault();
441 // set the new position value, and write the positions to the hub
442 shadeData = webTargets.moveShade(shadeId, newPosition.setPosition(capabilities, coordSys, newPercent));
443 updateShadePositions(shadeData);
446 private void stopShade(HDPowerViewWebTargets webTargets, int shadeId) throws HubInvalidResponseException,
447 HubProcessingException, HubMaintenanceException, HubShadeTimeoutException {
448 updateShadePositions(webTargets.stopShade(shadeId));
449 // Positions in response from stop motion is not updated to to actual positions yet,
450 // so we need to request hard refresh.
451 requestRefreshShadePosition();
454 private void identifyShade(HDPowerViewWebTargets webTargets, int shadeId) throws HubInvalidResponseException,
455 HubProcessingException, HubMaintenanceException, HubShadeTimeoutException {
456 updateShadePositions(webTargets.jogShade(shadeId));
459 private void calibrateShade(HDPowerViewWebTargets webTargets, int shadeId) throws HubInvalidResponseException,
460 HubProcessingException, HubMaintenanceException, HubShadeTimeoutException {
461 updateShadePositions(webTargets.calibrateShade(shadeId));
464 private void updateShadePositions(ShadeData shadeData) {
465 ShadePosition shadePosition = shadeData.positions;
466 if (shadePosition == null) {
469 updateCapabilities(shadeData);
470 updatePositionStates(shadePosition);
474 * Request that the shade shall undergo a 'hard' refresh for querying its current position
476 protected synchronized void requestRefreshShadePosition() {
477 if (refreshPositionFuture == null) {
478 refreshPositionFuture = scheduler.schedule(this::doRefreshShadePosition, 0, TimeUnit.SECONDS);
483 * Request that the shade shall undergo a 'hard' refresh for querying its survey data
485 protected synchronized void requestRefreshShadeSurvey() {
486 if (refreshSignalFuture == null) {
487 refreshSignalFuture = scheduler.schedule(this::doRefreshShadeSignal, 0, TimeUnit.SECONDS);
492 * Request that the shade shall undergo a 'hard' refresh for querying its battery level state
494 protected synchronized void requestRefreshShadeBatteryLevel() {
495 if (refreshBatteryLevelFuture == null) {
496 refreshBatteryLevelFuture = scheduler.schedule(this::doRefreshShadeBatteryLevel, 0, TimeUnit.SECONDS);
500 private void doRefreshShadePosition() {
501 this.doRefreshShade(RefreshKind.POSITION);
502 refreshPositionFuture = null;
505 private void doRefreshShadeSignal() {
506 this.doRefreshShade(RefreshKind.SURVEY);
507 refreshSignalFuture = null;
510 private void doRefreshShadeBatteryLevel() {
511 this.doRefreshShade(RefreshKind.BATTERY_LEVEL);
512 refreshBatteryLevelFuture = null;
515 private void doRefreshShade(RefreshKind kind) {
517 HDPowerViewHubHandler bridge;
518 if ((bridge = getBridgeHandler()) == null) {
519 throw new HubProcessingException("Missing bridge handler");
521 HDPowerViewWebTargets webTargets = bridge.getWebTargets();
525 shadeData = webTargets.refreshShadePosition(shadeId);
526 updateShadePositions(shadeData);
527 updateDetectedCapabilities(shadeData);
530 List<SurveyData> surveyData = webTargets.getShadeSurvey(shadeId);
531 if (!surveyData.isEmpty()) {
532 if (logger.isDebugEnabled()) {
533 StringJoiner joiner = new StringJoiner(", ");
534 surveyData.forEach(data -> joiner.add(data.toString()));
535 logger.debug("Survey response for shade {}: {}", shadeId, joiner.toString());
537 shadeData = webTargets.getShade(shadeId);
538 updateSignalStrengthState(shadeData.signalStrength);
540 logger.info("No data from shade {} survey", shadeId);
542 * Setting channel to UNDEF here would be reverted on next poll, since
543 * signal strength is part of shade response. So leaving current value,
544 * even though refreshing the value failed.
549 shadeData = webTargets.refreshShadeBatteryLevel(shadeId);
550 updateBatteryStates(shadeData.batteryStatus, shadeData.batteryStrength);
553 throw new NotSupportedException("Unsupported refresh kind " + kind.toString());
555 } catch (HubInvalidResponseException e) {
556 Throwable cause = e.getCause();
558 logger.warn("Bridge returned a bad JSON response: {}", e.getMessage());
560 logger.warn("Bridge returned a bad JSON response: {} -> {}", e.getMessage(), cause.getMessage());
562 } catch (HubMaintenanceException e) {
563 // exceptions are logged in HDPowerViewWebTargets
564 } catch (HubShadeTimeoutException e) {
565 logger.info("Shade {} wireless refresh time out", shadeId);
566 } catch (HubException e) {
567 // ScheduledFutures will be cancelled by dispose(), naturally causing InterruptedException in invoke()
568 // for any ongoing requests. Logging this would only cause confusion.
570 logger.warn("Unexpected error: {}", e.getMessage());