]> git.basschouten.com Git - openhab-addons.git/blob
e5e9f7ceee9098948ec05bcddd5d033dcde18341
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 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.hdpowerview.internal.handler;
14
15 import static org.openhab.binding.hdpowerview.internal.HDPowerViewBindingConstants.*;
16 import static org.openhab.binding.hdpowerview.internal.api.CoordinateSystem.*;
17
18 import java.util.Map;
19 import java.util.concurrent.ScheduledFuture;
20 import java.util.concurrent.TimeUnit;
21
22 import javax.ws.rs.NotSupportedException;
23
24 import org.eclipse.jdt.annotation.NonNullByDefault;
25 import org.eclipse.jdt.annotation.Nullable;
26 import org.openhab.binding.hdpowerview.internal.HDPowerViewBindingConstants;
27 import org.openhab.binding.hdpowerview.internal.HDPowerViewWebTargets;
28 import org.openhab.binding.hdpowerview.internal.HubMaintenanceException;
29 import org.openhab.binding.hdpowerview.internal.HubProcessingException;
30 import org.openhab.binding.hdpowerview.internal.api.CoordinateSystem;
31 import org.openhab.binding.hdpowerview.internal.api.Firmware;
32 import org.openhab.binding.hdpowerview.internal.api.ShadePosition;
33 import org.openhab.binding.hdpowerview.internal.api.responses.Shade;
34 import org.openhab.binding.hdpowerview.internal.api.responses.Shades.ShadeData;
35 import org.openhab.binding.hdpowerview.internal.api.responses.Survey;
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.core.library.types.DecimalType;
40 import org.openhab.core.library.types.OnOffType;
41 import org.openhab.core.library.types.PercentType;
42 import org.openhab.core.library.types.QuantityType;
43 import org.openhab.core.library.types.StopMoveType;
44 import org.openhab.core.library.types.UpDownType;
45 import org.openhab.core.library.unit.Units;
46 import org.openhab.core.thing.Bridge;
47 import org.openhab.core.thing.ChannelUID;
48 import org.openhab.core.thing.Thing;
49 import org.openhab.core.thing.ThingStatus;
50 import org.openhab.core.thing.ThingStatusDetail;
51 import org.openhab.core.types.Command;
52 import org.openhab.core.types.RefreshType;
53 import org.openhab.core.types.UnDefType;
54 import org.slf4j.Logger;
55 import org.slf4j.LoggerFactory;
56
57 import com.google.gson.JsonParseException;
58
59 /**
60  * Handles commands for an HD PowerView Shade
61  *
62  * @author Andy Lintner - Initial contribution
63  * @author Andrew Fiddian-Green - Added support for secondary rail positions
64  */
65 @NonNullByDefault
66 public class HDPowerViewShadeHandler extends AbstractHubbedThingHandler {
67
68     private enum RefreshKind {
69         POSITION,
70         SURVEY,
71         BATTERY_LEVEL
72     }
73
74     private final Logger logger = LoggerFactory.getLogger(HDPowerViewShadeHandler.class);
75     private final ShadeCapabilitiesDatabase db = new ShadeCapabilitiesDatabase();
76
77     private @Nullable ScheduledFuture<?> refreshPositionFuture = null;
78     private @Nullable ScheduledFuture<?> refreshSignalFuture = null;
79     private @Nullable ScheduledFuture<?> refreshBatteryLevelFuture = null;
80     private @Nullable Capabilities capabilities;
81     private int shadeId;
82     private boolean isDisposing;
83
84     public HDPowerViewShadeHandler(Thing thing) {
85         super(thing);
86     }
87
88     @Override
89     public void initialize() {
90         logger.debug("Initializing shade handler");
91         isDisposing = false;
92         try {
93             shadeId = getShadeId();
94         } catch (NumberFormatException e) {
95             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
96                     "@text/offline.conf-error.invalid-id");
97             return;
98         }
99         Bridge bridge = getBridge();
100         if (bridge == null) {
101             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED);
102             return;
103         }
104         if (!(bridge.getHandler() instanceof HDPowerViewHubHandler)) {
105             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED,
106                     "@text/offline.conf-error.invalid-bridge-handler");
107             return;
108         }
109         ThingStatus bridgeStatus = bridge.getStatus();
110         if (bridgeStatus == ThingStatus.ONLINE) {
111             updateStatus(ThingStatus.UNKNOWN);
112         } else {
113             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
114         }
115     }
116
117     @Override
118     public void dispose() {
119         logger.debug("Disposing shade handler for shade {}", shadeId);
120         isDisposing = true;
121         ScheduledFuture<?> future = refreshPositionFuture;
122         if (future != null) {
123             future.cancel(true);
124         }
125         refreshPositionFuture = null;
126         future = refreshSignalFuture;
127         if (future != null) {
128             future.cancel(true);
129         }
130         refreshSignalFuture = null;
131         future = refreshBatteryLevelFuture;
132         if (future != null) {
133             future.cancel(true);
134         }
135         refreshBatteryLevelFuture = null;
136         capabilities = null;
137     }
138
139     @Override
140     public void handleCommand(ChannelUID channelUID, Command command) {
141         String channelId = channelUID.getId();
142
143         if (RefreshType.REFRESH == command) {
144             switch (channelId) {
145                 case CHANNEL_SHADE_POSITION:
146                 case CHANNEL_SHADE_SECONDARY_POSITION:
147                 case CHANNEL_SHADE_VANE:
148                     requestRefreshShadePosition();
149                     break;
150                 case CHANNEL_SHADE_LOW_BATTERY:
151                 case CHANNEL_SHADE_BATTERY_LEVEL:
152                 case CHANNEL_SHADE_BATTERY_VOLTAGE:
153                     requestRefreshShadeBatteryLevel();
154                     break;
155                 case CHANNEL_SHADE_SIGNAL_STRENGTH:
156                     requestRefreshShadeSurvey();
157                     break;
158             }
159             return;
160         }
161
162         HDPowerViewHubHandler bridge = getBridgeHandler();
163         if (bridge == null) {
164             logger.warn("Missing bridge handler");
165             return;
166         }
167         HDPowerViewWebTargets webTargets = bridge.getWebTargets();
168         if (webTargets == null) {
169             logger.warn("Web targets not initialized");
170             return;
171         }
172         try {
173             handleShadeCommand(channelId, command, webTargets, shadeId);
174         } catch (JsonParseException e) {
175             logger.warn("Bridge returned a bad JSON response: {}", e.getMessage());
176         } catch (HubProcessingException e) {
177             // ScheduledFutures will be cancelled by dispose(), naturally causing InterruptedException in invoke()
178             // for any ongoing requests. Logging this would only cause confusion.
179             if (!isDisposing) {
180                 logger.warn("Unexpected error: {}", e.getMessage());
181             }
182         } catch (HubMaintenanceException e) {
183             // exceptions are logged in HDPowerViewWebTargets
184         }
185     }
186
187     private void handleShadeCommand(String channelId, Command command, HDPowerViewWebTargets webTargets, int shadeId)
188             throws HubProcessingException, HubMaintenanceException {
189         switch (channelId) {
190             case CHANNEL_SHADE_POSITION:
191                 if (command instanceof PercentType) {
192                     moveShade(PRIMARY_ZERO_IS_CLOSED, ((PercentType) command).intValue(), webTargets, shadeId);
193                 } else if (command instanceof UpDownType) {
194                     moveShade(PRIMARY_ZERO_IS_CLOSED, UpDownType.UP == command ? 0 : 100, webTargets, shadeId);
195                 } else if (command instanceof StopMoveType) {
196                     if (StopMoveType.STOP == command) {
197                         stopShade(webTargets, shadeId);
198                     } else {
199                         logger.warn("Unexpected StopMoveType command");
200                     }
201                 }
202                 break;
203
204             case CHANNEL_SHADE_VANE:
205                 if (command instanceof PercentType) {
206                     moveShade(VANE_TILT_COORDS, ((PercentType) command).intValue(), webTargets, shadeId);
207                 } else if (command instanceof OnOffType) {
208                     moveShade(VANE_TILT_COORDS, OnOffType.ON == command ? 100 : 0, webTargets, shadeId);
209                 }
210                 break;
211
212             case CHANNEL_SHADE_SECONDARY_POSITION:
213                 if (command instanceof PercentType) {
214                     moveShade(SECONDARY_ZERO_IS_OPEN, ((PercentType) command).intValue(), webTargets, shadeId);
215                 } else if (command instanceof UpDownType) {
216                     moveShade(SECONDARY_ZERO_IS_OPEN, UpDownType.UP == command ? 0 : 100, webTargets, shadeId);
217                 } else if (command instanceof StopMoveType) {
218                     if (StopMoveType.STOP == command) {
219                         stopShade(webTargets, shadeId);
220                     } else {
221                         logger.warn("Unexpected StopMoveType command");
222                     }
223                 }
224                 break;
225
226             case CHANNEL_SHADE_CALIBRATE:
227                 if (OnOffType.ON == command) {
228                     calibrateShade(webTargets, shadeId);
229                 }
230                 break;
231         }
232     }
233
234     /**
235      * Update the state of the channels based on the ShadeData provided.
236      *
237      * @param shadeData the ShadeData to be used; may be null.
238      */
239     protected void onReceiveUpdate(@Nullable ShadeData shadeData) {
240         if (shadeData != null) {
241             updateStatus(ThingStatus.ONLINE);
242             updateCapabilities(shadeData);
243             updateSoftProperties(shadeData);
244             updateFirmwareProperties(shadeData);
245             ShadePosition shadePosition = shadeData.positions;
246             if (shadePosition != null) {
247                 updatePositionStates(shadePosition);
248             }
249             updateBatteryLevelStates(shadeData.batteryStatus);
250             updateState(CHANNEL_SHADE_BATTERY_VOLTAGE,
251                     shadeData.batteryStrength > 0 ? new QuantityType<>(shadeData.batteryStrength / 10, Units.VOLT)
252                             : UnDefType.UNDEF);
253             updateState(CHANNEL_SHADE_SIGNAL_STRENGTH, new DecimalType(shadeData.signalStrength));
254         } else {
255             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
256         }
257     }
258
259     private void updateCapabilities(ShadeData shade) {
260         if (capabilities != null) {
261             // Already cached.
262             return;
263         }
264         Integer value = shade.capabilities;
265         if (value != null) {
266             int valueAsInt = value.intValue();
267             logger.debug("Caching capabilities {} for shade {}", valueAsInt, shade.id);
268             capabilities = db.getCapabilities(valueAsInt);
269         } else {
270             logger.debug("Capabilities not included in shade response");
271         }
272     }
273
274     private Capabilities getCapabilitiesOrDefault() {
275         Capabilities capabilities = this.capabilities;
276         if (capabilities == null) {
277             return new Capabilities();
278         }
279         return capabilities;
280     }
281
282     /**
283      * Update the Thing's properties based on the contents of the provided ShadeData.
284      *
285      * Checks the database of known Shade 'types' and 'capabilities' and logs any unknown or incompatible values, so
286      * that developers can be kept updated about the potential need to add support for that type resp. capabilities.
287      *
288      * @param shadeData
289      */
290     private void updateSoftProperties(ShadeData shadeData) {
291         final Map<String, String> properties = getThing().getProperties();
292         boolean propChanged = false;
293
294         // update 'type' property
295         final int type = shadeData.type;
296         String propKey = HDPowerViewBindingConstants.PROPERTY_SHADE_TYPE;
297         String propOldVal = properties.getOrDefault(propKey, "");
298         String propNewVal = db.getType(type).toString();
299         if (!propNewVal.equals(propOldVal)) {
300             propChanged = true;
301             getThing().setProperty(propKey, propNewVal);
302             if ((type > 0) && !db.isTypeInDatabase(type)) {
303                 db.logTypeNotInDatabase(type);
304             }
305         }
306
307         // update 'capabilities' property
308         final Integer temp = shadeData.capabilities;
309         final int capabilitiesVal = temp != null ? temp.intValue() : -1;
310         Capabilities capabilities = db.getCapabilities(capabilitiesVal);
311         propKey = HDPowerViewBindingConstants.PROPERTY_SHADE_CAPABILITIES;
312         propOldVal = properties.getOrDefault(propKey, "");
313         propNewVal = capabilities.toString();
314         if (!propNewVal.equals(propOldVal)) {
315             propChanged = true;
316             getThing().setProperty(propKey, propNewVal);
317             if ((capabilitiesVal >= 0) && !db.isCapabilitiesInDatabase(capabilitiesVal)) {
318                 db.logCapabilitiesNotInDatabase(type, capabilitiesVal);
319             }
320         }
321
322         if (propChanged && db.isCapabilitiesInDatabase(capabilitiesVal) && db.isTypeInDatabase(type)
323                 && (capabilitiesVal != db.getType(type).getCapabilities())) {
324             db.logCapabilitiesMismatch(type, capabilitiesVal);
325         }
326     }
327
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());
334         }
335         if (motorFirmware != null) {
336             properties.put(PROPERTY_MOTOR_FIRMWARE_VERSION, motorFirmware.toString());
337         }
338         updateProperties(properties);
339     }
340
341     /**
342      * After a hard refresh, update the Thing's properties based on the contents of the provided ShadeData.
343      *
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.
347      *
348      * @param shadeData
349      */
350     private void updateHardProperties(ShadeData shadeData) {
351         final ShadePosition positions = shadeData.positions;
352         if (positions == null) {
353             return;
354         }
355         Capabilities capabilities = getCapabilitiesOrDefault();
356         final Map<String, String> properties = getThing().getProperties();
357
358         // update 'secondary rail detected' property
359         String propKey = HDPowerViewBindingConstants.PROPERTY_SECONDARY_RAIL_DETECTED;
360         String propOldVal = properties.getOrDefault(propKey, "");
361         boolean propNewBool = positions.secondaryRailDetected();
362         String propNewVal = String.valueOf(propNewBool);
363         if (!propNewVal.equals(propOldVal)) {
364             getThing().setProperty(propKey, propNewVal);
365             if (propNewBool != capabilities.supportsSecondary()) {
366                 db.logPropertyMismatch(propKey, shadeData.type, capabilities.getValue(), propNewBool);
367             }
368         }
369
370         // update 'tilt anywhere detected' property
371         propKey = HDPowerViewBindingConstants.PROPERTY_TILT_ANYWHERE_DETECTED;
372         propOldVal = properties.getOrDefault(propKey, "");
373         propNewBool = positions.tiltAnywhereDetected();
374         propNewVal = String.valueOf(propNewBool);
375         if (!propNewVal.equals(propOldVal)) {
376             getThing().setProperty(propKey, propNewVal);
377             if (propNewBool != capabilities.supportsTiltAnywhere()) {
378                 db.logPropertyMismatch(propKey, shadeData.type, capabilities.getValue(), propNewBool);
379             }
380         }
381     }
382
383     private void updatePositionStates(ShadePosition shadePos) {
384         Capabilities capabilities = this.capabilities;
385         if (capabilities == null) {
386             logger.debug("The 'shadeCapabilities' field has not yet been initialized");
387             updateState(CHANNEL_SHADE_POSITION, UnDefType.UNDEF);
388             updateState(CHANNEL_SHADE_VANE, UnDefType.UNDEF);
389             updateState(CHANNEL_SHADE_SECONDARY_POSITION, UnDefType.UNDEF);
390             return;
391         }
392         updateState(CHANNEL_SHADE_POSITION, shadePos.getState(capabilities, PRIMARY_ZERO_IS_CLOSED));
393         updateState(CHANNEL_SHADE_VANE, shadePos.getState(capabilities, VANE_TILT_COORDS));
394         updateState(CHANNEL_SHADE_SECONDARY_POSITION, shadePos.getState(capabilities, SECONDARY_ZERO_IS_OPEN));
395     }
396
397     private void updateBatteryLevelStates(int batteryStatus) {
398         int mappedValue;
399         switch (batteryStatus) {
400             case 1: // Low
401                 mappedValue = 10;
402                 break;
403             case 2: // Medium
404                 mappedValue = 50;
405                 break;
406             case 3: // High
407             case 4: // Plugged in
408                 mappedValue = 100;
409                 break;
410             default: // No status available (0) or invalid
411                 updateState(CHANNEL_SHADE_LOW_BATTERY, UnDefType.UNDEF);
412                 updateState(CHANNEL_SHADE_BATTERY_LEVEL, UnDefType.UNDEF);
413                 return;
414         }
415         updateState(CHANNEL_SHADE_LOW_BATTERY, batteryStatus == 1 ? OnOffType.ON : OnOffType.OFF);
416         updateState(CHANNEL_SHADE_BATTERY_LEVEL, new DecimalType(mappedValue));
417     }
418
419     private void moveShade(CoordinateSystem coordSys, int newPercent, HDPowerViewWebTargets webTargets, int shadeId)
420             throws HubProcessingException, HubMaintenanceException {
421         ShadePosition newPosition = null;
422         // (try to) read the positions from the hub
423         Shade shade = webTargets.getShade(shadeId);
424         if (shade != null) {
425             ShadeData shadeData = shade.shade;
426             if (shadeData != null) {
427                 updateCapabilities(shadeData);
428                 newPosition = shadeData.positions;
429             }
430         }
431         // if no positions returned, then create a new position
432         if (newPosition == null) {
433             newPosition = new ShadePosition();
434         }
435         Capabilities capabilities = getCapabilitiesOrDefault();
436         // set the new position value, and write the positions to the hub
437         shade = webTargets.moveShade(shadeId, newPosition.setPosition(capabilities, coordSys, newPercent));
438         if (shade != null) {
439             updateShadePositions(shade);
440         }
441     }
442
443     private void stopShade(HDPowerViewWebTargets webTargets, int shadeId)
444             throws HubProcessingException, HubMaintenanceException {
445         Shade shade = webTargets.stopShade(shadeId);
446         if (shade != null) {
447             updateShadePositions(shade);
448         }
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();
452     }
453
454     private void calibrateShade(HDPowerViewWebTargets webTargets, int shadeId)
455             throws HubProcessingException, HubMaintenanceException {
456         Shade shade = webTargets.calibrateShade(shadeId);
457         if (shade != null) {
458             updateShadePositions(shade);
459         }
460     }
461
462     private void updateShadePositions(Shade shade) {
463         ShadeData shadeData = shade.shade;
464         if (shadeData == null) {
465             return;
466         }
467         ShadePosition shadePosition = shadeData.positions;
468         if (shadePosition == null) {
469             return;
470         }
471         updateCapabilities(shadeData);
472         updatePositionStates(shadePosition);
473     }
474
475     private int getShadeId() throws NumberFormatException {
476         String str = getConfigAs(HDPowerViewShadeConfiguration.class).id;
477         if (str == null) {
478             throw new NumberFormatException("null input string");
479         }
480         return Integer.parseInt(str);
481     }
482
483     /**
484      * Request that the shade shall undergo a 'hard' refresh for querying its current position
485      */
486     protected synchronized void requestRefreshShadePosition() {
487         if (refreshPositionFuture == null) {
488             refreshPositionFuture = scheduler.schedule(this::doRefreshShadePosition, 0, TimeUnit.SECONDS);
489         }
490     }
491
492     /**
493      * Request that the shade shall undergo a 'hard' refresh for querying its survey data
494      */
495     protected synchronized void requestRefreshShadeSurvey() {
496         if (refreshSignalFuture == null) {
497             refreshSignalFuture = scheduler.schedule(this::doRefreshShadeSignal, 0, TimeUnit.SECONDS);
498         }
499     }
500
501     /**
502      * Request that the shade shall undergo a 'hard' refresh for querying its battery level state
503      */
504     protected synchronized void requestRefreshShadeBatteryLevel() {
505         if (refreshBatteryLevelFuture == null) {
506             refreshBatteryLevelFuture = scheduler.schedule(this::doRefreshShadeBatteryLevel, 0, TimeUnit.SECONDS);
507         }
508     }
509
510     private void doRefreshShadePosition() {
511         this.doRefreshShade(RefreshKind.POSITION);
512         refreshPositionFuture = null;
513     }
514
515     private void doRefreshShadeSignal() {
516         this.doRefreshShade(RefreshKind.SURVEY);
517         refreshSignalFuture = null;
518     }
519
520     private void doRefreshShadeBatteryLevel() {
521         this.doRefreshShade(RefreshKind.BATTERY_LEVEL);
522         refreshBatteryLevelFuture = null;
523     }
524
525     private void doRefreshShade(RefreshKind kind) {
526         try {
527             HDPowerViewHubHandler bridge;
528             if ((bridge = getBridgeHandler()) == null) {
529                 throw new HubProcessingException("Missing bridge handler");
530             }
531             HDPowerViewWebTargets webTargets = bridge.getWebTargets();
532             if (webTargets == null) {
533                 throw new HubProcessingException("Web targets not initialized");
534             }
535             Shade shade;
536             switch (kind) {
537                 case POSITION:
538                     shade = webTargets.refreshShadePosition(shadeId);
539                     break;
540                 case SURVEY:
541                     Survey survey = webTargets.getShadeSurvey(shadeId);
542                     if (survey != null && survey.surveyData != null) {
543                         logger.debug("Survey response for shade {}: {}", survey.shadeId, survey.toString());
544                     } else {
545                         logger.warn("No response from shade {} survey", shadeId);
546                     }
547                     return;
548                 case BATTERY_LEVEL:
549                     shade = webTargets.refreshShadeBatteryLevel(shadeId);
550                     break;
551                 default:
552                     throw new NotSupportedException("Unsupported refresh kind " + kind.toString());
553             }
554             if (shade != null) {
555                 ShadeData shadeData = shade.shade;
556                 if (shadeData != null) {
557                     if (Boolean.TRUE.equals(shadeData.timedOut)) {
558                         logger.warn("Shade {} wireless refresh time out", shadeId);
559                     } else if (kind == RefreshKind.POSITION) {
560                         updateShadePositions(shade);
561                         updateHardProperties(shadeData);
562                     }
563                 }
564             }
565         } catch (HubProcessingException e) {
566             // ScheduledFutures will be cancelled by dispose(), naturally causing InterruptedException in invoke()
567             // for any ongoing requests. Logging this would only cause confusion.
568             if (!isDisposing) {
569                 logger.warn("Unexpected error: {}", e.getMessage());
570             }
571         } catch (HubMaintenanceException e) {
572             // exceptions are logged in HDPowerViewWebTargets
573         }
574     }
575 }