2 * Copyright (c) 2010-2024 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.argoclima.internal.device.api;
15 import java.net.InetAddress;
17 import java.net.URLEncoder;
18 import java.nio.charset.StandardCharsets;
19 import java.text.MessageFormat;
20 import java.util.Objects;
21 import java.util.Optional;
22 import java.util.SortedMap;
23 import java.util.function.Consumer;
24 import java.util.regex.Pattern;
26 import org.eclipse.jdt.annotation.NonNullByDefault;
27 import org.eclipse.jetty.client.HttpClient;
28 import org.openhab.binding.argoclima.internal.ArgoClimaTranslationProvider;
29 import org.openhab.binding.argoclima.internal.configuration.ArgoClimaConfigurationRemote;
30 import org.openhab.binding.argoclima.internal.device.api.DeviceStatus.DeviceProperties;
31 import org.openhab.binding.argoclima.internal.exception.ArgoApiCommunicationException;
32 import org.openhab.binding.argoclima.internal.exception.ArgoApiProtocolViolationException;
33 import org.openhab.core.i18n.TimeZoneProvider;
34 import org.slf4j.Logger;
35 import org.slf4j.LoggerFactory;
38 * Argo protocol implementation for a REMOTE connection to the device
40 * The HVAC device MUST be communicating with actual Argo servers for this method work.
41 * This means the device is either directly connected to the Internet (w/o traffic intercept), or there's an
42 * intercepting Stub server already running in a PASS-THROUGH mode (sniffing the messages but passing through to the
43 * actual vendor's servers)
46 * Use of this mode is actually NOT recommended for advanced users as cleartext device and Wi-Fi passwords are sent to
47 * Argo servers through unencrypted HTTP connection (sic!). If the Argo UI access is desired (ex. for FW update or IR
48 * remote-like experience), consider using this mode only on a dedicated Wi-Fi network (and possibly through VPN)
50 * @author Mateusz Bronk - Initial contribution
54 public class ArgoClimaRemoteDevice extends ArgoClimaDeviceApiBase {
55 private final Logger logger = LoggerFactory.getLogger(getClass());
57 private final InetAddress oemServerHostname;
58 private final int oemServerPort;
59 private final String usernameUrlEncoded;
60 private final String passwordMD5Hash;
61 private static final Pattern REMOTE_API_RESPONSE_EXPECTED = Pattern.compile(
62 "^[\\\\{][|](?<commands>[^|]+)[|](?<localIP>[^|]+)[|](?<lastSeen>[^|]+)[|][\\\\}]\\s*$",
63 Pattern.CASE_INSENSITIVE); // Capture group names are used in code!
68 * @param config The Thing configuration
69 * @param client The common HTTP client used for issuing requests to the remote server
70 * @param timeZoneProvider System-wide TZ provider, for parsing/displaying local dates
71 * @param i18nProvider Framework's translation provider
72 * @param oemServerHostname The address of the remote (vendor's) server
73 * @param oemServerPort The port of remote (vendor's) server
74 * @param username The username used for authenticating to the remote server (will be URL-encoded before send)
75 * @param passwordMD5 A MD5 hash of the password used for authenticating to the remote server (custom Basic-like
77 * @param onDevicePropertiesUpdate Callback to invoke when device properties get refreshed
79 public ArgoClimaRemoteDevice(ArgoClimaConfigurationRemote config, HttpClient client,
80 TimeZoneProvider timeZoneProvider, ArgoClimaTranslationProvider i18nProvider, InetAddress oemServerHostname,
81 int oemServerPort, String username, String passwordMD5,
82 Consumer<SortedMap<String, String>> onDevicePropertiesUpdate) {
83 super(config, client, timeZoneProvider, i18nProvider, onDevicePropertiesUpdate, "REMOTE_API");
84 this.oemServerHostname = oemServerHostname;
85 this.oemServerPort = oemServerPort;
86 this.usernameUrlEncoded = Objects.requireNonNull(URLEncoder.encode(username, StandardCharsets.UTF_8));
87 this.passwordMD5Hash = passwordMD5;
91 public final ReachabilityStatus isReachable() {
93 var status = extractDeviceStatusFromResponse(pollForCurrentStatusFromDeviceSync(getDeviceStateQueryUrl()));
95 this.deviceStatus.fromDeviceString(status.getCommandString());
96 } catch (ArgoApiProtocolViolationException e) {
97 throw new ArgoApiCommunicationException("Unrecognized API response",
98 "thing-status.cause.argoclima.exception.unrecognized-response", i18nProvider, e);
100 this.updateDevicePropertiesFromDeviceResponse(status.getProperties(), this.deviceStatus);
101 status.throwIfStatusIsStale();
102 return new ReachabilityStatus(true, "");
103 } catch (ArgoApiCommunicationException e) {
104 logger.debug("Device not reachable: {}", e.getMessage());
105 return new ReachabilityStatus(false,
106 Objects.requireNonNull(MessageFormat.format(
107 "Failed to communicate with Argo HVAC remote device at [http://{0}:{1,number,#}{2}]. {3}",
108 this.getDeviceStateQueryUrl().getHost(),
109 this.getDeviceStateQueryUrl().getPort() != -1 ? this.getDeviceStateQueryUrl().getPort()
110 : this.getDeviceStateQueryUrl().getDefaultPort(),
111 this.getDeviceStateQueryUrl().getPath(), e.getMessage())));
116 protected URL getDeviceStateQueryUrl() {
117 // Hard-coded values are part of ARGO protocol
118 return newUrl(Objects.requireNonNull(this.oemServerHostname.getHostName()), this.oemServerPort, "/UI/UI.php",
119 String.format("CM=UI_TC&USN=%s&PSW=%s&HMI=&UPD=0", this.usernameUrlEncoded, this.passwordMD5Hash));
123 protected URL getDeviceStateUpdateUrl() {
124 // Hard-coded values are part of ARGO protocol
125 return newUrl(Objects.requireNonNull(this.oemServerHostname.getHostName()), this.oemServerPort, "/UI/UI.php",
126 String.format("CM=UI_TC&USN=%s&PSW=%s&HMI=%s&UPD=1", this.usernameUrlEncoded, this.passwordMD5Hash,
127 this.deviceStatus.getDeviceCommandStatus()));
131 protected DeviceStatus extractDeviceStatusFromResponse(String apiResponse) throws ArgoApiCommunicationException {
132 if (apiResponse.isBlank()) {
133 throw new ArgoApiCommunicationException("The remote API response was empty. Check username and password",
134 "thing-status.cause.argoclima.empty-remote-response", i18nProvider);
137 var matcher = REMOTE_API_RESPONSE_EXPECTED.matcher(apiResponse);
138 if (!matcher.matches()) {
139 throw new ArgoApiCommunicationException("The remote API response [%s] was not recognized",
140 "thing-status.cause.argoclima.unrecognized-remote-response", i18nProvider, apiResponse);
143 // Group names must match regex above
144 var properties = new DeviceProperties(Objects.requireNonNull(matcher.group("localIP")),
145 Objects.requireNonNull(matcher.group("lastSeen")), Optional.of(
146 getWebUiUrl(Objects.requireNonNull(this.oemServerHostname.getHostName()), this.oemServerPort)));
148 return new DeviceStatus(Objects.requireNonNull(matcher.group("commands")), properties, i18nProvider);
152 * Return the full URL to the Vendor's web application
154 * @param hostName The OEM server host
155 * @param port The OEM server port
156 * @return Full URL to the UI webapp
158 public static URL getWebUiUrl(String hostName, int port) {
159 return newUrl(hostName, port, "/UI/WEBAPP/webapp.php", "");