2 * Copyright (c) 2010-2024 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.argoclima.internal.device.api.protocol.elements;
15 import java.time.Duration;
16 import java.time.Instant;
17 import java.util.Optional;
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;
33 * Base implementation of common functionality across all API elements
34 * (ex. handling pending commands and their confirmations)
36 * @author Mateusz Bronk - Initial contribution
39 public abstract class ArgoApiElementBase implements IArgoElement {
44 * Helper class for handling (pending) commands sent to the device (and awaiting confirmation)
46 * @author Mateusz Bronk - Initial contribution
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;
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}.
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;
73 * Named c-tor for rejected command
75 * @return Rejected command ({@code handled = False})
77 public static HandleCommandResult rejected() {
78 return new HandleCommandResult(false, Optional.empty(), Optional.empty());
82 * Named c-tor for accepted command
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)
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)})
94 public static HandleCommandResult accepted(String deviceCommandToSend, State plannedState) {
95 return new HandleCommandResult(true, Optional.of(deviceCommandToSend), Optional.of(plannedState));
99 * Check if this command is stale (has been issued before
100 * {@link ArgoClimaBindingConstants#PENDING_COMMAND_EXPIRE_TIME} ago.
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)
108 public boolean hasExpired() {
109 return Duration.between(updateRequestedTime, Instant.now())
110 .compareTo(ArgoClimaBindingConstants.PENDING_COMMAND_EXPIRE_TIME) > 0;
114 * Check if the command is confirmable (for R/W params, where the device acknowledges receipt of the command)
116 * @return True if the command is confirmable. False for write-only parameters
118 public boolean isConfirmable() {
119 return requiresDeviceConfirmation;
123 * Set confirmable status (update from default: true)
125 * @param requiresDeviceConfirmation New {@code confirmable} value
126 * @return This object (for chaining)
128 public HandleCommandResult setConfirmable(boolean requiresDeviceConfirmation) {
129 this.requiresDeviceConfirmation = requiresDeviceConfirmation;
134 * Check if the command is deferred
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)
141 * @return True if the command is deferred (has no immediate effect). False - otherwise
143 public boolean isDeferred() {
148 * Set deferred status (update from default: false)
151 * @param deferred New {@code deferred} value
152 * @return This object (for chaining)
154 public HandleCommandResult setDeferred(boolean deferred) {
155 this.deferred = deferred;
160 public String toString() {
161 return String.format("HandleCommandResult(wasHandled=%s,deviceCommand=%s,plannedState=%s,isObsolete=%s)",
162 handled, deviceCommandToSend, plannedState, hasExpired());
167 * Types of command finalization (reason why command is no longer tracked/retried)
169 * @author Mateusz Bronk - Initial contribution
171 public enum CommandFinalizationReason {
172 /** Command is confirmable and device confirmed now having the desired state */
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 */
179 * Pending (confirmable) command has not received confirmation within
180 * {@link ArgoClimaBindingConstants#PENDING_COMMAND_EXPIRE_TIME}
188 private static final Logger LOGGER = LoggerFactory.getLogger(ArgoApiElementBase.class);
189 protected final IArgoSettingProvider settingsProvider;
192 * Last status value received from device (has most accurate device-side state, but may be stale if there are
193 * in-flight commands!)
195 private Optional<String> lastRawValueFromDevice = Optional.empty();
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.)
202 private Optional<HandleCommandResult> inFlightCommand = Optional.empty();
205 * Internal (element type-specific) method for handling the command (accepting or rejecting it).
207 * @implNote Tracking of command result is handled by this class through {@link #handleCommand(Command, boolean)}
209 * @param command The command to handle
210 * @return Handling result (an accepted or rejected command, with handling traits such as confirmable/deferred)
212 protected abstract HandleCommandResult handleCommandInternalEx(Command command);
215 * Internal (element type-specific) method for handling the element status update.
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)
221 protected abstract void updateFromApiResponseInternal(String responseValue);
226 * @param settingsProvider the settings provider (getting device state as well as schedule configuration)
228 public ArgoApiElementBase(IArgoSettingProvider settingsProvider) {
229 this.settingsProvider = settingsProvider;
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)
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
244 if (!this.hasInFlightCommand()) {
245 // No in-flight command, we're done
246 return this.toState();
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();
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);
261 LOGGER.debug("Update made, but values mismatch... {} (device) != {} (command)", responseValue,
264 return this.toState(); // Return previous state (of the pending command, not the one device just reported)
268 public final void notifyCommandSent() {
269 if (this.isUpdatePending()) {
270 inFlightCommand.ifPresent(cmd -> {
271 if (!cmd.isConfirmable()) {
272 confirmPendingCommand(CommandFinalizationReason.SENT_NON_CONFIRMABLE);
279 public final void abortPendingCommand() {
280 confirmPendingCommand(CommandFinalizationReason.ABORTED);
284 public String toString() {
285 return String.format("RAW[%s]", lastRawValueFromDevice.orElse("N/A"));
289 public final boolean isUpdatePending() {
290 if (!hasInFlightCommand()) {
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;
304 * Wrapper implementation for handling confirmations/deferrals. Delegates actual work to
305 * {@link #handleCommandInternalEx(Command)}
308 public final boolean handleCommand(Command command, boolean isConfirmable) {
309 var result = this.handleCommandInternalEx(command);
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
315 result.setConfirmable(false);
317 if (!result.isDeferred()) {
318 // Deferred commands do not count as in-flight (will get intercepted when other command uses their
320 synchronized (this) {
321 this.inFlightCommand = Optional.of(result);
325 return result.handled;
331 * Default implementation of a typical param, which is NOT always sent (to be further overridden in inheriting
335 public boolean isAlwaysSent() {
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
346 public String getDeviceApiValue() {
347 if (!isUpdatePending()) {
348 return ArgoDeviceStatus.NO_VALUE;
350 return this.inFlightCommand.get().deviceCommandToSend.get();
354 * Helper method to check if any one of the schedule timers is currently running
356 * @return Index of one of the schedule timers (1|2|3) which is currently active on the device. Empty optional -
359 protected final Optional<ScheduleTimerType> isScheduleTimerEnabled() {
360 var currentTimer = EnumParam
361 .fromType(settingsProvider.getSetting(ArgoDeviceSettingType.ACTIVE_TIMER).getState(), TimerType.class);
363 if (currentTimer.isEmpty()) {
364 return Optional.empty();
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()));
373 return Optional.empty();
378 * Called when an in-flight command reaches a final state (successful or not) and no longer requires tracking
380 * @param reason The reason for finalizing the command (for logging)
382 private final void confirmPendingCommand(CommandFinalizationReason reason) {
383 var commandName = inFlightCommand.map(c -> c.plannedState.map(s -> s.toFullString()).orElse("N/A"))
386 case CONFIRMED_APPLIED:
387 LOGGER.debug("[{}] Update confirmed!", commandName);
390 LOGGER.debug("[{}] Command aborted!", commandName);
393 LOGGER.debug("[{}] Long-pending update found. Cancelling...!", commandName);
395 case SENT_NON_CONFIRMABLE:
396 LOGGER.debug("[{}] Update confirmed (in good faith)!", commandName);
399 synchronized (this) {
400 this.inFlightCommand = Optional.empty();
405 public final boolean hasInFlightCommand() {
406 if (inFlightCommand.isEmpty()) {
407 return false; // no withstanding command
410 // If last command was not handled correctly -> there's nothing to update
411 return inFlightCommand.map(c -> c.handled).orElse(false);
414 private final String getInFlightCommandsRawValueOrDefault() {
415 final String valueNotAvailablePlaceholder = "N/A";
416 return inFlightCommand.map(c -> c.deviceCommandToSend.orElse(valueNotAvailablePlaceholder))
417 .orElse(valueNotAvailablePlaceholder);
424 * Utility function trying to convert from String to int
426 * @param value Value to convert
427 * @return Converted value (if successful) or empty (on failure)
429 protected static Optional<Integer> strToInt(String value) {
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();
439 * Normalize the value to be within range (and multiple of step, if any)
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
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
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
470 if (step.isEmpty()) {
471 return newValue; // No rounding to step value
474 return Math.round(newValue.doubleValue() / step.orElseThrow().doubleValue()) * step.orElseThrow().doubleValue();
478 * Normalizes the incoming value (respecting steps), with amplification of movement
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
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
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);
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
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
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();