}
+ ```
+
+## Argo Web APP details
+
+The supported devices will typically have a web interface similar to the one shown on the image below.
+![Argo Web APP](doc/ArgoWebAPP_UI.png)
+
+For example, the original web application for **Ulisse 13 DCI ECO** air conditioner is available through [http://31.14.128.210/UI/WEBAPP/webapp.php?logo=Argo](http://31.14.128.210/UI/WEBAPP/webapp.php?logo=Argo) link, as referenced in the [manual](https://www.pumppujapaneli.fi/flaria/media/ulisse-kayttoohje.pdf).
+
+## Credits
+
+The author would like to thank the following individuals:
+
+- Maintainers of [IRremoteESP8266](https://github.com/crankyoldgit/IRremoteESP8266) and [Tasmota](https://github.com/arendst/Tasmota) projects - for reviewing an accepting the infrared-related part of Argo protocol into their libraries!
+- [@nyffchanium](https://github.com/nyffchanium) for creating an awesome [Argoclima integration for HomeAssistant](https://github.com/nyffchanium/argoclima-integration) which was used to confirm & speed up the analysis of device's protocol.
+While most of the learnings come from analyzing the JavaScript code in Argo's own application and network captures, the HA integration has proven **invaluable** as a secondary/confirmed source and allowed to validate a few concepts early on!
+ - In case you're experiencing issues, make sure to read the HomeAssistant binding's [README](https://github.com/nyffchanium/argoclima-integration/blob/master/readme.md) as well, for useful troubleshooting info!
+- [@lallinger](https://github.com/lallinger) for a [dummy server](https://github.com/nyffchanium/argoclima-integration/tree/master/dummy-server) which was the idea that served as the cornerstone of the stub server built into this binding (which later got extended to do something useful, instead of just keeping the device happy).
+
+## Disclaimer
+
+This project is not affiliated with, funded, or in any way associated with Argoclima S.p.A.
+All third-party product, company names, logos and trademarks™ or registered® trademarks remain the property of their respective holders.
diff --git a/bundles/org.openhab.binding.argoclima/doc/ArgoWebAPP_UI.png b/bundles/org.openhab.binding.argoclima/doc/ArgoWebAPP_UI.png
new file mode 100644
index 0000000000000..b0c2771169f33
Binary files /dev/null and b/bundles/org.openhab.binding.argoclima/doc/ArgoWebAPP_UI.png differ
diff --git a/bundles/org.openhab.binding.argoclima/doc/Argoclima_connection_Advanced_REMOTE_API_PROXY.png b/bundles/org.openhab.binding.argoclima/doc/Argoclima_connection_Advanced_REMOTE_API_PROXY.png
new file mode 100644
index 0000000000000..8e32677da0575
Binary files /dev/null and b/bundles/org.openhab.binding.argoclima/doc/Argoclima_connection_Advanced_REMOTE_API_PROXY.png differ
diff --git a/bundles/org.openhab.binding.argoclima/doc/Argoclima_connection_Advanced_REMOTE_API_STUB.png b/bundles/org.openhab.binding.argoclima/doc/Argoclima_connection_Advanced_REMOTE_API_STUB.png
new file mode 100644
index 0000000000000..2c9c3ddeac1bc
Binary files /dev/null and b/bundles/org.openhab.binding.argoclima/doc/Argoclima_connection_Advanced_REMOTE_API_STUB.png differ
diff --git a/bundles/org.openhab.binding.argoclima/doc/Argoclima_connection_Basic_LOCAL_CONNECTION.png b/bundles/org.openhab.binding.argoclima/doc/Argoclima_connection_Basic_LOCAL_CONNECTION.png
new file mode 100644
index 0000000000000..d48524c3ee29c
Binary files /dev/null and b/bundles/org.openhab.binding.argoclima/doc/Argoclima_connection_Basic_LOCAL_CONNECTION.png differ
diff --git a/bundles/org.openhab.binding.argoclima/doc/Argoclima_connection_Basic_REMOTE_CONNECTION.png b/bundles/org.openhab.binding.argoclima/doc/Argoclima_connection_Basic_REMOTE_CONNECTION.png
new file mode 100644
index 0000000000000..7835d5d85409e
Binary files /dev/null and b/bundles/org.openhab.binding.argoclima/doc/Argoclima_connection_Basic_REMOTE_CONNECTION.png differ
diff --git a/bundles/org.openhab.binding.argoclima/doc/OpenWRT_LUCI_port_forwarding_rule.png b/bundles/org.openhab.binding.argoclima/doc/OpenWRT_LUCI_port_forwarding_rule.png
new file mode 100644
index 0000000000000..d885eb7dd0d1d
Binary files /dev/null and b/bundles/org.openhab.binding.argoclima/doc/OpenWRT_LUCI_port_forwarding_rule.png differ
diff --git a/bundles/org.openhab.binding.argoclima/pom.xml b/bundles/org.openhab.binding.argoclima/pom.xml
new file mode 100644
index 0000000000000..eac46a4409525
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/pom.xml
@@ -0,0 +1,17 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.addons.bundles
+ org.openhab.addons.reactor.bundles
+ 4.2.0-SNAPSHOT
+
+
+ org.openhab.binding.argoclima
+
+ openHAB Add-ons :: Bundles :: ArgoClima Binding
+
+
diff --git a/bundles/org.openhab.binding.argoclima/scripts/download_ota_fw_files.py b/bundles/org.openhab.binding.argoclima/scripts/download_ota_fw_files.py
new file mode 100755
index 0000000000000..f1029934fac89
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/scripts/download_ota_fw_files.py
@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+"""
+Downloads current Argo Ulisse firmware binary files from manufacturer's servers
+"""
+
+__license__ = """This program and the accompanying materials are made available under the
+terms of the Eclipse Public License 2.0 which is available at
+http://www.eclipse.org/legal/epl-2.0
+
+SPDX-License-Identifier: EPL-2.0
+"""
+
+import hashlib
+import secrets
+import urllib.request
+from enum import Enum
+from itertools import cycle
+
+
+# Randomized values. Do not seem to impact the downloaded file
+USERNAME = secrets.token_hex(4)
+PASSWORD_MD5 = hashlib.md5(secrets.token_hex(4).encode('ASCII')).hexdigest()
+CPU_ID = secrets.token_hex(8)
+
+
+class FwType(str, Enum):
+ UNIT = 'OU_FW'
+ WIFI = 'UI_FW'
+
+
+def get_uri(fw_type: FwType, page: int):
+ return f'http://31.14.128.210/UI/UI.php?CM={fw_type}&PK={page}&USN={USERNAME}&PSW={PASSWORD_MD5}&CPU_ID={CPU_ID}'
+
+
+def get_api_response(fw_type: FwType, page: int):
+ with urllib.request.urlopen(get_uri(fw_type, page)) as response:
+ data: str = response.read().decode().rstrip()
+ if not data.endswith('|||'):
+ raise RuntimeError(f"Invalid upstream response {data}")
+ return {e.split('=')[0]: str.join("=", e.split('=')[1:]) for e in data[:-3].split('|')}
+
+
+def download_fw_from_remote_server(fw_type: FwType, split_into_multiple_files=False):
+ print(f'> {get_uri(fw_type, -1)}...')
+ ver_response = get_api_response(fw_type, -1)
+ try:
+ size = int(ver_response['SIZE'])
+ chunk_count = int(ver_response['NUM_PACK'])
+ checksum = int(ver_response['CKS']) # CRC-16?
+ base_offset = int(ver_response['OFFSET'])
+ print(f'FW Version: {ver_response}\n\tRelease: {ver_response["RELEASE"]}\n\tSize: {size}'
+ f'\n\t#chunks: {chunk_count}\n\tchecksum: {checksum}')
+
+ total_received_size = 0
+ data = ""
+ current_offset = base_offset
+ for i in range(0, chunk_count):
+ chunk_response = get_api_response(fw_type, i)
+ current_chunk_size_bytes = int(chunk_response['SIZE'])
+ print(f'{fw_type} chunk [{i+1}/{chunk_count}] - Response: {chunk_response}')
+
+ response_offset = int(chunk_response['OFFSET'])
+ if response_offset != current_offset:
+ if not split_into_multiple_files:
+ difference = response_offset - current_offset
+ print(f"Current offset is {current_offset}, but the response wants to write to {response_offset}."
+ f" Padding with 0xDEADBEEF")
+ fillers = cycle(['DE', 'AD', 'BE', 'EF'])
+ for x in range(0, difference):
+ data += next(fillers)
+ current_offset += difference
+ else:
+ save_to_file(base_offset, data, fw_type, total_received_size, ver_response["RELEASE"])
+ total_received_size = 0
+ data = ""
+ current_offset = response_offset
+ base_offset = response_offset
+ total_received_size += current_chunk_size_bytes
+ current_offset += current_chunk_size_bytes
+ data += chunk_response['DATA'][:current_chunk_size_bytes*2]
+
+ save_to_file(base_offset, data, fw_type, total_received_size, ver_response["RELEASE"])
+
+ finally:
+ finish_response = get_api_response(fw_type, 256)
+ print(finish_response)
+
+
+def save_to_file(base_offset, data, fw_type, total_received_size, version):
+ print()
+ print('-' * 50)
+ print(f'Received {total_received_size} bytes. Total binary size: {len(data) / 2:.0f}[b]')
+ print(f'Data (base16):\n\t{data}\n')
+ fw_binary = bytes.fromhex(data)
+ filename = f'Argo_firmware_{fw_type}_v{version}__offset_0x{base_offset:X}.bin'
+ with open(filename, "wb") as output_file:
+ output_file.write(fw_binary)
+ print(f'Firmware written to {filename}')
+
+
+if __name__ == '__main__':
+ print(f'Username={USERNAME}, Password={PASSWORD_MD5}, CPU_ID={CPU_ID}')
+ download_fw_from_remote_server(fw_type=FwType.UNIT, split_into_multiple_files=False)
+ download_fw_from_remote_server(fw_type=FwType.WIFI, split_into_multiple_files=False)
diff --git a/bundles/org.openhab.binding.argoclima/src/main/feature/feature.xml b/bundles/org.openhab.binding.argoclima/src/main/feature/feature.xml
new file mode 100644
index 0000000000000..a3496fa22a810
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/feature/feature.xml
@@ -0,0 +1,9 @@
+
+
+ mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features
+
+
+ openhab-runtime-base
+ mvn:org.openhab.addons.bundles/org.openhab.binding.argoclima/${project.version}
+
+
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/ArgoClimaBindingConstants.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/ArgoClimaBindingConstants.java
new file mode 100644
index 0000000000000..840cda023d967
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/ArgoClimaBindingConstants.java
@@ -0,0 +1,212 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal;
+
+import java.time.Duration;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.thing.ThingTypeUID;
+
+/**
+ * The {@link ArgoClimaBindingConstants} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public class ArgoClimaBindingConstants {
+
+ public static final String BINDING_ID = "argoclima";
+
+ /////////////
+ // List of all Thing Type UIDs
+ /////////////
+ public static final ThingTypeUID THING_TYPE_ARGOCLIMA_LOCAL = new ThingTypeUID(BINDING_ID, "local");
+ public static final ThingTypeUID THING_TYPE_ARGOCLIMA_REMOTE = new ThingTypeUID(BINDING_ID, "remote");
+
+ /////////////
+ // Thing configuration parameters
+ /////////////
+ public static final String PARAMETER_HOSTNAME = "hostname";
+ public static final String PARAMETER_LOCAL_DEVICE_IP = "localDeviceIP";
+ public static final String PARAMETER_HVAC_LISTEN_PORT = "hvacListenPort";
+ public static final String PARAMETER_DEVICE_CPU_ID = "deviceCpuId";
+ public static final String PARAMETER_CONNECTION_MODE = "connectionMode"; // LOCAL_CONNECTION | REMOTE_API_STUB |
+ // REMOTE_API_PROXY
+ public static final String PARAMETER_USE_LOCAL_CONNECTION = "useLocalConnection";
+ public static final String PARAMETER_REFRESH_INTERNAL = "refreshInterval";
+ public static final String PARAMETER_STUB_SERVER_PORT = "stubServerPort";
+ public static final String PARAMETER_STUB_SERVER_LISTEN_ADDRESSES = "stubServerListenAddresses";
+ public static final String PARAMETER_OEM_SERVER_PORT = "oemServerPort";
+ public static final String PARAMETER_OEM_SERVER_ADDRESS = "oemServerAddress";
+ public static final String PARAMETER_INCLUDE_DEVICE_SIDE_PASSWORDS_IN_PROPERTIES = "includeDeviceSidePasswordsInProperties";
+ public static final String PARAMETER_MATCH_ANY_INCOMING_DEVICE_IP = "matchAnyIncomingDeviceIp";
+
+ public static final String PARAMETER_USERNAME = "username";
+ public static final String PARAMETER_PASSWORD = "password";
+
+ public static final String PARAMETER_SCHEDULE_GROUP_NAME = "schedule%d"; // 1..3
+ public static final String PARAMETER_SCHEDULE_X_DAYS = PARAMETER_SCHEDULE_GROUP_NAME + "DayOfWeek";
+ public static final String PARAMETER_SCHEDULE_X_ON_TIME = PARAMETER_SCHEDULE_GROUP_NAME + "OnTime";
+ public static final String PARAMETER_SCHEDULE_X_OFF_TIME = PARAMETER_SCHEDULE_GROUP_NAME + "OffTime";
+ public static final String PARAMETER_ACTIONS_GROUP_NAME = "actions";
+ public static final String PARAMETER_RESET_TO_FACTORY_DEFAULTS = "resetToFactoryDefaults";
+
+ /////////////
+ // Thing configuration properties
+ /////////////
+ public static final String PROPERTY_CPU_ID = "cpuId";
+ public static final String PROPERTY_LOCAL_IP_ADDRESS = "localIpAddress";
+ public static final String PROPERTY_UNIT_FW = "unitFirmwareVersion";
+ public static final String PROPERTY_WIFI_FW = "wifiFirmwareVersion";
+ public static final String PROPERTY_LAST_SEEN = "lastSeen";
+ public static final String PROPERTY_WEB_UI = "argoWebUI";
+ public static final String PROPERTY_WEB_UI_USERNAME = "argoWebUIUsername";
+ public static final String PROPERTY_WEB_UI_PASSWORD = "argoWebUIPassword";
+ public static final String PROPERTY_WIFI_SSID = "wifiSSID";
+ public static final String PROPERTY_WIFI_PASSWORD = "wifiPassword";
+ public static final String PROPERTY_LOCAL_TIME = "localTime";
+
+ /////////////
+ // List of all Channel IDs
+ /////////////
+ public static final String CHANNEL_POWER = "ac-controls#power";
+ public static final String CHANNEL_MODE = "ac-controls#mode";
+ public static final String CHANNEL_SET_TEMPERATURE = "ac-controls#set-temperature";
+ public static final String CHANNEL_CURRENT_TEMPERATURE = "ac-controls#current-temperature";
+ public static final String CHANNEL_FAN_SPEED = "ac-controls#fan-speed";
+ public static final String CHANNEL_ECO_MODE = "modes#eco-mode";
+ public static final String CHANNEL_TURBO_MODE = "modes#turbo-mode";
+ public static final String CHANNEL_NIGHT_MODE = "modes#night-mode";
+ public static final String CHANNEL_ACTIVE_TIMER = "timers#active-timer";
+ public static final String CHANNEL_DELAY_TIMER = "timers#delay-timer";
+ // Note: schedule timers day of week/time setting not currently supported as channels (YAGNI), and moved to config
+ public static final String CHANNEL_MODE_EX = "unsupported#mode-ex";
+ public static final String CHANNEL_SWING_MODE = "unsupported#swing-mode";
+ public static final String CHANNEL_FILTER_MODE = "unsupported#filter-mode";
+
+ public static final String CHANNEL_I_FEEL_ENABLED = "settings#ifeel-enabled";
+ public static final String CHANNEL_DEVICE_LIGHTS = "settings#device-lights";
+
+ public static final String CHANNEL_TEMPERATURE_DISPLAY_UNIT = "settings#temperature-display-unit";
+ public static final String CHANNEL_ECO_POWER_LIMIT = "settings#eco-power-limit";
+
+ /////////////
+ // Binding's hard-coded configuration (not parameterized)
+ /////////////
+ /** Maximum number of failed status polls after which the device will be considered offline */
+ public static final int MAX_API_RETRIES = 3;
+
+ /**
+ * Time to wait between command issue and communicating with the device. Allows to include multiple commands in one
+ * device communication session (preferred).
+ * Time window chosen so that it is not (too) perceptible by an user, while still enough for rules/groups to be able
+ * to fit
+ */
+ public static final Duration SEND_COMMAND_DEBOUNCE_TIME = Duration.ofMillis(100);
+
+ /**
+ * The minimum resolution during which the command sending background thread does any meaningful action. This is
+ * merely to avoid busy wait and doesn't mean the thread is doing anything of use on every cycle. There are separate
+ * configurable "update" and "(re)send" frequencies governing that. This parameter only controls the lowest possible
+ * resolution of those (a "tick")
+ */
+ public static final Duration SEND_COMMAND_DUTY_CYCLE = Duration.ofSeconds(1);
+
+ /**
+ * The frequency to poll the device with, waiting for the command confirmation
+ */
+ public static final Duration POLL_FREQUENCY_AFTER_COMMAND_SENT_LOCAL = Duration.ofSeconds(3);
+
+ /**
+ * The frequency to poll the Argo servers with, waiting for the command confirmation
+ */
+ public static final Duration POLL_FREQUENCY_AFTER_COMMAND_SENT_REMOTE = Duration.ofSeconds(5);
+
+ /**
+ * The frequency to re-send the pending command to the device at (if it hadn't been confirmed yet).
+ * Aka. the optimistic time when the device "should acknowledge. Should be greater than
+ * {@link #POLL_FREQUENCY_AFTER_COMMAND_SENT_LOCAL}
+ *
+ * @see #SEND_COMMAND_MAX_WAIT_TIME_LOCAL_DIRECT
+ * @see #SEND_COMMAND_MAX_WAIT_TIME_LOCAL_INDIRECT
+ */
+ public static final Duration SEND_COMMAND_RETRY_FREQUENCY_LOCAL = Duration.ofSeconds(10);
+
+ /**
+ * The frequency to re-send the pending command to the remote Argo server at (if it hadn't been confirmed yet).
+ * Aka. the optimistic time when the server "should acknowledge. Should be greater than
+ * {@link #POLL_FREQUENCY_AFTER_COMMAND_SENT_REMOTE}
+ *
+ * @see #SEND_COMMAND_MAX_WAIT_TIME_REMOTE
+ */
+ public static final Duration SEND_COMMAND_RETRY_FREQUENCY_REMOTE = Duration.ofSeconds(20);
+
+ /**
+ * Max time to wait for a pending command to be confirmed by the device in a local-direct mode (when we are issuing
+ * communications to a device in local LAN).
+ *
+ * During this time, the commands may get {@link #SEND_COMMAND_RETRY_FREQUENCY_LOCAL retried} and the device status
+ * may be
+ * {@link #POLL_FREQUENCY_AFTER_COMMAND_SENT_LOCAL re-fetched}
+ */
+ public static final Duration SEND_COMMAND_MAX_WAIT_TIME_LOCAL_DIRECT = Duration.ofSeconds(20); // 60-remote
+
+ /**
+ * Max time to wait for a pending command to be confirmed in an *indirect* mode (where we're only
+ * sniffing/intercepting communications)
+ *
+ * A healthy device seems to be polling Argo servers every minute (and if the server returns a pending command
+ * request, does a few more more frequent exchanges as well), so 2 minutes seem safe
+ */
+ public static final Duration SEND_COMMAND_MAX_WAIT_TIME_LOCAL_INDIRECT = Duration.ofSeconds(120);
+
+ /**
+ * Max time to wait for a pending command to be confirmed in an *remote* mode (where we're talking to a remote Argo
+ * server)
+ *
+ * The server seems to confirm a bit faster than our intercepting proxy and we want to minimize traffic our binding
+ * issues against remote side, hence a more conservative value
+ */
+ public static final Duration SEND_COMMAND_MAX_WAIT_TIME_REMOTE = Duration.ofSeconds(60);
+
+ /**
+ * Time to wait for (confirmable) command to be reported back by the device (by changing its state to the requested
+ * value). If this period elapses w/o the device confirming, the command is considered not handled and REJECTED
+ * (would not be retried any more, and the reported device's state will be the actual one device sent, not the
+ * "in-flight" desired one)
+ *
+ * @implNote This is just a final "give up" time (not affecting any send logic). Should be no shorter than max try
+ * time
+ */
+ public static final Duration PENDING_COMMAND_EXPIRE_TIME = SEND_COMMAND_MAX_WAIT_TIME_LOCAL_INDIRECT
+ .plus(Duration.ofSeconds(1));
+
+ /**
+ * Timeout for getting the HTTP response from Argo servers in pass-through(proxy) mode
+ */
+ public static final Duration UPSTREAM_PROXY_HTTP_REQUEST_TIMEOUT = Duration.ofSeconds(30);
+
+ /////////////
+ // R&D-only switches
+ /////////////
+ /**
+ * Whether the binding shall wait for the device confirming commands have been received (by flipping to the desired
+ * state) or work in a fire and forget mode and stop tracking upon first send.
+ *
+ * This applies only to confirmable commands (read-write) and is a default behavior of Argo's own web implementation
+ *
+ * @implNote This is a debug-only switch (makes little to no sense to disable it in real-world usage)
+ */
+ public static final boolean AWAIT_DEVICE_CONFIRMATIONS_AFTER_COMMANDS = true;
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/ArgoClimaConfigProvider.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/ArgoClimaConfigProvider.java
new file mode 100644
index 0000000000000..22463ddaf8d71
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/ArgoClimaConfigProvider.java
@@ -0,0 +1,216 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.argoclima.internal.configuration.IScheduleConfigurationProvider.ScheduleTimerType;
+import org.openhab.binding.argoclima.internal.device.api.types.Weekday;
+import org.openhab.core.config.core.ConfigDescription;
+import org.openhab.core.config.core.ConfigDescriptionBuilder;
+import org.openhab.core.config.core.ConfigDescriptionParameter;
+import org.openhab.core.config.core.ConfigDescriptionParameter.Type;
+import org.openhab.core.config.core.ConfigDescriptionParameterBuilder;
+import org.openhab.core.config.core.ConfigDescriptionParameterGroup;
+import org.openhab.core.config.core.ConfigDescriptionParameterGroupBuilder;
+import org.openhab.core.config.core.ConfigDescriptionProvider;
+import org.openhab.core.config.core.ParameterOption;
+import org.openhab.core.thing.ThingRegistry;
+import org.openhab.core.thing.ThingUID;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link ArgoClimaConfigProvider} class provides dynamic configuration entries
+ * for the things supported by the binding (on top of static properties defined in
+ * {@code thing-types.xml})
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = { ConfigDescriptionProvider.class })
+public class ArgoClimaConfigProvider implements ConfigDescriptionProvider {
+ private final Logger logger = LoggerFactory.getLogger(getClass());
+ private final ThingRegistry thingRegistry;
+ private final ArgoClimaTranslationProvider i18nProvider;
+ private static final int SCHEDULE_TIMERS_COUNT = 3;
+
+ public record ScheduleDefaults(String startTime, String endTime, EnumSet weekdays) {
+ /**
+ * @implNote Overriding the default-generated method, as it doesn't preserve {@code NonNull} annotation on the
+ * element set.
+ */
+ public EnumSet weekdays() {
+ return weekdays;
+ }
+ }
+
+ private static final Map SCHEDULE_DEFAULTS = Map.of(
+ ScheduleTimerType.SCHEDULE_1,
+ new ScheduleDefaults("08:00", "18:00",
+ EnumSet.of(
+ Weekday.MON, Weekday.TUE, Weekday.WED, Weekday.THU, Weekday.FRI, Weekday.SAT, Weekday.SUN)),
+ ScheduleTimerType.SCHEDULE_2,
+ new ScheduleDefaults("15:00", "20:00",
+ EnumSet.of(Weekday.MON, Weekday.TUE, Weekday.WED, Weekday.THU, Weekday.FRI)),
+ ScheduleTimerType.SCHEDULE_3, new ScheduleDefaults("11:00", "22:00", EnumSet.of(Weekday.SAT, Weekday.SUN)));
+
+ public static final ScheduleDefaults getScheduleDefaults(ScheduleTimerType scheduleTimerType) {
+ if (!EnumSet.allOf(ScheduleTimerType.class).contains(scheduleTimerType)) {
+ throw new IllegalArgumentException("Invalid schedule timer: " + scheduleTimerType.toString());
+ }
+ var result = SCHEDULE_DEFAULTS.get(scheduleTimerType);
+ Objects.requireNonNull(result);
+ return result;
+ }
+
+ @Activate
+ public ArgoClimaConfigProvider(final @Reference ThingRegistry thingRegistry,
+ final @Reference ArgoClimaTranslationProvider i18nProvider) {
+ this.thingRegistry = thingRegistry;
+ this.i18nProvider = i18nProvider;
+ }
+
+ /**
+ * Provides a collection of {@link ConfigDescription}s.
+ *
+ * @param locale locale
+ * @return the configuration descriptions provided by this provider (not
+ * null, could be empty)
+ */
+ @Override
+ public Collection getConfigDescriptions(@Nullable Locale locale) {
+ return Collections.emptySet(); // no dynamic values
+ }
+
+ /**
+ * Provides a {@link ConfigDescription} for the given URI.
+ *
+ * @param uri URI of the config description (may be either thing or thing-type URI)
+ * @param locale locale (not using this value, as our i18n provider comes with it pre-populated!)
+ * @return config description or null if no config description could be found
+ *
+ * @implNote {@code ConfigDescriptionParameterBuilder} doesn't have non-null-defaults, while
+ * {@code ConfigDescriptionBuilder} does... so while it's quite redundant, using Objects.requireNonNull()
+ * to keep number of warnings low
+ */
+ @Override
+ @Nullable
+ public ConfigDescription getConfigDescription(URI uri, @Nullable Locale locale) {
+ if (!"thing".equalsIgnoreCase(uri.getScheme())) {
+ return null; // Deliberately not supporting "thing-type" (no dynamic parameters there)
+ }
+ ThingUID thingUID = new ThingUID(Objects.requireNonNull(uri.getSchemeSpecificPart()));
+ if (!thingUID.getBindingId().equals(ArgoClimaBindingConstants.BINDING_ID)) {
+ return null;
+ }
+
+ var thing = this.thingRegistry.get(thingUID);
+ if (thing == null) {
+ logger.trace("getConfigDescription: No thing found for uri: {}", uri);
+ return null;
+ }
+
+ var paramGroups = new ArrayList();
+ for (int i = 1; i <= SCHEDULE_TIMERS_COUNT; ++i) {
+ paramGroups.add(ConfigDescriptionParameterGroupBuilder
+ .create(String.format(ArgoClimaBindingConstants.PARAMETER_SCHEDULE_GROUP_NAME, i))
+ .withLabel(
+ i18nProvider.getText("dynamic-config.argoclima.group.schedule.label", "Schedule {0} ", i))
+ .withDescription(i18nProvider.getText("dynamic-config.argoclima.group.schedule.description",
+ "Schedule timer - profile {0}.", i))
+ .build());
+ }
+ if (thing.isEnabled()) {
+ paramGroups.add(ConfigDescriptionParameterGroupBuilder.create("actions").withContext("actions")
+ .withLabel(i18nProvider.getText("dynamic-config.argoclima.group.actions.label", "Actions"))
+ .build());
+ }
+
+ var parameters = new ArrayList();
+
+ var daysOfWeek = List.<@Nullable ParameterOption> of(
+ new ParameterOption(Weekday.MON.toString(),
+ i18nProvider.getText("dynamic-config.argoclima.schedule.days.monday", "Monday")),
+ new ParameterOption(Weekday.TUE.toString(),
+ i18nProvider.getText("dynamic-config.argoclima.schedule.days.tuesday", "Tuesday")),
+ new ParameterOption(Weekday.WED.toString(),
+ i18nProvider.getText("dynamic-config.argoclima.schedule.days.wednesday", "Wednesday")),
+ new ParameterOption(Weekday.THU.toString(),
+ i18nProvider.getText("dynamic-config.argoclima.schedule.days.thursday", "Thursday")),
+ new ParameterOption(Weekday.FRI.toString(),
+ i18nProvider.getText("dynamic-config.argoclima.schedule.days.friday", "Friday")),
+ new ParameterOption(Weekday.SAT.toString(),
+ i18nProvider.getText("dynamic-config.argoclima.schedule.days.saturday", "Saturday")),
+ new ParameterOption(Weekday.SUN.toString(),
+ i18nProvider.getText("dynamic-config.argoclima.schedule.days.sunday", "Sunday")));
+
+ for (int i = 1; i <= SCHEDULE_TIMERS_COUNT; ++i) {
+ // NOTE: Deliberately *not* using .withContext("dayOfWeek") - doesn't seem to work correctly :(
+ parameters.add(Objects.requireNonNull(ConfigDescriptionParameterBuilder
+ .create(String.format(ArgoClimaBindingConstants.PARAMETER_SCHEDULE_X_DAYS, i), Type.TEXT)
+ .withRequired(true)
+ .withGroupName(String.format(ArgoClimaBindingConstants.PARAMETER_SCHEDULE_GROUP_NAME, i))//
+ .withLabel(i18nProvider.getText("dynamic-config.argoclima.schedule.days.label", "Days"))
+ .withDescription(i18nProvider.getText("dynamic-config.argoclima.schedule.days.description",
+ "Days when the schedule is run"))
+ .withOptions(daysOfWeek)
+ .withDefault(getScheduleDefaults(ScheduleTimerType.fromInt(i)).weekdays().toString())
+ .withMultiple(true).withMultipleLimit(7).build()));
+
+ // NOTE: Deliberately *not* using .withContext("time") - does work, but causes UI to detect each entry to
+ // the page as a change
+ parameters.add(Objects.requireNonNull(ConfigDescriptionParameterBuilder
+ .create(String.format(ArgoClimaBindingConstants.PARAMETER_SCHEDULE_X_ON_TIME, i), Type.TEXT)
+ .withRequired(true)
+ .withGroupName(String.format(ArgoClimaBindingConstants.PARAMETER_SCHEDULE_GROUP_NAME, i))
+ .withPattern("\\d{1-2}:\\d{1-2}")
+ .withLabel(i18nProvider.getText("dynamic-config.argoclima.schedule.on-time.label", "On Time"))
+ .withDescription(i18nProvider.getText("dynamic-config.argoclima.schedule.on-time.description",
+ "Time when the A/C turns on"))
+ .withDefault(getScheduleDefaults(ScheduleTimerType.fromInt(i)).startTime()).build()));
+ parameters.add(Objects.requireNonNull(ConfigDescriptionParameterBuilder
+ .create(String.format(ArgoClimaBindingConstants.PARAMETER_SCHEDULE_X_OFF_TIME, i), Type.TEXT)
+ .withRequired(true)
+ .withGroupName(String.format(ArgoClimaBindingConstants.PARAMETER_SCHEDULE_GROUP_NAME, i))
+ .withLabel(i18nProvider.getText("dynamic-config.argoclima.schedule.off-time.label", "Off Time"))
+ .withDescription(i18nProvider.getText("dynamic-config.argoclima.schedule.off-time.description",
+ "Time when the A/C turns off"))
+ .withDefault(getScheduleDefaults(ScheduleTimerType.fromInt(i)).endTime()).build()));
+ }
+ if (thing.isEnabled()) {
+ parameters.add(Objects.requireNonNull(ConfigDescriptionParameterBuilder
+ .create(ArgoClimaBindingConstants.PARAMETER_RESET_TO_FACTORY_DEFAULTS, Type.BOOLEAN)
+ .withRequired(false).withGroupName(ArgoClimaBindingConstants.PARAMETER_ACTIONS_GROUP_NAME)
+ .withLabel(i18nProvider.getText("dynamic-config.argoclima.schedule.reset.label", "Reset Settings"))
+ .withDescription(i18nProvider.getText("dynamic-config.argoclima.schedule.reset.description",
+ "Reset device settings to factory defaults"))
+ .withDefault("false").withVerify(true).build()));
+ }
+
+ return ConfigDescriptionBuilder.create(uri).withParameterGroups(paramGroups).withParameters(parameters).build();
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/ArgoClimaHandlerFactory.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/ArgoClimaHandlerFactory.java
new file mode 100644
index 0000000000000..cad8fc31c5dba
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/ArgoClimaHandlerFactory.java
@@ -0,0 +1,77 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal;
+
+import static org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants.*;
+
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.argoclima.internal.handler.ArgoClimaHandlerLocal;
+import org.openhab.binding.argoclima.internal.handler.ArgoClimaHandlerRemote;
+import org.openhab.core.i18n.TimeZoneProvider;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.binding.BaseThingHandlerFactory;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerFactory;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * The {@link ArgoClimaHandlerFactory} is responsible for creating things and thing
+ * handlers.
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+@Component(configurationPid = "binding." + ArgoClimaBindingConstants.BINDING_ID, service = ThingHandlerFactory.class)
+public class ArgoClimaHandlerFactory extends BaseThingHandlerFactory {
+ private final HttpClientFactory httpClientFactory;
+ private final TimeZoneProvider timeZoneProvider;
+ private final ArgoClimaTranslationProvider i18nProvider;
+
+ private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_ARGOCLIMA_LOCAL,
+ THING_TYPE_ARGOCLIMA_REMOTE);
+
+ @Activate
+ public ArgoClimaHandlerFactory(final @Reference HttpClientFactory httpClientFactory,
+ final @Reference TimeZoneProvider timeZoneProvider,
+ final @Reference ArgoClimaTranslationProvider i18nProvider) {
+ this.httpClientFactory = httpClientFactory;
+ this.timeZoneProvider = timeZoneProvider;
+ this.i18nProvider = i18nProvider;
+ }
+
+ @Override
+ public boolean supportsThingType(ThingTypeUID thingTypeUID) {
+ return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
+ }
+
+ @Override
+ protected @Nullable ThingHandler createHandler(Thing thing) {
+ ThingTypeUID thingTypeUID = thing.getThingTypeUID();
+
+ if (THING_TYPE_ARGOCLIMA_LOCAL.equals(thingTypeUID)) {
+ return new ArgoClimaHandlerLocal(thing, httpClientFactory, timeZoneProvider, i18nProvider);
+ }
+ if (THING_TYPE_ARGOCLIMA_REMOTE.equals(thingTypeUID)) {
+ return new ArgoClimaHandlerRemote(thing, httpClientFactory, timeZoneProvider, i18nProvider);
+ }
+
+ return null;
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/ArgoClimaTranslationProvider.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/ArgoClimaTranslationProvider.java
new file mode 100644
index 0000000000000..a79a2554476fb
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/ArgoClimaTranslationProvider.java
@@ -0,0 +1,70 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.i18n.LocaleProvider;
+import org.openhab.core.i18n.TranslationProvider;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.BundleContext;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * Provides a convenience wrapper around framework-provided {@link TranslationProvider}, pre-filling the bundle and
+ * locale parameters
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = { ArgoClimaTranslationProvider.class })
+public class ArgoClimaTranslationProvider {
+ private final TranslationProvider i18nProvider;
+ private final LocaleProvider localeProvider;
+ private final @Nullable Bundle bundle;
+
+ @Activate
+ public ArgoClimaTranslationProvider(final @Reference TranslationProvider i18nProvider,
+ final @Reference LocaleProvider localeProvider, final BundleContext context) {
+ this.bundle = context.getBundle();
+ this.i18nProvider = i18nProvider;
+ this.localeProvider = localeProvider;
+ }
+
+ /**
+ * Similar to {@link TranslationProvider#getText(Bundle, String, String, java.util.Locale, Object...)}.
+ * Pre-fills {@code Bundle} and {@code Locale} params to reduce boilerplate.
+ *
+ * @param key the key to be translated (can be empty)
+ * @param defaultText the default text to be used (can be null or empty)
+ * @param arguments the arguments to be injected into the translation (each arg can be null)
+ * @return the translated text or the default text (can be null or empty)
+ */
+ public @Nullable String getText(String key, @Nullable String defaultText, Object @Nullable... arguments) {
+ return i18nProvider.getText(bundle, key, defaultText, localeProvider.getLocale(), arguments);
+ }
+
+ /**
+ * Similar to {@link TranslationProvider#getText(Bundle, String, String, java.util.Locale)}.
+ * Pre-fills {@code Bundle} and {@code Locale} params to reduce boilerplate.
+ *
+ * @param key the key to be translated (can be empty)
+ * @param defaultText the default text to be used (can be null or empty)
+ * @return the translated text or the default text (can be null or empty)
+ */
+ public @Nullable String getText(String key, @Nullable String defaultText) {
+ return i18nProvider.getText(bundle, key, defaultText, localeProvider.getLocale());
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/configuration/ArgoClimaConfigurationBase.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/configuration/ArgoClimaConfigurationBase.java
new file mode 100644
index 0000000000000..73f0ffb388081
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/configuration/ArgoClimaConfigurationBase.java
@@ -0,0 +1,385 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.configuration;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.EnumSet;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNull;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants;
+import org.openhab.binding.argoclima.internal.ArgoClimaConfigProvider;
+import org.openhab.binding.argoclima.internal.ArgoClimaTranslationProvider;
+import org.openhab.binding.argoclima.internal.device.api.types.Weekday;
+import org.openhab.binding.argoclima.internal.exception.ArgoConfigurationException;
+import org.openhab.binding.argoclima.internal.utils.StringUtils;
+import org.openhab.core.config.core.Configuration;
+
+/**
+ * The {@link ArgoClimaConfigurationBase} class contains fields mapping thing configuration parameters.
+ * Contains common configuration parameters (same for all supported device types).
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public abstract class ArgoClimaConfigurationBase extends Configuration implements IScheduleConfigurationProvider {
+ /////////////////////
+ // TYPES
+ /////////////////////
+ @FunctionalInterface
+ public interface ConfigValueSupplier {
+ public T get() throws ArgoConfigurationException;
+ }
+
+ /////////////////////
+ // Configuration parameters
+ // These names are defined in thing-types.xml and/or ArgoClimaConfigProvider and get injected on instantiation
+ // through {@link org.openhab.core.thing.binding.BaseThingHandler#getConfigAs getConfigAs}
+ /////////////////////
+ private int refreshInterval = 30; // in seconds
+ private String deviceCpuId = "";
+ private int oemServerPort = 80;
+ private String oemServerAddress = "31.14.128.210";
+
+ // Note this boilerplate is actually necessary as these values are injected by framework!
+ private Set schedule1DayOfWeek = ArgoClimaConfigProvider.getScheduleDefaults(ScheduleTimerType.SCHEDULE_1)
+ .weekdays();
+ private String schedule1OnTime = ArgoClimaConfigProvider.getScheduleDefaults(ScheduleTimerType.SCHEDULE_1)
+ .startTime();
+ private String schedule1OffTime = ArgoClimaConfigProvider.getScheduleDefaults(ScheduleTimerType.SCHEDULE_1)
+ .endTime();
+ private Set schedule2DayOfWeek = ArgoClimaConfigProvider.getScheduleDefaults(ScheduleTimerType.SCHEDULE_2)
+ .weekdays();
+ private String schedule2OnTime = ArgoClimaConfigProvider.getScheduleDefaults(ScheduleTimerType.SCHEDULE_2)
+ .startTime();
+ private String schedule2OffTime = ArgoClimaConfigProvider.getScheduleDefaults(ScheduleTimerType.SCHEDULE_2)
+ .endTime();
+ private Set schedule3DayOfWeek = ArgoClimaConfigProvider.getScheduleDefaults(ScheduleTimerType.SCHEDULE_3)
+ .weekdays();
+ private String schedule3OnTime = ArgoClimaConfigProvider.getScheduleDefaults(ScheduleTimerType.SCHEDULE_3)
+ .startTime();
+ private String schedule3OffTime = ArgoClimaConfigProvider.getScheduleDefaults(ScheduleTimerType.SCHEDULE_3)
+ .endTime();
+
+ public boolean resetToFactoryDefaults = false;
+
+ /////////////////////
+ // Other fields
+ /////////////////////
+ private static final DateTimeFormatter SCHEDULE_ON_OFF_TIME_FORMATTER = DateTimeFormatter.ofPattern("H:mm[:ss]");
+ protected @Nullable ArgoClimaTranslationProvider i18nProvider;
+
+ /**
+ * Initializes the configuration class post construction, injecting i18n provider for localized configuration
+ * exceptions
+ *
+ * @implNote This class requires default/parameterless c-tor for framework-side initialization (from file)
+ * @param i18nProvider Framework's translation provider
+ */
+ public void initialize(ArgoClimaTranslationProvider i18nProvider) {
+ this.i18nProvider = i18nProvider;
+ }
+
+ /**
+ * Get the user-configured CPUID of the Argo device (used in matching to a concrete device in a stub mode)
+ *
+ * @return The configured CPUID (if provided by the user = not blank)
+ */
+ public Optional getDeviceCpuId() {
+ return this.deviceCpuId.isBlank() ? Optional. empty() : Optional.of(this.deviceCpuId);
+ }
+
+ /**
+ * Get the refresh interval the device is polled with (in seconds)
+ *
+ * @return The interval value {@code 0} - to disable polling
+ */
+ public int getRefreshInterval() {
+ return this.refreshInterval;
+ }
+
+ /**
+ * If true, allows the binding to directly communicate with the device (or vendor's server - for remote thing type).
+ * When false, binding will not communicate directly with the device and wait for it to call it (through
+ * intercepting/stub server)
+ *
+ * Mode-specific considerations :
+ *
+ * in {@code REMOTE_API_STUB} mode - will not issue any outbound connections on its own
+ * in {@code REMOTE_API_PROXY} mode - will still communicate with vendor's servers but ONLY when queried by the
+ * device (a pass-through)
+ *
+ *
+ * @implNote While this is configured by its dedicated settings (for better UX) and valid only for Local Thing
+ * types, internal implementation uses {@code refreshInterval == 0} to signify no comms. This is because
+ * without a refresh, the binding would have to function in a fire and forget mode sending commands back
+ * to HVAC and never receiving any ACK... which makes little sense, hence is not supported
+ *
+ * @return True if the Thing is allowed to communicate outwards on its own, False otherwise
+ */
+ public boolean useDirectConnection() {
+ return getRefreshInterval() > 0; // Uses virtual method overridden for local device!
+ }
+
+ /**
+ * The OEM server's address, used to pass through the communications to (in REMOTE_API_PROXY) mode
+ *
+ * @return The vendor's server IP address
+ * @throws ArgoConfigurationException In case the IP cannot be found
+ */
+ public InetAddress getOemServerAddress() throws ArgoConfigurationException {
+ try {
+ return Objects.requireNonNull(InetAddress.getByName(oemServerAddress));
+ } catch (UnknownHostException e) {
+ throw ArgoConfigurationException.forInvalidParamValue(
+ ArgoClimaBindingConstants.PARAMETER_OEM_SERVER_ADDRESS, oemServerAddress, i18nProvider, e);
+ }
+ }
+
+ /**
+ * The OEM server's port, used to pass through the communications to (in REMOTE_API_PROXY) mode
+ *
+ * @return Vendor's server port. {@code -1} for no value
+ */
+ public int getOemServerPort() {
+ return this.oemServerPort;
+ }
+
+ /**
+ * Converts "raw" {@code Set} into an {@code EnumSet}
+ *
+ * @implNote Because this configuration parameter is *dynamic* (and deliberately not defined in
+ * {@code thing-types.xml}) when OH is loading a textual thing file, it does not have a full definition
+ * yet, hence CANNOT infer its data type.
+ * The Thing.xtext definition for {@code ModelProperty} allows for arrays, but these are always implicit/
+ * For example {@code schedule1DayOfWeek="MON","TUE"} deserializes as a Collection (and is properly cast
+ * to enum later), however a {@code schedule1DayOfWeek="MON"} deserializes to a String, and causes a
+ * {@link ClassCastException} on access. This impl. accounts for that forced "as-String" interpretation on
+ * load, and coerces such values back to a collection.
+ * @param rawInput The value to process
+ * @param paramName Name of the textual parameter (for error messaging)
+ * @return Converted value
+ * @throws ArgoConfigurationException In case the conversion fails
+ */
+ private EnumSet canonizeWeekdaysAfterDeserialization(Set rawInput, String paramName)
+ throws ArgoConfigurationException {
+ try {
+ var items = rawInput.toArray();
+ if (items.length == 1 && !(items[0] instanceof Weekday)) {
+ // Text based configuration -> falling back to string parse
+ var strValue = StringUtils.strip(items[0].toString(), "[]- \t\"'").trim();
+ var daysStr = StringUtils.splitByWholeSeparator(strValue, ",").stream();
+
+ var result = EnumSet.noneOf(Weekday.class);
+ daysStr.map(ds -> Weekday.valueOf(ds.strip())).forEach(wd -> result.add(wd));
+ return result;
+ } else {
+ // UI/API configuration (nicely strong-typed already)
+ return EnumSet.copyOf(rawInput);
+ }
+ } catch (ClassCastException | IllegalArgumentException e) {
+ throw ArgoConfigurationException.forInvalidParamValue(paramName, rawInput.toString(), i18nProvider, e);
+ }
+ }
+
+ record ConfigParam (K paramValue, String paramName) {
+ }
+
+ @Override
+ public EnumSet getScheduleDayOfWeek(ScheduleTimerType scheduleType) throws ArgoConfigurationException {
+ ConfigParam> configValue;
+ switch (scheduleType) {
+ case SCHEDULE_1:
+ configValue = new ConfigParam<>(schedule1DayOfWeek,
+ ArgoClimaBindingConstants.PARAMETER_SCHEDULE_X_DAYS.formatted(1));
+ break;
+ case SCHEDULE_2:
+ configValue = new ConfigParam<>(schedule2DayOfWeek,
+ ArgoClimaBindingConstants.PARAMETER_SCHEDULE_X_DAYS.formatted(2));
+ break;
+ case SCHEDULE_3:
+ configValue = new ConfigParam<>(schedule3DayOfWeek,
+ ArgoClimaBindingConstants.PARAMETER_SCHEDULE_X_DAYS.formatted(3));
+ break;
+ default:
+ throw new IllegalArgumentException("Invalid schedule timer: " + scheduleType.toString());
+ }
+
+ if (configValue.paramValue().isEmpty()) {
+ return ArgoClimaConfigProvider.getScheduleDefaults(scheduleType).weekdays();
+ }
+ return canonizeWeekdaysAfterDeserialization(configValue.paramValue(), configValue.paramName());
+ }
+
+ @Override
+ public LocalTime getScheduleOnTime(ScheduleTimerType scheduleType) throws ArgoConfigurationException {
+ ConfigParam configValue;
+ switch (scheduleType) {
+ case SCHEDULE_1:
+ configValue = new ConfigParam<>(schedule1OnTime,
+ ArgoClimaBindingConstants.PARAMETER_SCHEDULE_X_ON_TIME.formatted(1));
+ break;
+ case SCHEDULE_2:
+ configValue = new ConfigParam<>(schedule2OnTime,
+ ArgoClimaBindingConstants.PARAMETER_SCHEDULE_X_ON_TIME.formatted(2));
+ break;
+ case SCHEDULE_3:
+ configValue = new ConfigParam<>(schedule3OnTime,
+ ArgoClimaBindingConstants.PARAMETER_SCHEDULE_X_ON_TIME.formatted(3));
+ break;
+ default:
+ throw new IllegalArgumentException("Invalid schedule timer: " + scheduleType.toString());
+ }
+
+ try {
+ return LocalTime.parse(configValue.paramValue(), SCHEDULE_ON_OFF_TIME_FORMATTER);
+ } catch (DateTimeParseException e) {
+ throw ArgoConfigurationException.forInvalidParamValue(configValue.paramName(), configValue.paramValue(),
+ i18nProvider, e);
+ }
+ }
+
+ @Override
+ public LocalTime getScheduleOffTime(ScheduleTimerType scheduleType) throws ArgoConfigurationException {
+ ConfigParam configValue;
+ switch (scheduleType) {
+ case SCHEDULE_1:
+ configValue = new ConfigParam<>(schedule1OffTime,
+ ArgoClimaBindingConstants.PARAMETER_SCHEDULE_X_OFF_TIME.formatted(1));
+ break;
+ case SCHEDULE_2:
+ configValue = new ConfigParam<>(schedule2OffTime,
+ ArgoClimaBindingConstants.PARAMETER_SCHEDULE_X_OFF_TIME.formatted(2));
+ break;
+ case SCHEDULE_3:
+ configValue = new ConfigParam<>(schedule3OffTime,
+ ArgoClimaBindingConstants.PARAMETER_SCHEDULE_X_OFF_TIME.formatted(3));
+ break;
+ default:
+ throw new IllegalArgumentException("Invalid schedule timer: " + scheduleType.toString());
+ }
+
+ try {
+ return LocalTime.parse(configValue.paramValue(), SCHEDULE_ON_OFF_TIME_FORMATTER);
+ } catch (DateTimeParseException e) {
+ throw ArgoConfigurationException.forInvalidParamValue(configValue.paramName(), configValue.paramValue(),
+ i18nProvider, e);
+ }
+ }
+
+ /////////////////////
+ // Helper functions
+ /////////////////////
+
+ /**
+ * Utility function for logging only. Gets a parsed value from the supplier function or, exceptionally the raw
+ * value. Swallows exceptions.
+ *
+ * @param Actual type of variable returned by the supplier (parsed)
+ * @param fn Parser function
+ * @return String param value (if parsed correctly), or the default value post-fixed with {@code [raw]} - on parse
+ * failure.
+ */
+ protected static <@NonNull T> String getOrDefault(ConfigValueSupplier fn) {
+ try {
+ return fn.get().toString();
+ } catch (ArgoConfigurationException e) {
+ return e.rawValue + "[raw]";
+ }
+ }
+
+ @Override
+ public final String toString() {
+ return String.format("Config: { %s, deviceCpuId=%s, refreshInterval=%d, oemServerPort=%d, oemServerAddress=%s,"
+ + "schedule1DayOfWeek=%s, schedule1OnTime=%s, schedule1OffTime=%s, schedule2DayOfWeek=%s, schedule2OnTime=%s, schedule2OffTime=%s, schedule3DayOfWeek=%s, schedule3OnTime=%s, schedule3OffTime=%s, resetToFactoryDefaults=%s}",
+ getExtraFieldDescription(), deviceCpuId, refreshInterval, oemServerPort,
+ getOrDefault(this::getOemServerAddress),
+ getOrDefault(() -> getScheduleDayOfWeek(ScheduleTimerType.SCHEDULE_1)),
+ getOrDefault(() -> getScheduleOnTime(ScheduleTimerType.SCHEDULE_1)),
+ getOrDefault(() -> getScheduleOffTime(ScheduleTimerType.SCHEDULE_1)),
+ getOrDefault(() -> getScheduleDayOfWeek(ScheduleTimerType.SCHEDULE_2)),
+ getOrDefault(() -> getScheduleOnTime(ScheduleTimerType.SCHEDULE_2)),
+ getOrDefault(() -> getScheduleOffTime(ScheduleTimerType.SCHEDULE_2)),
+ getOrDefault(() -> getScheduleDayOfWeek(ScheduleTimerType.SCHEDULE_3)),
+ getOrDefault(() -> getScheduleOnTime(ScheduleTimerType.SCHEDULE_3)),
+ getOrDefault(() -> getScheduleOffTime(ScheduleTimerType.SCHEDULE_3)), resetToFactoryDefaults);
+ }
+
+ /**
+ * Return derived class'es extra configuration parameters (for a common {@link toString} implementation)
+ *
+ * @return Comma-separated list of configuration parameter=value pairs or empty String if derived class does not
+ * introduce any.
+ */
+ protected abstract String getExtraFieldDescription();
+
+ /**
+ * Validate derived configuration
+ *
+ * @throws ArgoConfigurationException - on validation failure
+ */
+ protected abstract void validateInternal() throws ArgoConfigurationException;
+
+ /**
+ * Validate current config
+ *
+ * @return Error message if config is invalid. Empty string - otherwise
+ */
+ public final String validate() {
+ try {
+ if (refreshInterval < 0) {
+ throw ArgoConfigurationException.forParamBelowMin(ArgoClimaBindingConstants.PARAMETER_REFRESH_INTERNAL,
+ oemServerPort, i18nProvider, 0);
+ }
+
+ if (oemServerPort < 0 || oemServerPort > 65535) {
+ throw ArgoConfigurationException.forParamOutOfRange(ArgoClimaBindingConstants.PARAMETER_OEM_SERVER_PORT,
+ oemServerPort, i18nProvider, 0, 65535);
+ }
+
+ // want the side-effect of these calls
+ getOemServerAddress();
+
+ getScheduleDayOfWeek(ScheduleTimerType.SCHEDULE_1);
+ getScheduleOnTime(ScheduleTimerType.SCHEDULE_1);
+ getScheduleOffTime(ScheduleTimerType.SCHEDULE_1);
+
+ getScheduleDayOfWeek(ScheduleTimerType.SCHEDULE_2);
+ getScheduleOnTime(ScheduleTimerType.SCHEDULE_2);
+ getScheduleOffTime(ScheduleTimerType.SCHEDULE_2);
+
+ getScheduleDayOfWeek(ScheduleTimerType.SCHEDULE_3);
+ getScheduleOnTime(ScheduleTimerType.SCHEDULE_3);
+ getScheduleOffTime(ScheduleTimerType.SCHEDULE_3);
+
+ validateInternal();
+ return "";
+ } catch (Exception e) {
+ var msg = Optional.ofNullable(e.getLocalizedMessage());
+ var cause = Optional.ofNullable(e.getCause());
+ return msg.orElse("Unknown exception, message is null") // The message theoretically can be null
+ // (Exception's i-face) but in practice never is, so
+ // keeping cryptic non-i18nized text instead of
+ // throwing
+ .concat(cause.map(c -> "\n\t[" + c.getClass().getSimpleName() + "]").orElse(""));
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/configuration/ArgoClimaConfigurationLocal.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/configuration/ArgoClimaConfigurationLocal.java
new file mode 100644
index 0000000000000..86124fc64c0e3
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/configuration/ArgoClimaConfigurationLocal.java
@@ -0,0 +1,217 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.configuration;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants;
+import org.openhab.binding.argoclima.internal.exception.ArgoConfigurationException;
+
+/**
+ * The {@link ArgoClimaConfigurationLocal} class extends base configuration parameters with ones specific
+ * to local connection (including a remote API stub / proxy)
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public class ArgoClimaConfigurationLocal extends ArgoClimaConfigurationBase {
+ public enum ConnectionMode {
+ LOCAL_CONNECTION,
+ REMOTE_API_STUB,
+ REMOTE_API_PROXY
+ }
+
+ public enum DeviceSidePasswordDisplayMode {
+ NEVER,
+ MASKED,
+ CLEARTEXT
+ }
+
+ private String hostname = "";
+ private ConnectionMode connectionMode = ConnectionMode.LOCAL_CONNECTION;
+ private int hvacListenPort = 1001;
+ private String localDeviceIP = "";
+ private boolean useLocalConnection = true;
+ private int stubServerPort = 8239; // Note the original Argo server listens on '80', but picking a non privileged
+ // port (>1024) as a default, since this needs remapping on firewall, and openHAB
+ // is typically listening on 80 or 8080
+ private List stubServerListenAddresses = List.of("0.0.0.0");
+ private DeviceSidePasswordDisplayMode includeDeviceSidePasswordsInProperties = DeviceSidePasswordDisplayMode.NEVER;
+ private boolean matchAnyIncomingDeviceIp = false;
+
+ /**
+ * Retrieves the *target* IP address of the LOCAL Argo device (from hostname and/or IP)
+ *
+ * @return The IP address of the Argo device (for use in local communication)
+ * @throws ArgoConfigurationException if no IP address for the {@code hostname} could be found
+ */
+ public InetAddress getHostname() throws ArgoConfigurationException {
+ try {
+ return Objects.requireNonNull(InetAddress.getByName(hostname));
+ } catch (UnknownHostException e) {
+ throw ArgoConfigurationException.forInvalidParamValue(ArgoClimaBindingConstants.PARAMETER_HOSTNAME,
+ hostname, i18nProvider, e);
+ }
+ }
+
+ /**
+ * Retrieves the local IPv4 address of the Argo device (in its current subnet) - if available/known
+ *
+ * If the device is behind NAT, this address will be different from the one determined from
+ * {@link #getHostname() getHostname}
+ *
+ * @return Local IP address of the HVAC device (for use in matching remote responses to the device)
+ * @throws ArgoConfigurationException if the {@code localDeviceIP} is invalid
+ */
+ public Optional getLocalDeviceIP() throws ArgoConfigurationException {
+ try {
+ if (this.localDeviceIP.isBlank()) {
+ return Optional. empty();
+ }
+ return Optional.ofNullable(InetAddress.getByName(localDeviceIP)); // it's actually not Nullable, but
+ // InetAddress doesn't have null
+ // annotations... so this useless runtime
+ // check spares us one compiler warning
+ // (yay! ;))
+ } catch (UnknownHostException e) {
+ throw ArgoConfigurationException.forInvalidParamValue(ArgoClimaBindingConstants.PARAMETER_LOCAL_DEVICE_IP,
+ localDeviceIP, i18nProvider, e);
+ }
+ }
+
+ /**
+ * Returns the local Argo device port (1001 by default, unless re-mapped on firewall)
+ *
+ * @return device's local port
+ */
+ public int getHvacListenPort() {
+ return this.hvacListenPort;
+ }
+
+ /**
+ * Return the configured connection mode: local vs. remote API (with/without pass-through to Argo servers)
+ *
+ * @return The connection mode
+ */
+ public ConnectionMode getConnectionMode() {
+ return this.connectionMode;
+ }
+
+ /**
+ * Get the stub server listen port
+ *
+ * @return Stub server listen port or {@code -1} if N/A
+ */
+ public int getStubServerPort() {
+ return this.stubServerPort;
+ }
+
+ /**
+ * Get the stub server listen IP addresses (from hostnames)
+ *
+ * @return A set of listen addresses
+ * @throws ArgoConfigurationException if at least one of the {@code stubServerListenAddresses} is a hostname and
+ * cannot be resolved to an IP address
+ */
+ public Set getStubServerListenAddresses() throws ArgoConfigurationException {
+ var addresses = new LinkedHashSet();
+ for (var t : stubServerListenAddresses) {
+ try {
+ addresses.add(Objects.requireNonNull(InetAddress.getByName(t)));
+ } catch (UnknownHostException e) {
+ throw ArgoConfigurationException.forInvalidParamValue(
+ ArgoClimaBindingConstants.PARAMETER_STUB_SERVER_LISTEN_ADDRESSES, t, i18nProvider, e);
+ }
+ }
+ return addresses;
+ }
+
+ @Override
+ public int getRefreshInterval() {
+ if (!this.useLocalConnection) {
+ return 0;
+ }
+ return super.getRefreshInterval();
+ }
+
+ /**
+ * Returns information whether the device-side incoming passwords are to be shown as properties (and if so: in the
+ * clear or replaced with ***)
+ *
+ * @return Configured value
+ */
+ public DeviceSidePasswordDisplayMode getIncludeDeviceSidePasswordsInProperties() {
+ return this.includeDeviceSidePasswordsInProperties;
+ }
+
+ /**
+ * Should the incoming (intercepted) device-side updates be a strict match to local IP (if provided) or hostname
+ * (fallback)
+ *
+ * @return True - if requiring exact match, False - if IP mismatch is allowed
+ */
+ public boolean getMatchAnyIncomingDeviceIp() {
+ return this.matchAnyIncomingDeviceIp;
+ }
+
+ @Override
+ protected String getExtraFieldDescription() {
+ return String.format(
+ "hostname=%s, localDeviceIP=%s, hvacListenPort=%d, connectionMode=%s, useLocalConnection=%s, stubServerPort=%d, stubServerListenAddresses=%s, includeDeviceSidePasswordsInProperties=%s, matchAnyIncomingDeviceIp=%s",
+ getOrDefault(this::getHostname), getOrDefault(this::getLocalDeviceIP), hvacListenPort, connectionMode,
+ useLocalConnection, stubServerPort, getOrDefault(this::getStubServerListenAddresses),
+ includeDeviceSidePasswordsInProperties, matchAnyIncomingDeviceIp);
+ }
+
+ @Override
+ protected void validateInternal() throws ArgoConfigurationException {
+ if (hostname.isEmpty()) {
+ throw ArgoConfigurationException.forEmptyRequiredParam(ArgoClimaBindingConstants.PARAMETER_HOSTNAME,
+ i18nProvider);
+ }
+
+ if (!useLocalConnection && connectionMode == ConnectionMode.LOCAL_CONNECTION) {
+ throw ArgoConfigurationException.forConflictingParams(
+ ArgoClimaBindingConstants.PARAMETER_USE_LOCAL_CONNECTION, "OFF",
+ ArgoClimaBindingConstants.PARAMETER_CONNECTION_MODE, ConnectionMode.LOCAL_CONNECTION, i18nProvider);
+ }
+
+ if (getRefreshInterval() == 0 && connectionMode == ConnectionMode.LOCAL_CONNECTION) {
+ throw ArgoConfigurationException.forConflictingParams(ArgoClimaBindingConstants.PARAMETER_REFRESH_INTERNAL,
+ getRefreshInterval(), ArgoClimaBindingConstants.PARAMETER_CONNECTION_MODE,
+ ConnectionMode.LOCAL_CONNECTION, i18nProvider);
+ }
+
+ if (hvacListenPort < 0 || hvacListenPort > 65535) {
+ throw ArgoConfigurationException.forParamOutOfRange(ArgoClimaBindingConstants.PARAMETER_HVAC_LISTEN_PORT,
+ hvacListenPort, i18nProvider, 0, 65535);
+ }
+
+ if (stubServerPort < 0 || stubServerPort > 65535) {
+ throw ArgoConfigurationException.forParamOutOfRange(ArgoClimaBindingConstants.PARAMETER_STUB_SERVER_PORT,
+ stubServerPort, i18nProvider, 0, 65535);
+ }
+
+ // want the side-effect of these calls!
+ getHostname();
+ getStubServerListenAddresses();
+ getLocalDeviceIP();
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/configuration/ArgoClimaConfigurationRemote.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/configuration/ArgoClimaConfigurationRemote.java
new file mode 100644
index 0000000000000..578168711f17b
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/configuration/ArgoClimaConfigurationRemote.java
@@ -0,0 +1,97 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.configuration;
+
+import java.security.NoSuchAlgorithmException;
+import java.time.Duration;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants;
+import org.openhab.binding.argoclima.internal.exception.ArgoConfigurationException;
+import org.openhab.binding.argoclima.internal.utils.PasswordUtils;
+
+/**
+ * The {@link ArgoClimaConfigurationRemote} class contains fields mapping thing configuration parameters
+ * for a remote Argo device (comms via Argo servers)
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public class ArgoClimaConfigurationRemote extends ArgoClimaConfigurationBase {
+
+ /**
+ * The duration after which the device would be considered non-responsive (and taken OFFLINE)
+ */
+ public static final Duration LAST_SEEN_UNAVAILABILITY_THRESHOLD = Duration.ofMinutes(20);
+
+ /**
+ * Argo configuration parameters specific to remote connection
+ * These names are defined in thing-types.xml and get injected on instantiation
+ * through {@link org.openhab.core.thing.binding.BaseThingHandler#getConfigAs getConfigAs}
+ */
+ private String username = "";
+ private String password = "";
+
+ /**
+ * Get the username (login) to use in authenticating to Argo server
+ *
+ * @return username (as configured by the user)
+ */
+ public String getUsername() {
+ return this.username;
+ }
+
+ /**
+ * Get the masked password used in authenticating to Argo server (for logging)
+ *
+ * @return {@code ***}-masked string instead of the same length as configured password
+ */
+ private final String getPasswordMasked() {
+ return PasswordUtils.maskPassword(password);
+ }
+
+ /**
+ * Get MD5 hash of the configured password (for Basic auth)
+ *
+ * @return MD5 hash of password
+ * @throws ArgoConfigurationException In case MD5 is not available in the security provider
+ */
+ public String getPasswordHashed() throws ArgoConfigurationException {
+ try {
+ return PasswordUtils.md5HashPassword(password);
+ } catch (NoSuchAlgorithmException e) {
+ throw ArgoConfigurationException.forInvalidParamValue(ArgoClimaBindingConstants.PARAMETER_PASSWORD,
+ PasswordUtils.maskPassword(password), i18nProvider, e); // User-provided value is likely NOT at
+ // fault, but using this exception for
+ // generic error messaging (cause will be
+ // displayed anyway)
+ }
+ }
+
+ @Override
+ protected String getExtraFieldDescription() {
+ return String.format("username=%s, password=%s", username, getPasswordMasked());
+ }
+
+ @Override
+ protected void validateInternal() throws ArgoConfigurationException {
+ if (username.isBlank()) {
+ throw ArgoConfigurationException.forEmptyRequiredParam(ArgoClimaBindingConstants.PARAMETER_USERNAME,
+ i18nProvider);
+ }
+ if (password.isBlank()) {
+ throw ArgoConfigurationException.forEmptyRequiredParam(ArgoClimaBindingConstants.PARAMETER_PASSWORD,
+ i18nProvider);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/configuration/IScheduleConfigurationProvider.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/configuration/IScheduleConfigurationProvider.java
new file mode 100644
index 0000000000000..51fef84e8d91c
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/configuration/IScheduleConfigurationProvider.java
@@ -0,0 +1,89 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.configuration;
+
+import java.time.LocalTime;
+import java.util.EnumSet;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.argoclima.internal.device.api.types.Weekday;
+import org.openhab.binding.argoclima.internal.exception.ArgoConfigurationException;
+
+/**
+ * Interface for schedule provider
+ * The device (its remote) supports 3 schedules, so the same is implemented herein.
+ *
+ * Noteworthy, the device itself (when communicated-to) only takes the type of timer (schedule) and on/off times +
+ * weekdays, so technically number of schedules supported may be expanded beyond 3
+ *
+ * @implNote Only one schedule may be active at a time. Currently implemented through config, as it is easier to edit
+ * this way. Note that delay timer is instead implemented as a channel!
+ *
+ * @implNote While the boilerplate can be reduced, config-side these are modeled as individual properties (easier to
+ * edit), hence not doing anything fancy here
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public interface IScheduleConfigurationProvider {
+ /**
+ * The type of schedule timer (1|2|3)
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+ public enum ScheduleTimerType {
+ SCHEDULE_1,
+ SCHEDULE_2,
+ SCHEDULE_3;
+
+ public static ScheduleTimerType fromInt(int value) {
+ switch (value) {
+ case 1:
+ return SCHEDULE_1;
+ case 2:
+ return SCHEDULE_2;
+ case 3:
+ return SCHEDULE_3;
+ default:
+ throw new IllegalArgumentException(String.format("Invalid value for ScheduleTimerType: %d", value));
+ }
+ }
+ }
+
+ /**
+ * The days of week when schedule shall be active
+ *
+ * @param scheduleType Which schedule timer to target (1|2|3)
+ * @return The configured value
+ * @throws ArgoConfigurationException In case of configuration error
+ */
+ public EnumSet getScheduleDayOfWeek(ScheduleTimerType scheduleType) throws ArgoConfigurationException;
+
+ /**
+ * The time of day schedule 1 shall turn the AC on
+ *
+ * @param scheduleType Which schedule timer to target (1|2|3)
+ * @return The configured value
+ * @throws ArgoConfigurationException In case of configuration error
+ */
+ public LocalTime getScheduleOnTime(ScheduleTimerType scheduleType) throws ArgoConfigurationException;
+
+ /**
+ * The time of day schedule 1 shall turn the AC off
+ *
+ * @param scheduleType Which schedule timer to target (1|2|3)
+ * @return The configured value
+ * @throws ArgoConfigurationException In case of configuration error
+ */
+ public LocalTime getScheduleOffTime(ScheduleTimerType scheduleType) throws ArgoConfigurationException;
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/ArgoClimaDeviceApiBase.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/ArgoClimaDeviceApiBase.java
new file mode 100644
index 0000000000000..16ad0f66e19e8
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/ArgoClimaDeviceApiBase.java
@@ -0,0 +1,269 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api;
+
+import java.io.EOFException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+import java.util.function.Consumer;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.util.URIUtil;
+import org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants;
+import org.openhab.binding.argoclima.internal.ArgoClimaTranslationProvider;
+import org.openhab.binding.argoclima.internal.configuration.ArgoClimaConfigurationBase;
+import org.openhab.binding.argoclima.internal.device.api.protocol.ArgoApiDataElement;
+import org.openhab.binding.argoclima.internal.device.api.protocol.ArgoDeviceStatus;
+import org.openhab.binding.argoclima.internal.device.api.protocol.elements.IArgoCommandableElement.IArgoElement;
+import org.openhab.binding.argoclima.internal.device.api.types.ArgoDeviceSettingType;
+import org.openhab.binding.argoclima.internal.exception.ArgoApiCommunicationException;
+import org.openhab.binding.argoclima.internal.exception.ArgoApiProtocolViolationException;
+import org.openhab.core.i18n.TimeZoneProvider;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Common implementation of Argo API (across local and remote connection modes)
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public abstract class ArgoClimaDeviceApiBase implements IArgoClimaDeviceAPI {
+ private final Logger logger = LoggerFactory.getLogger(getClass());
+ private final HttpClient client;
+ protected final TimeZoneProvider timeZoneProvider;
+ protected final ArgoClimaTranslationProvider i18nProvider;
+ protected final ArgoDeviceStatus deviceStatus;
+ protected Consumer> onDevicePropertiesUpdate;
+ protected SortedMap deviceProperties;
+ private final String remoteEndName;
+
+ /**
+ * C-tor
+ *
+ * @param config The configuration class (common part)
+ * @param client The common HTTP client used for making connections from OH to the device
+ * @param timeZoneProvider The common TZ provider
+ * @param onDevicePropertiesUpdate Callback to invoke on device-side dynamic property update (ex. lastSeen)
+ * @param remoteEndName The name of the "remote end" party, for use in logging
+ * @param i18nProvider Framework's translation provider
+ */
+ public ArgoClimaDeviceApiBase(ArgoClimaConfigurationBase config, HttpClient client,
+ TimeZoneProvider timeZoneProvider, ArgoClimaTranslationProvider i18nProvider,
+ Consumer> onDevicePropertiesUpdate, String remoteEndName) {
+ this.client = client;
+ this.timeZoneProvider = timeZoneProvider;
+ this.i18nProvider = i18nProvider;
+ this.deviceStatus = new ArgoDeviceStatus(config);
+ this.onDevicePropertiesUpdate = onDevicePropertiesUpdate;
+ this.deviceProperties = new TreeMap();
+ this.remoteEndName = remoteEndName.isBlank() ? "DEVICE" : remoteEndName.trim().toUpperCase();
+ }
+
+ /**
+ * Return the URL used for querying device state (poll)
+ *
+ * @return The "poll for status" URL (w/o any changes)
+ */
+ protected abstract URL getDeviceStateQueryUrl();
+
+ /**
+ * Return the URL used for updating device state (a command)
+ *
+ * @return The "send command" URL (effecting changes)
+ */
+ protected abstract URL getDeviceStateUpdateUrl();
+
+ /**
+ * Extract device status from just-polled API result (local or remote)
+ *
+ * @param apiResponse The response received from device (body of the response, ex. one obtained through
+ * {@link #pollForCurrentStatusFromDeviceSync(URL)}.
+ * @return The {@link DeviceStatus} parsed from response (with properties pre-parsed)
+ * @throws ArgoApiCommunicationException If the response body was not recognized as a valid protocol message
+ */
+ protected abstract DeviceStatus extractDeviceStatusFromResponse(String apiResponse)
+ throws ArgoApiCommunicationException;
+
+ /**
+ * Helper class method for converting strings to URIs (assumes HTTP for the protocol)
+ *
+ * @implNote Throwing unchecked exceptions, as this function is used in practice only for URLs returned by
+ * {@link org.eclipse.jetty.util.URIUtil#newURI}, so a scenario where it would be malformed is extremely
+ * unlikely and we DO NOT want nice handling for it
+ *
+ * @param server The server address (hostname or IP)
+ * @param port The server port
+ * @param path The resource path (ex. '/')
+ * @param query The query parameters
+ *
+ * @return Converted URL
+ */
+ protected static final URL newUrl(String server, int port, String path, String query) {
+ var uriStr = URIUtil.newURI("http", server, port, path, query);
+ try {
+ return new URL(uriStr);
+ } catch (MalformedURLException e) {
+ throw new IllegalArgumentException("Failed to build url from: " + uriStr, e);
+ }
+ }
+
+ /**
+ * Trigger device-side communication (synchronous!) and get the response
+ *
+ * Note: The Argo API violates HTTP spec. and uses GET requests for both state retrieval (idempotent) as well as
+ * control! The query params of the URL determine the mode.
+ *
+ * In case of binding/Thing shutdown, this function may terminate early (not waiting for I/O to complete) and return
+ * an empty string
+ *
+ * @implNote This method should not be used if {@link ArgoClimaBindingConstants#PARAMETER_USE_LOCAL_CONNECTION} is
+ * false, though the implementation is NOT enforcing it (SHOULD NOT != MAY NOT).
+ * @param url URL to call (should contain full protocol message to send to the device through HTTP GET (such as ones
+ * obtained through {@link #getDeviceStateQueryUrl} or {@link #getDeviceStateUpdateUrl()}
+ * @return The device-side reply (HTTP response body)
+ * @throws ArgoApiCommunicationException Thrown in case of communication issues (including timeouts) or if the
+ * API returned a response different from {@code HTTP 200 OK}
+ */
+ protected String pollForCurrentStatusFromDeviceSync(URL url) throws ArgoApiCommunicationException {
+ try {
+ logger.trace("Communication: OPENHAB --> {}: [GET {}]", remoteEndName, url);
+
+ ContentResponse resp = this.client.GET(url.toString()); // sync
+
+ logger.trace(" [response]: OPENHAB <-- {}: [{} {} {} - {} bytes], body=[{}]", remoteEndName,
+ resp.getVersion(), resp.getStatus(), resp.getReason(), resp.getContent().length,
+ resp.getContentAsString());
+
+ if (resp.getStatus() != 200) {
+ throw new ArgoApiCommunicationException(
+ "API request yielded invalid response status {0} {1} (expected HTTP 200 OK). URL was: {2}",
+ "thing-status.cause.argoclima.invalid-api-response-status", i18nProvider, resp.getStatus(),
+ resp.getReason(), url);
+ }
+ return Objects.requireNonNull(resp.getContentAsString());
+ } catch (InterruptedException ex) {
+ logger.trace("Interrupted...");
+ return "";
+ } catch (ExecutionException ex) {
+ var cause = Optional.ofNullable(ex.getCause());
+ if (cause.isPresent() && cause.get() instanceof EOFException) {
+ throw new ArgoApiCommunicationException(
+ "Device did not respond on its socket (EOF). Check that the device is correctly communicating with Argo servers (or openHAB stub server)",
+ "thing-status.cause.argoclima.device-eof", i18nProvider);
+ }
+
+ throw new ArgoApiCommunicationException("Device communication error: {0}",
+ "thing-status.cause.argoclima.communication-error", i18nProvider, ex.getCause(),
+ Objects.requireNonNullElse(ex.getCause(), ex).getLocalizedMessage());
+
+ } catch (TimeoutException e) {
+ throw new ArgoApiCommunicationException("Timeout: {0}",
+ "thing-status.cause.argoclima.communication-error.timeout", i18nProvider, e.getLocalizedMessage());
+ }
+ }
+
+ /**
+ * Updates cached device properties with the values just received from device and notifies the framework through
+ * callback
+ *
+ * @param metadata The properties received from device
+ * @param status The status update from device
+ */
+ protected void updateDevicePropertiesFromDeviceResponse(DeviceStatus.DeviceProperties metadata,
+ ArgoDeviceStatus status) {
+ var metaProperties = metadata.asPropertiesRaw(this.timeZoneProvider);
+ var responseProperties = Map. of(ArgoClimaBindingConstants.PROPERTY_UNIT_FW,
+ status.getSetting(ArgoDeviceSettingType.UNIT_FIRMWARE_VERSION).toString(false));
+
+ synchronized (this) {
+ // not clearing the existing properties (grow-only)
+ this.deviceProperties.putAll(metaProperties);
+ this.deviceProperties.putAll(responseProperties);
+ }
+ this.onDevicePropertiesUpdate.accept(getCurrentDeviceProperties());
+ }
+
+ @Override
+ public final SortedMap getCurrentDeviceProperties() {
+ return Collections.unmodifiableSortedMap(this.deviceProperties);
+ }
+
+ @Override
+ public Map queryDeviceForUpdatedState() throws ArgoApiCommunicationException {
+ var deviceResponse = extractDeviceStatusFromResponse(
+ pollForCurrentStatusFromDeviceSync(getDeviceStateQueryUrl()));
+ try {
+ this.deviceStatus.fromDeviceString(deviceResponse.getCommandString());
+ } catch (ArgoApiProtocolViolationException e) {
+ throw new ArgoApiCommunicationException("Unrecognized API response",
+ "thing-status.cause.argoclima.exception.unrecognized-response", i18nProvider, e);
+ }
+ this.updateDevicePropertiesFromDeviceResponse(deviceResponse.getProperties(), this.deviceStatus);
+ deviceResponse.throwIfStatusIsStale();
+ return this.deviceStatus.getCurrentStateMap();
+ }
+
+ @Override
+ public Map getLastStateReadFromDevice() {
+ return this.deviceStatus.getCurrentStateMap();
+ }
+
+ @Override
+ public void sendCommandsToDevice() throws ArgoApiCommunicationException {
+ var deviceResponse = pollForCurrentStatusFromDeviceSync(getDeviceStateUpdateUrl());
+
+ notifyCommandsPassedToDevice(); // Just sent directly
+ logger.trace("State update command finished. Device response: {}", deviceResponse);
+ }
+
+ @Override
+ public void notifyCommandsPassedToDevice() {
+ deviceStatus.getItemsWithPendingUpdates().forEach(x -> x.notifyCommandSent());
+ }
+
+ @Override
+ public boolean handleSettingCommand(ArgoDeviceSettingType settingType, Command command) {
+ return this.deviceStatus.getSetting(settingType).handleCommand(command);
+ }
+
+ @Override
+ public State getCurrentStateNoPoll(ArgoDeviceSettingType settingType) {
+ return this.deviceStatus.getSetting(settingType).getState();
+ }
+
+ @Override
+ public boolean hasPendingCommands() {
+ var itemsWithPendingUpdates = this.deviceStatus.getItemsWithPendingUpdates();
+ logger.trace("Items to update: {}", itemsWithPendingUpdates);
+ return !itemsWithPendingUpdates.isEmpty();
+ }
+
+ @Override
+ public List> getItemsWithPendingUpdates() {
+ return this.deviceStatus.getItemsWithPendingUpdates();
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/ArgoClimaLocalDevice.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/ArgoClimaLocalDevice.java
new file mode 100644
index 0000000000000..735c4da8e6c52
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/ArgoClimaLocalDevice.java
@@ -0,0 +1,246 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api;
+
+import java.net.InetAddress;
+import java.net.URL;
+import java.time.OffsetDateTime;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.SortedMap;
+import java.util.function.Consumer;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jetty.client.HttpClient;
+import org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants;
+import org.openhab.binding.argoclima.internal.ArgoClimaTranslationProvider;
+import org.openhab.binding.argoclima.internal.configuration.ArgoClimaConfigurationLocal;
+import org.openhab.binding.argoclima.internal.device.api.types.ArgoDeviceSettingType;
+import org.openhab.binding.argoclima.internal.device.passthrough.requests.DeviceSidePostRtUpdateDTO;
+import org.openhab.binding.argoclima.internal.device.passthrough.requests.DeviceSideUpdateDTO;
+import org.openhab.binding.argoclima.internal.exception.ArgoApiCommunicationException;
+import org.openhab.binding.argoclima.internal.exception.ArgoApiProtocolViolationException;
+import org.openhab.core.i18n.TimeZoneProvider;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.types.State;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Argo protocol implementation for a LOCAL connection to the device
+ *
+ * IMPORTANT: Local doesn't necessarily mean "directly reachable". This class is also used for devices behind NAT, where
+ * all the communication is happening indirect (through intercepted device-side polls, and modifying responses through
+ * stub/proxy server)
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public class ArgoClimaLocalDevice extends ArgoClimaDeviceApiBase {
+ private final Logger logger = LoggerFactory.getLogger(getClass());
+ private final InetAddress ipAddress; // The direct IP address
+ private final Optional localIpAddress; // The indirect IP address (local subnet) - possibly not
+ // reachable if behind NAT (optional)
+ private final Optional cpuId; // The configured CPU id (if any) - for matching intercepted responses
+ private final String id;
+ private final Consumer> onStateUpdate;
+ private final Consumer onReachableStatusChange;
+ private final int port;
+ private final boolean matchAnyIncomingDeviceIp;
+
+ /**
+ * C-tor
+ *
+ * @param config The Thing configuration
+ * @param targetDeviceIpAddress The IP address of the directly-connected device (for indirect mode, the device does
+ * NOT need to be reachable through this address!)
+ * @param port The port to talk to the directly-connected device
+ * @param localDeviceIpAddress Optional, local subnet IP of the device (ex. if behind NAT). Used to match
+ * intercepted responses (in indirect mode) to this thing. This may be
+ * {@link ArgoClimaConfigurationLocal#getMatchAnyIncomingDeviceIp() bypassed}
+ * @param cpuId Optional, CPUID of the Wi-Fi chip of the device. If provided, will be used to match intercepted
+ * responses (in indirect mode) to this thing
+ * @param client The common HTTP client used for issuing direct requests
+ * @param timeZoneProvider System-wide TZ provider, for parsing/displaying local dates
+ * @param i18nProvider Framework's translation provider
+ * @param onStateUpdate Callback to be invoked when device status gets updated(device-side channel updates)
+ * @param onReachableStatusChange Callback to be invoked when device's reachability status (online) changes
+ * @param onDevicePropertiesUpdate Callback to invoke when device properties get refreshed
+ * @param thingUid The UID of the Thing owning this server (used for logging)
+ */
+ public ArgoClimaLocalDevice(ArgoClimaConfigurationLocal config, InetAddress targetDeviceIpAddress, int port,
+ Optional localDeviceIpAddress, Optional cpuId, HttpClient client,
+ TimeZoneProvider timeZoneProvider, ArgoClimaTranslationProvider i18nProvider,
+ Consumer> onStateUpdate, Consumer onReachableStatusChange,
+ Consumer> onDevicePropertiesUpdate, String thingUid) {
+ super(config, client, timeZoneProvider, i18nProvider, onDevicePropertiesUpdate, "");
+ this.ipAddress = targetDeviceIpAddress;
+ this.port = port;
+ this.localIpAddress = localDeviceIpAddress;
+ this.cpuId = cpuId;
+ this.matchAnyIncomingDeviceIp = config.getMatchAnyIncomingDeviceIp();
+ this.onStateUpdate = onStateUpdate;
+ this.onReachableStatusChange = onReachableStatusChange;
+ this.id = thingUid;
+ }
+
+ @Override
+ protected URL getDeviceStateQueryUrl() {
+ // Hard-coded values are part of ARGO protocol
+ return newUrl(Objects.requireNonNull(this.ipAddress.getHostName()), this.port, "/", "HMI=&UPD=0");
+ }
+
+ @Override
+ protected URL getDeviceStateUpdateUrl() {
+ // Hard-coded values are part of ARGO protocol
+ return newUrl(Objects.requireNonNull(this.ipAddress.getHostName()), this.port, "/",
+ String.format("HMI=%s&UPD=1", this.deviceStatus.getDeviceCommandStatus()));
+ }
+
+ @Override
+ public final ReachabilityStatus isReachable() {
+ try {
+ var status = extractDeviceStatusFromResponse(pollForCurrentStatusFromDeviceSync(getDeviceStateQueryUrl()));
+
+ try {
+ this.deviceStatus.fromDeviceString(status.getCommandString());
+ } catch (ArgoApiProtocolViolationException e) {
+ throw new ArgoApiCommunicationException("Unrecognized API response",
+ "thing-status.cause.argoclima.exception.unrecognized-response", i18nProvider, e);
+ }
+ this.updateDevicePropertiesFromDeviceResponse(status.getProperties(), this.deviceStatus);
+
+ return new ReachabilityStatus(true, "");
+ } catch (ArgoApiCommunicationException e) {
+ logger.debug("Device not reachable: {}", e.getMessage());
+ return new ReachabilityStatus(false,
+ Objects.requireNonNull(i18nProvider.getText("thing-status.argoclima.local-unreachable",
+ "Failed to communicate with Argo HVAC device at [http://{0}:{1,number,#}{2}]. {3}",
+ this.getDeviceStateQueryUrl().getHost(),
+ this.getDeviceStateQueryUrl().getPort() != -1 ? this.getDeviceStateQueryUrl().getPort()
+ : this.getDeviceStateQueryUrl().getDefaultPort(),
+ this.getDeviceStateQueryUrl().getPath(), e.getLocalizedMessage())));
+ }
+ }
+
+ @Override
+ protected DeviceStatus extractDeviceStatusFromResponse(String apiResponse) {
+ // local device response does not have all properties, but is always fresh
+ return new DeviceStatus(apiResponse, OffsetDateTime.now(), i18nProvider);
+ }
+
+ /**
+ * Update device state from intercepted message from device to remote server (device's own send of command)
+ * This is sent in response to cloud-side command (likely a form of acknowledgement)
+ *
+ * @implNote This function is a WORK IN PROGRESS (and not doing anything useful at the present!)
+ * @param fromDevice the POST message sent by the device, in acknowledgement of fulfilling remote-side command
+ */
+ public void updateDeviceStateFromPostRtRequest(DeviceSidePostRtUpdateDTO fromDevice) {
+ if (this.cpuId.isEmpty()) {
+ logger.trace(
+ "Got post update confirmation from device {}, but was not able to match it to this device b/c no CPUID is configured. Configure {} setting to allow this mode...",
+ fromDevice.cpuId, ArgoClimaBindingConstants.PARAMETER_DEVICE_CPU_ID);
+ return;
+ }
+ if (!this.cpuId.get().equalsIgnoreCase(fromDevice.cpuId)) {
+ logger.trace("Got post update from device [ID={}], but this entity belongs to device [ID={}]. Ignoring...",
+ fromDevice.cpuId, this.cpuId.orElse("???"));
+ return;
+ }
+
+ // NOTICE (on possible future extension): The values from 'data' param of the response are NOT following the HMI
+ // syntax in the GET requests (much more data is available in this requests - and while actual responses seem
+ // empty... perhaps a response to this can provide iFeel temperatures?)
+ // There are some similarities -> ex. target/actual temperatures are at offset 112 & 113 of the array,
+ // so at the very least, could get the known values (but not as trivial as:
+ // # fromDevice.dataParam.split(ArgoDeviceStatus.HMI_ELEMENT_SEPARATOR).Arrays.stream(paramArray).skip(111)
+ // # .limit(ArgoDeviceStatus.HMI_UPDATE_ELEMENT_COUNT).toList()
+ // Overall, this needs more reverse-engineering (but works w/o this information, so not implementing for now)
+ }
+
+ /**
+ * Update device state from intercepted message from device to remote server (device's own polling)
+ *
+ * Important: The device-sent message will only be used for update if it matches to configured value
+ * (this is to avoid updating status of a completely different device)
+ *
+ * Most robust match is by CPUID, though if n/a, localIP is used as heuristic alternative as well
+ *
+ * @param deviceUpdate The device-side update request
+ */
+ public void updateDeviceStateFromPushRequest(DeviceSideUpdateDTO deviceUpdate)
+ throws ArgoApiCommunicationException {
+ String hmiStringFromDevice = deviceUpdate.currentValues;
+ String deviceIP = deviceUpdate.deviceIp;
+ String deviceCpuId = deviceUpdate.cpuId;
+
+ if (this.cpuId.isPresent() && !this.cpuId.get().equalsIgnoreCase(deviceCpuId)) {
+ logger.trace(
+ "Got poll update from device [ID={} | IP={}], but this entity belongs to device [ID={}]. Ignoring...",
+ deviceCpuId, deviceIP, this.cpuId.get());
+ return; // direct mismatch
+ }
+
+ if (!this.localIpAddress.orElse(this.ipAddress).getHostAddress().equalsIgnoreCase(deviceIP)) {
+ if (this.matchAnyIncomingDeviceIp) {
+ logger.debug(
+ "Got poll update from device {}[IP={}], which is not a match to this device [{}={}]. Ignoring the mismatch due to matchAnyIncomingDeviceIp==true...",
+ deviceCpuId, deviceIP, this.localIpAddress.isPresent() ? "localIP" : "hostname",
+ this.localIpAddress.orElse(this.ipAddress).getHostAddress());
+ } else {
+ if (this.cpuId.isEmpty() && this.localIpAddress.isEmpty()) {
+ logger.info(
+ "[{}] Got poll update from device {}[IP={}], but was not able to match it to this device with IP={}. Configure {} and/or {} settings to allow detection...",
+ id, deviceCpuId, deviceIP, this.ipAddress.getHostAddress(),
+ ArgoClimaBindingConstants.PARAMETER_DEVICE_CPU_ID,
+ ArgoClimaBindingConstants.PARAMETER_LOCAL_DEVICE_IP);
+ } else {
+ logger.trace(
+ "Got poll update from device [ID={} | IP={}], but this entity belongs to device [ID={} | IP={}]. Ignoring...",
+ deviceCpuId, deviceIP, this.cpuId.orElse("???"),
+ this.localIpAddress.orElse(this.ipAddress).getHostAddress());
+ }
+ return; // IP address heuristic mismatch
+ }
+ }
+
+ this.onReachableStatusChange.accept(ThingStatus.ONLINE); // Device communicated with us, so we consider it
+ // ONLINE
+ try {
+ this.deviceStatus.fromDeviceString(hmiStringFromDevice);
+ } catch (ArgoApiProtocolViolationException e) {
+ throw new ArgoApiCommunicationException("Unrecognized API response",
+ "thing-status.cause.argoclima.exception.unrecognized-response", i18nProvider, e);
+ }
+ this.onStateUpdate.accept(this.deviceStatus.getCurrentStateMap()); // Update channels from device's state
+
+ var properties = new DeviceStatus.DeviceProperties(OffsetDateTime.now(), deviceUpdate);
+ synchronized (this) {
+ // update shared properties (which may be updated using direct method as well)
+ this.deviceProperties.putAll(properties.asPropertiesRaw(this.timeZoneProvider));
+ }
+ this.onDevicePropertiesUpdate.accept(getCurrentDeviceProperties());
+ }
+
+ /**
+ * Get latest "command" string to be sent back to the device in response to its own poll
+ * If there are no updates pending, this string will be similar to a canned "nothing to do" response
+ *
+ * @return Command string to send back to device
+ */
+ public String getCurrentCommandString() {
+ return this.deviceStatus.getDeviceCommandStatus();
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/ArgoClimaRemoteDevice.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/ArgoClimaRemoteDevice.java
new file mode 100644
index 0000000000000..7cf852c3c63ba
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/ArgoClimaRemoteDevice.java
@@ -0,0 +1,161 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api;
+
+import java.net.InetAddress;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.text.MessageFormat;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.SortedMap;
+import java.util.function.Consumer;
+import java.util.regex.Pattern;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jetty.client.HttpClient;
+import org.openhab.binding.argoclima.internal.ArgoClimaTranslationProvider;
+import org.openhab.binding.argoclima.internal.configuration.ArgoClimaConfigurationRemote;
+import org.openhab.binding.argoclima.internal.device.api.DeviceStatus.DeviceProperties;
+import org.openhab.binding.argoclima.internal.exception.ArgoApiCommunicationException;
+import org.openhab.binding.argoclima.internal.exception.ArgoApiProtocolViolationException;
+import org.openhab.core.i18n.TimeZoneProvider;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Argo protocol implementation for a REMOTE connection to the device
+ *
+ * The HVAC device MUST be communicating with actual Argo servers for this method work.
+ * This means the device is either directly connected to the Internet (w/o traffic intercept), or there's an
+ * intercepting Stub server already running in a PASS-THROUGH mode (sniffing the messages but passing through to the
+ * actual vendor's servers)
+ *
+ *
+ * Use of this mode is actually NOT recommended for advanced users as cleartext device and Wi-Fi passwords are sent to
+ * Argo servers through unencrypted HTTP connection (sic!). If the Argo UI access is desired (ex. for FW update or IR
+ * remote-like experience), consider using this mode only on a dedicated Wi-Fi network (and possibly through VPN)
+ *
+ * @author Mateusz Bronk - Initial contribution
+ *
+ */
+@NonNullByDefault
+public class ArgoClimaRemoteDevice extends ArgoClimaDeviceApiBase {
+ private final Logger logger = LoggerFactory.getLogger(getClass());
+
+ private final InetAddress oemServerHostname;
+ private final int oemServerPort;
+ private final String usernameUrlEncoded;
+ private final String passwordMD5Hash;
+ private static final Pattern REMOTE_API_RESPONSE_EXPECTED = Pattern.compile(
+ "^[\\\\{][|](?[^|]+)[|](?[^|]+)[|](?[^|]+)[|][\\\\}]\\s*$",
+ Pattern.CASE_INSENSITIVE); // Capture group names are used in code!
+
+ /**
+ * C-tor
+ *
+ * @param config The Thing configuration
+ * @param client The common HTTP client used for issuing requests to the remote server
+ * @param timeZoneProvider System-wide TZ provider, for parsing/displaying local dates
+ * @param i18nProvider Framework's translation provider
+ * @param oemServerHostname The address of the remote (vendor's) server
+ * @param oemServerPort The port of remote (vendor's) server
+ * @param username The username used for authenticating to the remote server (will be URL-encoded before send)
+ * @param passwordMD5 A MD5 hash of the password used for authenticating to the remote server (custom Basic-like
+ * auth)
+ * @param onDevicePropertiesUpdate Callback to invoke when device properties get refreshed
+ */
+ public ArgoClimaRemoteDevice(ArgoClimaConfigurationRemote config, HttpClient client,
+ TimeZoneProvider timeZoneProvider, ArgoClimaTranslationProvider i18nProvider, InetAddress oemServerHostname,
+ int oemServerPort, String username, String passwordMD5,
+ Consumer> onDevicePropertiesUpdate) {
+ super(config, client, timeZoneProvider, i18nProvider, onDevicePropertiesUpdate, "REMOTE_API");
+ this.oemServerHostname = oemServerHostname;
+ this.oemServerPort = oemServerPort;
+ this.usernameUrlEncoded = Objects.requireNonNull(URLEncoder.encode(username, StandardCharsets.UTF_8));
+ this.passwordMD5Hash = passwordMD5;
+ }
+
+ @Override
+ public final ReachabilityStatus isReachable() {
+ try {
+ var status = extractDeviceStatusFromResponse(pollForCurrentStatusFromDeviceSync(getDeviceStateQueryUrl()));
+ try {
+ this.deviceStatus.fromDeviceString(status.getCommandString());
+ } catch (ArgoApiProtocolViolationException e) {
+ throw new ArgoApiCommunicationException("Unrecognized API response",
+ "thing-status.cause.argoclima.exception.unrecognized-response", i18nProvider, e);
+ }
+ this.updateDevicePropertiesFromDeviceResponse(status.getProperties(), this.deviceStatus);
+ status.throwIfStatusIsStale();
+ return new ReachabilityStatus(true, "");
+ } catch (ArgoApiCommunicationException e) {
+ logger.debug("Device not reachable: {}", e.getMessage());
+ return new ReachabilityStatus(false,
+ Objects.requireNonNull(MessageFormat.format(
+ "Failed to communicate with Argo HVAC remote device at [http://{0}:{1,number,#}{2}]. {3}",
+ this.getDeviceStateQueryUrl().getHost(),
+ this.getDeviceStateQueryUrl().getPort() != -1 ? this.getDeviceStateQueryUrl().getPort()
+ : this.getDeviceStateQueryUrl().getDefaultPort(),
+ this.getDeviceStateQueryUrl().getPath(), e.getMessage())));
+ }
+ }
+
+ @Override
+ protected URL getDeviceStateQueryUrl() {
+ // Hard-coded values are part of ARGO protocol
+ return newUrl(Objects.requireNonNull(this.oemServerHostname.getHostName()), this.oemServerPort, "/UI/UI.php",
+ String.format("CM=UI_TC&USN=%s&PSW=%s&HMI=&UPD=0", this.usernameUrlEncoded, this.passwordMD5Hash));
+ }
+
+ @Override
+ protected URL getDeviceStateUpdateUrl() {
+ // Hard-coded values are part of ARGO protocol
+ return newUrl(Objects.requireNonNull(this.oemServerHostname.getHostName()), this.oemServerPort, "/UI/UI.php",
+ String.format("CM=UI_TC&USN=%s&PSW=%s&HMI=%s&UPD=1", this.usernameUrlEncoded, this.passwordMD5Hash,
+ this.deviceStatus.getDeviceCommandStatus()));
+ }
+
+ @Override
+ protected DeviceStatus extractDeviceStatusFromResponse(String apiResponse) throws ArgoApiCommunicationException {
+ if (apiResponse.isBlank()) {
+ throw new ArgoApiCommunicationException("The remote API response was empty. Check username and password",
+ "thing-status.cause.argoclima.empty-remote-response", i18nProvider);
+ }
+
+ var matcher = REMOTE_API_RESPONSE_EXPECTED.matcher(apiResponse);
+ if (!matcher.matches()) {
+ throw new ArgoApiCommunicationException("The remote API response [%s] was not recognized",
+ "thing-status.cause.argoclima.unrecognized-remote-response", i18nProvider, apiResponse);
+ }
+
+ // Group names must match regex above
+ var properties = new DeviceProperties(Objects.requireNonNull(matcher.group("localIP")),
+ Objects.requireNonNull(matcher.group("lastSeen")), Optional.of(
+ getWebUiUrl(Objects.requireNonNull(this.oemServerHostname.getHostName()), this.oemServerPort)));
+
+ return new DeviceStatus(Objects.requireNonNull(matcher.group("commands")), properties, i18nProvider);
+ }
+
+ /**
+ * Return the full URL to the Vendor's web application
+ *
+ * @param hostName The OEM server host
+ * @param port The OEM server port
+ * @return Full URL to the UI webapp
+ */
+ public static URL getWebUiUrl(String hostName, int port) {
+ return newUrl(hostName, port, "/UI/WEBAPP/webapp.php", "");
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/DeviceStatus.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/DeviceStatus.java
new file mode 100644
index 0000000000000..0d738ced246a2
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/DeviceStatus.java
@@ -0,0 +1,234 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api;
+
+import java.net.URL;
+import java.time.DateTimeException;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.FormatStyle;
+import java.util.Collections;
+import java.util.Optional;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants;
+import org.openhab.binding.argoclima.internal.ArgoClimaTranslationProvider;
+import org.openhab.binding.argoclima.internal.configuration.ArgoClimaConfigurationRemote;
+import org.openhab.binding.argoclima.internal.device.passthrough.requests.DeviceSideUpdateDTO;
+import org.openhab.binding.argoclima.internal.exception.ArgoApiCommunicationException;
+import org.openhab.core.i18n.TimeZoneProvider;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Represents the current device status, as-communicated by the device (either push or pull model)
+ *
+ * Includes both the "raw" {@link #getCommandString() commandString} as well as {@link #getProperties() properties}
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+class DeviceStatus {
+ //////////////
+ // TYPES
+ //////////////
+ /**
+ * Helper class for dealing with device properties
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+ static class DeviceProperties {
+ private static final Logger LOGGER = LoggerFactory.getLogger(DeviceProperties.class);
+ private final Optional localIP;
+ private final Optional lastSeen;
+ private final Optional vendorUiUrl;
+ private Optional cpuId = Optional.empty();
+ private Optional webUiUsername = Optional.empty();
+ private Optional webUiPassword = Optional.empty();
+ private Optional unitFWVersion = Optional.empty();
+ private Optional wifiFWVersion = Optional.empty();
+ private Optional wifiSSID = Optional.empty();
+ private Optional wifiPassword = Optional.empty();
+ private Optional localTime = Optional.empty();
+
+ /**
+ * C-tor (from remote server query response)
+ *
+ * @param localIP The local IP of the Argo device (or empty string if N/A)
+ * @param lastSeenStr The ISO-8601-formatted date/time of last update (or empty string if N/A)
+ * @param vendorUiAddress The optional full URL to vendor's web UI
+ */
+ public DeviceProperties(String localIP, String lastSeenStr, Optional vendorUiAddress) {
+ this.localIP = localIP.isEmpty() ? Optional.empty() : Optional.of(localIP);
+ this.vendorUiUrl = vendorUiAddress;
+ this.lastSeen = dateFromISOString(lastSeenStr, "LastSeen");
+ }
+
+ /**
+ * C-tor (from live poll response)
+ *
+ * @param lastSeen The date/time of last update (when the response got received)
+ */
+ public DeviceProperties(OffsetDateTime lastSeen) {
+ this.localIP = Optional.empty();
+ this.lastSeen = Optional.of(lastSeen);
+ this.vendorUiUrl = Optional.empty();
+ }
+
+ /**
+ * C-tor (from intercepted device-side query to remote)
+ *
+ * @param lastSeen The date/time of last update (when the message got intercepted)
+ * @param properties The intercepted device-side request (most rich with properties)
+ */
+ public DeviceProperties(OffsetDateTime lastSeen, DeviceSideUpdateDTO properties) {
+ this.localIP = Optional.of(properties.setup.localIP.orElse(properties.deviceIp));
+ this.lastSeen = Optional.of(lastSeen);
+ this.vendorUiUrl = Optional.of(ArgoClimaRemoteDevice.getWebUiUrl(properties.remoteServerId, 80));
+ this.cpuId = Optional.of(properties.cpuId);
+ this.webUiUsername = Optional.of(properties.setup.username.orElse(properties.username));
+ this.webUiPassword = properties.setup.password;
+ this.unitFWVersion = Optional.of(properties.setup.unitVersionInstalled.orElse(properties.unitFirmware));
+ this.wifiFWVersion = Optional.of(properties.setup.wifiVersionInstalled.orElse(properties.wifiFirmware));
+ this.wifiSSID = properties.setup.wifiSSID;
+ this.wifiPassword = properties.setup.wifiPassword;
+ this.localTime = properties.setup.localTime;
+ }
+
+ private static Optional dateFromISOString(String isoDateTime, String contextualName) {
+ if (isoDateTime.isEmpty()) {
+ return Optional.empty();
+ }
+
+ try {
+ return Optional.of(OffsetDateTime.from(DateTimeFormatter.ISO_DATE_TIME.parse(isoDateTime)));
+ } catch (DateTimeException ex) {
+ // Swallowing exception (no need to handle - proceed as if the date was never provided)
+ LOGGER.debug("Failed to parse [{}] timestamp: {}. Exception: {}", contextualName, isoDateTime,
+ ex.getMessage());
+ return Optional.empty();
+ }
+ }
+
+ private static String dateTimeToStringLocal(OffsetDateTime toConvert, TimeZoneProvider timeZoneProvider) {
+ var timeAtZone = toConvert.atZoneSameInstant(timeZoneProvider.getTimeZone());
+ return timeAtZone.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG));
+ }
+
+ /**
+ * Returns duration between last update and now. If last update is N/A, picking lowest possible time value
+ *
+ * @return Time elapsed since last device-side update
+ */
+ Duration getLastSeenDelta() {
+ return Duration.between(lastSeen.orElse(OffsetDateTime.MIN).toInstant(), Instant.now());
+ }
+
+ /**
+ * Return the properties in a map (ready to pass on to openHAB engine)
+ *
+ * @param timeZoneProvider TZ provider, for parsing date/time values
+ * @return Properties map
+ */
+ SortedMap asPropertiesRaw(TimeZoneProvider timeZoneProvider) {
+ var result = new TreeMap();
+
+ this.lastSeen.map((value) -> result.put(ArgoClimaBindingConstants.PROPERTY_LAST_SEEN,
+ dateTimeToStringLocal(value, timeZoneProvider)));
+ this.localIP.map(value -> result.put(ArgoClimaBindingConstants.PROPERTY_LOCAL_IP_ADDRESS, value));
+ this.vendorUiUrl.map(value -> result.put(ArgoClimaBindingConstants.PROPERTY_WEB_UI, value.toString()));
+ this.cpuId.map(value -> result.put(ArgoClimaBindingConstants.PROPERTY_CPU_ID, value));
+ this.webUiUsername.map(value -> result.put(ArgoClimaBindingConstants.PROPERTY_WEB_UI_USERNAME, value));
+ this.webUiPassword.map(value -> result.put(ArgoClimaBindingConstants.PROPERTY_WEB_UI_PASSWORD, value));
+ this.unitFWVersion.map(value -> result.put(ArgoClimaBindingConstants.PROPERTY_UNIT_FW, value));
+ this.wifiFWVersion.map(value -> result.put(ArgoClimaBindingConstants.PROPERTY_WIFI_FW, value));
+ this.wifiSSID.map(value -> result.put(ArgoClimaBindingConstants.PROPERTY_WIFI_SSID, value));
+ this.wifiPassword.map(value -> result.put(ArgoClimaBindingConstants.PROPERTY_WIFI_PASSWORD, value));
+ this.localTime.map(value -> result.put(ArgoClimaBindingConstants.PROPERTY_LOCAL_TIME, value));
+ return Collections.unmodifiableSortedMap(result);
+ }
+ }
+
+ //////////////
+ // FIELDS
+ //////////////
+ private final ArgoClimaTranslationProvider i18nProvider;
+ private String commandString;
+ private DeviceProperties properties;
+
+ /**
+ * C-tor (from command string and properties - either from remote server response or device-side poll intercept)
+ *
+ * @param commandString The device-side {@code HMI} string, carrying its updates and commands
+ * @param properties The parsed device-side properties
+ * @param i18nProvider Framework's translation provider
+ * @implNote Consider: rewrite to a factory instead of this
+ */
+ public DeviceStatus(String commandString, DeviceProperties properties, ArgoClimaTranslationProvider i18nProvider) {
+ this.commandString = commandString;
+ this.properties = properties;
+ this.i18nProvider = i18nProvider;
+ }
+
+ /**
+ * C-tor (from just-received status response - live poll)
+ *
+ * @param commandString The command string received
+ * @param lastSeenDateTime The date/time when the request has been received
+ * @param i18nProvider Framework's translation provider
+ */
+ public DeviceStatus(String commandString, OffsetDateTime lastSeenDateTime,
+ ArgoClimaTranslationProvider i18nProvider) {
+ this(commandString, new DeviceProperties(lastSeenDateTime), i18nProvider);
+ }
+
+ /**
+ * Retrieve the device {@code HMI} string, carrying its updates and commands
+ *
+ * @return The status/command string
+ */
+ public String getCommandString() {
+ return this.commandString;
+ }
+
+ /**
+ * Retrieve device-side properties
+ *
+ * @return Device properties
+ */
+ public DeviceProperties getProperties() {
+ return this.properties;
+ }
+
+ /**
+ * Throw exception if last update time is older than
+ * {@link ArgoClimaConfigurationRemote#LAST_SEEN_UNAVAILABILITY_THRESHOLD the threshold}
+ *
+ * @throws ArgoApiCommunicationException If status is stale
+ */
+ public void throwIfStatusIsStale() throws ArgoApiCommunicationException {
+ var delta = this.properties.getLastSeenDelta();
+ if (delta.toSeconds() > ArgoClimaConfigurationRemote.LAST_SEEN_UNAVAILABILITY_THRESHOLD.toSeconds()) {
+ throw new ArgoApiCommunicationException(
+ // "or more", since this message is also used in thing status (and we're not updating
+ // offline->offline). Actual "Last seen" can always be retrieved from properties
+ "Device was last seen {0} (or more) mins ago (threshold is set at {1} min). Please ensure the HVAC is connected to Wi-Fi and communicating with Argo servers",
+ "thing-status.cause.argoclima.remote-device-stale", i18nProvider, delta.toMinutes(),
+ ArgoClimaConfigurationRemote.LAST_SEEN_UNAVAILABILITY_THRESHOLD.toMinutes());
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/IArgoClimaDeviceAPI.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/IArgoClimaDeviceAPI.java
new file mode 100644
index 0000000000000..8808892e3e79e
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/IArgoClimaDeviceAPI.java
@@ -0,0 +1,121 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api;
+
+import java.util.List;
+import java.util.Map;
+import java.util.SortedMap;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.argoclima.internal.device.api.protocol.ArgoApiDataElement;
+import org.openhab.binding.argoclima.internal.device.api.protocol.elements.IArgoCommandableElement.IArgoElement;
+import org.openhab.binding.argoclima.internal.device.api.types.ArgoDeviceSettingType;
+import org.openhab.binding.argoclima.internal.exception.ArgoApiCommunicationException;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+
+/**
+ * Interface for communication with Argo device(regardless of method)
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public interface IArgoClimaDeviceAPI {
+ public static record ReachabilityStatus(Boolean isReachable, String unreachabilityReason) {
+ }
+
+ /**
+ * Check if Argo device is reachable (this check MAY trigger device communications!)
+ *
+ * For local connection the checking is live (and synchronous!).
+ * For remote connection, the status is updated based off of last device's communication
+ *
+ * @return A 2-tuple with status: {@code }
+ */
+ ReachabilityStatus isReachable();
+
+ /**
+ * Query the Argo device for updated state.
+ *
+ * This ALWAYS triggers new device communication
+ *
+ * @return A map of {@code Setting->Value} read from device
+ * @throws ArgoApiCommunicationException thrown when unable to communicate with the Argo device
+ */
+ Map queryDeviceForUpdatedState() throws ArgoApiCommunicationException;
+
+ /**
+ * Returns last-retrieved device state
+ *
+ * This does *NOT* re-query the device
+ *
+ * @return A map of {@code Setting->Value} read from cache
+ */
+ Map getLastStateReadFromDevice();
+
+ /**
+ * Returns currently known properties of the device (from last-read state)
+ *
+ * @apiNote Does *not* query the device on its own
+ *
+ * @return A key-value map of device properties (both static/from configuration as well as the dynamic - read from
+ * device)
+ */
+ SortedMap getCurrentDeviceProperties();
+
+ /**
+ * Directly send any pending commands to the device (upon synchronizing with freshest device-side state)
+ *
+ * @throws ArgoApiCommunicationException thrown when unable to communicate with the Argo device
+ */
+ void sendCommandsToDevice() throws ArgoApiCommunicationException;
+
+ /**
+ * Notify that the pending commands have been passed to the device and are now pending confirmation from its end
+ *
+ * @implNote Used mostly for indirect mode, where the time when commands are consumed is dependent on device's own
+ * polling (can't trigger any device-facing comms in an indirect mode)
+ */
+ void notifyCommandsPassedToDevice();
+
+ /**
+ * Handle any setting command from UI
+ *
+ * @param settingType The name of setting receiving the value
+ * @param command The command/new value
+ * @return True - if command has been handled, False - otherwise
+ */
+ boolean handleSettingCommand(ArgoDeviceSettingType settingType, Command command);
+
+ /**
+ * Get the current value of a setting
+ *
+ * @param settingType The name of setting queried
+ * @return Current value of the setting
+ */
+ State getCurrentStateNoPoll(ArgoDeviceSettingType settingType);
+
+ /**
+ * Check if there are any commands pending send to the device
+ *
+ * @return True if there are commands pending, False otherwise
+ */
+ boolean hasPendingCommands();
+
+ /**
+ * Get items which have pending updates
+ *
+ * @return List of settings that have updates pending
+ */
+ List> getItemsWithPendingUpdates();
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/ArgoApiDataElement.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/ArgoApiDataElement.java
new file mode 100644
index 0000000000000..a0eacc6af645b
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/ArgoApiDataElement.java
@@ -0,0 +1,248 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api.protocol;
+
+import java.util.List;
+import java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.argoclima.internal.device.api.protocol.elements.IArgoCommandableElement;
+import org.openhab.binding.argoclima.internal.device.api.protocol.elements.IArgoCommandableElement.IArgoElement;
+import org.openhab.binding.argoclima.internal.device.api.types.ArgoDeviceSettingType;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+
+/**
+ * Wrapper for Argo API protocol knobs, providing an overlay functionality for converting them between framework values
+ * and raw protocol values, as well as command confirmation support
+ *
+ * Supports R/O (update-only), W/O (set-only) as well as R/W (update and set) knobs
+ *
+ * Since the Status(query) and Command(send) commands have different syntax and item ordering, this class is tracking
+ * respective position of an element in a protocol using {@link #queryResponseIndex} and
+ * {@link #statusUpdateRequestIndex}, respectively
+ *
+ * @param The underlying param type (with its internal logic of converting to/from Argo protocol
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public class ArgoApiDataElement implements IArgoCommandableElement {
+ /**
+ * Type of the data element
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+ public enum DataElementType {
+ READ_WRITE,
+ READ_ONLY,
+ WRITE_ONLY
+ }
+
+ /** The kind(type) of setting - aka. the *actual* thing it controls */
+ public final ArgoDeviceSettingType settingType;
+
+ /** The index of this API element in a device-side update */
+ public final int queryResponseIndex;
+
+ /** The index of this API element in a remote-side command */
+ public final int statusUpdateRequestIndex;
+
+ private DataElementType type;
+ private T rawValue;
+
+ /**
+ * Private c-tor
+ *
+ * @param settingType Kind of this knob (what it controls)
+ * @param rawValue The raw API protocol value
+ * @param queryIndex The index of this element in a device-side status update (or {@code -1} if N/A)
+ * @param updateIndex The index of this element in a cloud-side command (or {@code -1} if N/A)
+ * @param type The direction of this element (R/O, R/W, W/O)
+ */
+ private ArgoApiDataElement(ArgoDeviceSettingType settingType, T rawValue, int queryIndex, int updateIndex,
+ DataElementType type) {
+ this.settingType = settingType;
+ this.queryResponseIndex = queryIndex;
+ this.statusUpdateRequestIndex = updateIndex;
+ this.type = type;
+ this.rawValue = rawValue;
+ }
+
+ /**
+ * Named c-tor for a R/W element
+ *
+ * @param settingType Kind of this knob (what it controls)
+ * @param rawValue The raw API protocol value
+ * @param queryIndex The index of this element in a device-side status update
+ * @param updateIndex The index of this element in a cloud-side command
+ * @return The wrapped protocol API element
+ */
+ public static ArgoApiDataElement readWriteElement(ArgoDeviceSettingType settingType,
+ IArgoElement rawValue, int queryIndex, int updateIndex) {
+ return new ArgoApiDataElement<>(settingType, rawValue, queryIndex, updateIndex, DataElementType.READ_WRITE);
+ }
+
+ /**
+ * Named c-tor for a R/O element
+ *
+ * @param settingType Kind of this knob (what it controls)
+ * @param rawValue The raw API protocol value
+ * @param queryIndex The index of this element in a device-side status update
+ * @return The wrapped protocol API element
+ */
+ public static ArgoApiDataElement readOnlyElement(ArgoDeviceSettingType settingType,
+ IArgoElement rawValue, int queryIndex) {
+ return new ArgoApiDataElement<>(settingType, rawValue, queryIndex, -1, DataElementType.READ_ONLY);
+ }
+
+ /**
+ * Named c-tor for a W/O element
+ *
+ * @param settingType Kind of this knob (what it controls)
+ * @param rawValue The raw API protocol value
+ * @param updateIndex The index of this element in a cloud-side command
+ * @return The wrapped protocol API element
+ */
+ public static ArgoApiDataElement writeOnlyElement(ArgoDeviceSettingType settingType,
+ IArgoElement rawValue, int updateIndex) {
+ return new ArgoApiDataElement<>(settingType, rawValue, -1, updateIndex, DataElementType.WRITE_ONLY);
+ }
+
+ @Override
+ public void abortPendingCommand() {
+ this.rawValue.abortPendingCommand();
+ }
+
+ @Override
+ public boolean isUpdatePending() {
+ return this.rawValue.isUpdatePending();
+ }
+
+ @Override
+ public final boolean hasInFlightCommand() {
+ return this.rawValue.hasInFlightCommand();
+ }
+
+ @Override
+ public void notifyCommandSent() {
+ this.rawValue.notifyCommandSent();
+ }
+
+ @Override
+ public String toString() {
+ return toString(true);
+ }
+
+ /**
+ * Extended {@code toString()} method, allowing to also include the kind of knob
+ *
+ * @param includeType If true, includes the setting type (what it controls) in the string representation
+ * @return String representation
+ */
+ public String toString(boolean includeType) {
+ var prefix = "";
+ if (includeType) {
+ prefix = this.settingType.toString() + "=";
+ }
+ return prefix + rawValue.toString();
+ }
+
+ /**
+ * Output parsed value of this element (reported in a new device-side update) in OH framework-compatible
+ * representation
+ *
+ * This call does not update internal representation of this element!
+ *
+ * @param responseElements All "state" response elements sent by the device (device always sends state of ALL knobs)
+ * @return OH-compatible representation of current device state
+ */
+ public State fromDeviceResponse(List responseElements) {
+ if (this.type == DataElementType.READ_WRITE || this.type == DataElementType.READ_ONLY) {
+ return this.rawValue.updateFromApiResponse(responseElements.get(queryResponseIndex));
+ }
+ return UnDefType.NULL; // Write-only elements do not have any state reported
+ }
+
+ public State fromDeviceCommand(List responseElements) {
+ if (this.type == DataElementType.READ_WRITE || this.type == DataElementType.WRITE_ONLY) {
+ return this.rawValue.updateFromApiResponse(responseElements.get(statusUpdateRequestIndex));
+ }
+ return UnDefType.NULL; // Write-only elements do not have any state reported
+ }
+
+ /**
+ * Output this element's currently-stored value in OH framework-compatible representation
+ *
+ * @return OH-compatible representation of current device state
+ */
+ public State getState() {
+ return rawValue.toState();
+ }
+
+ /**
+ * Handle framework-side command targeting this element
+ *
+ * @param command The command to handle
+ * @return Status on whether the command has been handled (accepted). Note "handled" here doesn't mean
+ * sent and confirmed by the device, merely recognized by the framework and accepted for subsequent
+ * device-side communication (which happens asynchronously to this call)
+ */
+ public boolean handleCommand(Command command) {
+ if (this.type != DataElementType.WRITE_ONLY && this.type != DataElementType.READ_WRITE) {
+ return false; // attempting to write a R/O value
+ }
+ boolean waitForConfirmation = this.type != DataElementType.WRITE_ONLY;
+
+ return rawValue.handleCommand(command, waitForConfirmation);
+ }
+
+ public record deviceCommandRequest(Integer updateIndex, String apiValue) {
+ }
+
+ /**
+ * Convert this elements' current value to a device-compatible command request
+ *
+ * Value is returned only if this item has a pending update (or is always sent fresh as part of protocol)
+ *
+ * @return A pair of (updateIndex, ApiValue) representing this element as a command (if it had update)
+ */
+ public Optional toDeviceResponse() {
+ if (this.rawValue.isUpdatePending() || this.rawValue.isAlwaysSent()) {
+ return Optional
+ .of(new deviceCommandRequest(this.statusUpdateRequestIndex, this.rawValue.getDeviceApiValue()));
+ }
+ return Optional.empty();
+ }
+
+ /**
+ * Check if this element should be sent to device (either has withstanding command or is always sent)
+ *
+ * @return True if the element needs sending to the device. False - otherwise
+ */
+ public boolean shouldBeSentToDevice() {
+ return this.rawValue.isUpdatePending() || this.rawValue.isAlwaysSent();
+ }
+
+ /**
+ * Check if this element can be read (either allows reading, or doesn't, but there's a cached value available
+ * already)
+ *
+ * @return True if this element can be read. False - otherwise
+ */
+ public boolean isReadable() {
+ return this.type == DataElementType.READ_ONLY || this.type == DataElementType.READ_WRITE
+ || (this.type == DataElementType.WRITE_ONLY && this.rawValue.toState() != UnDefType.UNDEF);
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/ArgoDeviceStatus.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/ArgoDeviceStatus.java
new file mode 100644
index 0000000000000..24b4f6d3d57cb
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/ArgoDeviceStatus.java
@@ -0,0 +1,257 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api.protocol;
+
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.TreeMap;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.argoclima.internal.configuration.IScheduleConfigurationProvider;
+import org.openhab.binding.argoclima.internal.device.api.protocol.elements.ActiveTimerModeParam;
+import org.openhab.binding.argoclima.internal.device.api.protocol.elements.CurrentTimeParam;
+import org.openhab.binding.argoclima.internal.device.api.protocol.elements.CurrentWeekdayParam;
+import org.openhab.binding.argoclima.internal.device.api.protocol.elements.DelayMinutesParam;
+import org.openhab.binding.argoclima.internal.device.api.protocol.elements.EnumParam;
+import org.openhab.binding.argoclima.internal.device.api.protocol.elements.FwVersionParam;
+import org.openhab.binding.argoclima.internal.device.api.protocol.elements.IArgoCommandableElement.IArgoElement;
+import org.openhab.binding.argoclima.internal.device.api.protocol.elements.OnOffParam;
+import org.openhab.binding.argoclima.internal.device.api.protocol.elements.RangeParam;
+import org.openhab.binding.argoclima.internal.device.api.protocol.elements.TemperatureParam;
+import org.openhab.binding.argoclima.internal.device.api.protocol.elements.TimeParam;
+import org.openhab.binding.argoclima.internal.device.api.protocol.elements.TimeParam.TimeParamType;
+import org.openhab.binding.argoclima.internal.device.api.protocol.elements.WeekdayParam;
+import org.openhab.binding.argoclima.internal.device.api.types.ArgoDeviceSettingType;
+import org.openhab.binding.argoclima.internal.device.api.types.FanLevel;
+import org.openhab.binding.argoclima.internal.device.api.types.FlapLevel;
+import org.openhab.binding.argoclima.internal.device.api.types.OperationMode;
+import org.openhab.binding.argoclima.internal.device.api.types.TemperatureScale;
+import org.openhab.binding.argoclima.internal.exception.ArgoApiProtocolViolationException;
+import org.openhab.core.types.State;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The actual HVAC device status tracked by this binding. Converts to and from Argo protocol messages
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public class ArgoDeviceStatus implements IArgoSettingProvider {
+ private final Logger logger = LoggerFactory.getLogger(getClass());
+ private final IScheduleConfigurationProvider scheduleSettingsProvider;
+
+ /**
+ * A placeholder value in the protocol indicating no value/null (or no command) carried instead of an actual data
+ * element. Useful for allowing to change only a few settings, not the entire state at once
+ */
+ public static final String NO_VALUE = "N";
+ /**
+ * Number of data elements carried in a device-side "HMI" update - FROM the device
+ *
+ * @implNote Not sure what HMI stands for, but is used by Argo for a name for a query param, so adopting this name
+ */
+ public static final int HMI_UPDATE_ELEMENT_COUNT = 39;
+ /**
+ * Number of data elements carried in a remote-side status/"HMI" command sent TO the device
+ */
+ public static final int HMI_COMMAND_ELEMENT_COUNT = 36;
+ public static final String HMI_ELEMENT_SEPARATOR = ",";
+
+ /**
+ * The actual protocol elements, by their kind, type and read/write indexes in the response
+ *
+ * @implNote In the future consider applying builder pattern to make it more readable w/o IDE
+ */
+ private final List> allElements = List.of(
+ ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.TARGET_TEMPERATURE,
+ new TemperatureParam(this, 19.0, 36.0, 0.5), 0, 0),
+ ArgoApiDataElement.readOnlyElement(ArgoDeviceSettingType.ACTUAL_TEMPERATURE,
+ new TemperatureParam(this, 19.0, 36.0, 0.1), 1), // Unfortunately iFeel temperature seems impossible
+ // to be set remotely (needs IR remote)
+ ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.POWER, new OnOffParam(this), 2, 2),
+ ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.MODE, new EnumParam<>(this, OperationMode.class),
+ 3, 3),
+ ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.FAN_LEVEL, new EnumParam<>(this, FanLevel.class),
+ 4, 4),
+ ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.FLAP_LEVEL,
+ new EnumParam<>(this, FlapLevel.class), 5, 5),
+ ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.I_FEEL_TEMPERATURE, new OnOffParam(this), 6, 6),
+ ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.FILTER_MODE, new OnOffParam(this), 7, 7),
+ ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.ECO_MODE, new OnOffParam(this), 8, 8),
+ ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.TURBO_MODE, new OnOffParam(this), 9, 9),
+ ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.NIGHT_MODE, new OnOffParam(this), 10, 10),
+ ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.LIGHT, new OnOffParam(this), 11, 11),
+ ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.ACTIVE_TIMER, new ActiveTimerModeParam(this), 12,
+ 12),
+ ArgoApiDataElement.writeOnlyElement(ArgoDeviceSettingType.CURRENT_DAY_OF_WEEK,
+ new CurrentWeekdayParam(this), 18),
+ ArgoApiDataElement.writeOnlyElement(ArgoDeviceSettingType.TIMER_N_ENABLED_DAYS, new WeekdayParam(this), 19),
+ ArgoApiDataElement.writeOnlyElement(ArgoDeviceSettingType.CURRENT_TIME, new CurrentTimeParam(this), 20),
+ ArgoApiDataElement.writeOnlyElement(ArgoDeviceSettingType.TIMER_0_DELAY_TIME,
+ new DelayMinutesParam(this, TimeParam.fromHhMm(0, 10), TimeParam.fromHhMm(19, 50), 10,
+ Optional.of(60)),
+ 21),
+ ArgoApiDataElement.writeOnlyElement(ArgoDeviceSettingType.TIMER_N_ON_TIME,
+ new TimeParam(this, TimeParamType.ON), 22),
+ ArgoApiDataElement.writeOnlyElement(ArgoDeviceSettingType.TIMER_N_OFF_TIME,
+ new TimeParam(this, TimeParamType.OFF), 23),
+ ArgoApiDataElement.writeOnlyElement(ArgoDeviceSettingType.RESET_TO_FACTORY_SETTINGS, new OnOffParam(this),
+ 24),
+ ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.ECO_POWER_LIMIT, new RangeParam(this, 30, 99), 22,
+ 25),
+ ArgoApiDataElement.readWriteElement(ArgoDeviceSettingType.DISPLAY_TEMPERATURE_SCALE,
+ new EnumParam<>(this, TemperatureScale.class), 24, 26),
+ ArgoApiDataElement.readOnlyElement(ArgoDeviceSettingType.UNIT_FIRMWARE_VERSION, new FwVersionParam(this),
+ 23));
+
+ /**
+ * The same elements as in {@link #allElements}, but grouped by kind/type for easier access
+ *
+ * @implNote Not using {@code Collectors.toMap()} due to possible false-positive(!) unchecked warnings w/ the
+ * accumulator|stream
+ */
+ private final Map> dataElements = allElements.stream()
+ .collect(TreeMap::new, (m, v) -> m.put(v.settingType, v), TreeMap::putAll);
+
+ /**
+ * C-tor
+ *
+ * @param scheduleSettingsProvider schedule settings provider
+ */
+ public ArgoDeviceStatus(IScheduleConfigurationProvider scheduleSettingsProvider) {
+ this.scheduleSettingsProvider = scheduleSettingsProvider;
+ }
+
+ @Override
+ public ArgoApiDataElement getSetting(ArgoDeviceSettingType type) {
+ if (dataElements.containsKey(type)) {
+ return Objects.requireNonNull(dataElements.get(type));
+ }
+ throw new IllegalArgumentException("Wrong setting type: " + type.toString());
+ }
+
+ /**
+ * Get the current HVAC state in a SettingKind=CurrentValue compact format
+ */
+ @Override
+ public String toString() {
+ return dataElements.entrySet().stream().sorted(Map.Entry.comparingByKey())
+ .map(x -> String.format("%s=%s", x.getKey(), x.getValue().toString(false)))
+ .collect(Collectors.joining(", ", "{", "}"));
+ }
+
+ @Override
+ public IScheduleConfigurationProvider getScheduleProvider() {
+ return this.scheduleSettingsProvider;
+ }
+
+ /**
+ * Get a full current HVAC state in a framework-compatible format
+ *
+ * @return OH-compatible HVAC state, by element kind
+ */
+ public Map getCurrentStateMap() {
+ return dataElements.entrySet().stream().sorted((a, b) -> a.getKey().compareTo(b.getKey()))
+ .filter(x -> x.getValue().isReadable())
+ .collect(TreeMap::new, (m, v) -> m.put(v.getKey(), v.getValue().getState()), TreeMap::putAll);
+ }
+
+ /**
+ * Update *this* state from device-side update
+ *
+ * @param deviceOutput The device-side 'HMI' update
+ * @throws ArgoApiProtocolViolationException If API response doesn't match protocol format
+ */
+ public void fromDeviceString(String deviceOutput) throws ArgoApiProtocolViolationException {
+ var values = Arrays.asList(deviceOutput.split(HMI_ELEMENT_SEPARATOR));
+ if (values.size() != HMI_UPDATE_ELEMENT_COUNT) {
+ throw new ArgoApiProtocolViolationException(MessageFormat.format(
+ "Invalid device API response: [{0}]. Expected to contain {1} elements while has {2}.", deviceOutput,
+ HMI_UPDATE_ELEMENT_COUNT, values.size()));
+ }
+ synchronized (this) {
+ dataElements.entrySet().stream().forEach(v -> v.getValue().fromDeviceResponse(values));
+ }
+ logger.trace("Current HVAC state(after update): {}", this.toString());
+ }
+
+ /**
+ * Convert *this* state to a device-facing command
+ *
+ * Does NOT represent entire state (to avoid triggering actions which were just due to stale data), but rather sends
+ * only pending commands and "static" parts of the protocol, such as current time
+ *
+ * @implNote The value 'N' in the protocol seems to be for "NULL" (or "no update") and is used as placeholder for
+ * values that are not changing
+ * @return The command ready to be sent to the device, effecting *this* state (its withstanding/pending part)
+ */
+ public String getDeviceCommandStatus() {
+ var commands = new ArrayList(
+ Objects.requireNonNull(Collections.nCopies(HMI_COMMAND_ELEMENT_COUNT, NO_VALUE)));
+
+ var itemsToSend = dataElements.entrySet().stream().filter(x -> x.getValue().shouldBeSentToDevice()).toList();
+
+ if (logger.isDebugEnabled() || logger.isTraceEnabled()) {
+ var stringifiedItemsToSend = itemsToSend.stream().map(x -> x.getKey().toString())
+ .collect(Collectors.joining(", "));
+ if (hasUpdatesPending()) {
+ logger.debug("Sending {} updates to device {}", itemsToSend.size(), stringifiedItemsToSend);
+ } else {
+ logger.trace("Sending {} updates to device {}", itemsToSend.size(), stringifiedItemsToSend);
+ }
+ }
+
+ itemsToSend.stream().map(x -> x.getValue().toDeviceResponse()).forEach(p -> {
+ try {
+ commands.set(p.orElseThrow().updateIndex(), p.orElseThrow().apiValue());
+ } catch (IndexOutOfBoundsException e) {
+ throw new IllegalArgumentException(String.format(
+ "Attempting to set device command %d := %s, while only commands 0..%d are supported",
+ p.orElseThrow().updateIndex(), p.orElseThrow().apiValue(), commands.size()));
+ }
+ });
+
+ return String.join(HMI_ELEMENT_SEPARATOR, commands);
+ }
+
+ /**
+ * Check if this state has any updates pending (to be sent or confirmed by the HVAC device)
+ * The concrete items with update pending can be retrieved using {@link #getItemsWithPendingUpdates()}
+ *
+ * The "static" parts of protocol (such as current time) do not count as updates!
+ *
+ * @return True if there are any updates pending. False - otherwise
+ */
+ public boolean hasUpdatesPending() {
+ return this.dataElements.values().stream().anyMatch(x -> x.isUpdatePending());
+ }
+
+ /**
+ * Retrieve the elements of this state that have updates pending
+ *
+ * @return List of items with withstanding updates
+ */
+ public List> getItemsWithPendingUpdates() {
+ return this.dataElements.values().stream().filter(x -> x.isUpdatePending())
+ .sorted((x, y) -> Integer.compare(x.statusUpdateRequestIndex, y.statusUpdateRequestIndex))
+ .collect(Collectors.toList());
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/IArgoSettingProvider.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/IArgoSettingProvider.java
new file mode 100644
index 0000000000000..23325d3451290
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/IArgoSettingProvider.java
@@ -0,0 +1,42 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api.protocol;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.argoclima.internal.configuration.IScheduleConfigurationProvider;
+import org.openhab.binding.argoclima.internal.device.api.protocol.elements.IArgoCommandableElement.IArgoElement;
+import org.openhab.binding.argoclima.internal.device.api.types.ArgoDeviceSettingType;
+
+/**
+ * Interface for accessing HVAC-specific settings (knobs that can be controlled or report status)
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public interface IArgoSettingProvider {
+ /**
+ * Retrieve a concrete HVAC protocol element by its kind
+ *
+ * @param type The kind of element (setting) to return
+ * @return The controllable element of requested kind
+ * @throws RuntimeException In case the element is N/A
+ */
+ public ArgoApiDataElement getSetting(ArgoDeviceSettingType type);
+
+ /**
+ * Get the schedule provider (for configuring schedule timers)
+ *
+ * @return Current schedule provider
+ */
+ public IScheduleConfigurationProvider getScheduleProvider();
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/ActiveTimerModeParam.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/ActiveTimerModeParam.java
new file mode 100644
index 0000000000000..7e3864e64c2b3
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/ActiveTimerModeParam.java
@@ -0,0 +1,102 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api.protocol.elements;
+
+import java.util.EnumSet;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.argoclima.internal.device.api.protocol.IArgoSettingProvider;
+import org.openhab.binding.argoclima.internal.device.api.types.ArgoDeviceSettingType;
+import org.openhab.binding.argoclima.internal.device.api.types.TimerType;
+import org.openhab.binding.argoclima.internal.exception.ArgoConfigurationException;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.types.Command;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Timer mode parameter (handling schedule timers as well as delay timer) - special class of enum, as the timers are not
+ * fully standalone elements
+ *
+ * @author Mateusz Bronk - Initial contribution
+ *
+ */
+@NonNullByDefault
+public class ActiveTimerModeParam extends EnumParam {
+ private final Logger logger = LoggerFactory.getLogger(getClass());
+
+ /**
+ * C-tor
+ *
+ * @param settingsProvider the settings provider (getting device state as well as schedule configuration)
+ */
+ public ActiveTimerModeParam(IArgoSettingProvider settingsProvider) {
+ super(settingsProvider, TimerType.class);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Does pre-work for schedule timers and - if one of them is selected - injects (sends commands) to appropriate
+ * elements.
+ * Coordinates multiple timer parameters (ex. for TIMER1, need to fetch schedule1 params for day of week, on time
+ * and off time), and finally lets the super class handle THIS setting
+ */
+ @Override
+ protected HandleCommandResult handleCommandInternalEx(Command command) {
+ if (!(command instanceof StringType)) {
+ return HandleCommandResult.rejected(); // Unsupported command type, nothing to do anyway
+ }
+
+ var requestedValue = fromType(command, TimerType.class);
+ if (requestedValue.isEmpty()) {
+ return HandleCommandResult.rejected(); // Value not valid for a timer enum, rejecting command as a whole
+ }
+ TimerType newTimerType = requestedValue.orElseThrow(); // Boilerplate, guaranteed no-throw at this point
+
+ if (!EnumSet.of(TimerType.SCHEDULE_TIMER_1, TimerType.SCHEDULE_TIMER_2, TimerType.SCHEDULE_TIMER_3)
+ .contains(newTimerType)) {
+ return super.handleCommandInternalEx(command); // Not a schedule timer requested -> handle regularly
+ }
+
+ var scheduleTimerKind = TimerType.toScheduleTimerType(newTimerType);
+
+ try { // for getting values from settings
+ var activeDays = settingsProvider.getScheduleProvider().getScheduleDayOfWeek(scheduleTimerKind);
+ var scheduleOnTime = settingsProvider.getScheduleProvider().getScheduleOnTime(scheduleTimerKind);
+ var scheduleOffTime = settingsProvider.getScheduleProvider().getScheduleOffTime(scheduleTimerKind);
+ logger.debug("New timer value is: {}. Days={}, On={}, Off={}", newTimerType, activeDays, scheduleOnTime,
+ scheduleOffTime);
+
+ // get the elements that need to update with additional commands (now that the timer has been selected)
+ var timerDays = settingsProvider.getSetting(ArgoDeviceSettingType.TIMER_N_ENABLED_DAYS);
+ var timerOn = settingsProvider.getSetting(ArgoDeviceSettingType.TIMER_N_ON_TIME);
+ var timerOff = settingsProvider.getSetting(ArgoDeviceSettingType.TIMER_N_OFF_TIME);
+
+ // send the respective commands (as DecimalTypes, to cut on extra conversions)
+ timerOn.handleCommand(
+ new DecimalType(TimeParam.fromHhMm(scheduleOnTime.getHour(), scheduleOnTime.getMinute())));
+ timerOff.handleCommand(
+ new DecimalType(TimeParam.fromHhMm(scheduleOffTime.getHour(), scheduleOffTime.getMinute())));
+ timerDays.handleCommand(new DecimalType(WeekdayParam.toRawValue(activeDays)));
+
+ // finally go back to handling the timer type (as a regular enum)
+ return super.handleCommandInternalEx(command);
+ } catch (ArgoConfigurationException e) {
+ logger.debug("Invalid schedule configuration for {}. Error: {}", newTimerType, e.getMessage());
+ return HandleCommandResult.rejected(); // This technically won't ever happen as invalid config would fail
+ // binding startup (aka. way before control ever reaches this place)
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/ArgoApiElementBase.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/ArgoApiElementBase.java
new file mode 100644
index 0000000000000..155acd0019cf2
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/ArgoApiElementBase.java
@@ -0,0 +1,514 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api.protocol.elements;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants;
+import org.openhab.binding.argoclima.internal.configuration.IScheduleConfigurationProvider.ScheduleTimerType;
+import org.openhab.binding.argoclima.internal.device.api.protocol.ArgoDeviceStatus;
+import org.openhab.binding.argoclima.internal.device.api.protocol.IArgoSettingProvider;
+import org.openhab.binding.argoclima.internal.device.api.protocol.elements.IArgoCommandableElement.IArgoElement;
+import org.openhab.binding.argoclima.internal.device.api.types.ArgoDeviceSettingType;
+import org.openhab.binding.argoclima.internal.device.api.types.TimerType;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Base implementation of common functionality across all API elements
+ * (ex. handling pending commands and their confirmations)
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public abstract class ArgoApiElementBase implements IArgoElement {
+ ///////////
+ // TYPES
+ ///////////
+ /**
+ * Helper class for handling (pending) commands sent to the device (and awaiting confirmation)
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+ public static class HandleCommandResult {
+ public final boolean handled;
+ public final Optional deviceCommandToSend;
+ public final Optional plannedState;
+ private final Instant updateRequestedTime;
+ private boolean deferred = false;
+ private boolean requiresDeviceConfirmation = true;
+
+ /**
+ * Private C-tor
+ *
+ * @param handled If the command was handled
+ * @param deviceCommandToSend The actual command to send to device (only if {@code handled=True})
+ * @param plannedState The expected state of the device after the command (reaching it will serve as
+ * confirmation). present only if {@code handled=True}.
+ */
+ private HandleCommandResult(boolean handled, Optional deviceCommandToSend,
+ Optional plannedState) {
+ this.updateRequestedTime = Instant.now();
+ this.handled = handled;
+ this.deviceCommandToSend = deviceCommandToSend;
+ this.plannedState = plannedState;
+ }
+
+ /**
+ * Named c-tor for rejected command
+ *
+ * @return Rejected command ({@code handled = False})
+ */
+ public static HandleCommandResult rejected() {
+ return new HandleCommandResult(false, Optional.empty(), Optional.empty());
+ }
+
+ /**
+ * Named c-tor for accepted command
+ *
+ * By default the command starts with: {@link #isConfirmable() confirmable}{@code =True} and
+ * {@link #isDeferred() deferred}{@code =False}, which means caller expect device-side confirmation and the
+ * command is effective immediately after sending to the device (standalone command)
+ *
+ * @param deviceCommandToSend The actual command to send to device
+ * @param plannedState The expected state of the device after the command (if {@link #isConfirmable()
+ * confirmable} is {@code True}, reaching it will serve as confirmation)
+ * @return Accepted command ({@code confirmable=True & deferred=False} - changeable via
+ * {@link #setConfirmable(boolean)} or {@link #setDeferred(boolean)})
+ */
+ public static HandleCommandResult accepted(String deviceCommandToSend, State plannedState) {
+ return new HandleCommandResult(true, Optional.of(deviceCommandToSend), Optional.of(plannedState));
+ }
+
+ /**
+ * Check if this command is stale (has been issued before
+ * {@link ArgoClimaBindingConstants#PENDING_COMMAND_EXPIRE_TIME} ago.
+ *
+ * @implNote This class does NOT track actual command completion (only their issuance), hence it is expected
+ * that a completed command will be simply removed by the caller.
+ * @implNote For the same reason, even though this check only makes sense for {@code confirmable} commands - it
+ * is not checked herein and responsibility of the caller
+ * @return True if the command is obsolete (has been issued more than expire time ago)
+ */
+ public boolean hasExpired() {
+ return Duration.between(updateRequestedTime, Instant.now())
+ .compareTo(ArgoClimaBindingConstants.PENDING_COMMAND_EXPIRE_TIME) > 0;
+ }
+
+ /**
+ * Check if the command is confirmable (for R/W params, where the device acknowledges receipt of the command)
+ *
+ * @return True if the command is confirmable. False for write-only parameters
+ */
+ public boolean isConfirmable() {
+ return requiresDeviceConfirmation;
+ }
+
+ /**
+ * Set confirmable status (update from default: true)
+ *
+ * @param requiresDeviceConfirmation New {@code confirmable} value
+ * @return This object (for chaining)
+ */
+ public HandleCommandResult setConfirmable(boolean requiresDeviceConfirmation) {
+ this.requiresDeviceConfirmation = requiresDeviceConfirmation;
+ return this;
+ }
+
+ /**
+ * Check if the command is deferred
+ *
+ * A command is considered "deferred", if it isn't standalone, and - even when sent to the device - doesn't
+ * yield an immediate effect.
+ * For example, setting a delay timer value, when the device is not in a timer mode doesn't make any meaningful
+ * change to the device (until said mode is entered, which is controlled by different API element)
+ *
+ * @return True if the command is deferred (has no immediate effect). False - otherwise
+ */
+ public boolean isDeferred() {
+ return deferred;
+ }
+
+ /**
+ * Set deferred status (update from default: false)
+ *
+ * @see #isDeferred()
+ * @param deferred New {@code deferred} value
+ * @return This object (for chaining)
+ */
+ public HandleCommandResult setDeferred(boolean deferred) {
+ this.deferred = deferred;
+ return this;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("HandleCommandResult(wasHandled=%s,deviceCommand=%s,plannedState=%s,isObsolete=%s)",
+ handled, deviceCommandToSend, plannedState, hasExpired());
+ }
+ }
+
+ /**
+ * Types of command finalization (reason why command is no longer tracked/retried)
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+ public enum CommandFinalizationReason {
+ /** Command is confirmable and device confirmed now having the desired state */
+ CONFIRMED_APPLIED,
+ /** Command is not-confirmable has been just sent to the device (in good faith) */
+ SENT_NON_CONFIRMABLE,
+ /** Pending command has been aborted by the caller */
+ ABORTED,
+ /**
+ * Pending (confirmable) command has not received confirmation within
+ * {@link ArgoClimaBindingConstants#PENDING_COMMAND_EXPIRE_TIME}
+ */
+ EXPIRED
+ }
+
+ ///////////
+ // FIELDS
+ ///////////
+ private static final Logger LOGGER = LoggerFactory.getLogger(ArgoApiElementBase.class);
+ protected final IArgoSettingProvider settingsProvider;
+
+ /**
+ * Last status value received from device (has most accurate device-side state, but may be stale if there are
+ * in-flight commands!)
+ */
+ private Optional lastRawValueFromDevice = Optional.empty();
+
+ /**
+ * Active (in-flight) change request (upon accepting framework's Command) issued against this element. Tracked since
+ * acceptance (before send to the device) all the way to finalization (confirmed/successful, but also aborted,
+ * non-confirmable etc.)
+ */
+ private Optional inFlightCommand = Optional.empty();
+
+ /**
+ * Internal (element type-specific) method for handling the command (accepting or rejecting it).
+ *
+ * @implNote Tracking of command result is handled by this class through {@link #handleCommand(Command, boolean)}
+ *
+ * @param command The command to handle
+ * @return Handling result (an accepted or rejected command, with handling traits such as confirmable/deferred)
+ */
+ protected abstract HandleCommandResult handleCommandInternalEx(Command command);
+
+ /**
+ * Internal (element type-specific) method for handling the element status update.
+ *
+ * @implNote Tracking of command confirmations and/or expiration is handled by this class through
+ * {@link #updateFromApiResponse(String)}
+ * @param responseValue The raw API value (from device)
+ */
+ protected abstract void updateFromApiResponseInternal(String responseValue);
+
+ /**
+ * C-tor
+ *
+ * @param settingsProvider the settings provider (getting device state as well as schedule configuration)
+ */
+ public ArgoApiElementBase(IArgoSettingProvider settingsProvider) {
+ this.settingsProvider = settingsProvider;
+ }
+
+ @Override
+ public final State updateFromApiResponse(String responseValue) {
+ var noPendingUpdates = !isUpdatePending(); // Capturing the current in-flight state (before modifying this
+ // object and introducing side-effects)
+
+ synchronized (this) {
+ this.lastRawValueFromDevice = Optional.of(responseValue); // Persist last value from device (Side-effect:
+ // may change behavior of isUpdatePending()
+ if (noPendingUpdates) {
+ this.updateFromApiResponseInternal(responseValue); // No in-flight commands => Update THIS object with
+ // the new state
+
+ if (!this.hasInFlightCommand()) {
+ // No in-flight command, we're done
+ return this.toState();
+ }
+ }
+ }
+
+ // There's an ongoing confirmable command (not yet acknowledged), so we're *NOT* simply taking device-side
+ // value as the ACTUAL one (b/c it is slow to respond and we don't want values flapping). Instead, we try to
+ // see if the value is matching what we'd expect to change (confirming our command)
+ var expectedStateValue = getInFlightCommandsRawValueOrDefault();
+
+ if (responseValue.equals(expectedStateValue)) { // Comparing by raw values, not by planned state
+ confirmPendingCommand(CommandFinalizationReason.CONFIRMED_APPLIED);
+ } else if (this.inFlightCommand.map(x -> x.hasExpired()).orElse(false)) {
+ confirmPendingCommand(CommandFinalizationReason.EXPIRED);
+ } else {
+ LOGGER.debug("Update made, but values mismatch... {} (device) != {} (command)", responseValue,
+ expectedStateValue);
+ }
+ return this.toState(); // Return previous state (of the pending command, not the one device just reported)
+ }
+
+ @Override
+ public final void notifyCommandSent() {
+ if (this.isUpdatePending()) {
+ inFlightCommand.ifPresent(cmd -> {
+ if (!cmd.isConfirmable()) {
+ confirmPendingCommand(CommandFinalizationReason.SENT_NON_CONFIRMABLE);
+ }
+ });
+ }
+ }
+
+ @Override
+ public final void abortPendingCommand() {
+ confirmPendingCommand(CommandFinalizationReason.ABORTED);
+ }
+
+ @Override
+ public String toString() {
+ return String.format("RAW[%s]", lastRawValueFromDevice.orElse("N/A"));
+ }
+
+ @Override
+ public final boolean isUpdatePending() {
+ if (!hasInFlightCommand()) {
+ return false;
+ }
+
+ // Check if the device is not already reporting the requested state (nothing pending if so)
+ // (not inlining this code for better readability)
+ var deviceReportsValueAlready = lastRawValueFromDevice
+ .map(devValue -> devValue.equals(getInFlightCommandsRawValueOrDefault())).orElse(false);
+ return !deviceReportsValueAlready;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Wrapper implementation for handling confirmations/deferrals. Delegates actual work to
+ * {@link #handleCommandInternalEx(Command)}
+ */
+ @Override
+ public final boolean handleCommand(Command command, boolean isConfirmable) {
+ var result = this.handleCommandInternalEx(command);
+
+ if (result.handled) {
+ if (!isConfirmable) {
+ // The value is not confirmable (upon sending to the device, we'll just assume it will flip to the
+ // desired state)
+ result.setConfirmable(false);
+ }
+ if (!result.isDeferred()) {
+ // Deferred commands do not count as in-flight (will get intercepted when other command uses their
+ // value)
+ synchronized (this) {
+ this.inFlightCommand = Optional.of(result);
+ }
+ }
+ }
+ return result.handled;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Default implementation of a typical param, which is NOT always sent (to be further overridden in inheriting
+ * classes)
+ */
+ @Override
+ public boolean isAlwaysSent() {
+ return false;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Default implementation (to be further overridden in inheriting classes) getting pending command or
+ * {@code NO_VALUE} special value to not effect any change
+ */
+ @Override
+ public String getDeviceApiValue() {
+ if (!isUpdatePending()) {
+ return ArgoDeviceStatus.NO_VALUE;
+ }
+ return this.inFlightCommand.get().deviceCommandToSend.get();
+ }
+
+ /**
+ * Helper method to check if any one of the schedule timers is currently running
+ *
+ * @return Index of one of the schedule timers (1|2|3) which is currently active on the device. Empty optional -
+ * otherwise
+ */
+ protected final Optional isScheduleTimerEnabled() {
+ var currentTimer = EnumParam
+ .fromType(settingsProvider.getSetting(ArgoDeviceSettingType.ACTIVE_TIMER).getState(), TimerType.class);
+
+ if (currentTimer.isEmpty()) {
+ return Optional.empty();
+ }
+
+ switch (currentTimer.orElseThrow()) {
+ case SCHEDULE_TIMER_1:
+ case SCHEDULE_TIMER_2:
+ case SCHEDULE_TIMER_3:
+ return Optional.of(TimerType.toScheduleTimerType(currentTimer.orElseThrow()));
+ default:
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * Called when an in-flight command reaches a final state (successful or not) and no longer requires tracking
+ *
+ * @param reason The reason for finalizing the command (for logging)
+ */
+ private final void confirmPendingCommand(CommandFinalizationReason reason) {
+ var commandName = inFlightCommand.map(c -> c.plannedState.map(s -> s.toFullString()).orElse("N/A"))
+ .orElse("Unknown");
+ switch (reason) {
+ case CONFIRMED_APPLIED:
+ LOGGER.debug("[{}] Update confirmed!", commandName);
+ break;
+ case ABORTED:
+ LOGGER.debug("[{}] Command aborted!", commandName);
+ break;
+ case EXPIRED:
+ LOGGER.debug("[{}] Long-pending update found. Cancelling...!", commandName);
+ break;
+ case SENT_NON_CONFIRMABLE:
+ LOGGER.debug("[{}] Update confirmed (in good faith)!", commandName);
+ break;
+ }
+ synchronized (this) {
+ this.inFlightCommand = Optional.empty();
+ }
+ }
+
+ @Override
+ public final boolean hasInFlightCommand() {
+ if (inFlightCommand.isEmpty()) {
+ return false; // no withstanding command
+ }
+
+ // If last command was not handled correctly -> there's nothing to update
+ return inFlightCommand.map(c -> c.handled).orElse(false);
+ }
+
+ private final String getInFlightCommandsRawValueOrDefault() {
+ final String valueNotAvailablePlaceholder = "N/A";
+ return inFlightCommand.map(c -> c.deviceCommandToSend.orElse(valueNotAvailablePlaceholder))
+ .orElse(valueNotAvailablePlaceholder);
+ }
+
+ /////////////
+ // HELPERS
+ /////////////
+ /**
+ * Utility function trying to convert from String to int
+ *
+ * @param value Value to convert
+ * @return Converted value (if successful) or empty (on failure)
+ */
+ protected static Optional strToInt(String value) {
+ try {
+ return Optional.of(Integer.parseInt(value));
+ } catch (NumberFormatException e) {
+ LOGGER.trace("The value {} is not a valid integer. Error: {}", value, e.getMessage());
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * Normalize the value to be within range (and multiple of step, if any)
+ *
+ * @param The number type
+ * @param newValue Value to convert
+ * @param minValue Lower bound
+ * @param maxValue Upper bound
+ * @param step Optional step for the value (result will be rounded to nearest step)
+ * @param unitDescription Unit description (for logging)
+ * @return Range within MIN..MAX bounds (which is a multiple of step). Returned as a {@code Number} for the caller
+ * to convert back to the desired type. Note we're not casting back to {@code T} as it would need to be an
+ * unchecked cast
+ */
+ protected static > Number adjustRange(T newValue, final T minValue,
+ final T maxValue, final Optional step, final String unitDescription) {
+ if (newValue.compareTo(minValue) < 0) {
+ LOGGER.debug("Requested value: [{}{}] would exceed minimum value: [{}{}]. Setting: {}{}.", newValue,
+ unitDescription, minValue, unitDescription, minValue, unitDescription); // The over-repetition is
+ // due to SLF4J formatter
+ // not supporting numbered
+ // params, and using full
+ // MessageFormat is not only
+ // an overkill but also
+ // SLOWER
+ return minValue;
+ }
+ if (newValue.compareTo(maxValue) > 0) {
+ LOGGER.debug("Requested value: [{}{}] would exceed maximum value: [{}{}]. Setting: {}{}.", newValue,
+ unitDescription, maxValue, unitDescription, maxValue, unitDescription); // See comment above
+ return maxValue;
+ }
+
+ if (step.isEmpty()) {
+ return newValue; // No rounding to step value
+ }
+
+ return Math.round(newValue.doubleValue() / step.orElseThrow().doubleValue()) * step.orElseThrow().doubleValue();
+ }
+
+ /**
+ * Normalizes the incoming value (respecting steps), with amplification of movement
+ *
+ * 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
+ * to a full next step (60), not to ignore user's intent to change something
+ *
+ * @param newValue Value to convert
+ * @param currentValue The current value to amplify (in case normalization wouldn't otherwise change anything). If
+ * empty, this method doesn't amplify anything
+ * @param minValue Lower bound
+ * @param maxValue Upper bound
+ * @param step Optional step for the value (result will be rounded to nearest step)
+ * @param unitDescription Unit description (for logging)
+ * @return Sanitized value (with amplified movement). Returned as a {@code Number} for the caller
+ * to convert back to the desired type. Note we're not casting back to {@code T} as it would need to be an
+ * unchecked cast
+ */
+ protected static > Number adjustRangeWithAmplification(T newValue,
+ Optional currentValue, final T minValue, final T maxValue, final T step, final String unitDescription) {
+ Number normalized = adjustRange(newValue, minValue, maxValue, Optional.of(step), unitDescription);
+
+ if (currentValue.isEmpty() || normalized.doubleValue() == newValue.doubleValue()
+ || newValue.compareTo(minValue) < 0 || newValue.compareTo(maxValue) > 0) {
+ return normalized; // there was no previous value or normalization didn't remove any precision or reached a
+ // boundary -> new normalized value wins
+ }
+
+ final Number thisValue = currentValue.orElseThrow();
+ if (normalized.doubleValue() != thisValue.doubleValue()) {
+ return normalized; // the normalized value changed enough to be meaningful on its own-> use it
+ }
+
+ // Value before normalization has moved, but not enough to move a step (and would have been ignored). Let's
+ // amplify that effect and add a new step
+ var movementDirection = Math.signum((newValue.doubleValue() - normalized.doubleValue()));
+ return normalized.doubleValue() + movementDirection * step.doubleValue();
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/CurrentTimeParam.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/CurrentTimeParam.java
new file mode 100644
index 0000000000000..8877c0d91abc5
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/CurrentTimeParam.java
@@ -0,0 +1,90 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api.protocol.elements;
+
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.Objects;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.argoclima.internal.device.api.protocol.IArgoSettingProvider;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The element reporting current time to the device
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public class CurrentTimeParam extends ArgoApiElementBase {
+ private final Logger logger = LoggerFactory.getLogger(getClass());
+
+ /**
+ * C-tor
+ *
+ * @param settingsProvider the settings provider (getting device state as well as schedule configuration)
+ */
+ public CurrentTimeParam(IArgoSettingProvider settingsProvider) {
+ super(settingsProvider);
+ }
+
+ private static ZonedDateTime utcNow() {
+ return ZonedDateTime.now(Objects.requireNonNull(ZoneId.of("UTC")));
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @implNote This element doesn't really get any device-side commands
+ */
+ @Override
+ protected void updateFromApiResponseInternal(String responseValue) {
+ logger.debug("Got state: {} for a parameter that doesn't support it!", responseValue);
+ }
+
+ @Override
+ public State toState() {
+ return new DateTimeType(utcNow());
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * The current time is always sent
+ */
+ @Override
+ public boolean isAlwaysSent() {
+ return true;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Specialized implementation, always providing latest *now* value
+ */
+ @Override
+ public String getDeviceApiValue() {
+ var t = utcNow();
+ return Integer.toString(TimeParam.fromHhMm(t.getHour(), t.getMinute()));
+ }
+
+ @Override
+ protected HandleCommandResult handleCommandInternalEx(Command command) {
+ logger.debug("Got command for a parameter that doesn't support it!");
+ return HandleCommandResult.rejected(); // Does not handle any commands
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/CurrentWeekdayParam.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/CurrentWeekdayParam.java
new file mode 100644
index 0000000000000..ead2596f892d1
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/CurrentWeekdayParam.java
@@ -0,0 +1,95 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api.protocol.elements;
+
+import java.time.DayOfWeek;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.TextStyle;
+import java.util.Locale;
+import java.util.Objects;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.argoclima.internal.device.api.protocol.IArgoSettingProvider;
+import org.openhab.binding.argoclima.internal.device.api.types.Weekday;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The element reporting current day of week to the device
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public class CurrentWeekdayParam extends ArgoApiElementBase {
+ private final Logger logger = LoggerFactory.getLogger(getClass());
+
+ /**
+ * C-tor
+ *
+ * @param settingsProvider the settings provider (getting device state as well as schedule configuration)
+ */
+ public CurrentWeekdayParam(IArgoSettingProvider settingsProvider) {
+ super(settingsProvider);
+ }
+
+ private static DayOfWeek utcToday() {
+ return ZonedDateTime.now(Objects.requireNonNull(ZoneId.of("UTC"))).getDayOfWeek();
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @implNote This element doesn't really get any device-side commands
+ */
+ @Override
+ protected void updateFromApiResponseInternal(String responseValue) {
+ logger.debug("Got state: {} for a parameter that doesn't support it!", responseValue);
+ }
+
+ @Override
+ public State toState() {
+ return new org.openhab.core.library.types.StringType(
+ utcToday().getDisplayName(TextStyle.SHORT_STANDALONE, Locale.US));
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * The current day of week is always sent
+ */
+ @Override
+ public boolean isAlwaysSent() {
+ return true;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Specialized implementation, always providing latest *today* value
+ *
+ * @implNote deliberately using ordinal, not getIntValue() here as the latter is for bitmasks!
+ */
+ @Override
+ public String getDeviceApiValue() {
+ return Integer.toString(Weekday.ofDay(utcToday()).ordinal());
+ }
+
+ @Override
+ protected HandleCommandResult handleCommandInternalEx(Command command) {
+ logger.debug("Got command for a parameter that doesn't support it!");
+ return HandleCommandResult.rejected(); // Does not handle any commands
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/DelayMinutesParam.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/DelayMinutesParam.java
new file mode 100644
index 0000000000000..2b042372a5f00
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/DelayMinutesParam.java
@@ -0,0 +1,220 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api.protocol.elements;
+
+import java.util.Optional;
+
+import javax.measure.quantity.Time;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.argoclima.internal.device.api.protocol.ArgoDeviceStatus;
+import org.openhab.binding.argoclima.internal.device.api.protocol.IArgoSettingProvider;
+import org.openhab.binding.argoclima.internal.device.api.types.ArgoDeviceSettingType;
+import org.openhab.binding.argoclima.internal.device.api.types.TimerType;
+import org.openhab.core.library.types.IncreaseDecreaseType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+
+/**
+ * Delay timer element (accepting values in minutes and constrained in both range and precision)
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public class DelayMinutesParam extends ArgoApiElementBase {
+ private final int minValue;
+ private final int maxValue;
+ private final int step;
+ private Optional currentValue;
+
+ /**
+ * C-tor
+ *
+ * @param settingsProvider the settings provider (getting device state as well as schedule configuration)
+ * @param min Minimum value of this timer (in minutes)
+ * @param max Maximum value of this timer (in minutes)
+ * @param step Minimum step of the timer (values will be rounded to nearest step, increments/decrements will move by
+ * step)
+ * @param initialValue The initial value of this setting, in minutes (since the value is write-only, need to provide
+ * a value for the increments/decrements to work)
+ */
+ public DelayMinutesParam(IArgoSettingProvider settingsProvider, int min, int max, int step,
+ Optional initialValue) {
+ super(settingsProvider);
+ this.minValue = min;
+ this.maxValue = max;
+ this.step = step;
+ this.currentValue = initialValue;
+ }
+
+ /**
+ * Converts the raw value to framework-compatible {@link State}
+ *
+ * @param value Value to convert
+ * @return Converted value (or {@code UNDEF} on conversion failure)
+ */
+ private static State valueToState(Optional value) {
+ if (value.isEmpty()) {
+ return UnDefType.UNDEF;
+ }
+
+ return new QuantityType(value.get(), Units.MINUTE);
+ }
+
+ /**
+ * @see {@link ArgoApiElementBase#adjustRange}
+ */
+ private int adjustRange(int newValue) {
+ return ArgoApiElementBase.adjustRange(newValue, minValue, maxValue, Optional.of(step), " min").intValue();
+ }
+
+ /**
+ * @see {@link ArgoApiElementBase#adjustRangeWithAmplification}
+ */
+ private int adjustRangeWithAmplification(int newValue) {
+ return ArgoApiElementBase.adjustRangeWithAmplification(newValue, currentValue, minValue, maxValue, step, " min")
+ .intValue();
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @implNote The currently used context of this class (on/off schedule time) has WRITE-ONLY elements, hence this
+ * method is unlikely to ever be called
+ */
+ @Override
+ protected void updateFromApiResponseInternal(String responseValue) {
+ strToInt(responseValue).ifPresent(raw -> {
+ currentValue = Optional.of(adjustRange(raw));
+ });
+ }
+
+ @Override
+ public State toState() {
+ return valueToState(currentValue);
+ }
+
+ @Override
+ public String toString() {
+ if (currentValue.isEmpty()) {
+ return "???";
+ }
+ return currentValue.get().toString() + " min";
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Timer delay value is always sent to the device together with Timer=Delay command
+ * (so that the clock resets)
+ */
+ @Override
+ public boolean isAlwaysSent() {
+ return isDelayTimerBeingActivated();
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * The delay timer value should be send whenever there's an active change (command) to a delay timer (technically
+ * flipping from Delay timer back to the Delay timer, w/o changing the delay value should re-arm the timer)
+ */
+ @Override
+ public String getDeviceApiValue() {
+ var defaultResult = super.getDeviceApiValue();
+
+ if (!ArgoDeviceStatus.NO_VALUE.equals(defaultResult) || currentValue.isEmpty()
+ || !isDelayTimerBeingActivated()) {
+ return defaultResult; // There's already a pending command recognized by binding, or delay timer is has no
+ // pending command -
+ // we're good to go with the default
+ }
+
+ // There's a pending change to Delay timer -> let's send our value then
+ return Integer.toString(currentValue.orElseThrow());
+ }
+
+ /**
+ * Checks if Delay timer is currently being commanded to become active on the device (pending commands!)
+ *
+ * @return True, if delay timer is currently being activated on the device, False otherwise
+ */
+ private boolean isDelayTimerBeingActivated() {
+ var setting = settingsProvider.getSetting(ArgoDeviceSettingType.ACTIVE_TIMER);
+ var currentTimerValue = EnumParam.fromType(setting.getState(), TimerType.class);
+
+ var isDelayCurrentlySet = currentTimerValue.map(t -> t.equals(TimerType.DELAY_TIMER)).orElse(false);
+
+ return isDelayCurrentlySet && setting.hasInFlightCommand();
+ }
+
+ /**
+ * Checks if Delay timer is active already (or being commanded to do so)
+ *
+ * Used to defer timer value updates in case there's no timer action ongoing (no need to send the timer value to the
+ * device)
+ *
+ * @return True, if delay timer is currently active on the device, False otherwise
+ */
+ private final boolean isDelayTimerCurrentlyActive() {
+ var currentTimer = EnumParam
+ .fromType(settingsProvider.getSetting(ArgoDeviceSettingType.ACTIVE_TIMER).getState(), TimerType.class);
+
+ return currentTimer.map(t -> t.equals(TimerType.DELAY_TIMER)).orElse(false);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @implNote Since this method rounds to next step and some updates may be missed, we're forcing any direction
+ * movements to move a full step through {@link #adjustRangeWithAmplification(int)}
+ */
+ @Override
+ protected HandleCommandResult handleCommandInternalEx(Command command) {
+ int newRawValue;
+
+ if (command instanceof Number numberCommand) {
+ newRawValue = numberCommand.intValue(); // Raw value, not unit-aware
+
+ if (command instanceof QuantityType> quantityTypeCommand) { // let's try to get it with unit
+ // (opportunistically)
+ var inMinutes = quantityTypeCommand.toUnit(Units.MINUTE);
+ if (null != inMinutes) {
+ newRawValue = inMinutes.intValue();
+ }
+ }
+ } else if (command instanceof IncreaseDecreaseType increaseDecreaseTypeCommand) {
+ var base = this.currentValue.orElse(adjustRange((this.minValue + this.maxValue) / 2));
+ if (IncreaseDecreaseType.INCREASE.equals(increaseDecreaseTypeCommand)) {
+ base += step;
+ } else if (IncreaseDecreaseType.DECREASE.equals(increaseDecreaseTypeCommand)) {
+ base -= step;
+ }
+ newRawValue = base;
+ } else {
+ return HandleCommandResult.rejected(); // unsupported type of command
+ }
+
+ newRawValue = adjustRangeWithAmplification(newRawValue);
+
+ // Not checking if current value is the same as requested (delay timer set resets the clock)
+ this.currentValue = Optional.of(newRawValue);
+
+ // Accept the command (and if it was sent when no timer was active, make it deferred)
+ return HandleCommandResult.accepted(Integer.toString(newRawValue), valueToState(Optional.of(newRawValue)))
+ .setDeferred(!isDelayTimerCurrentlyActive());
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/EnumParam.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/EnumParam.java
new file mode 100644
index 0000000000000..1773bec11b101
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/EnumParam.java
@@ -0,0 +1,149 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api.protocol.elements;
+
+import java.util.EnumSet;
+import java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.argoclima.internal.device.api.protocol.IArgoSettingProvider;
+import org.openhab.binding.argoclima.internal.device.api.types.IArgoApiEnum;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.Type;
+import org.openhab.core.types.UnDefType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Enum-type of a param (supports mapping to/from enumerations implementing {@link IArgoApiEnum}
+ *
+ * @implNote Some enums (ex. timer type) may require unique handling of updates, hence this class'es implementation of
+ * {@link #handleCommandInternalEx(Command)} is not final
+ *
+ * @author Mateusz Bronk - Initial contribution
+ *
+ * @param The type of underlying enum
+ */
+@NonNullByDefault
+public class EnumParam & IArgoApiEnum> extends ArgoApiElementBase {
+ private static final Logger LOGGER = LoggerFactory.getLogger(EnumParam.class);
+ private Optional currentValue;
+ private final Class cls;
+
+ /**
+ * C-tor
+ *
+ * @param settingsProvider the settings provider (getting device state as well as schedule configuration)
+ * @param cls The type of underlying Enum (implementing {@link IArgoApiEnum} for mapping to/from integer values)
+ */
+ public EnumParam(IArgoSettingProvider settingsProvider, Class cls) {
+ super(settingsProvider);
+ this.cls = cls;
+ this.currentValue = Optional.empty();
+ }
+
+ /**
+ * Gets the raw enum value from {@link Type} ({@link Command} or {@link State}) which are themselves strings
+ *
+ * @see #valueToState(Optional) for a reverse conversion
+ *
+ * @param The type of underlying enum - implementing {@link IArgoApiEnum}
+ * @param value Value to convert
+ * @param cls The class of underlying Enum (implementing {@link IArgoApiEnum} for mapping to/from integer values)
+ * @return Converted value (or empty, on conversion failure)
+ */
+ public static & IArgoApiEnum> Optional fromType(Type value, Class cls) {
+ if (value instanceof StringType stringTypeCommand) {
+ String newValue = stringTypeCommand.toFullString();
+ try {
+ return Optional.of(Enum.valueOf(cls, newValue));
+ } catch (IllegalArgumentException ex) {
+ LOGGER.debug("Failed to convert value: {} to enum. {}", value, ex.getMessage());
+ return Optional.empty();
+ }
+ }
+ return Optional.empty(); // Not a string Command/State -> ignoring the conversion
+ }
+
+ /**
+ * Converts enum value to framework-compatible {@link State}
+ *
+ * @see {@link #fromType(Type, Class)} for a reverse conversion
+ * @param The type of underlying enum - implementing {@link IArgoApiEnum}
+ * @param value The value to convert (wrapped into an optional)
+ * @return Converted value. {@link UnDefType.UNDEF} if n/a
+ */
+ private static & IArgoApiEnum> State valueToState(Optional value) {
+ if (value.isEmpty()) {
+ return UnDefType.UNDEF;
+ }
+ return new StringType(value.orElseThrow().toString());
+ }
+
+ @Override
+ protected void updateFromApiResponseInternal(String responseValue) {
+ strToInt(responseValue).ifPresent(raw -> {
+ this.currentValue = this.fromInt(raw);
+ });
+ }
+
+ @Override
+ public State toState() {
+ return valueToState(currentValue);
+ }
+
+ @Override
+ public String toString() {
+ if (currentValue.isEmpty()) {
+ return "???";
+ }
+ return currentValue.get().toString();
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Default behavior - may be overridden for specialized enums
+ */
+ @Override
+ protected HandleCommandResult handleCommandInternalEx(Command command) {
+ if (!(command instanceof StringType)) {
+ return HandleCommandResult.rejected(); // Unsupported command type
+ }
+
+ var requestedValue = fromType(command, cls);
+ if (requestedValue.isEmpty()) {
+ return HandleCommandResult.rejected(); // Value not valid for this enum
+ }
+
+ E val = requestedValue.orElseThrow(); // boilerplate, guaranteed to always succeed
+ if (currentValue.map(cv -> (cv.compareTo(val) == 0)).orElse(false)) {
+ return HandleCommandResult.rejected(); // Current value is the same as requested - nothing to do
+ }
+
+ this.currentValue = requestedValue; // We allow it!
+ return HandleCommandResult.accepted(Integer.toString(val.getIntValue()), valueToState(requestedValue));
+ }
+
+ /**
+ * Convert from int value to this enum
+ *
+ * @param value Int value (must match the underlying enum's {@link IArgoApiEnum#getIntValue()}
+ * @return Converted value or empty if no match
+ */
+ private Optional fromInt(int value) {
+ return EnumSet.allOf(this.cls).stream().filter(p -> p.getIntValue() == value).findFirst();
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/FwVersionParam.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/FwVersionParam.java
new file mode 100644
index 0000000000000..ae067b6ecbf08
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/FwVersionParam.java
@@ -0,0 +1,71 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api.protocol.elements;
+
+import java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.argoclima.internal.device.api.protocol.IArgoSettingProvider;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+
+/**
+ * Read-only element communicating the unit firmware version
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public class FwVersionParam extends ArgoApiElementBase {
+ private Optional currentValue = Optional.empty();
+
+ /**
+ * C-tor
+ *
+ * @param settingsProvider the settings provider (getting device state as well as schedule configuration)
+ */
+ public FwVersionParam(IArgoSettingProvider settingsProvider) {
+ super(settingsProvider);
+ }
+
+ private static State valueToState(Optional value) {
+ if (value.isEmpty()) {
+ return UnDefType.UNDEF;
+ }
+ return new StringType("0" + value.get());
+ }
+
+ @Override
+ protected void updateFromApiResponseInternal(String responseValue) {
+ this.currentValue = Optional.of(responseValue);
+ }
+
+ @Override
+ public State toState() {
+ return valueToState(currentValue);
+ }
+
+ @Override
+ public String toString() {
+ if (currentValue.isEmpty()) {
+ return "???";
+ }
+ return "0" + currentValue.get();
+ }
+
+ @Override
+ protected HandleCommandResult handleCommandInternalEx(Command command) {
+ return HandleCommandResult.rejected(); // this is not handling any commands
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/IArgoCommandableElement.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/IArgoCommandableElement.java
new file mode 100644
index 0000000000000..b1260cb3ce625
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/IArgoCommandableElement.java
@@ -0,0 +1,135 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api.protocol.elements;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+
+/**
+ * Interface for Argo API parameter (individual HMI element)
+ * Carries high-level command-management options
+ *
+ * @see IArgoElement
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public interface IArgoCommandableElement {
+ /////////
+ // TYPES
+ /////////
+ /**
+ * Specialized interface for individual HMI elements, implementing low-level manipulation on their values
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+ interface IArgoElement extends IArgoCommandableElement {
+
+ /**
+ * Returns the raw Argo command to be sent to the device (if update is pending)
+ *
+ * @return Command to send to device (if update pending), or
+ * {@link org.openhab.binding.argoclima.internal.device.api.protocol.ArgoDeviceStatus#NO_VALUE NO_VALUE}
+ * - otherwise
+ */
+ public String getDeviceApiValue();
+
+ /**
+ * Handles channel command
+ *
+ * @param command The command to handle
+ * @param isConfirmable Whether the command result is confirmable by the device
+ * @return True - if command has been handled (= accepted by the framework and ready to be sent to device),
+ * False -
+ * otherwise
+ */
+ public boolean handleCommand(Command command, boolean isConfirmable);
+
+ /**
+ * Returns true if the value is always sent to the device on next communication cycle (regardless of whether
+ * this
+ * value has new updates or received a direct command).
+ * Example: current time
+ *
+ * Note items marked as always-sent do NOT count towards pending updates (unless they had received a direct
+ * command). Ex. the always-sent comment will be sent together with any other "direct" commands, but won't
+ * trigger
+ * an update cycle on its own, and rather be appended to the user-triggered values on each update (for example,
+ * time
+ * update is NOT sent to the device each minute, but gets synchronized on every command)
+ *
+ * @return True if the value is always sent in an update cycle
+ */
+ public boolean isAlwaysSent();
+
+ /**
+ * Return **current** state of the element (including side-effects of any pending commands)
+ *
+ * @return Device's state as {@link State}
+ */
+ public State toState();
+
+ /**
+ * Updates this API element's state from device's response
+ *
+ * @param responseValue Raw API input
+ * @return State after update
+ */
+ public State updateFromApiResponse(String responseValue);
+ }
+
+ /**
+ * Notify that the withstanding command has just been sent to the device (and is now pending device-side
+ * confirmation - if confirmable)
+ *
+ * @implNote Used for write-only params, to indicate they have been (hopefully) correctly sent to the device
+ */
+ public void notifyCommandSent();
+
+ /**
+ * Abort pending command targeting this knob (do not send it anymore, consider current device-side state as stable)
+ */
+ public void abortPendingCommand();
+
+ /**
+ * Checks if there's any command in flight (pending to be sent to the device, or sent and awaiting confirmation - if
+ * confirmable)
+ *
+ * This method is similar to {@link #isUpdatePending()}, but doesn't consider device's current state, only the
+ * existence of non-finalized command
+ *
+ * @return True if command pending, False otherwise
+ */
+ public boolean hasInFlightCommand();
+
+ /**
+ * Checks if there's any update withstanding to be sent to the device (pending = not yet sent or not confirmed by
+ * the device yet)
+ *
+ * This method is similar to {@link #hasInFlightCommand()}, but also considers device's current state (if the device
+ * reports the desired/commanded state already, it's considered not to have any update pending)
+ *
+ * @return True if update pending, False otherwise
+ */
+ public boolean isUpdatePending();
+
+ /**
+ * Return string representation of the current state of the device in a human-friendly format (for logging)
+ * Returns mostly a protocol-like value, not necessarily the framework-converted one
+ *
+ * @return String representation of the element
+ */
+ @Override
+ public String toString();
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/OnOffParam.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/OnOffParam.java
new file mode 100644
index 0000000000000..49cca1ee52dbb
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/OnOffParam.java
@@ -0,0 +1,91 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api.protocol.elements;
+
+import java.security.InvalidParameterException;
+import java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.argoclima.internal.device.api.protocol.ArgoDeviceStatus;
+import org.openhab.binding.argoclima.internal.device.api.protocol.IArgoSettingProvider;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+
+/**
+ * The API element representing ON/OFF knob
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public class OnOffParam extends ArgoApiElementBase {
+ private Optional currentValue = Optional.empty();
+ private static final String VALUE_ON = "1";
+ private static final String VALUE_OFF = "0";
+
+ /**
+ * C-tor
+ *
+ * @param settingsProvider the settings provider (getting device state as well as schedule configuration)
+ */
+ public OnOffParam(IArgoSettingProvider settingsProvider) {
+ super(settingsProvider);
+ }
+
+ private static State valueToState(Optional value) {
+ return value. map(v -> OnOffType.from(v)).orElse(UnDefType.UNDEF);
+ }
+
+ @Override
+ protected void updateFromApiResponseInternal(String responseValue) {
+ if (OnOffParam.VALUE_ON.equals(responseValue)) {
+ this.currentValue = Optional.of(true);
+ } else if (OnOffParam.VALUE_OFF.equals(responseValue)) {
+ this.currentValue = Optional.of(false);
+ } else if (ArgoDeviceStatus.NO_VALUE.equals(responseValue)) {
+ this.currentValue = Optional.empty();
+ } else {
+ throw new InvalidParameterException(String.format("Invalid value of parameter: {}", responseValue));
+ }
+ }
+
+ @Override
+ public State toState() {
+ return valueToState(currentValue);
+ }
+
+ @Override
+ public String toString() {
+ if (currentValue.isEmpty()) {
+ return "???";
+ }
+ return currentValue.get() ? "ON" : "OFF";
+ }
+
+ @Override
+ protected HandleCommandResult handleCommandInternalEx(Command command) {
+ if (command instanceof OnOffType onOffTypeCommand) {
+ if (OnOffType.ON.equals(onOffTypeCommand)) {
+ var targetValue = Optional.of(true);
+ currentValue = targetValue;
+ return HandleCommandResult.accepted(VALUE_ON, valueToState(targetValue));
+ } else if (OnOffType.OFF.equals(onOffTypeCommand)) {
+ var targetValue = Optional.of(false);
+ currentValue = targetValue;
+ return HandleCommandResult.accepted(VALUE_OFF, valueToState(targetValue));
+ }
+ }
+ return HandleCommandResult.rejected();
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/RangeParam.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/RangeParam.java
new file mode 100644
index 0000000000000..5b244e35aa95d
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/RangeParam.java
@@ -0,0 +1,121 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api.protocol.elements;
+
+import java.util.Optional;
+
+import javax.measure.Unit;
+import javax.measure.quantity.Dimensionless;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.argoclima.internal.device.api.protocol.IArgoSettingProvider;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * API element representing an integer in range of allowed values
+ *
+ * @implNote Since ECO power limit is the only value this is used for now, the {@link #UNIT} is hard-coded to percent
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public class RangeParam extends ArgoApiElementBase {
+ private static final Unit UNIT = Units.PERCENT;
+ private final Logger logger = LoggerFactory.getLogger(getClass());
+ private final double minValue;
+ private final double maxValue;
+ private Optional currentValue = Optional.empty();
+
+ /**
+ * C-tor
+ *
+ * @param settingsProvider the settings provider (getting device state as well as schedule configuration)
+ * @param min Minimum settable value
+ * @param max Maximum settable value
+ */
+ public RangeParam(IArgoSettingProvider settingsProvider, double min, double max) {
+ super(settingsProvider);
+ this.minValue = min;
+ this.maxValue = max;
+ }
+
+ private static State valueToState(Optional value) {
+ if (value.isEmpty()) {
+ return UnDefType.UNDEF;
+ }
+ return new QuantityType(value.get(), UNIT);
+ }
+
+ /**
+ * Normalize value to be in range of MIN..MAX
+ *
+ * @implNote Even though min-max ranges are floating-point, this is operating on integers, as currently there's no
+ * use of this class which goes beyond integers
+ * @param newValue The value to normalize (as int)
+ * @return Normalized value
+ */
+ private int normalizeValue(int newValue) {
+ if (newValue < minValue) {
+ logger.debug("Requested value: {} would exceed minimum value: {}. Setting: {}.", newValue, minValue,
+ (int) minValue);
+ return (int) minValue;
+ }
+ if (newValue > maxValue) {
+ logger.debug("Requested value: {} would exceed maximum value: {}. Setting: {}.", newValue, maxValue,
+ (int) maxValue);
+ return (int) maxValue;
+ }
+ return newValue;
+ }
+
+ @Override
+ protected void updateFromApiResponseInternal(String responseValue) {
+ strToInt(responseValue).ifPresent(raw -> {
+ currentValue = Optional.of(raw);
+ });
+ }
+
+ @Override
+ public State toState() {
+ return valueToState(currentValue);
+ }
+
+ @Override
+ public String toString() {
+ if (currentValue.isEmpty()) {
+ return "???";
+ }
+ return currentValue.get().toString();
+ }
+
+ @Override
+ protected HandleCommandResult handleCommandInternalEx(Command command) {
+ if (command instanceof Number numberCommand) {
+ final int newValue = normalizeValue(numberCommand.intValue());
+
+ if (currentValue.map(cv -> (cv.intValue() == newValue)).orElse(false)) {
+ return HandleCommandResult.rejected(); // Current value is the same as requested - nothing to do
+ }
+ this.currentValue = Optional.of(newValue);
+ return HandleCommandResult.accepted(Integer.toString(newValue), valueToState(this.currentValue));
+ }
+
+ return HandleCommandResult.rejected();
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/TemperatureParam.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/TemperatureParam.java
new file mode 100644
index 0000000000000..18c4680d305c5
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/TemperatureParam.java
@@ -0,0 +1,140 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api.protocol.elements;
+
+import java.util.Optional;
+
+import javax.measure.quantity.Temperature;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.argoclima.internal.device.api.protocol.IArgoSettingProvider;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.SIUnits;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+
+/**
+ * The element for controlling/receiving temperature
+ *
+ * Device API always communicates in degrees Celsius, even if the display unit (configurable) is Fahrenheit.
+ *
+ * While the settable temperature seems to be by 0.5 °C (at least this is what the remote API does), the reported temp.
+ * is by 0.1 °C and technically the device accepts setting values with such precision. This is not practiced though, not
+ * to introduce unknown side-effects
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public class TemperatureParam extends ArgoApiElementBase {
+ private final double minValue;
+ private final double maxValue;
+ private final double step;
+ private Optional currentValue = Optional.empty();
+
+ /**
+ * C-tor
+ *
+ * @param settingsProvider the settings provider (getting device state as well as schedule configuration)
+ * @param min Minimum value of this timer (in minutes)
+ * @param max Maximum value of this timer (in minutes)
+ * @param step Minimum step of the timer (values will be rounded to nearest step, increments/decrements will move by
+ * step). Step dictates the resolution of this param
+ */
+ public TemperatureParam(IArgoSettingProvider settingsProvider, double min, double max, double step) {
+ super(settingsProvider);
+ this.minValue = min;
+ this.maxValue = max;
+ this.step = step;
+ }
+
+ /**
+ * Converts the raw value to framework-compatible {@link State} (always in degrees Celsius
+ *
+ * @param value Value to convert
+ * @return Converted value (or empty, on conversion failure)
+ */
+ private static State valueToState(Optional value) {
+ if (value.isEmpty()) {
+ return UnDefType.UNDEF;
+ }
+ return new QuantityType(value.get(), SIUnits.CELSIUS);
+ }
+
+ /**
+ * @see {@link ArgoApiElementBase#adjustRangeWithAmplification}
+ */
+ private double adjustRangeWithAmplification(double newValue) {
+ var normalized = ArgoApiElementBase
+ .adjustRangeWithAmplification(newValue, currentValue, minValue, maxValue, step, " °C").doubleValue();
+ return Math.round(normalized * 10.0) / 10.0; // single-digit precision
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @implNote The raw API uses integers and degrees Celsius. Temperature is multiplied by 10.
+ * @implNote Deliberately not normalizing incoming value (if the device reported it, let's consider it valid, even
+ * if it is out of range!)
+ */
+ @Override
+ protected void updateFromApiResponseInternal(String responseValue) {
+ strToInt(responseValue).ifPresent(raw -> {
+ this.currentValue = Optional.of(raw / 10.0);
+ });
+ }
+
+ @Override
+ public State toState() {
+ return valueToState(currentValue);
+ }
+
+ @Override
+ public String toString() {
+ if (currentValue.isEmpty()) {
+ return "???";
+ }
+ return currentValue.get().toString() + " °C";
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @implNote The raw API uses integers and degrees Celsius. Temperature is multiplied by 10.
+ */
+ @Override
+ protected HandleCommandResult handleCommandInternalEx(Command command) {
+ double newRawValue;
+
+ if (command instanceof Number numberCommand) {
+ newRawValue = numberCommand.doubleValue(); // Raw value, not unit-aware
+
+ if (command instanceof QuantityType> quantityTypeCommand) { // let's try to get it with unit
+ // (opportunistically)
+ var inCelsius = quantityTypeCommand.toUnit(SIUnits.CELSIUS);
+ if (null != inCelsius) {
+ newRawValue = inCelsius.doubleValue();
+ }
+ }
+ } else {
+ return HandleCommandResult.rejected(); // unsupported type of command
+ }
+
+ newRawValue = adjustRangeWithAmplification(newRawValue);
+
+ this.currentValue = Optional.of(newRawValue);
+ // Accept the command
+ return HandleCommandResult.accepted(Integer.toUnsignedString((int) (newRawValue * 10.0)),
+ valueToState(Optional.of(newRawValue)));
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/TimeParam.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/TimeParam.java
new file mode 100644
index 0000000000000..145ec7f9a3283
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/TimeParam.java
@@ -0,0 +1,249 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api.protocol.elements;
+
+import java.time.LocalTime;
+import java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.argoclima.internal.device.api.protocol.ArgoDeviceStatus;
+import org.openhab.binding.argoclima.internal.device.api.protocol.IArgoSettingProvider;
+import org.openhab.binding.argoclima.internal.exception.ArgoConfigurationException;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Time element (accepting values in HH:MM) - eg. for schedule timers on/off
+ *
+ * @see CurrentTimeParam
+ * @see DelayMinutesParam
+ * @implNote These other "time" params could technically be sharing common codebase, though for simplicity sake it was
+ * easier to implement them as unrelated (possible future refactor oppty)
+ *
+ * @implNote This class could use {@link LocalTime} for internal storage, but raw int has been chosen instead to cut on
+ * back and forth conversions, dealing with seconds etc... (and is simple-enough)
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public class TimeParam extends ArgoApiElementBase {
+ /**
+ * Kind of schedule parameter (on or off)
+ */
+ public enum TimeParamType {
+ ON,
+ OFF
+ }
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(TimeParam.class);
+ private static final int MIN_VALUE = 0; // 0:00
+ private static final int MAX_VALUE = 23 * 60 + 59; // 23:59
+ private final TimeParamType paramType;
+ private Optional currentValue = Optional.empty();
+
+ /**
+ * C-tor (allows full range of values: {@code 0:00 <> 25:59})
+ *
+ * @implNote Even though the Argo HVAC supports 3 schedule timers, when sent to a device, there's only one
+ * on/off/weekday option, hence value of this setting changes indirectly (when changing Schedule timer
+ * cycle)
+ * @param settingsProvider the settings provider (getting device state as well as schedule configuration)
+ * @param paramType The kind of parameter (ON or OFF time). This element requires this knowledge to be able to
+ * retrieve default value from settings (based off of currently selected timer value)
+ */
+ public TimeParam(IArgoSettingProvider settingsProvider, TimeParamType paramType) {
+ super(settingsProvider);
+ this.paramType = paramType;
+ }
+
+ /**
+ * Gets the raw time value from hours and minutes (normalized to be in range of [{@link #MIN_VALUE} ,
+ * {@link #MAX_VALUE}]
+ *
+ * @param hour Hour to convert (0..23)
+ * @param minute Minute to convert (0..59)
+ * @return The Argo API raw value for the time
+ */
+ public static int fromHhMm(int hour, int minute) {
+ return normalizeTime(hour * 60 + minute);
+ }
+
+ /**
+ * Converts the raw value to framework-compatible {@link State}
+ *
+ * @implNote While the data is technically TIME, and could be represented as
+ * {@link org.openhab.core.library.types.DateTimeType DateTimeType}, the OH framework doesn't seem to
+ * provide a class for time of day only (w/o Date component).
+ * A next best semantically-correct way of representing this value would be by
+ * {@link org.openhab.core.library.types.QuantityType QuantityType<Time>(..., Units.MINUTE)}}, yet
+ * this displays somewhat weirdly (as it is more suited for duration, not time of day).
+ *
+ * Hence the value is represented as a {@link org.openhab.core.library.types.StringType StringType}, which
+ * makes it display "normally", and is OK for this use case, as these schedule parameters are actually
+ * **NOT** mapped to any channel (and instead sourced from config), thus not causing any awkward usage for
+ * the user
+ *
+ * @param value Value to convert
+ * @return Converted value (or empty, on conversion failure)
+ */
+ private static State valueToState(Optional value) {
+ if (value.isEmpty()) {
+ return UnDefType.UNDEF;
+ }
+ return new StringType(rawValueToHHMMString(value.orElseThrow()));
+ }
+
+ private static int normalizeTime(int newValue) {
+ if (newValue < MIN_VALUE) {
+ LOGGER.debug("Requested value: {} would exceed minimum value: {}. Setting: {}.", newValue, MIN_VALUE,
+ MIN_VALUE);
+ return MIN_VALUE;
+ }
+ if (newValue > MAX_VALUE) {
+ LOGGER.debug("Requested value: {} would exceed maximum value: {}. Setting: {}.", newValue, MAX_VALUE,
+ MAX_VALUE);
+ return MAX_VALUE;
+ }
+ return newValue;
+ }
+
+ private static String rawValueToHHMMString(int rawValue) {
+ int hh = rawValue / 60;
+ int mm = rawValue % 60;
+ return String.format("%02d:%02d", hh, mm);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @implNote The currently used context of this class (on/off schedule time) has WRITE-ONLY elements, hence this
+ * method is unlikely to ever be called
+ */
+ @Override
+ protected void updateFromApiResponseInternal(String responseValue) {
+ strToInt(responseValue).ifPresent(raw -> {
+ this.currentValue = Optional.of(normalizeTime(raw));
+ });
+ }
+
+ @Override
+ public State toState() {
+ return valueToState(currentValue);
+ }
+
+ @Override
+ public String toString() {
+ if (currentValue.isEmpty()) {
+ return "???";
+ }
+ return rawValueToHHMMString(currentValue.get().intValue());
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Timer on/off values are always sent to the device together with other values (as long as there are other updates,
+ * and any schedule timer is currently active)
+ */
+ @Override
+ public boolean isAlwaysSent() {
+ return isScheduleTimerEnabled().isPresent();
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Specialized implementation allowing to get a value from default config provider (if it wansn't set before)
+ * Since the value is write-only and framework's value may be N/A we need to re-fetch it in such case.
+ */
+ @Override
+ public String getDeviceApiValue() {
+ var defaultResult = super.getDeviceApiValue();
+ var activeScheduleTimer = isScheduleTimerEnabled();
+
+ if (!ArgoDeviceStatus.NO_VALUE.equals(defaultResult) || activeScheduleTimer.isEmpty()) {
+ return defaultResult; // There's already a pending command recognized by binding, or schedule timer is off -
+ // we're good to go with the default
+ }
+
+ if (currentValue.isPresent()) {
+ // We have a value, and schedule timer is enabled, so let's send it
+ // Consideration: Only send those as long as the pending command is *schedule timer change*, not *any
+ // change*?... Seems to not be required though so... YAGNI
+ return Integer.toString(currentValue.orElseThrow());
+ }
+
+ // OOPS - We have a schedule timer active already, but no value (and have to provide something). Let's fetch it
+ // from the configuration
+ var timerId = activeScheduleTimer.orElseThrow();
+
+ try {
+ LocalTime configuredValue;
+ if (paramType == TimeParamType.ON) {
+ configuredValue = settingsProvider.getScheduleProvider().getScheduleOnTime(timerId);
+ } else {
+ configuredValue = settingsProvider.getScheduleProvider().getScheduleOffTime(timerId);
+ }
+ // let's initialize our value from the config's one (lazily)
+ currentValue = Optional.of(fromHhMm(configuredValue.getHour(), configuredValue.getMinute()));
+ return Integer.toString(currentValue.orElseThrow());
+ } catch (ArgoConfigurationException e) {
+ LOGGER.debug("Retrieving default configured value for {} timer failed. Error: {}", paramType,
+ e.getMessage());
+ return defaultResult;
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Gets the local time value from Numbers as well as HH:MM string representation.
+ *
+ * @see #valueToState
+ */
+ @Override
+ protected HandleCommandResult handleCommandInternalEx(Command command) {
+ int newRawValue;
+
+ if (command instanceof Number numberCommand) {
+ newRawValue = numberCommand.intValue(); // Raw value, not unit-aware
+
+ if (command instanceof QuantityType> quantityTypeCommand) { // let's try to get it with unit
+ // (opportunistically)
+ var inMinutes = quantityTypeCommand.toUnit(Units.MINUTE);
+ if (null != inMinutes) {
+ newRawValue = inMinutes.intValue();
+ }
+ }
+ } else if (command instanceof StringType stringTypeCommand) {
+ var asTime = LocalTime.parse(stringTypeCommand.toFullString());
+ newRawValue = fromHhMm(asTime.getHour(), asTime.getMinute());
+ } else {
+ return HandleCommandResult.rejected(); // unsupported type of command
+ }
+
+ newRawValue = normalizeTime(newRawValue);
+
+ // Not checking if current value is the same as requested (this is a send-always value, so no real need)
+ this.currentValue = Optional.of(newRawValue);
+ // Accept the command (and if it was sent when no timer was active, make it deferred)
+ return HandleCommandResult.accepted(Integer.toString(newRawValue), valueToState(Optional.of(newRawValue)))
+ .setDeferred(isScheduleTimerEnabled().isEmpty());
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/WeekdayParam.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/WeekdayParam.java
new file mode 100644
index 0000000000000..4e06ea27a7846
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/protocol/elements/WeekdayParam.java
@@ -0,0 +1,217 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api.protocol.elements;
+
+import java.util.EnumSet;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.argoclima.internal.device.api.protocol.ArgoDeviceStatus;
+import org.openhab.binding.argoclima.internal.device.api.protocol.IArgoSettingProvider;
+import org.openhab.binding.argoclima.internal.device.api.types.Weekday;
+import org.openhab.binding.argoclima.internal.exception.ArgoConfigurationException;
+import org.openhab.binding.argoclima.internal.utils.StringUtils;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Weekdays element (accepting sets of days for schedule to run on)
+ *
+ * @see TimeParam
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public class WeekdayParam extends ArgoApiElementBase {
+ private final Logger logger = LoggerFactory.getLogger(getClass());
+ private Optional> currentValue = Optional.empty();
+
+ /**
+ * C-tor
+ *
+ * @param settingsProvider the settings provider (getting device state as well as schedule configuration)
+ */
+ public WeekdayParam(IArgoSettingProvider settingsProvider) {
+ super(settingsProvider);
+ }
+
+ /**
+ * Converts the internal {@code EnumSet}-based storage to raw ARGO API value ("flags enum" - represented as
+ * int/bitmap)
+ *
+ * @param values The set of days to convert
+ * @implNote This impl. assumes all the values are in range of the underlying enum type (no craziness such as
+ * casting 1000 to Weekday)
+ * @return Int representation of the set of weekdays
+ */
+ public static int toRawValue(EnumSet values) {
+ int ret = 0;
+ for (Weekday val : values) {
+ ret |= val.getIntValue();
+ }
+ return ret;
+ }
+
+ /**
+ * Unpacks the Argo API integer with flags-packed weekdays into EnumSet
+ *
+ * @implNote This is not checking if the int value is not having values outside of the Enum values (these will be
+ * silently skipped on conversion!). Could do a bitmask-based sanity check, but... Occam's Razor ;)
+ *
+ * @param value The raw value to convert
+ * @return Unpacked value
+ */
+ public static EnumSet fromRawValue(int value) {
+ EnumSet ret = EnumSet.noneOf(Weekday.class);
+ for (Weekday val : EnumSet.allOf(Weekday.class)) {
+ if ((val.getIntValue() & value) != 0) {
+ ret.add(val);
+ }
+ }
+ return ret;
+ }
+
+ /**
+ * Converts the raw value to framework-compatible {@link State}
+ *
+ * @implNote While the raw data is technically an integer, and could be represented as
+ * {@link org.openhab.core.library.types.DecimalType DecimalType}, a {@code String} was chosen for better
+ * readability
+ *
+ * This parameter is actually **NOT** mapped to any channel (and instead sourced from config), thus not
+ * causing any awkward usage for the user
+ *
+ * @param value Value to convert
+ * @return Converted value (or empty, on conversion failure)
+ */
+ private static State valueToState(Optional> value) {
+ if (value.isEmpty()) {
+ return UnDefType.UNDEF;
+ }
+ return new StringType(value.orElseThrow().toString());
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @implNote The currently used context of this class (schedule timer) has WRITE-ONLY elements, hence this
+ * method is unlikely to ever be called
+ */
+ @Override
+ protected void updateFromApiResponseInternal(String responseValue) {
+ strToInt(responseValue).ifPresent(raw -> {
+ this.currentValue = Optional.of(fromRawValue(raw));
+ });
+ }
+
+ @Override
+ public State toState() {
+ return valueToState(currentValue);
+ }
+
+ @Override
+ public String toString() {
+ if (currentValue.isEmpty()) {
+ return "???";
+ }
+ return Objects.requireNonNull(currentValue.orElseThrow().toString());
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Timer weekday values are always sent to the device together with other values (as long as there are other
+ * updates,
+ * and any schedule timer is currently active)
+ */
+ @Override
+ public boolean isAlwaysSent() {
+ return isScheduleTimerEnabled().isPresent();
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Specialized implementation allowing to get a value from default config provider (if it wansn't set before)
+ * Since the value is write-only and framework's value may be N/A we need to re-fetch it in such case.
+ */
+ @Override
+ public String getDeviceApiValue() {
+ var defaultResult = super.getDeviceApiValue();
+ var activeScheduleTimer = isScheduleTimerEnabled();
+
+ if (!ArgoDeviceStatus.NO_VALUE.equals(defaultResult) || activeScheduleTimer.isEmpty()) {
+ return defaultResult; // There's already a pending command recognized by binding, or schedule timer is off -
+ // we're good to go with the default
+ }
+
+ if (currentValue.isPresent()) {
+ // We have a value, and schedule timer is enabled, so let's send it
+ // Consideration: Only send those as long as the pending command is *schedule timer change*, not *any
+ // change*?... Seems to not be required though so... YAGNI
+ return Integer.toString(toRawValue(currentValue.get()));
+ }
+
+ // OOPS - We have a schedule timer active already, but no value (and have to provide something). Let's fetch it
+ // from the configuration
+ var timerId = activeScheduleTimer.orElseThrow();
+
+ try {
+ EnumSet configuredValue = settingsProvider.getScheduleProvider().getScheduleDayOfWeek(timerId);
+
+ // let's initialize our value from the config's one (lazily)
+ currentValue = Optional.of(configuredValue);
+ return Integer.toString(toRawValue(currentValue.get()));
+ } catch (ArgoConfigurationException e) {
+ logger.debug("Retrieving configured weekdays value for timer failed. Error: {}", e.getMessage());
+ return defaultResult;
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Gets the local time value from Numbers as well as comma-separated String representation such as
+ * {@code [SUN, MON, TUE, WED, THU, FRI, SAT]}
+ *
+ * @see #valueToState
+ */
+ @Override
+ protected HandleCommandResult handleCommandInternalEx(Command command) {
+ EnumSet newValue;
+
+ if (command instanceof Number numberCommand) {
+ var rawValue = numberCommand.intValue();
+ newValue = fromRawValue(rawValue);
+ } else if (command instanceof StringType stringTypeCommand) {
+ var toParse = StringUtils.strip(stringTypeCommand.toFullString(), "[]{}()");
+ EnumSet parsed = EnumSet.noneOf(Weekday.class);
+ for (String s : toParse.split(",")) {
+ parsed.add(Weekday.valueOf(s.strip()));
+ }
+ newValue = parsed;
+ } else {
+ return HandleCommandResult.rejected(); // unsupported type of command
+ }
+
+ // Not checking if current value is the same as requested (this is a send-always value, so no real need)
+ this.currentValue = Optional.of(newValue);
+ // Accept the command (and if it was sent when no timer was active, make it deferred)
+ return HandleCommandResult.accepted(Integer.toString(toRawValue(newValue)), valueToState(Optional.of(newValue)))
+ .setDeferred(isScheduleTimerEnabled().isEmpty());
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/types/ArgoDeviceSettingType.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/types/ArgoDeviceSettingType.java
new file mode 100644
index 0000000000000..77eceb4b0e7ae
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/types/ArgoDeviceSettingType.java
@@ -0,0 +1,47 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api.types;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Type representing the concrete Argo API element knob
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public enum ArgoDeviceSettingType {
+ TARGET_TEMPERATURE,
+ ACTUAL_TEMPERATURE,
+ POWER,
+ MODE,
+ FAN_LEVEL,
+ FLAP_LEVEL,
+ I_FEEL_TEMPERATURE,
+ FILTER_MODE,
+ ECO_MODE,
+ TURBO_MODE,
+ NIGHT_MODE,
+ LIGHT,
+ ECO_POWER_LIMIT,
+ RESET_TO_FACTORY_SETTINGS,
+ UNIT_FIRMWARE_VERSION,
+ DISPLAY_TEMPERATURE_SCALE,
+ CURRENT_TIME,
+ CURRENT_DAY_OF_WEEK,
+ ACTIVE_TIMER,
+ TIMER_0_DELAY_TIME,
+ TIMER_N_ENABLED_DAYS,
+ TIMER_N_ON_TIME,
+ TIMER_N_OFF_TIME
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/types/FanLevel.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/types/FanLevel.java
new file mode 100644
index 0000000000000..1b6235f9b5a57
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/types/FanLevel.java
@@ -0,0 +1,42 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api.types;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Type representing Argo HVAC Fan levels (int values are matching device's API)
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public enum FanLevel implements IArgoApiEnum {
+ AUTO(0),
+ LEVEL_1(1),
+ LEVEL_2(2),
+ LEVEL_3(3),
+ LEVEL_4(4),
+ LEVEL_5(5),
+ LEVEL_6(6);
+
+ private int value;
+
+ FanLevel(int intValue) {
+ this.value = intValue;
+ }
+
+ @Override
+ public int getIntValue() {
+ return this.value;
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/types/FlapLevel.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/types/FlapLevel.java
new file mode 100644
index 0000000000000..7b1628340c9ce
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/types/FlapLevel.java
@@ -0,0 +1,45 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api.types;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Type representing Argo devices flap setting (swing). Int values are matching device's API.
+ *
+ * Note: While this is supported by Argo remote protocol, the Ulisse device doesn't react to these settings
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public enum FlapLevel implements IArgoApiEnum {
+ AUTO(0),
+ LEVEL_1(1),
+ LEVEL_2(2),
+ LEVEL_3(3),
+ LEVEL_4(4),
+ LEVEL_5(5),
+ LEVEL_6(6),
+ LEVEL_7(7);
+
+ private int value;
+
+ FlapLevel(int intValue) {
+ this.value = intValue;
+ }
+
+ @Override
+ public int getIntValue() {
+ return this.value;
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/types/IArgoApiEnum.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/types/IArgoApiEnum.java
new file mode 100644
index 0000000000000..b969f6a0bfef6
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/types/IArgoApiEnum.java
@@ -0,0 +1,25 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api.types;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Enum extension interface, providing raw integer value which is value's representation in the Argo protocol
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public interface IArgoApiEnum {
+ public int getIntValue();
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/types/OperationMode.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/types/OperationMode.java
new file mode 100644
index 0000000000000..15f2ce32622e2
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/types/OperationMode.java
@@ -0,0 +1,40 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api.types;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Type representing Argo HVAC operation mode (int values are matching device's API)
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public enum OperationMode implements IArgoApiEnum {
+ COOL(1),
+ DRY(2),
+ WARM(3),
+ FAN(4),
+ AUTO(5);
+
+ private int value;
+
+ OperationMode(int intValue) {
+ this.value = intValue;
+ }
+
+ @Override
+ public int getIntValue() {
+ return this.value;
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/types/TemperatureScale.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/types/TemperatureScale.java
new file mode 100644
index 0000000000000..cd48151b70e50
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/types/TemperatureScale.java
@@ -0,0 +1,38 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api.types;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Type representing Argo HVAC displayed temperature scale (int values are matching device's API)
+ *
+ * @implNote This setting does not influence API (always in Celsius)
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public enum TemperatureScale implements IArgoApiEnum {
+ SCALE_CELSIUS(0),
+ SCALE_FARHENHEIT(1);
+
+ private int value;
+
+ TemperatureScale(int intValue) {
+ this.value = intValue;
+ }
+
+ @Override
+ public int getIntValue() {
+ return this.value;
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/types/TimerType.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/types/TimerType.java
new file mode 100644
index 0000000000000..4a3d7d99e15de
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/types/TimerType.java
@@ -0,0 +1,89 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api.types;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.argoclima.internal.configuration.IScheduleConfigurationProvider.ScheduleTimerType;
+
+/**
+ * Type representing Argo currently selected timer. Int values are matching device's API.
+ *
+ * The device supports a "delay" timer + 3 configurable "schedule" timers. All the schedule timers share the same API
+ * fields for configuring days of week when they are active as well as start/stop time
+ *
+ * @see ScheduleTimerType - for schedule-specific enum (used in binding configuration)
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public enum TimerType implements IArgoApiEnum {
+ NO_TIMER(0),
+ DELAY_TIMER(1),
+ SCHEDULE_TIMER_1(2),
+ SCHEDULE_TIMER_2(3),
+ SCHEDULE_TIMER_3(4);
+
+ private int value;
+
+ TimerType(int intValue) {
+ this.value = intValue;
+ }
+
+ @Override
+ public int getIntValue() {
+ return this.value;
+ }
+
+ /**
+ * Converts to {@link ScheduleTimerType}
+ *
+ * @implNote This function will throw, if passed a non-schedule-timer type. Not using optional response, given its
+ * simple usage and extra boilerplate it would do. Needs care when being used though!
+ * @param val Value to convert
+ * @return Converted value
+ * @throws IllegalArgumentException - on passing a timer which is not one of schedule timers
+ */
+ public static ScheduleTimerType toScheduleTimerType(TimerType val) {
+ switch (val) {
+ case SCHEDULE_TIMER_1:
+ return ScheduleTimerType.SCHEDULE_1;
+ case SCHEDULE_TIMER_2:
+ return ScheduleTimerType.SCHEDULE_2;
+ case SCHEDULE_TIMER_3:
+ return ScheduleTimerType.SCHEDULE_3;
+ default:
+ throw new IllegalArgumentException(
+ String.format("Unable to convert TimerType: %s to ScheduleTimerType", val));
+ }
+ }
+
+ /**
+ * Converts from {@link ScheduleTimerType}
+ *
+ * @param val Value to convert
+ * @return Converted value
+ * @throws IllegalArgumentException - on passing an out-of-range enum (extremely unlikely!)
+ */
+ public static TimerType fromScheduleTimerType(ScheduleTimerType val) {
+ switch (val) {
+ case SCHEDULE_1:
+ return TimerType.SCHEDULE_TIMER_1;
+ case SCHEDULE_2:
+ return TimerType.SCHEDULE_TIMER_2;
+ case SCHEDULE_3:
+ return TimerType.SCHEDULE_TIMER_3;
+ default:
+ throw new IllegalArgumentException(
+ String.format("Unable to convert ScheduleTimerType: %s to TimerType", val));
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/types/Weekday.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/types/Weekday.java
new file mode 100644
index 0000000000000..f39976e42dd6b
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/api/types/Weekday.java
@@ -0,0 +1,75 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.api.types;
+
+import java.time.DayOfWeek;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Custom Day of Week class implementation (with integer values matching Argo API) and support of stacking into
+ * EnumSet (flags-like)
+ *
+ * @implNote Ordering is important! The ordinal values start from 0 (0-SUN, 1-MON, ...) and are also used - for
+ * {@link org.openhab.binding.argoclima.internal.device.api.protocol.elements.CurrentWeekdayParam}
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public enum Weekday implements IArgoApiEnum {
+ SUN(0x01), // ordinal: 0
+ MON(0x02), // ordinal: 1
+ TUE(0x04), // ordinal: 2
+ WED(0x08), // ordinal: 3
+ THU(0x10), // ordinal: 4
+ FRI(0x20), // ordinal: 5
+ SAT(0x40); // ordinal: 6
+
+ private int value;
+
+ Weekday(int intValue) {
+ this.value = intValue;
+ }
+
+ @Override
+ public int getIntValue() {
+ return this.value;
+ }
+
+ /**
+ * Maps {@link java.time.DayOfWeek java.time.DayOfWeek} to Argo API custom enum ({@link Weekday})
+ *
+ * @param d The DayOfWeek to convert
+ * @return Argo-compatible Weekday for {@code d}
+ */
+ public static Weekday ofDay(DayOfWeek d) {
+ switch (d) {
+ case SUNDAY:
+ return Weekday.SUN;
+ case MONDAY:
+ return Weekday.MON;
+ case TUESDAY:
+ return Weekday.TUE;
+ case WEDNESDAY:
+ return Weekday.WED;
+ case THURSDAY:
+ return Weekday.THU;
+ case FRIDAY:
+ return Weekday.FRI;
+ case SATURDAY:
+ return Weekday.SAT;
+ default:
+ throw new IllegalArgumentException("Invalid day of week");
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/passthrough/PassthroughHttpClient.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/passthrough/PassthroughHttpClient.java
new file mode 100644
index 0000000000000..a1fcbcb907f54
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/passthrough/PassthroughHttpClient.java
@@ -0,0 +1,181 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.passthrough;
+
+import static org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants.BINDING_ID;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import javax.servlet.http.HttpServletResponse;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.client.util.StringContentProvider;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.util.HttpCookieStore;
+import org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * HTTP client, forwarding (proxy-like) original device's request (downstream) to a remote server
+ * (upstream) and passing the response through back to the device (with ability to intercept
+ * content and change it - MitM)
+ *
+ * @implNote The HTTP client is custom (as it needs to simulate the actual Argo device, with all its quirks), hence
+ * using separate instance and threadpool for it.
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public class PassthroughHttpClient {
+ private static final Logger LOGGER = LoggerFactory.getLogger(PassthroughHttpClient.class);
+ private static final String RPC_POOL_NAME = BINDING_ID + "_apiProxy";
+ private static final List HEADERS_TO_IGNORE = List.of("content-length", "content-type", "content-encoding",
+ "host", "accept-encoding");
+ private final HttpClient rawHttpClient;
+ private boolean isStarted = false;
+
+ /** The hostname of OEM vendor's (upstream) server to talk to */
+ public final String upstreamTargetHost;
+
+ /** The port of OEM vendor's (upstream) server to talk to */
+ public final int upstreamTargetPort;
+
+ /**
+ * C-tor, creates new HTTP client (requires starting!)
+ *
+ * @param upstreamIpAddress The hostname of OEM vendor's (upstream) server to talk to
+ * @param upstreamPort The port of OEM vendor's (upstream) server to talk to
+ * @param clientFactory Framework-provided factory for creating new Jetty's HTTP clients
+ */
+ public PassthroughHttpClient(String upstreamIpAddress, int upstreamPort, HttpClientFactory clientFactory) {
+ // Impl. note: Using Openhab's (globally-configurable) settings for custom threadpool. We technically may need
+ // less threads (and longer TTL) here... but not fiddling with thread pool settings post-creation, to avoid
+ // corner cases
+ this.rawHttpClient = clientFactory.createHttpClient(RPC_POOL_NAME);
+
+ this.rawHttpClient.setFollowRedirects(false);
+ this.rawHttpClient.setUserAgentField(null); // The device doesn't set it, and we want to be a transparent proxy
+ this.rawHttpClient.setCookieStore(new HttpCookieStore.Empty());
+
+ this.rawHttpClient.setRequestBufferSize(1024);
+ this.rawHttpClient.setResponseBufferSize(1024);
+
+ this.upstreamTargetHost = upstreamIpAddress;
+ this.upstreamTargetPort = upstreamPort;
+ }
+
+ /**
+ * Start pass-through HTTP client (simulating the device).
+ *
+ * @throws Exception In case of startup failure
+ */
+ public synchronized void start() throws Exception {
+ if (this.isStarted) {
+ stop();
+ }
+ this.rawHttpClient.start();
+ this.rawHttpClient.getContentDecoderFactories().clear(); // Prevent decoding gzip (device doesn't support it).
+ // Stops sending Accept header
+ this.isStarted = true;
+ }
+
+ /**
+ * Stops the pass-through HTTP client
+ *
+ * @throws Exception In case of stop failure
+ */
+ public synchronized void stop() throws Exception {
+ this.rawHttpClient.stop();
+ this.rawHttpClient.destroy();
+ this.isStarted = false;
+ }
+
+ /**
+ * Pass the downstream HTTP request through to upstream server (as-is)
+ *
+ * @param downstreamHttpRequest The device-side request to pass on
+ * @param downstreamHttpRequestBody The body of the request (provided separately, because the stream has been read
+ * already, as it is also used for sniffing)
+ * @return The response from remote side
+ * @throws InterruptedException if send thread is interrupted
+ * @throws TimeoutException if send times out
+ * @throws ExecutionException if execution fails
+ */
+ public ContentResponse passthroughRequest(Request downstreamHttpRequest, String downstreamHttpRequestBody)
+ throws InterruptedException, TimeoutException, ExecutionException {
+ var request = this.rawHttpClient.newRequest(this.upstreamTargetHost, this.upstreamTargetPort)
+ .method(downstreamHttpRequest.getMethod()).path(downstreamHttpRequest.getOriginalURI())
+ .version(downstreamHttpRequest.getHttpVersion())
+ .content(new StringContentProvider(downstreamHttpRequestBody))
+ .timeout(ArgoClimaBindingConstants.UPSTREAM_PROXY_HTTP_REQUEST_TIMEOUT.toMillis(),
+ TimeUnit.MILLISECONDS);
+
+ // re-add headers from downstream request to this one (except explicitly-ignored list)
+ for (var headerName : Collections.list(downstreamHttpRequest.getHeaderNames())) {
+ if (HEADERS_TO_IGNORE.stream().noneMatch(x -> x.equalsIgnoreCase(headerName))) {
+ request.header(headerName, downstreamHttpRequest.getHeader(headerName));
+ }
+ }
+
+ LOGGER.trace("Pass-through: DEVICE --> UPSTREAM_API: [{} {}], body=[{}]", request.getMethod(), request.getURI(),
+ downstreamHttpRequestBody);
+
+ return Objects.requireNonNull(request.send());
+ }
+
+ /**
+ * Forward upstream server's response back to the device-side (possibly overriding the body)
+ *
+ * @param response The response received from remote side (vendor's server)
+ * @param targetResponse The response to send to the device side (from this interceptor)
+ * @param overrideBodyToReturn If provided, replace the response body from upstream with THIS content (useful when
+ * communicating with the device indirectly, and want to "send" it a command (send = let it pool for it
+ * on its own)
+ * @throws IOException If response writing fails
+ */
+ public static void forwardUpstreamResponse(ContentResponse response, HttpServletResponse targetResponse,
+ Optional overrideBodyToReturn) throws IOException {
+ targetResponse.setContentType(Objects.requireNonNullElse(response.getMediaType(), "text/html"));
+
+ // NOTE: Argo servers send responses **without** charset, whereas Jetty's default includes it.
+ // The device seems to be fine w/ it, note though it is a difference in the protocol
+ // Merely setting the Encoding to null or overriding the header to MimeTypes.getContentTypeWithoutCharset(x)
+ // has no-effect as Jetty overrides it at writer creation. Would require more sophisticated filtering
+ // and possibly subclassing org.eclipse.jetty.server.Response to get 1:1 matching w/ remote response, so leaving
+ // as-is.
+ targetResponse.setCharacterEncoding(Objects.requireNonNullElse(response.getEncoding(), "ASCII"));
+
+ for (var header : response.getHeaders()) {
+ if (HEADERS_TO_IGNORE.stream().noneMatch(x -> x.equalsIgnoreCase(header.getName()))) {
+ targetResponse.setHeader(Objects.requireNonNull(header.getName()), header.getValue());
+ }
+ }
+
+ String responseBodyToReturn = overrideBodyToReturn.orElse(response.getContentAsString());
+ targetResponse.getWriter().write(responseBodyToReturn);
+ targetResponse.setStatus(response.getStatus());
+ LOGGER.trace(" [response]: DEVICE <-- UPSTREAM_API: [{} {} {} - {} bytes], body=[{}]", response.getVersion(),
+ response.getStatus(), response.getReason(), response.getContent().length, responseBodyToReturn);
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/passthrough/RemoteArgoApiServerStub.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/passthrough/RemoteArgoApiServerStub.java
new file mode 100644
index 0000000000000..ce3adcbd489fd
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/passthrough/RemoteArgoApiServerStub.java
@@ -0,0 +1,584 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.passthrough;
+
+import static org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants.BINDING_ID;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+import java.util.stream.Collectors;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.server.Connector;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.server.handler.AbstractHandler;
+import org.eclipse.jetty.util.UrlEncoded;
+import org.eclipse.jetty.util.thread.QueuedThreadPool;
+import org.openhab.binding.argoclima.internal.ArgoClimaTranslationProvider;
+import org.openhab.binding.argoclima.internal.configuration.ArgoClimaConfigurationLocal.DeviceSidePasswordDisplayMode;
+import org.openhab.binding.argoclima.internal.device.api.ArgoClimaLocalDevice;
+import org.openhab.binding.argoclima.internal.device.passthrough.requests.DeviceSidePostRtUpdateDTO;
+import org.openhab.binding.argoclima.internal.device.passthrough.requests.DeviceSideUpdateDTO;
+import org.openhab.binding.argoclima.internal.device.passthrough.responses.RemoteGetUiFlgResponseDTO;
+import org.openhab.binding.argoclima.internal.device.passthrough.responses.RemoteGetUiFlgResponseDTO.UiFlgResponseCommmands;
+import org.openhab.binding.argoclima.internal.exception.ArgoApiCommunicationException;
+import org.openhab.binding.argoclima.internal.exception.ArgoRemoteServerStubStartupException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Implements a stub HTTP server which simulates Argo remote APIs
+ * When used, may alleviate the need for device-side polling
+ *
+ * Can work in both full simulation (serving local responses) as well as a pass-through, relaying the traffic back to
+ * OEM's server, while sniffing it and - optionally - intercepting (ex. to inject a pending command)
+ *
+ * Use of this mode requires firewall/routing configuration in such a way that HVAC-originated requests
+ * targeting Argo remote server are instead targeted at OpenHAB instance!
+ *
+ * IMPORTANT: Argo HVAC, even when functioning in full-local mode (controlled directly, via local IP), **requires**
+ * connection to a "remote" server, and will drop Wi-Fi connection if it doesn't receive a valid protocolar response.
+ * Hence in order to isolate HVAC from OEM's server, having a simulated/stubbed local API server is required even for
+ * using the local APIs only
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public class RemoteArgoApiServerStub {
+ /////////////
+ // TYPES
+ /////////////
+ /**
+ * The type of API request as sent by the device
+ *
+ * @implNote The values come from reverse-engineering the communication and base on guesswork (may not be 100%
+ * correct)
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+ public enum DeviceRequestType {
+ /** Purpose unknown */
+ GET_UI_ACN,
+
+ /** Get current time from server */
+ GET_UI_NTP,
+
+ /** Submit current status (in GET param) and get latest command from remote-side */
+ GET_UI_FLG,
+
+ /** UI Update? - NOTE: Not known when the device sends this... */
+ GET_UI_UPD,
+
+ /** Wi-Fi firmware update request */
+ GET_OU_FW,
+
+ /** Unit firmware update request */
+ GET_UI_FW,
+
+ /** Confirm server-side request is fulfilled, respond with extended status */
+ POST_UI_RT,
+
+ /** Unrecognized command type */
+ UNKNOWN
+ }
+
+ /**
+ * HTTP request handler, receiving the device-side requests and reacting to them
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+ public class ArgoDeviceRequestHandler extends AbstractHandler {
+ private final DeviceSidePasswordDisplayMode includeDeviceSidePasswordsInProperties;
+
+ /**
+ * C-tor
+ *
+ * @param includeDeviceSidePasswordsInProperties Whether to include the device-sent passwords as thing
+ * properties and how (masked vs. cleartext). Note this affects OH display side only. Plain passwords
+ * are ALWAYS sent to Argo as-is in a passthrough mode!)
+ */
+ public ArgoDeviceRequestHandler(DeviceSidePasswordDisplayMode includeDeviceSidePasswordsInProperties) {
+ this.includeDeviceSidePasswordsInProperties = includeDeviceSidePasswordsInProperties;
+ }
+
+ /**
+ * Handle the intercepted request
+ *
+ * {@inheritDoc}
+ */
+ @Override
+ public void handle(@Nullable String target, @Nullable Request baseRequest, @Nullable HttpServletRequest request,
+ @Nullable HttpServletResponse response) throws IOException, ServletException {
+ Objects.requireNonNull(target);
+ Objects.requireNonNull(baseRequest);
+ Objects.requireNonNull(request);
+ Objects.requireNonNull(response);
+
+ var body = getRequestBodyAsString(baseRequest);
+ var requestType = detectRequestType(request, body);
+
+ // Stage1: Use the sniffed response to update internal state
+ switch (requestType) {
+ case GET_UI_FLG:
+ var updateDto = DeviceSideUpdateDTO.fromDeviceRequest(request,
+ this.includeDeviceSidePasswordsInProperties);
+ logger.trace("Got device-side update: {}", updateDto);
+ deviceApi.ifPresent(x -> {
+ try {
+ x.updateDeviceStateFromPushRequest(updateDto);
+ } catch (ArgoApiCommunicationException e) {
+ logger.trace(
+ "Received a GET UI_FLG message from Argo device, but it wasn't a valid protocolar message. Ignoring...");
+ }
+ }); // Use for new update
+ break;
+ case POST_UI_RT:
+ var postRtDto = DeviceSidePostRtUpdateDTO.fromDeviceRequestBody(body);
+ logger.trace("Got device-side POST: {}", postRtDto);
+ deviceApi.ifPresent(x -> x.updateDeviceStateFromPostRtRequest(postRtDto)); // Use for new update
+ break;
+ case GET_UI_NTP:
+ case UNKNOWN:
+ default:
+ break; // other device-side polls do not bring valuable information to update status with
+ }
+
+ // Stage2A: If in pass-through mode, get and forward the upstream response (with possible post-process)
+ if (passthroughClient.isPresent()) {
+ if (requestType.equals(DeviceRequestType.UNKNOWN)) {
+ logger.trace(
+ "The request received by the Argo server stub has unknown syntax. Not forwarding it to upstream server as a precaution");
+ // fall-through to default (canned) response
+ } else {
+ Optional upstreamResponse = Optional.empty();
+ try {
+ // CONSIDER: This implementation does NOT do any request pre-processing (ex. scrambling Wi-Fi
+ // password Argo has no need of knowing). It may be a nice enhancement in the future
+ upstreamResponse = Optional.of(passthroughClient.get().passthroughRequest(baseRequest, body));
+ } catch (InterruptedException | TimeoutException | ExecutionException e) {
+ // Deliberately not handling the upstream request exception here and allowing to fall-through to
+ // a "response faking" logic
+ logger.debug("Passthrough client fail: {}", e.getMessage());
+ }
+
+ if (upstreamResponse.isPresent()) { // On upstream request failure, fall back to stubbed response
+ var overridenBody = postProcessUpstreamResponse(requestType, upstreamResponse.get(), deviceApi);
+ PassthroughHttpClient.forwardUpstreamResponse(upstreamResponse.get(), response,
+ Optional.of(overridenBody));
+ baseRequest.setHandled(true);
+ return;
+ }
+ }
+ }
+
+ // Stage2B: In stub mode, serve a canned response to make the device happy
+ // The values used are there to simulate the actual server
+ response.setContentType("text/html");
+ response.setCharacterEncoding("ASCII");
+ response.setHeader("Server", "Microsoft-IIS/8.5"); // overrides Jetty's default (can be disabled by
+ // setSendServerVersion(false))
+ response.setHeader("Content-type", "text/html");
+ response.setHeader("X-Powered-By", "PHP/5.4.11");
+ response.setHeader("Access-Control-Allow-Origin", "*");
+ response.setStatus(HttpServletResponse.SC_OK);
+ baseRequest.setHandled(true);
+
+ if (baseRequest.getOriginalURI().contains("UI_NTP")) { // a little more lax parsing than request type (just
+ // in case of syntax variances)
+ response.getWriter().println(getNtpResponse(Instant.now()));
+ } else if (deviceApi.isPresent() && DeviceRequestType.GET_UI_FLG.equals(requestType)) {
+ // handle GET_UI_FLG always feeding "our" status
+ response.getWriter().println(createSyntheticGetUiFlgResponse(deviceApi.orElseThrow()));
+ } else {
+ // all other commands are NOT handled
+ response.getWriter().println(getFakeResponse(requestType)); // Reply with this canned text to ALL other
+ // device requests (it doesn't seem to care
+ // :))
+ }
+ }
+ }
+
+ /////////////
+ // FIELDS
+ /////////////
+ private static final String RPC_POOL_NAME = "OH-jetty-" + BINDING_ID + "_serverStub"; // For the new server's
+ // threadpool
+ private static final String REMOTE_SERVER_PATH = "/UI/UI.php";
+
+ private final Logger logger = LoggerFactory.getLogger(getClass());
+ private final Set listenIpAddresses;
+ private final int listenPort;
+ private final String id;
+ private final DeviceSidePasswordDisplayMode includeDeviceSidePasswordsInProperties;
+ private final ArgoClimaTranslationProvider i18nProvider;
+ private final Optional deviceApi;
+ private Optional server = Optional.empty();
+ private Optional passthroughClient = Optional.empty();
+
+ /**
+ * C-tor
+ *
+ * @param listenIpAddresses The set of IP addresses the server should listen at (one
+ * {@link org.eclipse.jetty.server.ServerConnector connector} will be created per each)
+ * @param listenPort The port all connectors should listen on
+ * @param thingUid The UID of the Thing owning this server (used for logging)
+ * @param passthroughClient Optional upstream service HTTP client - in stopped state (if provided, will be used for
+ * pass-through)
+ * @param deviceApi The current device API state tracked by the binding (used to update state from intercepted
+ * responses, and injecting commands)
+ * @param includeDeviceSidePasswordsInProperties Whether to include the device-sent passwords as thing properties
+ * and how (masked vs. cleartext). Note this does NOT prevent sending these values to Argo servers in a
+ * pass-through mode (not a remote security feature!)
+ * @param i18nProvider Framework's translation provider
+ */
+ public RemoteArgoApiServerStub(Set listenIpAddresses, int listenPort, String thingUid,
+ Optional passthroughClient, Optional deviceApi,
+ DeviceSidePasswordDisplayMode includeDeviceSidePasswordsInProperties,
+ ArgoClimaTranslationProvider i18nProvider) {
+ this.listenIpAddresses = listenIpAddresses;
+ this.listenPort = listenPort;
+ this.id = thingUid;
+ this.passthroughClient = passthroughClient;
+ this.deviceApi = deviceApi;
+ this.includeDeviceSidePasswordsInProperties = includeDeviceSidePasswordsInProperties;
+ this.i18nProvider = i18nProvider;
+ }
+
+ /**
+ * Start the stub server (and upstream API client, if used)
+ *
+ * @throws ArgoRemoteServerStubStartupException on startup failure (of either the server or the client)
+ */
+ public synchronized void start() throws ArgoRemoteServerStubStartupException {
+ // High log level is deliberate (it's no small feat to open a new HTTP socket!)
+ logger.info("[{}] Starting Argo API Stub listening at: {}", this.id,
+ this.listenIpAddresses.stream().map(x -> String.format("%s:%s", x.toString(), this.listenPort))
+ .collect(Collectors.joining(", ", "[", "]")));
+
+ try {
+ startJettyServer();
+ } catch (Exception e) {
+ server.ifPresent(s -> {
+ // Cleaning up after ourselves async (as the server may have multiple connectors open and take some time
+ // to stop, actually)
+ s.setStopTimeout(1000L);
+ try {
+ new Thread() {
+ @Override
+ public void run() {
+ try {
+ s.stop();
+ } catch (Exception stopException) {
+ logger.debug(
+ "Server startup has failed and subsequent stop has failed as well... Error: {}",
+ stopException.getMessage());
+ }
+ }
+ }.start();
+ } catch (Exception stopThreadStartException) {
+ logger.debug("Server startup has failed and subsequent stop has failed as well... Error: {}",
+ stopThreadStartException.getMessage());
+ }
+ });
+ throw new ArgoRemoteServerStubStartupException("Server startup failure: {0}",
+ "thing-status.argoclima.stub-server.start-failure.internal", i18nProvider, e,
+ e.getLocalizedMessage());
+ }
+
+ if (this.passthroughClient.isPresent()) {
+ try {
+ this.passthroughClient.get().start();
+ } catch (Exception e) {
+ passthroughClient.ifPresent(s -> {
+ try {
+ // Stopping synchronously (as a client that failed startup is anyway not doing anything)
+ s.stop();
+ } catch (Exception stopException) {
+ logger.debug(
+ "PassthroughClient startup has failed and subsequent stop has failed as well... Error: {}",
+ stopException.getMessage());
+ }
+ });
+ throw new ArgoRemoteServerStubStartupException(
+ "Passthrough API client (for host={0}, port={1,number,#}) failed to start: {2}",
+ "thing-status.argoclima.passtrough-client.start-failure", i18nProvider, e,
+ this.passthroughClient.get().upstreamTargetHost,
+ this.passthroughClient.get().upstreamTargetPort, e.getLocalizedMessage());
+ }
+ }
+ }
+
+ /**
+ * Stop the stub server (and upstream API client, if used)
+ *
+ * @implNote This swallows exceptions (as we can't do anything meaningful with them at this point, anyway
+ */
+ public synchronized void shutdown() {
+ if (this.server.isPresent()) {
+ try {
+ server.get().stop();
+ server.get().destroy();
+ this.server = Optional.empty();
+ } catch (Exception e) {
+ logger.debug("Unable to stop Remote Argo API Server Stub (listening on port {}). Error: {}",
+ this.listenPort, e.getMessage());
+ }
+ }
+
+ if (this.passthroughClient.isPresent()) {
+ try {
+ passthroughClient.get().stop();
+ passthroughClient = Optional.empty();
+ } catch (Exception e) {
+ logger.debug("Unable to stop Remote Argo API Passthrough HTTP client. Error: {}", e.getMessage());
+ }
+ }
+ }
+
+ /**
+ * Creates and starts custom HTTP server for simulating the Argo HTTP server
+ * The server will listen on port {@link #listenPort} on {@link #listenIpAddresses}
+ *
+ * @throws Exception If the server fails to start
+ */
+ private void startJettyServer() throws Exception {
+ if (this.server.isPresent()) {
+ server.get().stop();
+ server.get().destroy();
+ }
+
+ var server = new Server();
+ this.server = Optional.of(server);
+
+ var connectors = this.listenIpAddresses.stream().map(addr -> {
+ var connector = new ServerConnector(server);
+ connector.setHost(addr.getHostName());
+ connector.setPort(this.listenPort);
+ return connector;
+ }).toArray(Connector[]::new);
+ server.setConnectors(connectors);
+
+ var tp = server.getThreadPool();
+ if (tp instanceof QueuedThreadPool qtp) {
+ qtp.setName(RPC_POOL_NAME);
+ qtp.setDaemon(true); // Lower our priority (just in case)
+ }
+
+ server.setHandler(new ArgoDeviceRequestHandler(this.includeDeviceSidePasswordsInProperties));
+ server.start();
+ }
+
+ /**
+ * Reads the entire request body (ASCII) to a buffer
+ *
+ * @param downstreamHttpRequest The request sent by the HVAC device-side
+ * @return Request body as string
+ * @throws IOException In case of read errors
+ */
+ public static String getRequestBodyAsString(Request downstreamHttpRequest) throws IOException {
+ return downstreamHttpRequest.getReader().lines().collect(Collectors.joining(System.lineSeparator()));
+ }
+
+ /**
+ * Returns the Argo-like NTP response for a date provided as param
+ *
+ * @param time The date/time to return in the response
+ * @return Argo protocol response for NTP request (simulating real server)
+ */
+ private String getNtpResponse(Instant time) {
+ DateTimeFormatter fmt = DateTimeFormatter
+ .ofPattern("'NTP 'yyyy-MM-dd'T'HH:mm:ssxxx' UI SERVER (M.A.V. srl)'", Locale.ENGLISH)
+ .withZone(Objects.requireNonNull(ZoneId.of("GMT")));
+ return fmt.format(time);
+ }
+
+ /**
+ * Return a harmless Argo protocol response, causing the device parsing to be happy
+ *
+ * @param requestType the request type
+ *
+ * @implNote Note this is NOT a valid protocolar response to any particular request, just happens to be good enough
+ * to keep
+ * device happy-enough to continue the conversation
+ * @return The default API response (fake)
+ */
+ private String getFakeResponse(DeviceRequestType requestType) {
+ switch (requestType) {
+ case POST_UI_RT:
+ return "|}|}";
+ default:
+ return "{|0|0|1|0|0|0|N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,2,N,592,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N|}[|0|||]ACN_FREE \t\t";
+ }
+ }
+
+ /**
+ * Detect the type of incoming request based off of query params (or body, if POST request)
+ *
+ * @param request The request
+ * @param requestBody The request body as string
+ * @return Parsed request type (or {@link DeviceRequestType#UNKNOWN} if unable to detect)
+ */
+ public DeviceRequestType detectRequestType(HttpServletRequest request, String requestBody) {
+ logger.trace("Incoming request: {} {}://{}:{}{}?{}", request.getMethod(), request.getScheme(),
+ request.getLocalAddr(), request.getLocalPort(), request.getPathInfo(), request.getQueryString());
+
+ // if (!request.getPathInfo().equalsIgnoreCase(REMOTE_SERVER_PATH)) {
+ if (!REMOTE_SERVER_PATH.equalsIgnoreCase(request.getPathInfo())) {
+ logger.debug("Unknown Argo device-side request path {}. Ignoring...", request.getPathInfo());
+ return DeviceRequestType.UNKNOWN;
+ }
+
+ var command = request.getParameter("CM");
+ if ("GET".equalsIgnoreCase(request.getMethod())) {
+ if ("UI_NTP".equalsIgnoreCase(command)) {
+ return DeviceRequestType.GET_UI_NTP; // Get time: GET /UI/UI.php?CM=UI_NTP (
+ }
+ if ("UI_FLG".equalsIgnoreCase(command)) {
+ return DeviceRequestType.GET_UI_FLG; // Param update: GET
+ // /UI/UI.php?CM=UI_FLG?USN=%s&PSW=%s&IP=%s&FW_OU=_svn.%s&FW_UI=_svn.%s&CPU_ID=%s&HMI=%s&TZ=%s&SETUP=%s&SERVER_ID=%s
+ }
+ if ("UI_UPD".equalsIgnoreCase(command)) {
+ return DeviceRequestType.GET_UI_UPD; // Unknown: GET /UI/UI.php?CM=UI_UPD?USN=%s&PSW=%s&CPU_ID=%s
+ }
+ if ("OU_FW".equalsIgnoreCase(command)) {
+ return DeviceRequestType.GET_OU_FW;
+ }
+ if ("UI_FW".equalsIgnoreCase(command)) {
+ return DeviceRequestType.GET_UI_FW; // Unit FW update request GET
+ // /UI/UI.php?CM=UI_FW&PK=%d&USN=%s&PSW=%s&CPU_ID=%s
+ }
+ if ("UI_ACN".equalsIgnoreCase(command)) {
+ return DeviceRequestType.GET_UI_ACN; // Unknown: GET /UI/UI.php?CM=UI_ACN&USN=%s&PSW=%s&CPU_ID=%s
+ // (AT+CIPSERVER=0?)
+ }
+ }
+
+ var commandFromBody = new UrlEncoded(requestBody).getString("CM");
+
+ if ("UI_RT".equalsIgnoreCase(commandFromBody) && "POST".equalsIgnoreCase(request.getMethod())) {
+ return DeviceRequestType.POST_UI_RT; // Unknown: POST /UI/UI.php body:
+ // CM=UI_RT&USN=%s&PSW=%s&CPU_ID=%s&DEL=%d&DATA=
+ // WiFi_Psw=UserName=Password=ServerID=TimeZone=uisetup.ddns.net |
+ // www.termauno.com | 95.254.67.59
+ }
+
+ logger.debug("Unknown command: CM(query)=[{}], CM(body)=[{}]", command, commandFromBody);
+ return DeviceRequestType.UNKNOWN;
+ }
+
+ /**
+ * Post-process the upstream response (injecting any our pending commands to the response)
+ *
+ * @param requestType The original request type
+ * @param upstreamResponse The original upstream response
+ * @param deviceApi The Argo device API tracked by this binding (channels and commands)
+ * @return Post-processed response body
+ */
+ private String postProcessUpstreamResponse(DeviceRequestType requestType, ContentResponse upstreamResponse,
+ Optional deviceApi) {
+ var originalResponseBody = Objects.requireNonNull(upstreamResponse.getContentAsString());
+
+ if (upstreamResponse.getStatus() != 200) {
+ logger.trace(
+ "Remote server response for {} command had HTTP status {}. Not parsing further & won't intercept",
+ requestType, upstreamResponse.getStatus());
+ return originalResponseBody;
+ }
+
+ switch (requestType) {
+ case GET_UI_FLG: // Only intercepting GET_UI_FLG response
+ var responseDto = RemoteGetUiFlgResponseDTO
+ .fromResponseString(Objects.requireNonNull(upstreamResponse.getContentAsString()));
+
+ deviceApi.ifPresent(api -> {
+ if (api.hasPendingCommands() && responseDto.preamble.flag5hasNewUpdate == 0) { // Will hijack
+ // body only if
+ // web-side
+ // didn't have
+ // anything for
+ // us on its own
+ String before = "";
+ if (logger.isTraceEnabled()) {
+ before = responseDto.toResponseString();
+ }
+
+ responseDto.preamble.flag0requestPostUiRt = 1; // Request POST confirmation of having
+ // applied this config (as we don't want to
+ // wait too long)
+ responseDto.preamble.flag5hasNewUpdate = 1; // Indicate this request carries new content
+
+ // Full replace of cloud-side commands (note we could *merge* with it, but seems to be an
+ // overkill)
+ responseDto.commands = UiFlgResponseCommmands.fromResponseString(api.getCurrentCommandString());
+
+ if (logger.isTraceEnabled()) {
+ var after = responseDto.toResponseString();
+ logger.trace("REPLACING the response body from [{}] to [{}]", before, after);
+ }
+
+ api.notifyCommandsPassedToDevice(); // Notify the withstanding commands have been consumed by
+ // the device
+ }
+ });
+ return responseDto.toResponseString();
+ case POST_UI_RT:
+ case GET_UI_NTP:
+ case UNKNOWN:
+ default:
+ return originalResponseBody;
+ }
+ }
+
+ /**
+ * Create a synthetic response to GET UI_FLG device-side request (factoring current device state)
+ *
+ * This is *always* sending the {@code ACN_FREE
+ * \t\t} suffix (seems to be a Connection:close equivalent of sorts). The real Argo server doesn't do that on all
+ * occasions and tends to keep the connection open, though implementing this way seems working (and more robust not
+ * to have to manage server-side socket lifespan)
+ *
+ * @param devApi The device API
+ * @return UI_FLG response body (string)
+ */
+ private String createSyntheticGetUiFlgResponse(ArgoClimaLocalDevice devApi) {
+ var responseDto = new RemoteGetUiFlgResponseDTO();
+
+ if (devApi.hasPendingCommands()) {
+ responseDto.preamble.flag0requestPostUiRt = 1;
+ responseDto.preamble.flag5hasNewUpdate = 1;
+ }
+ responseDto.commands = UiFlgResponseCommmands.fromResponseString(devApi.getCurrentCommandString());
+ devApi.notifyCommandsPassedToDevice(); // Notify the withstanding commands are about to be consumed by the
+ // device
+
+ // responseDto.acnSuffix is always the default (see impl. note)
+ return responseDto.toResponseString();
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/passthrough/requests/DeviceSidePostRtUpdateDTO.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/passthrough/requests/DeviceSidePostRtUpdateDTO.java
new file mode 100644
index 0000000000000..b7f104ab0c10b
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/passthrough/requests/DeviceSidePostRtUpdateDTO.java
@@ -0,0 +1,102 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.passthrough.requests;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import java.util.Objects;
+import java.util.TreeMap;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.util.MultiMap;
+import org.eclipse.jetty.util.UrlEncoded;
+
+/**
+ * Device's update - sent from AC to manufacturer's remote server (via POST ...CM=UI_RT command)
+ *
+ * @implNote These updates seem to only be sent if requested by the remote side (when the response to {@code GET UI_FLG}
+ * contains a
+ * {@link org.openhab.binding.argoclima.internal.device.passthrough.responses.RemoteGetUiFlgResponseDTO.UiFlgResponsePreamble#flag0requestPostUiRt
+ * flag0requestPostUiRt} bit set in the preamble}
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public class DeviceSidePostRtUpdateDTO {
+ /** The name of the POST command carried in body. Seems fixed to {@code UI_RT} for this format */
+ public final String command;
+
+ /** The username for the remote server (and hence the UI) */
+ public final String username;
+
+ /** A MD5 hash of password to the remote server (and hence the UI) */
+ public final String passwordHash;
+
+ /** The CPU_ID (unique and immutable HVAC identifier) send by the device */
+ public final String cpuId;
+
+ /** Unknown purpose, seems to be set to 1 in all requests observed. DEL is for delta? */
+ public final String delParam;
+
+ /**
+ * Unknown format - has multiple comma-separated values, and looks like a massive superset of the HMI string
+ * typically sent (156 values vs 39)
+ *
+ * @implNote The ordering of values is different from the HMI string, though there are similarities. Since it
+ * doesn't seem to carry anything immediately obvious or attractive, this is not being parsed at this
+ * point. Likely conveys all the "schedule" settings as well as configuration parameters though...
+ */
+ public final String dataParam;
+
+ /**
+ * Private c-tor (from response body, which seems to be URL-encoded query-like param set)
+ *
+ * @param bodyArgumentMap The payload, decomposed into K->V map
+ */
+ private DeviceSidePostRtUpdateDTO(Map bodyArgumentMap) {
+ this.command = Objects.requireNonNullElse(bodyArgumentMap.get("CM"), "");
+ this.username = Objects.requireNonNullElse(bodyArgumentMap.get("USN"), "");
+ this.passwordHash = Objects.requireNonNullElse(bodyArgumentMap.get("PSW"), "");
+ this.cpuId = Objects.requireNonNullElse(bodyArgumentMap.get("CPU_ID"), "");
+
+ this.delParam = Objects.requireNonNullElse(bodyArgumentMap.get("DEL"), "");
+ this.dataParam = Objects.requireNonNullElse(bodyArgumentMap.get("DATA"), "");
+ }
+
+ /**
+ * Named c-tor (constructs this DTO from device-side request body)
+ *
+ * @implNote Headers or URL do not seem to carry any meaningful (variable) information, hence not parsing them
+ * @implNote This class does only shallow parsing for now (ex. does not decode the 'data' element to an array)
+ * @param requestBody The body of the device-side request to parse
+ * @return Pre-parsed DTO
+ */
+ public static DeviceSidePostRtUpdateDTO fromDeviceRequestBody(String requestBody) {
+ var paramsParsed = new MultiMap<@Nullable String>(); // @Nullable here due to UrlEncoded API
+ UrlEncoded.decodeTo(requestBody, paramsParsed, StandardCharsets.US_ASCII);
+
+ Map flattenedParams = paramsParsed.keySet().stream().collect(TreeMap::new,
+ (m, v) -> m.put(Objects.requireNonNull(v), Objects.requireNonNull(paramsParsed.getString(v))),
+ TreeMap::putAll);
+
+ return new DeviceSidePostRtUpdateDTO(flattenedParams);
+ }
+
+ @Override
+ public String toString() {
+ return String.format(
+ "Device-side POST update:\n\tCommand=%s,\n\tCredentials=[username=%s, password(MD5)=%s],\n\tCPU_ID=%s,\n\tDEL=%s,\n\tDATA=%s.",
+ this.command, this.username, this.passwordHash, this.cpuId, this.delParam, this.dataParam);
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/passthrough/requests/DeviceSideUpdateDTO.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/passthrough/requests/DeviceSideUpdateDTO.java
new file mode 100644
index 0000000000000..1d2490d87ecee
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/passthrough/requests/DeviceSideUpdateDTO.java
@@ -0,0 +1,285 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.passthrough.requests;
+
+import java.nio.BufferUnderflowException;
+import java.nio.ByteBuffer;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.TreeMap;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.xml.bind.DatatypeConverter;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.argoclima.internal.configuration.ArgoClimaConfigurationLocal.DeviceSidePasswordDisplayMode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Device's update - sent from AC to manufacturer's remote server (via GET ...?CM=GET_UI_FLG command)
+ *
+ * These are the most common updates the device sends routinely to the vendor server
+ *
+ * @implNote The "SETUP" part is a particular goldmine for interesting stuff not available anywhere else
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public class DeviceSideUpdateDTO {
+ /////////////
+ // TYPES
+ /////////////
+ /**
+ * Provides parsing of "SETUP" part of query string, which seems to be base-16 encoded binary blob
+ *
+ * @implNote The values are based on guesswork and reverse engineering. Notable unknowns:
+ * - 4-byte value on bits 128-131 (TZ config?)
+ * - 20-byte value on bits 176-195 (?? - some value seems to be embedded on bytes 4..15 of it)
+ * - 34-byte value on bits 222-255 (?? - seem to be some reserved field + trailing padding "ABCD..u ")
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+ public class UiFlgSetupParam {
+ /** The value as sent by the device (Base16) */
+ public final String rawString;
+
+ /** The binary value upon conversion (empty on conversion failure) */
+ private Optional bytes = Optional.empty();
+
+ /** The Wi-Fi SSID embedded on bytes 0..31 of the blob (or empty - on parse failure) */
+ public Optional wifiSSID = Optional.empty();
+
+ /** The Wi-Fi password embedded on bytes 32..63 of the blob (or empty - on parse failure) */
+ public Optional wifiPassword = Optional.empty();
+
+ /** The UI username embedded on bytes 64..79 of the blob (or empty - on parse failure) */
+ public Optional username = Optional.empty();
+
+ /** The UI password embedded on bytes 80..111 of the blob (or empty - on parse failure) */
+ public Optional password = Optional.empty();
+
+ /** The local IPv4 of the device embedded on bytes 112-127 of the blob (or empty - on parse failure) */
+ public Optional localIP = Optional.empty();
+
+ /**
+ * The installed(?) Wi-Fi firmware version embedded on bytes 132-137 of the blob (or empty - on parse failure)
+ */
+ public Optional wifiVersionInstalled = Optional.empty();
+
+ /**
+ * The available(?) Wi-Fi firmware version embedded on bytes 138-143 of the blob (or empty - on parse failure)
+ */
+ public Optional wifiVersionAvailable = Optional.empty();
+
+ /**
+ * The installed(?) Unit firmware version embedded on bytes 164-169 of the blob (or empty - on parse failure)
+ */
+ public Optional unitVersionInstalled = Optional.empty();
+
+ /**
+ * The available(?) Unit firmware version embedded on bytes 170-175 of the blob (or empty - on parse failure)
+ */
+ public Optional unitVersionAvailable = Optional.empty();
+
+ /**
+ * ISO-formated local time (ending with whitespace) embedded on bytes 196-221 of the blob (or empty - on parse
+ * failure)
+ *
+ * @implNote Parsed as a 32-byte array for simplicity sake
+ */
+ public Optional localTime = Optional.empty();
+
+ /**
+ * C-tor
+ *
+ * @param rawString The raw 'setup' param string send by device
+ * @param includeDeviceSidePasswordsInProperties Whether to include the device-sent passwords as thing
+ * properties and how (masked with {@code ***} vs. cleartext). Note this is not a security feature
+ * (passwords are still sent!)
+ */
+ public UiFlgSetupParam(String rawString, DeviceSidePasswordDisplayMode includeDeviceSidePasswordsInProperties) {
+ this.rawString = rawString;
+ try {
+ this.bytes = Optional.ofNullable(DatatypeConverter.parseHexBinary(rawString));
+ var bb = ByteBuffer.wrap(this.bytes.orElseThrow());
+
+ // helper structures to parse with
+ var byte32arr = new byte[32];
+ var byte16arr = new byte[16];
+ var byte6arr = new byte[6];
+
+ bb.get(byte32arr);
+ this.wifiSSID = Optional.of(new String(byte32arr).trim());
+
+ bb.get(byte32arr);
+ switch (includeDeviceSidePasswordsInProperties) {
+ case CLEARTEXT:
+ this.wifiPassword = Optional.of(new String(byte32arr).trim()); // yep, it is passed through to
+ // vendor's servers **as
+ // plaintext**. Over plain HTTP!
+ // :///
+ break;
+ case MASKED:
+ this.wifiPassword = Optional.of(new String(byte32arr).trim().replaceAll(".", "*"));
+ break;
+ case NEVER:
+ default:
+ this.wifiPassword = Optional.empty();
+ break;
+ }
+
+ bb.position(0x40);
+ bb.get(byte16arr);
+ this.username = Optional.of(new String(byte16arr).trim());
+
+ bb.get(byte32arr);
+ switch (includeDeviceSidePasswordsInProperties) {
+ case CLEARTEXT:
+ this.password = Optional.of(new String(byte32arr).trim());
+ break;
+ case MASKED:
+ this.password = Optional.of(new String(byte32arr).trim().replaceAll(".", "*"));
+ break;
+ case NEVER:
+ default:
+ this.password = Optional.empty();
+ break;
+ }
+
+ bb.get(byte16arr);
+ this.localIP = Optional.of(new String(byte16arr).trim());
+
+ bb.position(0x84);
+ bb.get(byte6arr);
+ this.wifiVersionInstalled = Optional.of(new String(byte6arr).trim());
+ bb.get(byte6arr);
+ this.wifiVersionAvailable = Optional.of(new String(byte6arr).trim());
+
+ bb.position(0xa4);
+ bb.get(byte6arr);
+ this.unitVersionInstalled = Optional.of(new String(byte6arr).trim());
+ bb.get(byte6arr);
+ this.unitVersionAvailable = Optional.of(new String(byte6arr).trim());
+
+ bb.position(0xc4);
+ bb.get(byte32arr);
+ this.localTime = Optional.of(new String(byte32arr).trim());
+ } catch (IllegalArgumentException | BufferUnderflowException ex) {
+ logger.trace("Unrecognized device setup string: {}. Exception: {}", rawString, ex.getMessage());
+ this.bytes = Optional.empty(); // Removing raw bytes just to indicate we failed parsing somewhere
+ }
+ }
+
+ @Override
+ public String toString() {
+ return String.format(
+ "Device-side setup data:\n\twifi=[SSID=%s, password=%s],\n\tUser:password=%s:%s,\n\tIP=%s,\n\tWifi FW=[Installed=%s | Available=%s],\n\tUnit FW=[Installed=%s | Available=%s]\n\tLocal time=%s",
+ this.wifiSSID.orElse("???"), this.wifiPassword.orElse("???"), this.username.orElse("???"),
+ this.password.orElse("???"), this.localIP.orElse("???"), this.wifiVersionInstalled.orElse("???"),
+ this.wifiVersionAvailable.orElse("???"), this.unitVersionInstalled.orElse("???"),
+ this.unitVersionAvailable.orElse("???"), this.localTime.orElse("???"));
+ }
+ }
+
+ /////////////
+ // FIELDS
+ /////////////
+ private final Logger logger = LoggerFactory.getLogger(getClass());
+
+ /** The {@code CM} part of the request. Seems to be fixed to {@code UI_FLG} for this format */
+ public final String command;
+
+ /** The {@code USN} part of the request. Carries the web UI username */
+ public final String username;
+
+ /** The {@code PSW} part of the request. Carries a MD5 of web UI password */
+ public final String passwordHash;
+
+ /** The {@code IP} part of the request. Carries a local IP of the HVAC device */
+ public final String deviceIp;
+
+ /** The {@code FW_OU} part of the request. Carries current version of unit's firmware */
+ public final String unitFirmware;
+
+ /** The {@code FW_UI} part of the request. Carries current version of Wi-Fi firmware */
+ public final String wifiFirmware;
+
+ /** The {@code CPU_ID} part of the request. Carries a unique HVAC device chip ID */
+ public final String cpuId;
+
+ /**
+ * The {@code HMI} part of the request. Carries current status of the HVAC and may be parsed using
+ * {@link org.openhab.binding.argoclima.internal.device.api.protocol.ArgoDeviceStatus#fromDeviceString(String) }
+ */
+ public final String currentValues;
+
+ /** The {@code TZ} part of the request. Carries device's local timezone(?) */
+ public final String timezoneId;
+
+ /** The {@code SETUP} part of the request. Carries rich setup data, including passwords etc. */
+ public final UiFlgSetupParam setup;
+
+ /** The {@code SERVER_ID} part of the request. Carries a the vendor's remote server DNS name */
+ public final String remoteServerId;
+
+ /**
+ * Private c-tor (from pre-parsed request)
+ *
+ * @param parameterMap The body parameters converted to a K->V map
+ * @param includeDeviceSidePasswordsInProperties Whe. Note this is not a security feature (passwords are still
+ * sent!)
+ */
+ private DeviceSideUpdateDTO(Map parameterMap,
+ DeviceSidePasswordDisplayMode includeDeviceSidePasswordsInProperties) {
+ this.command = Objects.requireNonNullElse(parameterMap.get("CM"), "");
+ this.username = Objects.requireNonNullElse(parameterMap.get("USN"), "");
+ this.passwordHash = Objects.requireNonNullElse(parameterMap.get("PSW"), "");
+ this.deviceIp = Objects.requireNonNullElse(parameterMap.get("IP"), "");
+ this.unitFirmware = Objects.requireNonNullElse(parameterMap.get("FW_OU"), "");
+ this.wifiFirmware = Objects.requireNonNullElse(parameterMap.get("FW_UI"), "");
+ this.cpuId = Objects.requireNonNullElse(parameterMap.get("CPU_ID"), "");
+ this.currentValues = Objects.requireNonNullElse(parameterMap.get("HMI"), "");
+ this.timezoneId = Objects.requireNonNullElse(parameterMap.get("TZ"), "");
+ this.setup = new UiFlgSetupParam(Objects.requireNonNullElse(parameterMap.get("SETUP"), ""),
+ includeDeviceSidePasswordsInProperties);
+ this.remoteServerId = Objects.requireNonNullElse(parameterMap.get("SERVER_ID"), "");
+ }
+
+ /**
+ * Named c-tor (from device-side request)
+ *
+ * @param request The request sent by the device
+ * @param includeDeviceSidePasswordsInProperties If true, do not mask passwords the device sends with {@code ***}
+ * Note this is not a security feature (passwords are still sent!)
+ * @return Parsed DTO
+ */
+ public static DeviceSideUpdateDTO fromDeviceRequest(HttpServletRequest request,
+ DeviceSidePasswordDisplayMode includeDeviceSidePasswordsInProperties) {
+ Map flattenedParams = request.getParameterMap().entrySet().stream()
+ .collect(TreeMap::new,
+ (m, v) -> m.put(Objects.requireNonNull(v.getKey()),
+ (v.getValue().length < 1) ? "" : Objects.requireNonNull(v.getValue()[0])),
+ TreeMap::putAll);
+ return new DeviceSideUpdateDTO(flattenedParams, includeDeviceSidePasswordsInProperties);
+ }
+
+ @Override
+ public String toString() {
+ return String.format(
+ "Device-side update:\n\tCommand=%s,\n\tCredentials=[username=%s, password(MD5)=%s],\n\tIP=%s,\n\tFW=[Unit=%s | Wifi=%s],\n\tCPU_ID=%s,\n\tParameters=%s,\n\tSetup={%s},\n\tRemoteServer=%s.",
+ this.command, this.username, this.passwordHash, this.deviceIp, this.unitFirmware, this.wifiFirmware,
+ this.cpuId, this.currentValues, this.setup.toString().replaceAll("(?m)^", "\t"), this.remoteServerId);
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/passthrough/responses/RemoteGetUiFlgResponseDTO.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/passthrough/responses/RemoteGetUiFlgResponseDTO.java
new file mode 100644
index 0000000000000..d7bd46909580a
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/device/passthrough/responses/RemoteGetUiFlgResponseDTO.java
@@ -0,0 +1,330 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.device.passthrough.responses;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.argoclima.internal.device.api.protocol.ArgoDeviceStatus;
+
+/**
+ * Cloud-side response to GET UI_FLG command - sent from manufacturer's remote server back to HVAC
+ *
+ * @implNote Example full response is like
+ * {@code {|1|0|1|0|0|0|N,N,N,2,0,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N|}[|0|||]]}
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public class RemoteGetUiFlgResponseDTO {
+ /////////////
+ // TYPES
+ /////////////
+ /**
+ * The preamble part of the response containing flags
+ *
+ * @implNote Example: {@code |1|0|1|0|0|0|}
+ * @author Mateusz Bronk - Initial contribution
+ */
+ public static final class UiFlgResponsePreamble {
+ static final Pattern PREAMBLE_RX = Pattern.compile("^[|](\\d[|]){6}$");
+
+ /** Request the HVAC to send an immediate update via POST UI_RT (used on cloud-side updates) */
+ public int flag0requestPostUiRt = 0;
+
+ /** Unknown purpose, always zero */
+ public int flag1alwaysZero = 0;
+
+ /** Unknown purpose, always one */
+ public int flag2alwaysOne = 1;
+
+ /** Request to update Wi-Fi firmware of the device */
+ public int flag3updateWifiFW = 0;
+
+ /** Request to update Unit firmware of the device */
+ public int flag4updateUnitFW = 0;
+
+ /** Cloud has new updates for the device - request to apply (silently, with no beep) */
+ public int flag5hasNewUpdate = 0;
+
+ /**
+ * Default C-tor (empty, if constructed vanilla)
+ */
+ public UiFlgResponsePreamble() {
+ }
+
+ /**
+ * Private c-tor (from pre-parsed preamble headers)
+ *
+ * @param flags Parsed preamble
+ */
+ private UiFlgResponsePreamble(final List flags) {
+ if (flags.size() != 6) {
+ throw new IllegalArgumentException("flags");
+ }
+ this.flag0requestPostUiRt = flags.get(0); // When Device sends DEL=1, remote API requests it
+ this.flag1alwaysZero = flags.get(1);
+ this.flag2alwaysOne = flags.get(2);
+ this.flag3updateWifiFW = flags.get(3);
+ this.flag4updateUnitFW = flags.get(4);
+ this.flag5hasNewUpdate = flags.get(5);
+ }
+
+ /**
+ * Named c-tor
+ *
+ * @param preambleString The preamble string from parsed response
+ * @return This DTO
+ */
+ public static UiFlgResponsePreamble fromResponseString(String preambleString) {
+ // Preamble: |1|0|1|1|0|0|
+ if (!PREAMBLE_RX.matcher(preambleString).matches()) {
+ throw new IllegalArgumentException("preambleString");
+ }
+ var flags = Stream.of(preambleString.substring(1).split("[|]")). map(Integer::parseInt)
+ .collect(Collectors.toUnmodifiableList());
+ return new UiFlgResponsePreamble(flags);
+ }
+
+ /**
+ * Converts internal representation back to Argo-compatible preamble
+ *
+ * @return Preamble in proto-friendly format
+ */
+ public String toResponseString() {
+ return String.format("|%d|%d|%d|%d|%d|%d|", flag0requestPostUiRt, flag1alwaysZero, flag2alwaysOne,
+ flag3updateWifiFW, flag4updateUnitFW, flag5hasNewUpdate);
+ }
+ }
+
+ /**
+ * The "body" of the response (36-element), compatible with HMI command syntax produced by
+ * {@link ArgoDeviceStatus#getDeviceCommandStatus()}
+ *
+ * @implNote Example: {@code N,N,N,2,0,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N}
+ * @author Mateusz Bronk - Initial contribution
+ */
+ public static final class UiFlgResponseCommmands {
+ private final List commands;
+
+ /**
+ * Default C-tor (empty, if constructed vanilla)
+ */
+ public UiFlgResponseCommmands() {
+ commands = new ArrayList(Objects.requireNonNull(
+ Collections.nCopies(ArgoDeviceStatus.HMI_COMMAND_ELEMENT_COUNT, ArgoDeviceStatus.NO_VALUE)));
+ }
+
+ /**
+ * Private c-tor (from pre-parsed "body")
+ *
+ * @param commands The device commands to execute (HMI-like syntax)
+ */
+ private UiFlgResponseCommmands(List commands) {
+ if (commands.size() != ArgoDeviceStatus.HMI_COMMAND_ELEMENT_COUNT) {
+ throw new IllegalArgumentException("commands");
+ }
+ this.commands = new ArrayList<>(commands);
+ }
+
+ /**
+ * Named c-tor
+ *
+ * @param commandString The "command" part of parsed response
+ * @return This DTO
+ */
+ public static UiFlgResponseCommmands fromResponseString(String commandString) {
+ var values = commandString.split(ArgoDeviceStatus.HMI_ELEMENT_SEPARATOR);
+ if (values.length != ArgoDeviceStatus.HMI_COMMAND_ELEMENT_COUNT) {
+ throw new IllegalArgumentException("commandString");
+ }
+ return new UiFlgResponseCommmands(Arrays.asList(values));
+ }
+
+ /**
+ * Converts internal representation back to Argo-compatible body
+ *
+ * @return Commands in proto-friendly format
+ */
+ public String toResponseString() {
+ return String.join(ArgoDeviceStatus.HMI_ELEMENT_SEPARATOR, commands);
+ }
+ }
+
+ /**
+ * The "suffix" part of the response (of unknown purpose)
+ *
+ * @implNote Example: {@code [|0|||]}
+ * @author Mateusz Bronk - Initial contribution
+ */
+ public static final class UiFlgResponseUpd {
+ static final String CANNED_RESPONSE = "[|0|||]";
+ final String contents;
+
+ /**
+ * Default C-tor (empty, if constructed vanilla)
+ */
+ public UiFlgResponseUpd() {
+ this.contents = CANNED_RESPONSE;
+ }
+
+ /**
+ * Private c-tor (from pre-parsed "body")
+ *
+ * @param contents Actual postamble contents (pre-parsed)
+ */
+ private UiFlgResponseUpd(String contents) {
+ this.contents = contents;
+ }
+
+ /**
+ * Named c-tor
+ *
+ * @param updString The actual UPD (postamble) string sent
+ * @return This DTO
+ */
+ public static UiFlgResponseUpd fromResponseString(String updString) {
+ return new UiFlgResponseUpd(updString);
+ }
+
+ /**
+ * Converts internal representation back to Argo-compatible postamble
+ *
+ * @return Postamble in proto-friendly format
+ */
+ public String toResponseString() {
+ return contents;
+ }
+ }
+
+ /**
+ * The trailing part of the response (seems to be included as a server indicating something to the effect of
+ * 'Connection: Close')
+ *
+ * @implNote Example: {@code ACN_FREE \t\t}
+ * @author Mateusz Bronk - Initial contribution
+ */
+ public static final class UiFlgResponseACN {
+ static final String CANNED_RESPONSE = "ACN_FREE \t\t";
+ final String contents;
+
+ /**
+ * Default C-tor (do a connection close)
+ */
+ public UiFlgResponseACN() {
+ this.contents = CANNED_RESPONSE;
+ }
+
+ /**
+ * Private c-tor (from parsed part of response)
+ *
+ * @param contents The pre-parsed suffix
+ */
+ private UiFlgResponseACN(String contents) {
+ this.contents = contents;
+ }
+
+ /**
+ * Named c-tor (from raw response)
+ *
+ * @param updString The trailing part of response
+ * @return This DTO
+ */
+ public static UiFlgResponseACN fromResponseString(String updString) {
+ return new UiFlgResponseACN(updString);
+ }
+
+ /**
+ * Converts internal representation back to Argo-compatible suffix
+ *
+ * @return Suffix in proto-friendly format
+ */
+ public String toResponseString() {
+ return contents;
+ }
+ }
+
+ /////////////
+ // FIELDS
+ /////////////
+ static final Pattern GET_UI_FLG_RESPONSE_PATTERN = Pattern.compile(
+ "^[\\{](?([|]\\d)+[|])(?[^|]+)[|][\\}](?\\[[^\\]]+\\])(?.*$)",
+ Pattern.CASE_INSENSITIVE);
+ static final String RESPONSE_FORMAT = "{%s%s|}%s%s";
+
+ public UiFlgResponsePreamble preamble;
+ public UiFlgResponseCommmands commands;
+ public UiFlgResponseUpd updSuffix;
+ public UiFlgResponseACN acnSuffix;
+
+ /**
+ * Default c-tor (synthetic response)
+ */
+ public RemoteGetUiFlgResponseDTO() {
+ this.preamble = new UiFlgResponsePreamble();
+ this.commands = new UiFlgResponseCommmands();
+ this.updSuffix = new UiFlgResponseUpd();
+ this.acnSuffix = new UiFlgResponseACN();
+ }
+
+ /**
+ * Private c-tor (from pre-parsed actual response)
+ *
+ * @param preamble The preamble part of actual response
+ * @param commands The command part of actual response
+ * @param updSuffix The postamble part of actual response
+ * @param acnSuffix The connection suffix part of actual response
+ */
+ private RemoteGetUiFlgResponseDTO(UiFlgResponsePreamble preamble, UiFlgResponseCommmands commands,
+ UiFlgResponseUpd updSuffix, UiFlgResponseACN acnSuffix) {
+ this.preamble = preamble;
+ this.commands = commands;
+ this.updSuffix = updSuffix;
+ this.acnSuffix = acnSuffix;
+ }
+
+ /**
+ * Named c-for (from actual upstream response)
+ *
+ * @param getUiFlgResponse The response body
+ * @return This DTO
+ */
+ public static RemoteGetUiFlgResponseDTO fromResponseString(String getUiFlgResponse) {
+ var matcher = GET_UI_FLG_RESPONSE_PATTERN.matcher(getUiFlgResponse);
+ if (!matcher.matches()) {
+ throw new IllegalArgumentException("getUiFlgResponse");
+ }
+
+ return new RemoteGetUiFlgResponseDTO(
+ UiFlgResponsePreamble.fromResponseString(Objects.requireNonNull(matcher.group("preamble"))),
+ UiFlgResponseCommmands.fromResponseString(Objects.requireNonNull(matcher.group("commands"))),
+ UiFlgResponseUpd.fromResponseString(Objects.requireNonNull(matcher.group("updsuffix"))),
+ UiFlgResponseACN.fromResponseString(Objects.requireNonNull(matcher.group("acn"))));
+ }
+
+ /**
+ * Converts internal representation back to Argo-compatible suffix
+ *
+ * @return UI_FLG response body in proto-friendly format
+ */
+ public String toResponseString() {
+ return String.format(RESPONSE_FORMAT, this.preamble.toResponseString(), this.commands.toResponseString(),
+ this.updSuffix.toResponseString(), this.acnSuffix.toResponseString());
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/exception/ArgoApiCommunicationException.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/exception/ArgoApiCommunicationException.java
new file mode 100644
index 0000000000000..bd0e9c4a229e9
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/exception/ArgoApiCommunicationException.java
@@ -0,0 +1,70 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.exception;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.argoclima.internal.ArgoClimaTranslationProvider;
+
+/**
+ * The class {@code ArgoApiCommunicationException} is thrown in case of any issues with communication with the Argo HVAC
+ * device (incl. indirect communication, via sniffing)
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public class ArgoApiCommunicationException extends ArgoLocalizedException {
+
+ private static final long serialVersionUID = -6618438267962155601L;
+
+ public ArgoApiCommunicationException(String defaultMessage, String localizedMessageKey,
+ ArgoClimaTranslationProvider i18nProvider, @Nullable Throwable cause,
+ Object @Nullable... messageFormatArguments) {
+ super(defaultMessage, localizedMessageKey, i18nProvider, cause, messageFormatArguments);
+ }
+
+ public ArgoApiCommunicationException(String defaultMessage, String localizedMessageKey,
+ ArgoClimaTranslationProvider i18nProvider, @Nullable Throwable cause) {
+ super(defaultMessage, localizedMessageKey, i18nProvider, cause);
+ }
+
+ public ArgoApiCommunicationException(String defaultMessage, String localizedMessageKey,
+ ArgoClimaTranslationProvider i18nProvider, Object @Nullable... messageFormatArguments) {
+ super(defaultMessage, localizedMessageKey, i18nProvider, messageFormatArguments);
+ }
+
+ public ArgoApiCommunicationException(String defaultMessage, String localizedMessageKey,
+ ArgoClimaTranslationProvider i18nProvider) {
+ super(defaultMessage, localizedMessageKey, i18nProvider);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @implNote We want cause of all these exceptions included in the message by default
+ */
+ @Override
+ public @Nullable String getMessage() {
+ return super.getMessage(true);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @implNote We want cause of all these exceptions included in the message by default
+ */
+ @Override
+ public @Nullable String getLocalizedMessage() {
+ return super.getLocalizedMessage(true);
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/exception/ArgoApiProtocolViolationException.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/exception/ArgoApiProtocolViolationException.java
new file mode 100644
index 0000000000000..d40d86b81db21
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/exception/ArgoApiProtocolViolationException.java
@@ -0,0 +1,35 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.exception;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The class {@code ArgoApiProtocolViolationException} is thrown for if any API protocol violation occurs. These errors
+ * are rare and not propagated to the end-user directly (as Thing status), so not localized
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public class ArgoApiProtocolViolationException extends Exception {
+
+ private static final long serialVersionUID = -3438043281963104252L;
+
+ public ArgoApiProtocolViolationException(String message) {
+ super(message);
+ }
+
+ public ArgoApiProtocolViolationException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/exception/ArgoConfigurationException.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/exception/ArgoConfigurationException.java
new file mode 100644
index 0000000000000..73ecd594bf12c
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/exception/ArgoConfigurationException.java
@@ -0,0 +1,150 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.exception;
+
+import org.eclipse.jdt.annotation.NonNull;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.argoclima.internal.ArgoClimaTranslationProvider;
+
+/**
+ * The class {@code ArgoConfigurationException} is thrown in case of any configuration-related issue (ex. invalid value
+ * format)
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public class ArgoConfigurationException extends ArgoLocalizedException {
+
+ private static final long serialVersionUID = 174501670495658964L;
+ public final @Nullable String rawValue;
+
+ private ArgoConfigurationException(String paramName, @Nullable String paramValue, String defaultMessage,
+ String localizedMessageKey, @Nullable ArgoClimaTranslationProvider i18nProvider,
+ Object @Nullable... messageFormatArguments) {
+ super(defaultMessage, localizedMessageKey, i18nProvider, messageFormatArguments);
+ this.rawValue = paramValue;
+ }
+
+ private ArgoConfigurationException(String paramName, @Nullable String paramValue, String defaultMessage,
+ String localizedMessageKey, @Nullable ArgoClimaTranslationProvider i18nProvider, Throwable cause,
+ Object @Nullable... messageFormatArguments) {
+ super(defaultMessage, localizedMessageKey, i18nProvider, cause, messageFormatArguments);
+ this.rawValue = paramValue;
+ }
+
+ /**
+ * Named c-tor: for all kinds of invalid params (caused by underlying exception)
+ *
+ * @param Type of param
+ * @param paramName Config key of param
+ * @param paramValue Config value
+ * @param i18nProvider Framework's translation provider
+ * @param cause Inner cause
+ * @return new {@code ArgoConfigurationException}
+ */
+ public static <@NonNull T> ArgoConfigurationException forInvalidParamValue(String paramName, T paramValue,
+ @Nullable ArgoClimaTranslationProvider i18nProvider, Throwable cause) {
+ return new ArgoConfigurationException(paramName, paramValue.toString(), "Invalid \"{0}\" value: {1}",
+ "thing-status.argoclima.configuration.invalid-format", i18nProvider, cause, paramName, paramValue);
+ }
+
+ /**
+ * Named c-tor: for empty required params
+ *
+ * @param paramName Config key of param
+ * @param i18nProvider Framework's translation provider
+ * @return new {@code ArgoConfigurationException}
+ */
+ public static ArgoConfigurationException forEmptyRequiredParam(String paramName,
+ @Nullable ArgoClimaTranslationProvider i18nProvider) {
+ return new ArgoConfigurationException(paramName, "", "\"{0}\" is empty",
+ "thing-status.argoclima.configuration.empty-value", i18nProvider, paramName);
+ }
+
+ /**
+ * Named c-tor: For numeric params which are out of range
+ *
+ * @param Type of param (numeric)
+ * @param paramName Config key of param
+ * @param paramValue Config value
+ * @param i18nProvider Framework's translation provider
+ * @param rangeBegin Min value (inclusive)
+ * @param rangeEnd Max value (inclusive)
+ * @return new {@code ArgoConfigurationException}
+ */
+ public static <@NonNull T extends Number> ArgoConfigurationException forParamOutOfRange(String paramName,
+ T paramValue, @Nullable ArgoClimaTranslationProvider i18nProvider, T rangeBegin, T rangeEnd) {
+ return new ArgoConfigurationException(paramName, paramValue.toString(),
+ "\"{0}\" must be in range [{1,number,#}..{2,number,#}]",
+ "thing-status.argoclima.configuration.value-not-in-range", i18nProvider, paramName, rangeBegin,
+ rangeEnd);
+ }
+
+ /**
+ * Named c-tor: For numeric params which are below minimum value
+ *
+ * @param Type of param (numeric)
+ * @param paramName Config key of param
+ * @param paramValue Config value
+ * @param i18nProvider Framework's translation provider
+ * @param minValue Min value (inclusive)
+ * @return new {@code ArgoConfigurationException}
+ */
+ public static <@NonNull T extends Number> ArgoConfigurationException forParamBelowMin(String paramName,
+ T paramValue, @Nullable ArgoClimaTranslationProvider i18nProvider, T minValue) {
+ return new ArgoConfigurationException(paramName, paramValue.toString(), "\"{0}\" must be >= {1,number,#}",
+ "thing-status.argoclima.configuration.value-below-min", i18nProvider, paramName, minValue);
+ }
+
+ /**
+ * Named c-tor: For interdependent parameters that caused conflict
+ *
+ * @param Type of 1st param
+ * @param Value of 1st param
+ * @param paramName Config key of 1st param
+ * @param paramValue Config value of 1st param
+ * @param conflictingParamName Config key of 2nd param
+ * @param conflictingParamValue Config value of 2nd param
+ * @param i18nProvider Framework's translation provider
+ * @return new {@code ArgoConfigurationException}
+ */
+ public static <@NonNull T1, @NonNull T2> ArgoConfigurationException forConflictingParams(String paramName,
+ T1 paramValue, String conflictingParamName, T2 conflictingParamValue,
+ @Nullable ArgoClimaTranslationProvider i18nProvider) {
+ return new ArgoConfigurationException(paramName, paramValue.toString(),
+ "Cannot set \"{0}\" to {1}, when \"{2}\" is {3}",
+ "thing-status.argoclima.configuration.invalid-combination", i18nProvider, paramName, paramValue,
+ conflictingParamName, conflictingParamValue);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @implNote We want cause of all these exceptions included in the message by default
+ */
+ @Override
+ public @Nullable String getMessage() {
+ return super.getMessage(true);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @implNote We want cause of all these exceptions included in the message by default
+ */
+ @Override
+ public @Nullable String getLocalizedMessage() {
+ return super.getLocalizedMessage(true);
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/exception/ArgoLocalizedException.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/exception/ArgoLocalizedException.java
new file mode 100644
index 0000000000000..af10dd546bbad
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/exception/ArgoLocalizedException.java
@@ -0,0 +1,140 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.exception;
+
+import java.text.MessageFormat;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.argoclima.internal.ArgoClimaTranslationProvider;
+
+/**
+ * Base for localized exceptions (their messages are used for thing status)
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public class ArgoLocalizedException extends Exception {
+
+ private static final long serialVersionUID = 8729362177716420196L;
+
+ protected final @Nullable ArgoClimaTranslationProvider i18nProvider;
+ private final String localizedMessageKey;
+ private final List<@Nullable Object> localizedMessageParams; // using list for null annotations to be
+ // happy
+
+ protected ArgoLocalizedException(String defaultMessage, String localizedMessageKey,
+ @Nullable ArgoClimaTranslationProvider i18nProvider, @Nullable Throwable cause,
+ Object @Nullable... messageFormatArguments) {
+ super(MessageFormat.format(defaultMessage, messageFormatArguments), cause);
+ this.localizedMessageKey = localizedMessageKey;
+ this.localizedMessageParams = Arrays.asList(messageFormatArguments);
+ this.i18nProvider = i18nProvider;
+ }
+
+ protected ArgoLocalizedException(String defaultMessage, String localizedMessageKey,
+ @Nullable ArgoClimaTranslationProvider i18nProvider, @Nullable Throwable cause) {
+ super(defaultMessage, cause);
+ this.localizedMessageKey = localizedMessageKey;
+ this.localizedMessageParams = List.<@Nullable Object> of();
+ this.i18nProvider = i18nProvider;
+ }
+
+ protected ArgoLocalizedException(String defaultMessage, String localizedMessageKey,
+ @Nullable ArgoClimaTranslationProvider i18nProvider, Object @Nullable... messageFormatArguments) {
+ this(defaultMessage, localizedMessageKey, i18nProvider, (Throwable) null, messageFormatArguments);
+ }
+
+ protected ArgoLocalizedException(String defaultMessage, String localizedMessageKey,
+ @Nullable ArgoClimaTranslationProvider i18nProvider) {
+ this(defaultMessage, localizedMessageKey, i18nProvider, (Throwable) null);
+ }
+
+ @Override
+ public @Nullable String getLocalizedMessage() {
+ return this.getLocalizedMessage(false);
+ }
+
+ @Override
+ public @Nullable String getMessage() {
+ return this.getMessage(false);
+ }
+
+ /**
+ * Similar to {@link #getLocalizedMessage()}, but additionally can embed cause's message
+ *
+ * @param includeCause Whether to embed cause message
+ * @return Localized exception message
+ */
+ public final String getLocalizedMessage(boolean includeCause) {
+ if (i18nProvider == null) {
+ return getMessage(includeCause); // fallback
+ }
+ var i18nProvider = Objects.requireNonNull(this.i18nProvider);
+
+ @Nullable
+ String localizedMessage;
+ if (!localizedMessageParams.isEmpty()) {
+ localizedMessage = i18nProvider.getText(localizedMessageKey, null, localizedMessageParams.toArray());
+ } else {
+ localizedMessage = i18nProvider.getText(localizedMessageKey, null);
+ }
+
+ if (localizedMessage == null || localizedMessage.isBlank()) {
+ // default to EN-US message (fallback to class name on failure)
+ localizedMessage = Objects.requireNonNullElse(this.getMessage(), this.getClass().getSimpleName());
+
+ }
+ String localizedMessageNonNull = Objects.requireNonNull(localizedMessage); // This is 100% redundant, but
+ // Eclipse wasn't able to correctly
+ // interpret Optional.ofNullable()
+ // inside a map... so doing if-based
+ // logic, lists vs. arrays and this
+ // instead - avoids suppression :)
+
+ if (this.getCause() != null) {
+ var causeMessage = Objects.requireNonNull(this.getCause()).getLocalizedMessage();
+ if (causeMessage != null && !(localizedMessageNonNull.endsWith(causeMessage))) {
+ // Sometimes the cause is already embedded in the message at throw site. If it isn't though... let's add
+ localizedMessageNonNull += ". " + i18nProvider
+ .getText("thing-status.cause.argoclima.exception.caused-by", "Caused by: {0}", causeMessage);
+ }
+ }
+ return localizedMessageNonNull;
+ }
+
+ /**
+ * Similar to {@link #getMessage()}, but additionally can embed cause's message
+ *
+ * @implNote Guaranteed non-null. Will default to class name in case message was null
+ *
+ * @param includeCause Whether to embed cause message
+ * @return EN-US exception message
+ */
+ public final String getMessage(boolean includeCause) {
+ @Nullable
+ String message = super.getMessage();
+ if (message != null && this.getCause() != null) {
+ var causeMessage = Objects.requireNonNull(this.getCause()).getLocalizedMessage();
+ if (causeMessage != null && !(message.endsWith(causeMessage))) {
+ // Sometimes the cause is already embedded in the message at throw site. If it isn't though... let's add
+ // it
+ message += MessageFormat.format(". Caused by: {0}", causeMessage);
+ }
+ }
+ return Objects.requireNonNullElse(message, this.getClass().getSimpleName());
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/exception/ArgoRemoteServerStubStartupException.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/exception/ArgoRemoteServerStubStartupException.java
new file mode 100644
index 0000000000000..16d0096eb4c63
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/exception/ArgoRemoteServerStubStartupException.java
@@ -0,0 +1,51 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.exception;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.argoclima.internal.ArgoClimaTranslationProvider;
+
+/**
+ * The class {@code ArgoRemoteServerStubStartupException} is thrown in case of any issues when starting the stub Argo
+ * server (for intercepting mode)
+ *
+ * @see org.openhab.binding.argoclima.internal.device.passthrough.RemoteArgoApiServerStub
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public class ArgoRemoteServerStubStartupException extends ArgoLocalizedException {
+
+ private static final long serialVersionUID = 3798832375487523670L;
+
+ public ArgoRemoteServerStubStartupException(String defaultMessage, String localizedMessageKey,
+ ArgoClimaTranslationProvider i18nProvider, @Nullable Throwable cause,
+ Object @Nullable... messageFormatArguments) {
+ super(defaultMessage, localizedMessageKey, i18nProvider, cause, messageFormatArguments);
+ }
+
+ public ArgoRemoteServerStubStartupException(String defaultMessage, String localizedMessageKey,
+ ArgoClimaTranslationProvider i18nProvider, @Nullable Throwable cause) {
+ super(defaultMessage, localizedMessageKey, i18nProvider, cause);
+ }
+
+ public ArgoRemoteServerStubStartupException(String defaultMessage, String localizedMessageKey,
+ ArgoClimaTranslationProvider i18nProvider, Object @Nullable... messageFormatArguments) {
+ super(defaultMessage, localizedMessageKey, i18nProvider, messageFormatArguments);
+ }
+
+ public ArgoRemoteServerStubStartupException(String defaultMessage, String localizedMessageKey,
+ ArgoClimaTranslationProvider i18nProvider) {
+ super(defaultMessage, localizedMessageKey, i18nProvider);
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/handler/ArgoClimaHandlerBase.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/handler/ArgoClimaHandlerBase.java
new file mode 100644
index 0000000000000..f17648892b4b1
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/handler/ArgoClimaHandlerBase.java
@@ -0,0 +1,848 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.handler;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants;
+import org.openhab.binding.argoclima.internal.ArgoClimaTranslationProvider;
+import org.openhab.binding.argoclima.internal.configuration.ArgoClimaConfigurationBase;
+import org.openhab.binding.argoclima.internal.device.api.IArgoClimaDeviceAPI;
+import org.openhab.binding.argoclima.internal.device.api.types.ArgoDeviceSettingType;
+import org.openhab.binding.argoclima.internal.exception.ArgoApiCommunicationException;
+import org.openhab.binding.argoclima.internal.exception.ArgoApiProtocolViolationException;
+import org.openhab.binding.argoclima.internal.exception.ArgoConfigurationException;
+import org.openhab.binding.argoclima.internal.exception.ArgoRemoteServerStubStartupException;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.openhab.core.types.State;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@code ArgoClimaHandlerBase} is an abstract base class for common logic (across local and remote thing
+ * implementations) responsible for handling commands, which are sent to one of the channels.
+ *
+ * @see ArgoClimaHandlerLocal
+ * @see ArgoClimaHandlerRemote
+ *
+ * @param Type of configuration class used:
+ * {@link org.openhab.binding.argoclima.internal.configuration.ArgoClimaConfigurationLocal
+ * ArgoClimaConfigurationLocal} or
+ * {@link org.openhab.binding.argoclima.internal.configuration.ArgoClimaConfigurationRemote
+ * ArgoClimaConfigurationRemote}
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public abstract class ArgoClimaHandlerBase extends BaseThingHandler {
+ enum StateRequestType {
+ REQUEST_FRESH_STATE,
+ GET_CACHED_STATE
+ }
+
+ private final Logger logger = LoggerFactory.getLogger(getClass());
+ private final boolean awaitConfirmationUponSendingCommands;
+ private final Duration sendCommandStatusPollFrequency;
+ private final Duration sendCommandResubmitFrequency;
+ private final Duration sendCommandMaxWaitTime;
+ private final Duration sendCommandMaxWaitTimeIndirectMode;
+ protected final ArgoClimaTranslationProvider i18nProvider;
+
+ // Set-up through initialize()
+ private Optional deviceApi = Optional.empty();
+ private Optional config = Optional.empty();
+
+ // Threading/job related stuff
+ private Optional> refreshTask = Optional.empty();
+ private Optional> initializeFuture = Optional.empty();
+ private Optional> deviceCommandSenderFuture = Optional.empty();
+ private AtomicLong lastRefreshTime = new AtomicLong(Instant.now().toEpochMilli());
+ private AtomicInteger failedApiCallsCounter = new AtomicInteger(0);
+
+ /**
+ * C-tor
+ *
+ * @param thing The @code Thing} this handler serves (provided by the framework through
+ * {@link org.openhab.binding.argoclima.internal.ArgoClimaHandlerFactory ArgoClimaHandlerFactory}
+ * @param awaitConfirmationAfterSend If true, will wait for device to confirm the update after sending a command to
+ * it
+ * @param poolFrequencyAfterSend The status refresh frequency for updated status after issuing a command (relevant
+ * only if {@code awaitConfirmationAfterSend == true})
+ * @param sendRetryFrequency The retry frequency (to re-issue a command if no confirmation received). Relevant only
+ * if {@code awaitConfirmationAfterSend == true}, should be higher than {@code poolFrequencyAfterSend}
+ * @param sendMaxRetryTimeDirect Max time to wait for device-side confirmation in direct mode (when this binding is
+ * issuing the comms). (relevant only if {@code awaitConfirmationAfterSend == true})
+ * @param sendMaxWaitTimeIndirect Max time to wait for device-side confirmation in indirect mode (when this binding
+ * is only sniffing/intercepting the comms and injecting commands into a server replies). Typically
+ * longer than {@code sendMaxRetryTimeDirect}. Relevant only if
+ * {@code awaitConfirmationAfterSend == true})
+ * @param i18nProvider Framework's translation provider
+ */
+ public ArgoClimaHandlerBase(Thing thing, boolean awaitConfirmationAfterSend, Duration poolFrequencyAfterSend,
+ Duration sendRetryFrequency, Duration sendMaxRetryTimeDirect, Duration sendMaxWaitTimeIndirect,
+ final ArgoClimaTranslationProvider i18nProvider) {
+ super(thing);
+ this.awaitConfirmationUponSendingCommands = awaitConfirmationAfterSend;
+ this.sendCommandStatusPollFrequency = poolFrequencyAfterSend;
+ this.sendCommandResubmitFrequency = sendRetryFrequency;
+ this.sendCommandMaxWaitTime = sendMaxRetryTimeDirect;
+ this.sendCommandMaxWaitTimeIndirectMode = sendMaxWaitTimeIndirect;
+ this.i18nProvider = i18nProvider;
+ }
+
+ /**
+ * Initializes the thing config with concrete type and transforms it to the given class.
+ *
+ * @return config
+ * @throws ArgoConfigurationException in case of configuration errors
+ */
+ protected abstract ConfigT getConfigInternal() throws ArgoConfigurationException;
+
+ /**
+ * Creates and initializes concrete device API (the actual communication path to the device).
+ * In case the API has passive passive components (such as pass-through server), they are started as well
+ * (their lifecycle is tracked by this class)
+ *
+ * @param config The Thing configuration
+ * @return Initialized Device API
+ * @throws ArgoConfigurationException In case the API initialization fails due to Thing configuration issues
+ * @throws ArgoRemoteServerStubStartupException In case the Device API startup involved launching an intercepting
+ * server (thing type and configuration-dependent), and the startup has failed
+ */
+ protected abstract IArgoClimaDeviceAPI initializeDeviceApi(ConfigT config)
+ throws ArgoRemoteServerStubStartupException, ArgoConfigurationException;
+
+ /**
+ * {@inheritDoc}
+ *
+ * @implNote Initializes thing config and device API, and continues the thing initialization asynchronously through
+ * {@link #initializeThing()} - as this method must return quickly. Also launches device state regular
+ * polling (if configured) -
+ * {@link #startAutomaticRefresh()}. While the poll will also (re)initialize the device, a dedicated
+ * initialization logic is kept b/c polling may be disabled by the user (and there's no harm in triggering
+ * both poll and refresh -> first to complete will win).
+ * @implNote If either of the initialize/poll threads are launched, both {@link #config} and {@link #deviceApi} are
+ * guaranteed to have values (so their use is safe from any other method from this class except for
+ * {@code dispose()}, as they are either invoked by the threads started herein, or guaranteed by the
+ * framework to not get called if the device is not initialized. Hence a check for successful
+ * initialization is NOT performed on each and every method.
+ */
+ @Override
+ public final void initialize() {
+ // Step0: If this a re-initialize (ex. config change), let's stop everything and start anew (not supporting
+ // graceful updates to the refresher threads and/or passthrough server)
+ this.config.ifPresent(c -> stopRunningTasks());
+
+ // Step1: Init config
+ try {
+ this.config = Optional.of(getConfigInternal());
+ } catch (ArgoConfigurationException ex) {
+ logger.debug("[{}] {}", getThing().getUID().getId(), ex.getMessage()); // the non-i18nzed message is logged
+ // explicitly (not redundant with
+ // updateStatus's logging)
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, ex.getLocalizedMessage());
+ return;
+ }
+ logger.debug("[{}] Running with config: {}", getThing().getUID(), config.get().toString());
+
+ var configValidationError = config.get().validate();
+ if (!configValidationError.isEmpty()) {
+ var message = i18nProvider.getText("thing-status.argoclima.invalid-config",
+ "Invalid thing configuration. {0}", configValidationError);
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, message);
+ return;
+ }
+
+ // Step2: Init device API (this will start passthrough server & client threads, if configured)
+ try {
+ this.deviceApi = Optional.of(initializeDeviceApi(config.get()));
+ } catch (ArgoRemoteServerStubStartupException | ArgoConfigurationException e) {
+ logger.debug("[{}] Failed to initialize Device API. Error: {}", getThing().getUID(), e.getMessage()); // the
+ // non-i18nzed
+ // message
+ // is
+ // logged
+ // explicitly
+ // (not
+ // redundant
+ // with
+ // updateStatus's
+ // logging)
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR, e.getLocalizedMessage());
+ return;
+ } catch (Exception e) {
+ logger.debug("[{}] Failed to initialize Device API. Unknown Error: {}", getThing().getUID(),
+ e.getMessage()); // the non-i18nzed message is logged explicitly (not redundant with updateStatus's
+ // logging)
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR,
+ i18nProvider.getText("thing-status.argoclima.handler-init-failure",
+ "Error while initializing Thing: {0}", e.getLocalizedMessage()));
+ return;
+ }
+
+ // Step 3: Set the thing status to UNKNOWN temporarily and let the background task decide the real status.
+ // the framework is then able to reuse the resources from the thing handler initialization.
+ updateStatus(ThingStatus.UNKNOWN);
+
+ // Step 4: Start polling (if configured)
+ if (this.config.get().getRefreshInterval() > 0) {
+ lastRefreshTime.set(Instant.now().toEpochMilli()); // Skips 1st refresh cycle (no need, initializer will do
+ // it instead)
+ startAutomaticRefresh();
+ }
+
+ // Step 5: Kick off the "real" initialization logic :)
+ synchronized (this) {
+ initializeFuture = Optional.ofNullable(scheduler.submit(this::initializeThing));
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @implNote This is deliberately made final, as the class-specific disposal has been moved to
+ * {@link ArgoClimaHandlerBase#stopRunningTasks()}, to semantically separate a stop-on-dispose on a
+ * regular stop (which is also done on re-initialize)
+ */
+ @Override
+ public final void dispose() {
+ logger.trace("{}: Thing {} is disposing", getThing().getUID().getId(), thing.getUID());
+ stopRunningTasks();
+ logger.trace("{}: Disposed", getThing().getUID().getId());
+ }
+
+ /**
+ * @implNote This is overridden in {@link org.openhab.binding.argoclima.internal.handler.ArgoClimaHandlerLocal} to
+ * handle disposal of the
+ */
+ protected synchronized void stopRunningTasks() {
+ if (this.deviceApi.isPresent()) {
+ // Setting the device API as empty first, as its absence also serves as a marker of disposal started
+ // Note it may still be alive after that, as the shared HTTP client may have pending I/O withstanding.
+ // These will be cleaned up when the respective refresher tasks stop (later in this function)
+ deviceApi = Optional.empty();
+ }
+
+ try {
+ stopRefreshTask(); // Stop polling for new updates
+ } catch (Exception e) {
+ logger.trace("Exception during handler disposal", e);
+ }
+
+ try {
+ initializeFuture.ifPresent(initter -> initter.cancel(true));
+ } catch (Exception e) {
+ logger.trace("Exception during handler disposal", e);
+ }
+
+ try {
+ cancelPendingDeviceCommandSenderJob();
+ } catch (Exception e) {
+ logger.trace("Exception during handler disposal", e);
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @implNote The Argo API always gets every readable params in one go and sends multiple commands in one request.
+ * Hence, if the requested command is a {@link RefreshType}, the {@code channelUID} is ignored, since
+ * everything will be updated anyway (refresh one == refresh them all).
+ * @implNote The actual device comms are made on a separate thread (and have a baked-in debounce time to avoid
+ * sending multiple requests). The device responses (even in direct communication mode) are somewhat slow
+ * and may take 1-2 cycles for the new value to apply, which is why the binding waits for them to be
+ * confirmed (and uses a device-reported state only after it gives up trying to effect the command).
+ * @implNote In a remote or indirect (pass-through) modes, we're constrained by the device own poll cycles (seem to
+ * occur every minute, assuming the device is up and has successful uplink to Argo servers). Hence, an
+ * update may take long to apply (circa 1-2 min). During this time, if new commands are send to the
+ * Thing, they get stacked with the existing ones (each command tracks its expire time separately though!)
+ * @implNote While {@link ArgoDeviceSettingType#RESET_TO_FACTORY_SETTINGS} is an API parameter it is modeled as
+ * configuration property and NOT a channel (hence not available here). Similarly
+ * {@link ArgoDeviceSettingType#UNIT_FIRMWARE_VERSION} is a property, not a Channel.
+ */
+ @Override
+ public final void handleCommand(ChannelUID channelUID, Command command) {
+ if (command instanceof RefreshType) {
+ sendCommandsToDeviceAwaitConfirmation(true); // Irrespective of channel (all in one go). Note this has no
+ // effect for indirect/pass-through mode. We're bound to
+ // device's own poll cycles anyway
+ return;
+ }
+
+ boolean hasUpdates = false;
+ // Channel -> ArgoDeviceSettingType mapping (could be within enum, but kept here for better visibility)
+ if (ArgoClimaBindingConstants.CHANNEL_POWER.equals(channelUID.getId())) {
+ hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.POWER, command, channelUID);
+ }
+ if (ArgoClimaBindingConstants.CHANNEL_ACTIVE_TIMER.equals(channelUID.getId())) {
+ hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.ACTIVE_TIMER, command, channelUID);
+ }
+ if (ArgoClimaBindingConstants.CHANNEL_CURRENT_TEMPERATURE.equals(channelUID.getId())) {
+ hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.ACTUAL_TEMPERATURE, command, channelUID);
+ }
+ if (ArgoClimaBindingConstants.CHANNEL_ECO_MODE.equals(channelUID.getId())) {
+ hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.ECO_MODE, command, channelUID);
+ }
+ if (ArgoClimaBindingConstants.CHANNEL_FAN_SPEED.equals(channelUID.getId())) {
+ hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.FAN_LEVEL, command, channelUID);
+ }
+ if (ArgoClimaBindingConstants.CHANNEL_FILTER_MODE.equals(channelUID.getId())) {
+ hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.FILTER_MODE, command, channelUID);
+ }
+ if (ArgoClimaBindingConstants.CHANNEL_SWING_MODE.equals(channelUID.getId())) {
+ hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.FLAP_LEVEL, command, channelUID);
+ }
+ if (ArgoClimaBindingConstants.CHANNEL_I_FEEL_ENABLED.equals(channelUID.getId())) {
+ hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.I_FEEL_TEMPERATURE, command, channelUID);
+ }
+ if (ArgoClimaBindingConstants.CHANNEL_DEVICE_LIGHTS.equals(channelUID.getId())) {
+ hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.LIGHT, command, channelUID);
+ }
+ if (ArgoClimaBindingConstants.CHANNEL_MODE.equals(channelUID.getId())) {
+ hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.MODE, command, channelUID);
+ }
+ if (ArgoClimaBindingConstants.CHANNEL_MODE_EX.equals(channelUID.getId())) {
+ hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.MODE, command, channelUID);
+ }
+ if (ArgoClimaBindingConstants.CHANNEL_NIGHT_MODE.equals(channelUID.getId())) {
+ hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.NIGHT_MODE, command, channelUID);
+ }
+ if (ArgoClimaBindingConstants.CHANNEL_SET_TEMPERATURE.equals(channelUID.getId())) {
+ hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.TARGET_TEMPERATURE, command, channelUID);
+ }
+ if (ArgoClimaBindingConstants.CHANNEL_TURBO_MODE.equals(channelUID.getId())) {
+ hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.TURBO_MODE, command, channelUID);
+ }
+ if (ArgoClimaBindingConstants.CHANNEL_TEMPERATURE_DISPLAY_UNIT.equals(channelUID.getId())) {
+ hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.DISPLAY_TEMPERATURE_SCALE, command,
+ channelUID);
+ }
+ if (ArgoClimaBindingConstants.CHANNEL_ECO_POWER_LIMIT.equals(channelUID.getId())) {
+ hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.ECO_POWER_LIMIT, command, channelUID);
+ }
+
+ if (ArgoClimaBindingConstants.CHANNEL_DELAY_TIMER.equals(channelUID.getId())) {
+ hasUpdates |= handleIndividualSettingCommand(ArgoDeviceSettingType.TIMER_0_DELAY_TIME, command, channelUID);
+ }
+
+ if (hasUpdates) {
+ sendCommandsToDeviceAwaitConfirmation(false); // Schedule sending to device (without forcing value refresh)
+ }
+ }
+
+ /**
+ * Convert received HVAC state: {@code deviceState} into Thing channel updates
+ *
+ * @param deviceState The state read/received from device (may also be cached)
+ * @implNote Not all device-reported elements are modeled as channels, and may be reflected as configuration or
+ * properties (ex.: {@code UNIT_FIRMWARE_VERSION}, or schedule timer on/off/weekdays params)
+ * @implNote A single device update may update more than one channel. For example the device mode is represented as
+ * BOTH {@code CHANNEL_MODE} and {@code CHANNEL_MODE_EX}. This is because the remote protocol supports
+ * more values than the typical HVAC device. Hence, the full list of modes is available in its own
+ * advanced ("_EX") channel, and the regular one is providing most common options for better usability.
+ * Both Channels get updated off of the same API field though.
+ * @apiNote This method is also called asynchronously from an intercepting/stub server
+ */
+ protected final void updateChannelsFromDevice(Map deviceState) {
+ if (deviceApi.isEmpty()) {
+ return; // The thing handler is disposing. No need to update channels
+ }
+
+ for (Entry entry : deviceState.entrySet()) {
+ var channelNames = Set. of();
+ switch (entry.getKey()) {
+ case ACTIVE_TIMER:
+ channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_ACTIVE_TIMER);
+ break;
+ case ACTUAL_TEMPERATURE:
+ channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_CURRENT_TEMPERATURE);
+ break;
+ case DISPLAY_TEMPERATURE_SCALE:
+ channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_TEMPERATURE_DISPLAY_UNIT);
+ break;
+ case ECO_MODE:
+ channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_ECO_MODE);
+ break;
+ case ECO_POWER_LIMIT:
+ channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_ECO_POWER_LIMIT);
+ break;
+ case FAN_LEVEL:
+ channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_FAN_SPEED);
+ break;
+ case FILTER_MODE:
+ channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_FILTER_MODE);
+ break;
+ case FLAP_LEVEL:
+ channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_SWING_MODE);
+ break;
+ case I_FEEL_TEMPERATURE:
+ channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_I_FEEL_ENABLED);
+ break;
+ case LIGHT:
+ channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_DEVICE_LIGHTS);
+ break;
+ case MODE: // As 2 channels. See thing-type.xml for description of these
+ channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_MODE,
+ ArgoClimaBindingConstants.CHANNEL_MODE_EX);
+ break;
+ case NIGHT_MODE:
+ channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_NIGHT_MODE);
+ break;
+ case POWER:
+ channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_POWER);
+ break;
+ case TARGET_TEMPERATURE:
+ channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_SET_TEMPERATURE);
+ break;
+ case TIMER_0_DELAY_TIME:
+ channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_DELAY_TIMER);
+ break;
+ case TURBO_MODE:
+ channelNames = Set.of(ArgoClimaBindingConstants.CHANNEL_TURBO_MODE);
+ break;
+ case CURRENT_DAY_OF_WEEK: // not reflected anywhere (write-only part of protocol)
+ case CURRENT_TIME:
+ break;
+ case TIMER_N_ENABLED_DAYS: // Timer schedule is represented as config, not as channel
+ case TIMER_N_OFF_TIME:
+ case TIMER_N_ON_TIME:
+ break;
+ case RESET_TO_FACTORY_SETTINGS: // Represented as config
+ break;
+ case UNIT_FIRMWARE_VERSION: // Represented as property
+ break;
+ default:
+ break;
+ }
+
+ // Send updates to the framework
+ channelNames.forEach(chnl -> updateState(chnl, entry.getValue()));
+ }
+ }
+
+ /**
+ * Informs the underlying DeviceAPI about a framework-issued command and returns status if the update was
+ * commissioned.
+ *
+ * @param settingType The API-side setting type receiving a command
+ * @param command The command sent by the framework
+ * @param channelUID Original channel the command got issued through
+ * @return True if the command was handled and is now in-flight (about to be sent to device). False - otherwise
+ */
+ private final boolean handleIndividualSettingCommand(ArgoDeviceSettingType settingType, Command command,
+ ChannelUID channelUID) {
+ if (command instanceof RefreshType) {
+ return true; // Refresh commands always trigger an update
+ }
+
+ // Pass value to underlying handler (if handled, it will make it in-flight and communicated to device on next
+ // comms cycle)
+ boolean updateInitiated = this.deviceApi.orElseThrow().handleSettingCommand(settingType, command);
+ if (updateInitiated) {
+ // Get updated device state and inform framework immediately that the binding accepted it. Note this
+ // technically doesn't yet mean the device changed its state nor even that the command got sent just this
+ // minute, but given some values are write-only (never confirmed) and the value *is* committed to be sent,
+ // we're confirming at this point (so that the value doesn't linger as "predicted")
+ State currentState = this.deviceApi.orElseThrow().getCurrentStateNoPoll(settingType);
+ logger.trace("State of {} after update: {}", channelUID, currentState);
+ updateState(channelUID, currentState);
+ }
+ return updateInitiated;
+ }
+
+ /**
+ * Updates dynamic Thing properties from values read from device
+ *
+ * @param entries The new properties to append/replace (this does not clear existing properties!)
+ *
+ * @implNote Unfortunately framework's {@link BaseThingHandler#updateProperties(Map)} implementation
+ * clones the map into a {@code HashMap}, which means the edited properties will lose their sorting, yet
+ * still providing it via a {@code TreeMap} in hopes framework may respect the ordering some day ;)
+ * @apiNote This method is also called asynchronously from an intercepting/stub server
+ */
+ protected final void updateThingProperties(SortedMap entries) {
+ if (deviceApi.isEmpty()) {
+ return; // The thing handler is disposing. No need to update properties
+ }
+
+ TreeMap currentProps = new TreeMap<>(this.editProperties()); // This unfortunately loses sorting
+ entries.entrySet().stream().forEach(x -> currentProps.put(x.getKey(), x.getValue()));
+ this.updateProperties(currentProps);
+ }
+
+ /**
+ * Updates the status of the thing to ONLINE (no details)
+ *
+ * @apiNote This method is also called asynchronously from an intercepting/stub server
+ */
+ protected final void updateThingStatusToOnline(ThingStatus newStatus) {
+ if (ThingStatus.ONLINE.equals(newStatus)) {
+ // only one-way update from callback
+ updateStatus(ThingStatus.ONLINE);
+ } else {
+ logger.trace("The remote stub server attempted to update the thing status to {}. The request was ignored",
+ newStatus);
+ }
+ }
+
+ /**
+ * Trigger channel update from HVAC device. If {@code useCachedState==false}, will trigger outbound communications
+ * to the device (or remote Argo server, depending on mode).
+ *
+ * For local mode with pass-through, if {@code Use Local Connection} is on, the state is always sniffed from device
+ * pool, so the triggered update has no effect
+ *
+ * @param requestType If {@link StateRequestType#GET_CACHED_STATE} allows to use cached state. Otherwise triggers
+ * device communications (if permitted by other settings)
+ * @throws ArgoApiCommunicationException If communication with the device fails
+ */
+ private final void updateStateFromDevice(StateRequestType requestType) throws ArgoApiCommunicationException {
+ if (deviceApi.isEmpty()) {
+ return;
+ }
+ var devApi = deviceApi.orElseThrow();
+ updateChannelsFromDevice(
+ StateRequestType.GET_CACHED_STATE.equals(requestType) ? devApi.getLastStateReadFromDevice()
+ : devApi.queryDeviceForUpdatedState());
+ updateThingProperties(devApi.getCurrentDeviceProperties());
+ }
+
+ /**
+ * Start polling for device status at an interval configured via
+ * {@link ArgoClimaBindingConstants#PARAMETER_REFRESH_INTERNAL} (in seconds)
+ *
+ * @implNote Since both {@link #initializeThing() initialize} as well as {@link #startAutomaticRefresh() refresh}
+ * are doing similar device communication, the 1st refresh cycle is purposefully omitted so that
+ * initialization has chances to finish. Ex. first refresh time is
+ * {@code Thing initialize time + refresh frequency (s)}. This is accomplished through a
+ * {@link #lastRefreshTime} member instead of simply delaying the scheduler, as it gives more flexibility
+ * @implNote If N refreshes ({@link ArgoClimaBindingConstants#MAX_API_RETRIES})fail in a row, the Thing will be
+ * considered offline and require re-initialization on next refresh.
+ * @implNote In order not to flood the device (or Argo servers), the binding is *not* doing any "obsessing" and
+ * ad-hoc retries of failed connections, but instead waits till next refresh cycle
+ */
+ private final synchronized void startAutomaticRefresh() {
+ Runnable refresher = () -> {
+ try {
+ // Fail-safe: Do not trigger if time since last refresh is lower than frequency
+ if (isMinimumRefreshTimeExceeded()) {
+ // If the device is offline, try to re-initialize it
+ if (getThing().getStatus() == ThingStatus.OFFLINE) {
+ logger.trace("{}: Re-initialize device", getThing().getUID());
+ initializeThing();
+ return;
+ }
+
+ updateStateFromDevice(StateRequestType.REQUEST_FRESH_STATE);
+ failedApiCallsCounter.set(0); // we're good!
+ }
+ } catch (RuntimeException | ArgoApiCommunicationException e) {
+ var retryCount = failedApiCallsCounter.getAndIncrement() + 1; // 1-based
+ logger.trace("[{}] Polling for device-side update for HVAC device failed [{} of {}]. Error=[{}]",
+ getThing().getUID(), retryCount, ArgoClimaBindingConstants.MAX_API_RETRIES, e.getMessage());
+ if (retryCount >= ArgoClimaBindingConstants.MAX_API_RETRIES) {
+ var statusMsg = i18nProvider.getText("thing-status.argoclima.poll-failed",
+ "Polling for device-side update failed. Unable to communicate with HVAC device for past {0} refresh cycles. Last error: {1}",
+ ArgoClimaBindingConstants.MAX_API_RETRIES, e.getLocalizedMessage());
+
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, statusMsg);
+ // Not resetting the counter here (will track every failure till we reinitialize & poll successfully
+ }
+ }
+ };
+
+ if (refreshTask.isEmpty()) {
+ refreshTask = Optional.ofNullable(scheduler.scheduleWithFixedDelay(refresher, 0,
+ config.get().getRefreshInterval(), TimeUnit.SECONDS));
+ logger.trace("{}: Automatic refresh started ({} second interval)", getThing().getUID().getId(),
+ config.get().getRefreshInterval());
+ }
+ }
+
+ /**
+ * Checks if time since last refresh is greater than interval (and advances the last checked time if so)
+ *
+ * @return True if time elapsed since last refresh is greater than interval (in which case last checked marker is
+ * also advanced). False - otherwise
+ */
+ private final boolean isMinimumRefreshTimeExceeded() {
+ long currentTime = Instant.now().toEpochMilli();
+ long timeSinceLastRefresh = currentTime - lastRefreshTime.get();
+ if (timeSinceLastRefresh < config.get().getRefreshInterval() * 1000) {
+ return false;
+ }
+ lastRefreshTime.lazySet(currentTime);
+ return true;
+ }
+
+ /**
+ * Synchronous initializer of the Thing. Expected to be called from a worker thread/future.
+ * Performs device (or remote API) direct communication, unless explicitly disabled by settings
+ *
+ * @implNote In order not to flood the device (or Argo servers), the binding is *not* doing any "obsessing" and
+ * retries of failed connections, but instead waits till {@link #startAutomaticRefresh() refresher} to
+ * kick-off a retry (or a device-side pool happens, in intercepting/sniffing mode)
+ * @implNote Since {@code RESET} is modeled as a write-only (one shot) configuration setting (in line with how Main
+ * UI handles those for other bindings, like ZWave), if it was set and the thing comes online... let's
+ * send the reset to the device and **CLEAR** the config property (so that we won't reset every time the
+ * device comes up)
+ */
+ private final void initializeThing() {
+ if (this.config.get().getRefreshInterval() == 0) {
+ updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NOT_YET_READY,
+ "@text/thing-status.argoclima.awaiting-request");
+ return;
+ }
+
+ String message = "";
+ try {
+ var reachabilityTestResult = this.deviceApi.get().isReachable();
+
+ if (reachabilityTestResult.isReachable()) {
+ updateStatus(ThingStatus.ONLINE); // YAY!
+ updateStateFromDevice(StateRequestType.GET_CACHED_STATE); // The reachability test actually was a full
+ // protocolar message (b/c there's no other
+ // :)), so it has conveniently fetched us all
+ // updates. Let's use them now that the Thing
+ // is healthy!
+
+ // Handle reset config knob (if true) as one-shot command
+ if (config.isPresent() && config.orElseThrow().resetToFactoryDefaults) {
+ var resetSent = this.deviceApi.get()
+ .handleSettingCommand(ArgoDeviceSettingType.RESET_TO_FACTORY_SETTINGS, OnOffType.ON);
+ if (resetSent) {
+ logger.info("[{}] Resetting HVAC device to factory defaults. RESET: {}", getThing().getUID(),
+ this.deviceApi.map(
+ d -> d.getCurrentStateNoPoll(ArgoDeviceSettingType.RESET_TO_FACTORY_SETTINGS)
+ .toString())
+ .orElse(""));
+ sendCommandsToDeviceAwaitConfirmation(false); // Schedule sending to device
+
+ config.orElseThrow().resetToFactoryDefaults = false;
+
+ var configUpdated = editConfiguration();
+ configUpdated.put(ArgoClimaBindingConstants.PARAMETER_RESET_TO_FACTORY_DEFAULTS, false);
+ updateConfiguration(configUpdated); // Update (note this can't update text-based configs, so
+ // this would kick-off on every Thing reinitialize
+ }
+ }
+ return;
+ }
+ message = reachabilityTestResult.unreachabilityReason();
+ } catch (Exception e) {
+ // Since isReachable is a no-throw, hitting an exception (ex. during device-side message parsing) is very
+ // unlikely, though in case a stray one happens -> let's embed it in the user-facing message
+ logger.debug("{}: Initialization exception", getThing().getUID(), e);
+ message = e.getLocalizedMessage();
+ }
+
+ if (getThing().getStatus() != ThingStatus.OFFLINE) {
+ // Update to offline. If offline already, let's not update the reason not to flood framework with boring
+ // updates (first error wins user's attention)
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, message);
+ }
+ }
+
+ /**
+ * Start sending pending commands to the device. Await their confirmation by the device (write-only parameters
+ * faithfully confirm on send).
+ *
+ * This method is async and starts a new update job. At most one job is supported (on re-entry, while an update is
+ * running, the old update is stopped and replaced with the new one). Implementation does support staggering
+ * requests though (ex. calling it multiple times in short time period will cause all updates to be sent in one go)
+ *
+ * @implNote In order to limit flapping and outgoing I/O, the actual work is delayed by
+ * {@link ArgoClimaBindingConstants.SEND_COMMAND_DEBOUNCE_TIME} to allow multiple commands to fit in one
+ * go. It's a naive implementation, starting/stopping a thread, but we're within a threadpool so this is
+ * ~fine :)
+ *
+ * @implNote On retries and confirmations: The commands are re-sent every {@link #sendCommandResubmitFrequency} and
+ * the device state is checked (for value confirmation) every {@link #sendCommandStatusPollFrequency}
+ * until either all withstanding commands are confirmed or total wait time expires. These
+ * timers are checked every tick which is by {@link ArgoClimaBindingConstants.SEND_COMMAND_DUTY_CYCLE}
+ *
+ * @implNote For local devices, this implementation can be called in a {@code Use Local Connection == false}
+ * ({@link ArgoClimaBindingConstants#PARAMETER_USE_LOCAL_CONNECTION ref}) mode. In this case, a "re-send"
+ * or a "update from device" commands are not really triggering any device communication (merely update
+ * internal statuses), and we're waiting the device to call us. For this reason, while the regular
+ * completion time (when we can talk to the device direct) is typically shorter
+ * ({@link #sendCommandMaxWaitTime}), in this mode this API will wait a
+ * {@link #sendCommandMaxWaitTimeIndirectMode}
+ *
+ * @param forceRefresh If true, force an active no-op("ping") command to the device to get freshest state
+ */
+ private final void sendCommandsToDeviceAwaitConfirmation(boolean forceRefresh) {
+ if (sendCommandStatusPollFrequency.isNegative() || sendCommandResubmitFrequency.isNegative()
+ || sendCommandMaxWaitTime.isNegative() || sendCommandMaxWaitTimeIndirectMode.isNegative()) {
+ throw new IllegalArgumentException("The frequency cannot be negative");
+ }
+
+ // Note: While a lot of checks could be done before the thread launches, it deliberately has been moved to
+ // WITHIN the thread, b/c this function may be called many times in case multiple items receive command at once
+
+ Runnable commandSendWorker = () -> {
+ // Stage0: Naive debounce (not to overflow the device if multiple commands are sent at once). We *want* to
+ // get interrupted at this stage!
+ try {
+ Thread.sleep(ArgoClimaBindingConstants.SEND_COMMAND_DEBOUNCE_TIME.toMillis());
+ } catch (InterruptedException e) {
+ return; // Got interrupted while within debounce window (which was the point!)
+ }
+
+ // Stage1: Calculate what to do
+ var valuesToUpdate = this.deviceApi.orElseThrow().getItemsWithPendingUpdates();
+ logger.debug("[{}] Will UPDATE the following items: {}", getThing().getUID(), valuesToUpdate);
+
+ var config = this.config.orElseThrow();
+ var deviceApi = this.deviceApi.orElseThrow();
+ final var maxWorkTime = config.useDirectConnection() ? sendCommandMaxWaitTime
+ : sendCommandMaxWaitTimeIndirectMode;
+ final var giveUpTime = Instant.now().plus(maxWorkTime);
+ var nextCommandSendTime = Objects.requireNonNull(Instant.MIN); // 1st send is instant
+ var nextStateUpdateTime = Instant.now().plus(sendCommandStatusPollFrequency); // 1st poll is delayed
+ Optional lastException = Optional.empty();
+
+ // Stage 2: Start spinnin' ;)
+ while (true) { // Handles both polling as well as retries
+ try {
+ // 2.1: Send command to the device
+ if (Instant.now().isAfter(nextCommandSendTime)) {
+ nextCommandSendTime = Instant.now().plus(sendCommandResubmitFrequency);
+ if (!deviceApi.hasPendingCommands()) {
+ if (forceRefresh) {
+ updateStateFromDevice(
+ config.useDirectConnection() ? StateRequestType.REQUEST_FRESH_STATE
+ : StateRequestType.GET_CACHED_STATE);
+ } else {
+ logger.trace("Nothing to update... skipping"); // update might have occurred async
+ }
+ return; // no command sending state to device was issued, we're safe to consider our job
+ // D-O-N-E
+ }
+
+ if (config.useDirectConnection()) {
+ // Have a command and send it *now* (triggers I/O)
+ deviceApi.sendCommandsToDevice();
+ } else {
+ logger.trace(
+ "Not sending the device update directly - waiting for device-side poll to happen");
+ }
+
+ // Check if the device confirmed in the same message exchange where we sent our update (very
+ // unlikely for 1st-time commands, but quite possible if we retried or the command was a
+ // write-only)
+ if (!this.deviceApi.get().hasPendingCommands()) {
+ logger.trace("All pending commands got confirmed on 1st try after a (re)send!");
+ return; // Woo-hoo! Device is happy, we're DONE!
+ }
+ }
+
+ if (!awaitConfirmationUponSendingCommands) {
+ return; // Nobody want's confirmations? Okay, we have less work to do (aka, we're done!)
+ }
+
+ // 2.2: Let's wait for the device to confirm flipping to the just commanded state
+ // Note: the device takes long to process commands which is why we're not querying immediately after
+ // send, and give it few seconds before re-confirming
+ if (Instant.now().isAfter(nextStateUpdateTime)) {
+ nextStateUpdateTime = Instant.now().plus(sendCommandStatusPollFrequency);
+ updateStateFromDevice(config.useDirectConnection() ? StateRequestType.REQUEST_FRESH_STATE
+ : StateRequestType.GET_CACHED_STATE);
+ if (this.deviceApi.get().hasPendingCommands()) {
+ // No biggie, we just didn't get the confirmation yet. This exception will be swallowed (on
+ // next try) or logged (if we run out of tries)
+ throw new ArgoApiProtocolViolationException("Update not confirmed. Value was not set");
+ }
+ return; // Woo-hoo! Device is happy, we're A-OK!
+ }
+ // empty loop cycle (no command, no update), just spinning...
+ } catch (Exception ex) {
+ lastException = Optional.of(ex);
+ }
+
+ // 2.3: If we're still within the working window, let's roll the dice once more
+ if (Instant.now().isBefore(giveUpTime)) {
+ try {
+ Thread.sleep(ArgoClimaBindingConstants.SEND_COMMAND_DUTY_CYCLE.toMillis());
+ } catch (InterruptedException e) {
+ return; // Cancelled during duty cycle (we want interrupts to happen here!)
+ }
+ logger.trace("Failed to update. Will retry...");
+ continue;
+ }
+
+ // 2.3B: Out of tries. Do one last check before we fail
+ if (!this.deviceApi.get().hasPendingCommands()) {
+ logger.trace("All pending commands got confirmed on last try!");
+ return; // Woo-hoo! Device is happy, we're DONE!
+ }
+
+ // 2.4: Max time exceeded and update failed to send or not confirmed. Giving up :(
+ valuesToUpdate.stream().forEach(x -> x.abortPendingCommand());
+ updateChannelsFromDevice(deviceApi.getLastStateReadFromDevice()); // Update channels back to device
+ // values upon abort
+
+ // The device wasn't nice with us, so we're going to consider it offline
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, i18nProvider.getText(
+ "thing-status.argoclima.confirmation-not-received",
+ "Could not control HVAC device. Command(s): {0} were not confirmed by the device within {1} s",
+ valuesToUpdate.isEmpty() ? "REFRESH" : valuesToUpdate.toString(), maxWorkTime.toSeconds()));
+ logger.debug("[{}] Device command failed: {}", this.getThing().getUID().toString(),
+ lastException.map(ex -> ex.getMessage()).orElse("No error details"));
+ break;
+ }
+ };
+
+ synchronized (this) {
+ cancelPendingDeviceCommandSenderJob();
+ deviceCommandSenderFuture = Optional.ofNullable(scheduler.submit(commandSendWorker));
+ }
+ }
+
+ private final synchronized void stopRefreshTask() {
+ refreshTask.ifPresent(rt -> {
+ rt.cancel(true);
+ });
+ refreshTask = Optional.empty();
+ }
+
+ private final synchronized void cancelPendingDeviceCommandSenderJob() {
+ deviceCommandSenderFuture.ifPresent(x -> {
+ if (!x.isDone()) {
+ logger.trace("Cancelling previous update job");
+ x.cancel(true);
+ }
+ });
+ deviceCommandSenderFuture = Optional.empty();
+ }
+}
diff --git a/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/handler/ArgoClimaHandlerLocal.java b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/handler/ArgoClimaHandlerLocal.java
new file mode 100644
index 0000000000000..64843c48412e2
--- /dev/null
+++ b/bundles/org.openhab.binding.argoclima/src/main/java/org/openhab/binding/argoclima/internal/handler/ArgoClimaHandlerLocal.java
@@ -0,0 +1,169 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.argoclima.internal.handler;
+
+import java.util.Objects;
+import java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.util.MultiException;
+import org.openhab.binding.argoclima.internal.ArgoClimaBindingConstants;
+import org.openhab.binding.argoclima.internal.ArgoClimaTranslationProvider;
+import org.openhab.binding.argoclima.internal.configuration.ArgoClimaConfigurationLocal;
+import org.openhab.binding.argoclima.internal.configuration.ArgoClimaConfigurationLocal.ConnectionMode;
+import org.openhab.binding.argoclima.internal.device.api.ArgoClimaLocalDevice;
+import org.openhab.binding.argoclima.internal.device.api.IArgoClimaDeviceAPI;
+import org.openhab.binding.argoclima.internal.device.passthrough.PassthroughHttpClient;
+import org.openhab.binding.argoclima.internal.device.passthrough.RemoteArgoApiServerStub;
+import org.openhab.binding.argoclima.internal.exception.ArgoConfigurationException;
+import org.openhab.binding.argoclima.internal.exception.ArgoRemoteServerStubStartupException;
+import org.openhab.core.i18n.TimeZoneProvider;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.openhab.core.thing.Thing;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link ArgoClimaHandlerLocal} is responsible for handling commands, which are
+ * sent to one of the channels. Supports local device (either through direct connection or pass-through)
+ *
+ * @see ArgoClimaHandlerBase
+ *
+ * @author Mateusz Bronk - Initial contribution
+ */
+@NonNullByDefault
+public class ArgoClimaHandlerLocal extends ArgoClimaHandlerBase {
+ private final Logger logger = LoggerFactory.getLogger(getClass());
+
+ private final HttpClient commonHttpClient;
+ private final TimeZoneProvider timeZoneProvider;
+ private final HttpClientFactory clientFactory;
+
+ private Optional serverStub = Optional.empty();
+
+ /**
+ * C-tor
+ *
+ * @param thing The @code Thing} this handler serves (provided by the framework through
+ * {@link org.openhab.binding.argoclima.internal.ArgoClimaHandlerFactory ArgoClimaHandlerFactory}
+ * @param clientFactory The framework's HTTP client factory (injected by the runtime to the
+ * {@code ArgoClimaHandlerFactory})
+ * @param timeZoneProvider The framework's time zone provider (injected by the runtime to the
+ * {@code ArgoClimaHandlerFactory})
+ * @param i18nProvider Framework's translation provider
+ */
+ public ArgoClimaHandlerLocal(Thing thing, HttpClientFactory clientFactory, TimeZoneProvider timeZoneProvider,
+ final ArgoClimaTranslationProvider i18nProvider) {
+ super(thing, ArgoClimaBindingConstants.AWAIT_DEVICE_CONFIRMATIONS_AFTER_COMMANDS,
+ ArgoClimaBindingConstants.POLL_FREQUENCY_AFTER_COMMAND_SENT_LOCAL,
+ ArgoClimaBindingConstants.SEND_COMMAND_RETRY_FREQUENCY_LOCAL,
+ ArgoClimaBindingConstants.SEND_COMMAND_MAX_WAIT_TIME_LOCAL_DIRECT,
+ ArgoClimaBindingConstants.SEND_COMMAND_MAX_WAIT_TIME_LOCAL_INDIRECT, i18nProvider);
+ this.commonHttpClient = clientFactory.getCommonHttpClient();
+ this.clientFactory = clientFactory;
+ this.timeZoneProvider = timeZoneProvider;
+ }
+
+ @Override
+ protected ArgoClimaConfigurationLocal getConfigInternal() throws ArgoConfigurationException {
+ try {
+ var ret = getConfigAs(ArgoClimaConfigurationLocal.class); // This can **theoretically** return null if class
+ // is not default-constructible (but this one is,
+ // so not handling!)
+ ret.initialize(i18nProvider);
+ return ret;
+ } catch (IllegalArgumentException ex) {
+ throw ArgoConfigurationException.forInvalidParamValue("Error loading thing configuration",
+ "thing-status.argoclima.configuration.load-error", i18nProvider, ex);
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * For any {@code REMOTE_API_*}