]> git.basschouten.com Git - openhab-addons.git/blob
155acd0019cf23275f3cbd2098700d5dccdf6f23
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 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.argoclima.internal.device.api.protocol.elements;
14
15 import java.time.Duration;
16 import java.time.Instant;
17 import java.util.Optional;
18
19 import org.eclipse.jdt.annotation.NonNullByDefault;
20 import org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants;
21 import org.openhab.binding.argoclima.internal.configuration.IScheduleConfigurationProvider.ScheduleTimerType;
22 import org.openhab.binding.argoclima.internal.device.api.protocol.ArgoDeviceStatus;
23 import org.openhab.binding.argoclima.internal.device.api.protocol.IArgoSettingProvider;
24 import org.openhab.binding.argoclima.internal.device.api.protocol.elements.IArgoCommandableElement.IArgoElement;
25 import org.openhab.binding.argoclima.internal.device.api.types.ArgoDeviceSettingType;
26 import org.openhab.binding.argoclima.internal.device.api.types.TimerType;
27 import org.openhab.core.types.Command;
28 import org.openhab.core.types.State;
29 import org.slf4j.Logger;
30 import org.slf4j.LoggerFactory;
31
32 /**
33  * Base implementation of common functionality across all API elements
34  * (ex. handling pending commands and their confirmations)
35  *
36  * @author Mateusz Bronk - Initial contribution
37  */
38 @NonNullByDefault
39 public abstract class ArgoApiElementBase implements IArgoElement {
40     ///////////
41     // TYPES
42     ///////////
43     /**
44      * Helper class for handling (pending) commands sent to the device (and awaiting confirmation)
45      *
46      * @author Mateusz Bronk - Initial contribution
47      */
48     public static class HandleCommandResult {
49         public final boolean handled;
50         public final Optional<String> deviceCommandToSend;
51         public final Optional<State> plannedState;
52         private final Instant updateRequestedTime;
53         private boolean deferred = false;
54         private boolean requiresDeviceConfirmation = true;
55
56         /**
57          * Private C-tor
58          *
59          * @param handled If the command was handled
60          * @param deviceCommandToSend The actual command to send to device (only if {@code handled=True})
61          * @param plannedState The expected state of the device after the command (reaching it will serve as
62          *            confirmation). present only if {@code handled=True}.
63          */
64         private HandleCommandResult(boolean handled, Optional<String> deviceCommandToSend,
65                 Optional<State> plannedState) {
66             this.updateRequestedTime = Instant.now();
67             this.handled = handled;
68             this.deviceCommandToSend = deviceCommandToSend;
69             this.plannedState = plannedState;
70         }
71
72         /**
73          * Named c-tor for rejected command
74          *
75          * @return Rejected command ({@code handled = False})
76          */
77         public static HandleCommandResult rejected() {
78             return new HandleCommandResult(false, Optional.empty(), Optional.empty());
79         }
80
81         /**
82          * Named c-tor for accepted command
83          * <p>
84          * By default the command starts with: {@link #isConfirmable() confirmable}{@code =True} and
85          * {@link #isDeferred() deferred}{@code =False}, which means caller expect device-side confirmation and the
86          * command is effective immediately after sending to the device (standalone command)
87          *
88          * @param deviceCommandToSend The actual command to send to device
89          * @param plannedState The expected state of the device after the command (if {@link #isConfirmable()
90          *            confirmable} is {@code True}, reaching it will serve as confirmation)
91          * @return Accepted command ({@code confirmable=True & deferred=False} - changeable via
92          *         {@link #setConfirmable(boolean)} or {@link #setDeferred(boolean)})
93          */
94         public static HandleCommandResult accepted(String deviceCommandToSend, State plannedState) {
95             return new HandleCommandResult(true, Optional.of(deviceCommandToSend), Optional.of(plannedState));
96         }
97
98         /**
99          * Check if this command is stale (has been issued before
100          * {@link ArgoClimaBindingConstants#PENDING_COMMAND_EXPIRE_TIME} ago.
101          *
102          * @implNote This class does NOT track actual command completion (only their issuance), hence it is expected
103          *           that a completed command will be simply removed by the caller.
104          * @implNote For the same reason, even though this check only makes sense for {@code confirmable} commands - it
105          *           is not checked herein and responsibility of the caller
106          * @return True if the command is obsolete (has been issued more than expire time ago)
107          */
108         public boolean hasExpired() {
109             return Duration.between(updateRequestedTime, Instant.now())
110                     .compareTo(ArgoClimaBindingConstants.PENDING_COMMAND_EXPIRE_TIME) > 0;
111         }
112
113         /**
114          * Check if the command is confirmable (for R/W params, where the device acknowledges receipt of the command)
115          *
116          * @return True if the command is confirmable. False for write-only parameters
117          */
118         public boolean isConfirmable() {
119             return requiresDeviceConfirmation;
120         }
121
122         /**
123          * Set confirmable status (update from default: true)
124          *
125          * @param requiresDeviceConfirmation New {@code confirmable} value
126          * @return This object (for chaining)
127          */
128         public HandleCommandResult setConfirmable(boolean requiresDeviceConfirmation) {
129             this.requiresDeviceConfirmation = requiresDeviceConfirmation;
130             return this;
131         }
132
133         /**
134          * Check if the command is deferred
135          * <p>
136          * A command is considered "deferred", if it isn't standalone, and - even when sent to the device - doesn't
137          * yield an immediate effect.
138          * For example, setting a delay timer value, when the device is not in a timer mode doesn't make any meaningful
139          * change to the device (until said mode is entered, which is controlled by different API element)
140          *
141          * @return True if the command is deferred (has no immediate effect). False - otherwise
142          */
143         public boolean isDeferred() {
144             return deferred;
145         }
146
147         /**
148          * Set deferred status (update from default: false)
149          *
150          * @see #isDeferred()
151          * @param deferred New {@code deferred} value
152          * @return This object (for chaining)
153          */
154         public HandleCommandResult setDeferred(boolean deferred) {
155             this.deferred = deferred;
156             return this;
157         }
158
159         @Override
160         public String toString() {
161             return String.format("HandleCommandResult(wasHandled=%s,deviceCommand=%s,plannedState=%s,isObsolete=%s)",
162                     handled, deviceCommandToSend, plannedState, hasExpired());
163         }
164     }
165
166     /**
167      * Types of command finalization (reason why command is no longer tracked/retried)
168      *
169      * @author Mateusz Bronk - Initial contribution
170      */
171     public enum CommandFinalizationReason {
172         /** Command is confirmable and device confirmed now having the desired state */
173         CONFIRMED_APPLIED,
174         /** Command is not-confirmable has been just sent to the device (in good faith) */
175         SENT_NON_CONFIRMABLE,
176         /** Pending command has been aborted by the caller */
177         ABORTED,
178         /**
179          * Pending (confirmable) command has not received confirmation within
180          * {@link ArgoClimaBindingConstants#PENDING_COMMAND_EXPIRE_TIME}
181          */
182         EXPIRED
183     }
184
185     ///////////
186     // FIELDS
187     ///////////
188     private static final Logger LOGGER = LoggerFactory.getLogger(ArgoApiElementBase.class);
189     protected final IArgoSettingProvider settingsProvider;
190
191     /**
192      * Last status value received from device (has most accurate device-side state, but may be stale if there are
193      * in-flight commands!)
194      */
195     private Optional<String> lastRawValueFromDevice = Optional.empty();
196
197     /**
198      * Active (in-flight) change request (upon accepting framework's Command) issued against this element. Tracked since
199      * acceptance (before send to the device) all the way to finalization (confirmed/successful, but also aborted,
200      * non-confirmable etc.)
201      */
202     private Optional<HandleCommandResult> inFlightCommand = Optional.empty();
203
204     /**
205      * Internal (element type-specific) method for handling the command (accepting or rejecting it).
206      *
207      * @implNote Tracking of command result is handled by this class through {@link #handleCommand(Command, boolean)}
208      *
209      * @param command The command to handle
210      * @return Handling result (an accepted or rejected command, with handling traits such as confirmable/deferred)
211      */
212     protected abstract HandleCommandResult handleCommandInternalEx(Command command);
213
214     /**
215      * Internal (element type-specific) method for handling the element status update.
216      *
217      * @implNote Tracking of command confirmations and/or expiration is handled by this class through
218      *           {@link #updateFromApiResponse(String)}
219      * @param responseValue The raw API value (from device)
220      */
221     protected abstract void updateFromApiResponseInternal(String responseValue);
222
223     /**
224      * C-tor
225      *
226      * @param settingsProvider the settings provider (getting device state as well as schedule configuration)
227      */
228     public ArgoApiElementBase(IArgoSettingProvider settingsProvider) {
229         this.settingsProvider = settingsProvider;
230     }
231
232     @Override
233     public final State updateFromApiResponse(String responseValue) {
234         var noPendingUpdates = !isUpdatePending(); // Capturing the current in-flight state (before modifying this
235                                                    // object and introducing side-effects)
236
237         synchronized (this) {
238             this.lastRawValueFromDevice = Optional.of(responseValue); // Persist last value from device (Side-effect:
239                                                                       // may change behavior of isUpdatePending()
240             if (noPendingUpdates) {
241                 this.updateFromApiResponseInternal(responseValue); // No in-flight commands => Update THIS object with
242                                                                    // the new state
243
244                 if (!this.hasInFlightCommand()) {
245                     // No in-flight command, we're done
246                     return this.toState();
247                 }
248             }
249         }
250
251         // There's an ongoing confirmable command (not yet acknowledged), so we're *NOT* simply taking device-side
252         // value as the ACTUAL one (b/c it is slow to respond and we don't want values flapping). Instead, we try to
253         // see if the value is matching what we'd expect to change (confirming our command)
254         var expectedStateValue = getInFlightCommandsRawValueOrDefault();
255
256         if (responseValue.equals(expectedStateValue)) { // Comparing by raw values, not by planned state
257             confirmPendingCommand(CommandFinalizationReason.CONFIRMED_APPLIED);
258         } else if (this.inFlightCommand.map(x -> x.hasExpired()).orElse(false)) {
259             confirmPendingCommand(CommandFinalizationReason.EXPIRED);
260         } else {
261             LOGGER.debug("Update made, but values mismatch... {} (device) != {} (command)", responseValue,
262                     expectedStateValue);
263         }
264         return this.toState(); // Return previous state (of the pending command, not the one device just reported)
265     }
266
267     @Override
268     public final void notifyCommandSent() {
269         if (this.isUpdatePending()) {
270             inFlightCommand.ifPresent(cmd -> {
271                 if (!cmd.isConfirmable()) {
272                     confirmPendingCommand(CommandFinalizationReason.SENT_NON_CONFIRMABLE);
273                 }
274             });
275         }
276     }
277
278     @Override
279     public final void abortPendingCommand() {
280         confirmPendingCommand(CommandFinalizationReason.ABORTED);
281     }
282
283     @Override
284     public String toString() {
285         return String.format("RAW[%s]", lastRawValueFromDevice.orElse("N/A"));
286     }
287
288     @Override
289     public final boolean isUpdatePending() {
290         if (!hasInFlightCommand()) {
291             return false;
292         }
293
294         // Check if the device is not already reporting the requested state (nothing pending if so)
295         // (not inlining this code for better readability)
296         var deviceReportsValueAlready = lastRawValueFromDevice
297                 .map(devValue -> devValue.equals(getInFlightCommandsRawValueOrDefault())).orElse(false);
298         return !deviceReportsValueAlready;
299     }
300
301     /**
302      * {@inheritDoc}
303      * <p>
304      * Wrapper implementation for handling confirmations/deferrals. Delegates actual work to
305      * {@link #handleCommandInternalEx(Command)}
306      */
307     @Override
308     public final boolean handleCommand(Command command, boolean isConfirmable) {
309         var result = this.handleCommandInternalEx(command);
310
311         if (result.handled) {
312             if (!isConfirmable) {
313                 // The value is not confirmable (upon sending to the device, we'll just assume it will flip to the
314                 // desired state)
315                 result.setConfirmable(false);
316             }
317             if (!result.isDeferred()) {
318                 // Deferred commands do not count as in-flight (will get intercepted when other command uses their
319                 // value)
320                 synchronized (this) {
321                     this.inFlightCommand = Optional.of(result);
322                 }
323             }
324         }
325         return result.handled;
326     }
327
328     /**
329      * {@inheritDoc}
330      * <p>
331      * Default implementation of a typical param, which is NOT always sent (to be further overridden in inheriting
332      * classes)
333      */
334     @Override
335     public boolean isAlwaysSent() {
336         return false;
337     }
338
339     /**
340      * {@inheritDoc}
341      * <p>
342      * Default implementation (to be further overridden in inheriting classes) getting pending command or
343      * {@code NO_VALUE} special value to not effect any change
344      */
345     @Override
346     public String getDeviceApiValue() {
347         if (!isUpdatePending()) {
348             return ArgoDeviceStatus.NO_VALUE;
349         }
350         return this.inFlightCommand.get().deviceCommandToSend.get();
351     }
352
353     /**
354      * Helper method to check if any one of the schedule timers is currently running
355      *
356      * @return Index of one of the schedule timers (1|2|3) which is currently active on the device. Empty optional -
357      *         otherwise
358      */
359     protected final Optional<ScheduleTimerType> isScheduleTimerEnabled() {
360         var currentTimer = EnumParam
361                 .fromType(settingsProvider.getSetting(ArgoDeviceSettingType.ACTIVE_TIMER).getState(), TimerType.class);
362
363         if (currentTimer.isEmpty()) {
364             return Optional.empty();
365         }
366
367         switch (currentTimer.orElseThrow()) {
368             case SCHEDULE_TIMER_1:
369             case SCHEDULE_TIMER_2:
370             case SCHEDULE_TIMER_3:
371                 return Optional.of(TimerType.toScheduleTimerType(currentTimer.orElseThrow()));
372             default:
373                 return Optional.empty();
374         }
375     }
376
377     /**
378      * Called when an in-flight command reaches a final state (successful or not) and no longer requires tracking
379      *
380      * @param reason The reason for finalizing the command (for logging)
381      */
382     private final void confirmPendingCommand(CommandFinalizationReason reason) {
383         var commandName = inFlightCommand.map(c -> c.plannedState.map(s -> s.toFullString()).orElse("N/A"))
384                 .orElse("Unknown");
385         switch (reason) {
386             case CONFIRMED_APPLIED:
387                 LOGGER.debug("[{}] Update confirmed!", commandName);
388                 break;
389             case ABORTED:
390                 LOGGER.debug("[{}] Command aborted!", commandName);
391                 break;
392             case EXPIRED:
393                 LOGGER.debug("[{}] Long-pending update found. Cancelling...!", commandName);
394                 break;
395             case SENT_NON_CONFIRMABLE:
396                 LOGGER.debug("[{}] Update confirmed (in good faith)!", commandName);
397                 break;
398         }
399         synchronized (this) {
400             this.inFlightCommand = Optional.empty();
401         }
402     }
403
404     @Override
405     public final boolean hasInFlightCommand() {
406         if (inFlightCommand.isEmpty()) {
407             return false; // no withstanding command
408         }
409
410         // If last command was not handled correctly -> there's nothing to update
411         return inFlightCommand.map(c -> c.handled).orElse(false);
412     }
413
414     private final String getInFlightCommandsRawValueOrDefault() {
415         final String valueNotAvailablePlaceholder = "N/A";
416         return inFlightCommand.map(c -> c.deviceCommandToSend.orElse(valueNotAvailablePlaceholder))
417                 .orElse(valueNotAvailablePlaceholder);
418     }
419
420     /////////////
421     // HELPERS
422     /////////////
423     /**
424      * Utility function trying to convert from String to int
425      *
426      * @param value Value to convert
427      * @return Converted value (if successful) or empty (on failure)
428      */
429     protected static Optional<Integer> strToInt(String value) {
430         try {
431             return Optional.of(Integer.parseInt(value));
432         } catch (NumberFormatException e) {
433             LOGGER.trace("The value {} is not a valid integer. Error: {}", value, e.getMessage());
434             return Optional.empty();
435         }
436     }
437
438     /**
439      * Normalize the value to be within range (and multiple of step, if any)
440      *
441      * @param <T> The number type
442      * @param newValue Value to convert
443      * @param minValue Lower bound
444      * @param maxValue Upper bound
445      * @param step Optional step for the value (result will be rounded to nearest step)
446      * @param unitDescription Unit description (for logging)
447      * @return Range within MIN..MAX bounds (which is a multiple of step). Returned as a {@code Number} for the caller
448      *         to convert back to the desired type. Note we're not casting back to {@code T} as it would need to be an
449      *         unchecked cast
450      */
451     protected static <T extends Number & Comparable<T>> Number adjustRange(T newValue, final T minValue,
452             final T maxValue, final Optional<T> step, final String unitDescription) {
453         if (newValue.compareTo(minValue) < 0) {
454             LOGGER.debug("Requested value: [{}{}] would exceed minimum value: [{}{}]. Setting: {}{}.", newValue,
455                     unitDescription, minValue, unitDescription, minValue, unitDescription); // The over-repetition is
456                                                                                             // due to SLF4J formatter
457                                                                                             // not supporting numbered
458                                                                                             // params, and using full
459                                                                                             // MessageFormat is not only
460                                                                                             // an overkill but also
461                                                                                             // SLOWER
462             return minValue;
463         }
464         if (newValue.compareTo(maxValue) > 0) {
465             LOGGER.debug("Requested value: [{}{}] would exceed maximum value: [{}{}]. Setting: {}{}.", newValue,
466                     unitDescription, maxValue, unitDescription, maxValue, unitDescription); // See comment above
467             return maxValue;
468         }
469
470         if (step.isEmpty()) {
471             return newValue; // No rounding to step value
472         }
473
474         return Math.round(newValue.doubleValue() / step.orElseThrow().doubleValue()) * step.orElseThrow().doubleValue();
475     }
476
477     /**
478      * Normalizes the incoming value (respecting steps), with amplification of movement
479      * <p>
480      * Ex. if the step is 10, current value is 50 and the new value is 51... while 50 is still a closest, we're moving
481      * to a full next step (60), not to ignore user's intent to change something
482      *
483      * @param newValue Value to convert
484      * @param currentValue The current value to amplify (in case normalization wouldn't otherwise change anything). If
485      *            empty, this method doesn't amplify anything
486      * @param minValue Lower bound
487      * @param maxValue Upper bound
488      * @param step Optional step for the value (result will be rounded to nearest step)
489      * @param unitDescription Unit description (for logging)
490      * @return Sanitized value (with amplified movement). Returned as a {@code Number} for the caller
491      *         to convert back to the desired type. Note we're not casting back to {@code T} as it would need to be an
492      *         unchecked cast
493      */
494     protected static <T extends Number & Comparable<T>> Number adjustRangeWithAmplification(T newValue,
495             Optional<T> currentValue, final T minValue, final T maxValue, final T step, final String unitDescription) {
496         Number normalized = adjustRange(newValue, minValue, maxValue, Optional.of(step), unitDescription);
497
498         if (currentValue.isEmpty() || normalized.doubleValue() == newValue.doubleValue()
499                 || newValue.compareTo(minValue) < 0 || newValue.compareTo(maxValue) > 0) {
500             return normalized; // there was no previous value or normalization didn't remove any precision or reached a
501                                // boundary -> new normalized value wins
502         }
503
504         final Number thisValue = currentValue.orElseThrow();
505         if (normalized.doubleValue() != thisValue.doubleValue()) {
506             return normalized; // the normalized value changed enough to be meaningful on its own-> use it
507         }
508
509         // Value before normalization has moved, but not enough to move a step (and would have been ignored). Let's
510         // amplify that effect and add a new step
511         var movementDirection = Math.signum((newValue.doubleValue() - normalized.doubleValue()));
512         return normalized.doubleValue() + movementDirection * step.doubleValue();
513     }
514 }