From 75b5a2745572e7fc512611f5933657bce549946e Mon Sep 17 00:00:00 2001 From: Martin Date: Sat, 24 Aug 2024 09:49:23 +0200 Subject: [PATCH] [salus] Add `running-state` channel for it600 (#17221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ReverseEngineerProtocol Signed-off-by: Martin Grześlowski --- .../org.openhab.binding.salus/DEV_README.md | 84 ++++ bundles/org.openhab.binding.salus/README.md | 11 +- .../salus/internal/SalusBindingConstants.java | 4 +- .../aws/handler/AwsCloudBridgeHandler.java | 2 +- .../cloud/handler/CloudBridgeHandler.java | 3 +- .../salus/internal/handler/It600Handler.java | 35 +- .../resources/OH-INF/i18n/salus.properties | 3 + .../src/main/resources/OH-INF/thing/it600.xml | 9 + .../OH-INF/update/it600-running-state.xml | 14 + .../internal/ReverseEngineerProtocol.java | 404 ++++++++++++++++++ 10 files changed, 553 insertions(+), 16 deletions(-) create mode 100644 bundles/org.openhab.binding.salus/DEV_README.md create mode 100644 bundles/org.openhab.binding.salus/src/main/resources/OH-INF/update/it600-running-state.xml create mode 100644 bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/ReverseEngineerProtocol.java diff --git a/bundles/org.openhab.binding.salus/DEV_README.md b/bundles/org.openhab.binding.salus/DEV_README.md new file mode 100644 index 00000000000..cfe655528cf --- /dev/null +++ b/bundles/org.openhab.binding.salus/DEV_README.md @@ -0,0 +1,84 @@ +# ReverseEngineerProtocol CLI Documentation + +This documentation provides instructions on how to use the ReverseEngineerProtocol CLI program to reverse engineer the proprietary Salus protocol. + +## How to Run + +To execute the CLI program, run the `main` method from the `ReverseEngineerProtocol` class. You can either run it directly from an IDE or use the `java` command. The program requires three parameters: `email`, `password`, and the Salus backend type (`AwsSalusApi` or `HttpSalusApi`). + +### Running from an IDE + +1. Open the project in your IDE. +2. Navigate to the `ReverseEngineerProtocol` class. +3. Run the `main` method, passing in the required parameters. + +### Running from the Command Line + +```bash +java -cp ReverseEngineerProtocol +``` + +Replace `` with the path to your compiled classes, and ``, ``, and `` with your actual credentials and backend type. + +## Methods + +### `findDevices` + +Finds and lists all devices associated with your Salus cloud account. + +**Usage:** + +```bash +./ReverseEngineerProtocol findDevices +``` + +### `findDeviceProperties ` + +Retrieves all properties for the device with the given Device Serial Number (DSN). + +**Parameters:** +- ``: The Device Serial Number of the target device. + +**Usage:** + +```bash +./ReverseEngineerProtocol findDeviceProperties +``` + +### `findDeltaInProperties ` + +Initializes by loading all properties from the given device, then filters out the properties that have changed or remained unchanged. This method is useful for identifying which property corresponds to a specific value or state. + +**Parameters:** +- ``: The Device Serial Number of the target device. + +**Example Use Case:** + +To find which property stores the "running" state of a device: + +1. Run `findDeltaInProperties `. +2. Filter out properties that have changed (this can be done multiple times). +3. Trigger the device to change state (e.g., set the temperature higher than the current one to make the device run). +4. Filter out properties that have not changed. +5. Repeat steps 2-4 until the desired property is identified. + +**Usage:** + +```bash +./ReverseEngineerProtocol findDeltaInProperties +``` + +### `monitorProperty ` + +Monitors and retrieves the value of a specific property from a given device at specified intervals. + +**Parameters:** +- ``: The Device Serial Number of the target device. +- ``: The name of the property to monitor. +- ``: (optional; default 1) The sleep interval (in seconds) between each check. + +**Usage:** + +```bash +./ReverseEngineerProtocol monitorProperty +``` diff --git a/bundles/org.openhab.binding.salus/README.md b/bundles/org.openhab.binding.salus/README.md index bbe5a45e48c..7bc0d53d5dc 100644 --- a/bundles/org.openhab.binding.salus/README.md +++ b/bundles/org.openhab.binding.salus/README.md @@ -75,11 +75,12 @@ removed. ### `salus-it600-device` Channels -| Channel | Type | Read/Write | Description | -|-----------------------------|--------------------|------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| it600-temp-channel | Number:Temperature | RO | Current temperature in the room | -| it600-expected-temp-channel | Number:Temperature | RW | Sets the desired temperature in the room | -| it600-work-type-channel | String | RW | Sets the work type for the device. OFF - device is turned off MANUAL - schedules are turned off, following a manual temperature set, AUTOMATIC - schedules are turned on, following schedule, TEMPORARY_MANUAL - schedules are turned on, following manual temperature until the next schedule. | +| Channel | Type | Read/Write | Description | +|----------------------------|--------------------|------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| temperature | Number:Temperature | RO | Current temperature in the room | +| expected-temperature | Number:Temperature | RW | Sets the desired temperature in the room | +| work-type | String | RW | Sets the work type for the device. OFF - device is turned off MANUAL - schedules are turned off, following a manual temperature set, AUTOMATIC - schedules are turned on, following schedule, TEMPORARY_MANUAL - schedules are turned on, following manual temperature until the next schedule. | +| running-state | Switch | RO | Is the device running | ## Full Example diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/SalusBindingConstants.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/SalusBindingConstants.java index ecc290d325b..7b0b8c420ae 100644 --- a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/SalusBindingConstants.java +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/SalusBindingConstants.java @@ -17,9 +17,6 @@ import java.util.Set; import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.core.thing.ThingTypeUID; -/** - * @author Martin Grześlowski - Initial contribution - */ /** * The {@link SalusBindingConstants} class defines common constants, which are * used across the whole binding. @@ -65,6 +62,7 @@ public class SalusBindingConstants { public static final String TEMPERATURE = "temperature"; public static final String EXPECTED_TEMPERATURE = "expected-temperature"; public static final String WORK_TYPE = "work-type"; + public static final String RUNNING_STATE = "running-state"; } public static final String GENERIC_OUTPUT_CHANNEL = "generic-output-channel"; diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/handler/AwsCloudBridgeHandler.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/handler/AwsCloudBridgeHandler.java index 0c43588aae0..dc42b534f53 100644 --- a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/handler/AwsCloudBridgeHandler.java +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/handler/AwsCloudBridgeHandler.java @@ -47,7 +47,7 @@ public final class AwsCloudBridgeHandler extends AbstractBridgeHandler it600RequiredChannels() { return Set.of("ep9:sIT600TH:LocalTemperature_x100", "ep9:sIT600TH:HeatingSetpoint_x100", - "ep9:sIT600TH:HoldType"); + "ep9:sIT600TH:HoldType", "ep9:sIT600TH:RunningState"); } @Override diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/cloud/handler/CloudBridgeHandler.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/cloud/handler/CloudBridgeHandler.java index de0d3086438..868f5a6d4e3 100644 --- a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/cloud/handler/CloudBridgeHandler.java +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/cloud/handler/CloudBridgeHandler.java @@ -45,7 +45,8 @@ public final class CloudBridgeHandler extends AbstractBridgeHandler it600RequiredChannels() { return Set.of("ep_9:sIT600TH:LocalTemperature_x100", "ep_9:sIT600TH:HeatingSetpoint_x100", - "ep_9:sIT600TH:SetHeatingSetpoint_x100", "ep_9:sIT600TH:HoldType", "ep_9:sIT600TH:SetHoldType"); + "ep_9:sIT600TH:SetHeatingSetpoint_x100", "ep_9:sIT600TH:HoldType", "ep_9:sIT600TH:SetHoldType", + "ep_9:sIT600TH:RunningState"); } @Override diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/It600Handler.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/It600Handler.java index d6bb34b3a60..0907f40e3a2 100644 --- a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/It600Handler.java +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/It600Handler.java @@ -34,6 +34,7 @@ import org.openhab.binding.salus.internal.rest.DeviceProperty; import org.openhab.binding.salus.internal.rest.exceptions.AuthSalusApiException; import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException; import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.OnOffType; import org.openhab.core.library.types.QuantityType; import org.openhab.core.library.types.StringType; import org.openhab.core.thing.ChannelUID; @@ -143,6 +144,9 @@ public class It600Handler extends BaseThingHandler { case WORK_TYPE: handleCommandForWorkType(channelUID, command); break; + case RUNNING_STATE: + handleCommandForRunningState(channelUID, command); + break; default: logger.warn("Unknown channel `{}` for command `{}`", id, command); } @@ -257,16 +261,35 @@ public class It600Handler extends BaseThingHandler { command.getClass().getSimpleName(), channelUID); } + private void handleCommandForRunningState(ChannelUID channelUID, Command command) + throws SalusApiException, AuthSalusApiException { + if (!(command instanceof RefreshType)) { + return; + } + findLongProperty(channelPrefix + ":sIT600TH:RunningState", "RunningState")// + .map(DeviceProperty::getValue)// + .map(value -> value > 0)// + .map(OnOffType::from)// + .ifPresent(state -> { + updateState(channelUID, state); + updateStatus(ONLINE); + }); + } + private Optional findLongProperty(String name, String shortName) throws SalusApiException, AuthSalusApiException { var deviceProperties = findDeviceProperties(); - var property = deviceProperties.stream().filter(p -> p.getName().equals(name)) - .filter(DeviceProperty.LongDeviceProperty.class::isInstance) - .map(DeviceProperty.LongDeviceProperty.class::cast).findAny(); + var property = deviceProperties.stream()// + .filter(p -> p.getName().equals(name))// + .filter(DeviceProperty.LongDeviceProperty.class::isInstance)// + .map(DeviceProperty.LongDeviceProperty.class::cast)// + .findAny(); if (property.isEmpty()) { - property = deviceProperties.stream().filter(p -> p.getName().contains(shortName)) - .filter(DeviceProperty.LongDeviceProperty.class::isInstance) - .map(DeviceProperty.LongDeviceProperty.class::cast).findAny(); + property = deviceProperties.stream()// + .filter(p -> p.getName().contains(shortName))// + .filter(DeviceProperty.LongDeviceProperty.class::isInstance)// + .map(DeviceProperty.LongDeviceProperty.class::cast)// + .findAny(); } if (property.isEmpty()) { logger.debug("{}/{} property not found!", name, shortName); diff --git a/bundles/org.openhab.binding.salus/src/main/resources/OH-INF/i18n/salus.properties b/bundles/org.openhab.binding.salus/src/main/resources/OH-INF/i18n/salus.properties index a6529442125..877782719d4 100644 --- a/bundles/org.openhab.binding.salus/src/main/resources/OH-INF/i18n/salus.properties +++ b/bundles/org.openhab.binding.salus/src/main/resources/OH-INF/i18n/salus.properties @@ -22,6 +22,7 @@ thing-type.config.salus.salus-aws-bridge.clientId.description = The app client I thing-type.config.salus.salus-aws-bridge.companyCode.label = Company Code thing-type.config.salus.salus-aws-bridge.group.aws.label = AWS thing-type.config.salus.salus-aws-bridge.group.aws.description = AWS Properties +thing-type.config.salus.salus-aws-bridge.identityPoolId.label = Identity Pool ID thing-type.config.salus.salus-aws-bridge.maxHttpRetries.label = Max HTTP Retries thing-type.config.salus.salus-aws-bridge.maxHttpRetries.description = How many times HTTP requests can be retried thing-type.config.salus.salus-aws-bridge.password.label = Password @@ -70,6 +71,8 @@ channel-type.salus.generic-output-number-channel.label = Generic Number Output channel-type.salus.generic-output-number-channel.description = This channel type represents a generic output. The channel is read-only and its state is represented as a numeric. channel-type.salus.it600-expected-temp-channel.label = Expected Temperature channel-type.salus.it600-expected-temp-channel.description = Sets the desired temperature in room +channel-type.salus.it600-running-state.label = Running State +channel-type.salus.it600-running-state.description = Is the device running channel-type.salus.it600-temp-channel.label = Temperature channel-type.salus.it600-temp-channel.description = Current temperature in room channel-type.salus.it600-work-type-channel.label = Work Type diff --git a/bundles/org.openhab.binding.salus/src/main/resources/OH-INF/thing/it600.xml b/bundles/org.openhab.binding.salus/src/main/resources/OH-INF/thing/it600.xml index d5fd688c5b7..9b761a3a1e1 100644 --- a/bundles/org.openhab.binding.salus/src/main/resources/OH-INF/thing/it600.xml +++ b/bundles/org.openhab.binding.salus/src/main/resources/OH-INF/thing/it600.xml @@ -21,7 +21,11 @@ + + + 1 + dsn @@ -70,4 +74,9 @@ + + Switch + + Is the device running + diff --git a/bundles/org.openhab.binding.salus/src/main/resources/OH-INF/update/it600-running-state.xml b/bundles/org.openhab.binding.salus/src/main/resources/OH-INF/update/it600-running-state.xml new file mode 100644 index 00000000000..3f4b74b79c9 --- /dev/null +++ b/bundles/org.openhab.binding.salus/src/main/resources/OH-INF/update/it600-running-state.xml @@ -0,0 +1,14 @@ + + + + + + + salus:it600-running-state + + + + + diff --git a/bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/ReverseEngineerProtocol.java b/bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/ReverseEngineerProtocol.java new file mode 100644 index 00000000000..e8606aa89ad --- /dev/null +++ b/bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/ReverseEngineerProtocol.java @@ -0,0 +1,404 @@ +/** + * 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.salus.internal; + +import static java.lang.Math.max; +import static java.util.Objects.requireNonNull; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Queue; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.http2.client.HTTP2Client; +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.junit.jupiter.api.Test; +import org.openhab.binding.salus.internal.aws.http.AwsSalusApi; +import org.openhab.binding.salus.internal.cloud.rest.HttpSalusApi; +import org.openhab.binding.salus.internal.rest.Device; +import org.openhab.binding.salus.internal.rest.DeviceProperty; +import org.openhab.binding.salus.internal.rest.GsonMapper; +import org.openhab.binding.salus.internal.rest.HttpClient; +import org.openhab.binding.salus.internal.rest.exceptions.AuthSalusApiException; +import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * @author Martin Grześlowski - Initial contribution + */ +@NonNullByDefault +public class ReverseEngineerProtocol implements AutoCloseable { + static final Logger LOGGER = LoggerFactory.getLogger(ReverseEngineerProtocol.class); + final List methods = List.of("findDevices", "findDeviceProperties", "findDeltaInProperties", + "monitorProperty"); + final BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); + final String baseUrl = "https://service-api.eu.premium.salusconnect.io"; + final org.eclipse.jetty.client.HttpClient client = new org.eclipse.jetty.client.HttpClient( + new SslContextFactory.Client()); + final HttpClientFactory httpClientFactory = new HttpClientFactory() { + + @Override + public org.eclipse.jetty.client.HttpClient createHttpClient(String consumerName) { + throw new UnsupportedOperationException("ReverseEngineerProtocol.createHttpClient(consumerName)"); + } + + @Override + public org.eclipse.jetty.client.HttpClient createHttpClient(String consumerName, + @Nullable SslContextFactory sslContextFactory) { + throw new UnsupportedOperationException( + "ReverseEngineerProtocol.createHttpClient(consumerName, sslContextFactory)"); + } + + @Override + public org.eclipse.jetty.client.HttpClient getCommonHttpClient() { + return client; + } + + @Override + public HTTP2Client createHttp2Client(String consumerName) { + throw new UnsupportedOperationException("ReverseEngineerProtocol.createHttp2Client(consumerName)"); + } + + @Override + public HTTP2Client createHttp2Client(String consumerName, @Nullable SslContextFactory sslContextFactory) { + throw new UnsupportedOperationException( + "ReverseEngineerProtocol.createHttp2Client(consumerName, sslContextFactory)"); + } + }; + final SalusApi api; + + public ReverseEngineerProtocol(String username, String password, String apiType) throws Exception { + requireNonNull(username); + requireNonNull(password); + requireNonNull(apiType); + + client.start(); + var restClient = new HttpClient(client); + var gsonMapper = new GsonMapper(); + if (apiType.equals(AwsSalusApi.class.getSimpleName())) { + api = new AwsSalusApi(httpClientFactory, username, password.getBytes(StandardCharsets.UTF_8), baseUrl, + restClient, gsonMapper, "eu-central-1_XGRz3CgoY", "60912c00-287d-413b-a2c9-ece3ccef9230", + "4pk5efh3v84g5dav43imsv4fbj", "eu-central-1", "salus-eu", "a24u3z7zzwrtdl-ats"); + } else if (apiType.equals(HttpSalusApi.class.getSimpleName())) { + api = new HttpSalusApi(username, password.getBytes(StandardCharsets.UTF_8), baseUrl, restClient, gsonMapper, + Clock.systemDefaultZone()); + } else { + printUsage(); + throw new IllegalStateException("Invalid api type: " + apiType); + } + } + + public static void main(String[] args) throws Exception { + if (args.length < 3) { + printUsage(); + throw new IllegalStateException("Check usage"); + } + + var runIndefinitely = args.length == 3; + if (runIndefinitely) { + LOGGER.info("Will run indefinitely, use ctrl-C to exit"); + } + var queue = newQueue(args); + try (var reverseProtocol = new ReverseEngineerProtocol(requireNonNull(queue.poll()), + requireNonNull(queue.poll()), requireNonNull(queue.poll()))) { + // noinspection LoopConditionNotUpdatedInsideLoop + do { + reverseProtocol.run(queue); + } while (runIndefinitely); + } + LOGGER.info("Bye bye 👋"); + } + + private static Queue newQueue(String[] args) { + var queue = new ArrayBlockingQueue(args.length); + queue.addAll(Arrays.asList(args)); + return queue; + } + + private void run(Queue queue) throws Exception { + var method = findMethod(queue); + LOGGER.info("Will invoke method [" + method + "]"); + switch (method) { + case "findDevices": + findDevices(); + break; + case "findDeviceProperties": + findDeviceProperties(findDsn(queue)); + break; + case "findDeltaInProperties": + findDeltaInProperties(findDsn(queue)); + break; + case "monitorProperty": + monitorProperty(findDsn(queue), findPropertyName(queue, 6), findSleep(queue, 7)); + break; + default: + printUsage(); + throw new IllegalStateException("Invalid method: [" + method + "]"); + } + } + + private String findMethod(Queue args) throws IOException { + var item = args.poll(); + if (item != null) { + return item; + } + + int response = 0; + while (response < 1 || response > methods.size()) { + LOGGER.info(String.format("Please choose [method] 1-%d:", methods.size())); + for (int i = 0; i < methods.size(); i++) { + LOGGER.info(String.format("\t[%d]: %s", i + 1, methods.get(i))); + } + try { + response = Integer.parseInt(reader.readLine()); + } catch (NumberFormatException e) { + LOGGER.info(e.getMessage()); + } + } + return methods.get(response - 1); + } + + private String findNextElement(String name, Queue args) throws IOException { + var item = args.poll(); + if (item != null) { + return item; + } + LOGGER.info("Please pass [{}]:", name); + var line = ""; + while (line == null || line.isEmpty()) { + line = reader.readLine(); + } + return line; + } + + private String findDsn(Queue args) throws IOException { + return findNextElement("dsn", args); + } + + private String findPropertyName(Queue args, int idx) throws IOException { + return findNextElement("propertyName", args); + } + + @Nullable + private Long findSleep(Queue args, int idx) { + var item = args.poll(); + if (item == null) { + return null; + } + try { + return Long.parseLong(item); + } catch (NumberFormatException e) { + return null; + } + } + + private static void printUsage() { + LOGGER.info(""" + Usage: + \tReverseEngineerProtocol + \tSupported method types: + \t\tfindDevices + \t\tfindDeviceProperties + \t\tfindDeltaInProperties + \t\tmonitorProperty + """); + } + + @Test + void findDevices() throws AuthSalusApiException, SalusApiException { + var devices = api.findDevices(); + LOGGER.info(String.format("Your devices (%s):", api.getClass().getSimpleName())); + printDevices(devices); + } + + @Test + void findDeviceProperties(String dsn) throws AuthSalusApiException, SalusApiException { + var properties = api.findDeviceProperties(dsn); + LOGGER.info(String.format("Properties for device %s (%s):", dsn, api.getClass().getSimpleName())); + printDevicesProperties(properties); + } + + void findDeltaInProperties(String dsn) throws AuthSalusApiException, SalusApiException, IOException { + requireNonNull(dsn); + + var differentProperties = api.findDeviceProperties(dsn); + var answer = ""; + while (true) { + if (differentProperties.isEmpty()) { + LOGGER.info("There are no more properties 😬..."); + break; + } + printDevicesProperties(differentProperties); + + LOGGER.info("Read one more time and leave properties that changed (x) / not changed (q) or finish (f):"); + answer = reader.readLine(); + if (answer.equalsIgnoreCase("f")) { + break; + } + if (!answer.equalsIgnoreCase("x") && !answer.equalsIgnoreCase("q")) { + LOGGER.info("Wrong answer: " + answer); + continue; + } + var changed = answer.equalsIgnoreCase("x"); + + var beforeSize = differentProperties.size(); + var currentProperties = api.findDeviceProperties(dsn); + var oldProps = new TreeSet<>(differentProperties); + differentProperties = currentProperties.stream()// + .filter(currentProp -> filterProperties(oldProps, currentProp, changed)) + .collect(Collectors.toCollection(TreeSet::new)); + var currentSize = differentProperties.size(); + var delta = beforeSize - currentSize; + LOGGER.info(String.format("Current size: %d, beforeSize: %d, Δ: %d", currentSize, beforeSize, delta)); + } + + LOGGER.info(String.format("Properties for device %s (%s):", dsn, api.getClass().getSimpleName())); + if (differentProperties.isEmpty()) { + LOGGER.info("None 😬..."); + } else { + printDevicesProperties(differentProperties); + } + } + + private boolean filterProperties(SortedSet> oldProps, DeviceProperty currentProp, + boolean changed) { + return oldProps.stream()// + .filter(p -> p.getName().equals(currentProp.getName()))// + .anyMatch(p -> changed != Objects.equals(p.getValue(), currentProp.getValue())); + } + + void monitorProperty(String dsn, String propertyName, @Nullable Long sleep) + throws AuthSalusApiException, SalusApiException, InterruptedException { + requireNonNull(dsn); + requireNonNull(propertyName); + if (sleep == null) { + sleep = 1L; + } + + LOGGER.info("Finish loop by ctrl+c"); + while (true) { + var deviceProperty = api.findDeviceProperties(dsn).stream()// + .filter(p -> p.getName().equals(propertyName))// + .findAny(); + if (deviceProperty.isPresent()) { + LOGGER.info(deviceProperty.get() + ""); + } else { + LOGGER.info("Property does not exists!"); + break; + } + TimeUnit.SECONDS.sleep(sleep); + } + } + + private void printDevices(Collection devices) { + var sizeLength = String.valueOf(devices.size()).length(); + var longestDsn = max("dsn".length(), + devices.stream().map(Device::dsn).mapToInt(String::length).max().orElse(0)); + var longestName = max("name".length(), + devices.stream().map(Device::name).map(String::valueOf).mapToInt(String::length).max().orElse(0)); + var margins = 8; + var pipe = "═".repeat(sizeLength + longestDsn + longestName + margins); + System.out.printf("╔%s╦%s╦%s╗", "═".repeat(sizeLength + 2), "═".repeat(longestDsn + 2), + "═".repeat(longestName + 2)); + System.out.printf("║ %s ║ %s ║ %s ║", rightAlign("#", sizeLength), leftAlign("name", longestDsn), + leftAlign("value", longestName)); + System.out.printf("╠%s╬%s╬%s╣", "═".repeat(sizeLength + 2), "═".repeat(longestDsn + 2), + "═".repeat(longestName + 2)); + + var idx = 1; + for (var device : devices) { + System.out.printf("║ %s ║ %s ║ %s ║", // + rightAlign(String.valueOf(idx), sizeLength), // + leftAlign(device.dsn(), longestDsn), // + leftAlign(device.name(), longestName)); + idx++; + } + + System.out.printf("╚%s╩%s╩%s╝", "═".repeat(sizeLength + 2), "═".repeat(longestDsn + 2), + "═".repeat(longestName + 2)); + } + + private void printDevicesProperties(Collection> properties) { + var sizeLength = String.valueOf(properties.size()).length(); + var longestName = max("name".length(), + properties.stream().map(DeviceProperty::getName).mapToInt(String::length).max().orElse(0)); + var longestValue = max("value".length(), properties.stream().map(DeviceProperty::getValue).map(String::valueOf) + .mapToInt(String::length).max().orElse(0)); + var margins = 8; + var pipe = "═".repeat(sizeLength + longestName + longestValue + margins); + System.out.printf("╔%s╦%s╦%s╗", "═".repeat(sizeLength + 2), "═".repeat(longestName + 2), + "═".repeat(longestValue + 2)); + System.out.printf("║ %s ║ %s ║ %s ║", rightAlign("#", sizeLength), leftAlign("name", longestName), + leftAlign("value", longestValue)); + System.out.printf("╠%s╬%s╬%s╣", "═".repeat(sizeLength + 2), "═".repeat(longestName + 2), + "═".repeat(longestValue + 2)); + + var idx = 1; + for (var property : properties) { + System.out.printf("║ %s ║ %s ║ %s ║", // + rightAlign(String.valueOf(idx), sizeLength), // + leftAlign(property.getName(), longestName), // + leftAlign(property.getValue(), longestValue)); + idx++; + } + + System.out.printf("╚%s╩%s╩%s╝", "═".repeat(sizeLength + 2), "═".repeat(longestName + 2), + "═".repeat(longestValue + 2)); + } + + private String rightAlign(String inputString, int length) { + if (inputString.length() >= length) { + return inputString; + } + StringBuilder sb = new StringBuilder(); + while (sb.length() < length - inputString.length()) { + sb.append(' '); + } + sb.append(inputString); + + return sb.toString(); + } + + private String leftAlign(@Nullable Object obj, int length) { + var inputString = String.valueOf(obj); + if (inputString.length() >= length) { + return inputString; + } + StringBuilder sb = new StringBuilder(inputString); + while (sb.length() < length) { + sb.append(' '); + } + + return sb.toString(); + } + + @Override + public void close() throws Exception { + client.stop(); + } +}