]> git.basschouten.com Git - openhab-addons.git/commitdiff
[salus] Add `running-state` channel for it600 (#17221)
authorMartin <martin.grzeslowski@gmail.com>
Sat, 24 Aug 2024 07:49:23 +0000 (09:49 +0200)
committerGitHub <noreply@github.com>
Sat, 24 Aug 2024 07:49:23 +0000 (09:49 +0200)
* ReverseEngineerProtocol

Signed-off-by: Martin Grześlowski <martin.grzeslowski@gmail.com>
bundles/org.openhab.binding.salus/DEV_README.md [new file with mode: 0644]
bundles/org.openhab.binding.salus/README.md
bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/SalusBindingConstants.java
bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/handler/AwsCloudBridgeHandler.java
bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/cloud/handler/CloudBridgeHandler.java
bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/It600Handler.java
bundles/org.openhab.binding.salus/src/main/resources/OH-INF/i18n/salus.properties
bundles/org.openhab.binding.salus/src/main/resources/OH-INF/thing/it600.xml
bundles/org.openhab.binding.salus/src/main/resources/OH-INF/update/it600-running-state.xml [new file with mode: 0644]
bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/ReverseEngineerProtocol.java [new file with mode: 0644]

diff --git a/bundles/org.openhab.binding.salus/DEV_README.md b/bundles/org.openhab.binding.salus/DEV_README.md
new file mode 100644 (file)
index 0000000..cfe6555
--- /dev/null
@@ -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 <your-compiled-class-path> ReverseEngineerProtocol <email> <password> <backendType>
+```
+
+Replace `<your-compiled-class-path>` with the path to your compiled classes, and `<email>`, `<password>`, and `<backendType>` with your actual credentials and backend type.
+
+## Methods
+
+### `findDevices`
+
+Finds and lists all devices associated with your Salus cloud account.
+
+**Usage:**
+
+```bash
+./ReverseEngineerProtocol <email> <password> <backendType> findDevices
+```
+
+### `findDeviceProperties <dsn>`
+
+Retrieves all properties for the device with the given Device Serial Number (DSN).
+
+**Parameters:**
+- `<dsn>`: The Device Serial Number of the target device.
+
+**Usage:**
+
+```bash
+./ReverseEngineerProtocol <email> <password> <backendType> findDeviceProperties <dsn>
+```
+
+### `findDeltaInProperties <dsn>`
+
+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:**
+- `<dsn>`: 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 <dsn>`.
+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 <email> <password> <backendType> findDeltaInProperties <dsn>
+```
+
+### `monitorProperty <dsn> <propertyName> <sleep>`
+
+Monitors and retrieves the value of a specific property from a given device at specified intervals.
+
+**Parameters:**
+- `<dsn>`: The Device Serial Number of the target device.
+- `<propertyName>`: The name of the property to monitor.
+- `<sleep>`: (optional; default 1) The sleep interval (in seconds) between each check.
+
+**Usage:**
+
+```bash
+./ReverseEngineerProtocol <email> <password> <backendType> monitorProperty <dsn> <propertyName> <sleep>
+```
index bbe5a45e48c90b7a20e40b0c77ce03edc25ec1a8..7bc0d53d5dc771f57621dc78419647e0bc49dba4 100644 (file)
@@ -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
 
index ecc290d325b3fc2e2714f3e4dd5dd4213380f802..7b0b8c420aeda80933e08aa3d1b59a268beec46d 100644 (file)
@@ -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";
index 0c43588aae0880cf4ba2550a815da0ff908a1f28..dc42b534f5383fe034341f691954d97f78802632 100644 (file)
@@ -47,7 +47,7 @@ public final class AwsCloudBridgeHandler extends AbstractBridgeHandler<AwsCloudB
     @Override
     public Set<String> it600RequiredChannels() {
         return Set.of("ep9:sIT600TH:LocalTemperature_x100", "ep9:sIT600TH:HeatingSetpoint_x100",
-                "ep9:sIT600TH:HoldType");
+                "ep9:sIT600TH:HoldType", "ep9:sIT600TH:RunningState");
     }
 
     @Override
index de0d308643849222c62f896422af72f0f8fb0cef..868f5a6d4e325c258c7532831337b79e0ad28dba 100644 (file)
@@ -45,7 +45,8 @@ public final class CloudBridgeHandler extends AbstractBridgeHandler<CloudBridgeC
     @Override
     public Set<String> 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
index d6bb34b3a60ea95c257b43c96ec07f47b7714c88..0907f40e3a22670997a4347250aed34578931357 100644 (file)
@@ -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<DeviceProperty.LongDeviceProperty> 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);
index a6529442125b661a006d6ba0c984895935acee06..877782719d45c23011cabc96065e844cd7af213a 100644 (file)
@@ -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
index d5fd688c5b73d7c1511265019597c8c21c098216..9b761a3a1e181d7c382420812aab33d5fb6c99ed 100644 (file)
                        <channel id="temperature" typeId="it600-temp-channel"/>
                        <channel id="expected-temperature" typeId="it600-expected-temp-channel"/>
                        <channel id="work-type" typeId="it600-work-type-channel"/>
+                       <channel id="running-state" typeId="it600-running-state"/>
                </channels>
+               <properties>
+                       <property name="thingTypeVersion">1</property>
+               </properties>
                <representation-property>dsn</representation-property>
                <config-description>
                        <parameter name="dsn" type="text" required="true">
@@ -70,4 +74,9 @@
                        </options>
                </state>
        </channel-type>
+       <channel-type id="it600-running-state">
+               <item-type>Switch</item-type>
+               <label>Running State</label>
+               <description>Is the device running</description>
+       </channel-type>
 </thing:thing-descriptions>
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 (file)
index 0000000..3f4b74b
--- /dev/null
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
+<update:update-descriptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:update="https://openhab.org/schemas/update-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/update-description/v1.0.0 https://openhab.org/schemas/update-description-1.0.0.xsd">
+
+       <thing-type uid="salus:salus-it600-device">
+               <instruction-set targetVersion="1">
+                       <add-channel id="running-state">
+                               <type>salus:it600-running-state</type>
+                       </add-channel>
+               </instruction-set>
+       </thing-type>
+
+</update:update-descriptions>
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 (file)
index 0000000..e8606aa
--- /dev/null
@@ -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<String> 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<String> newQueue(String[] args) {
+        var queue = new ArrayBlockingQueue<String>(args.length);
+        queue.addAll(Arrays.asList(args));
+        return queue;
+    }
+
+    private void run(Queue<String> 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<String> 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<String> 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<String> args) throws IOException {
+        return findNextElement("dsn", args);
+    }
+
+    private String findPropertyName(Queue<String> args, int idx) throws IOException {
+        return findNextElement("propertyName", args);
+    }
+
+    @Nullable
+    private Long findSleep(Queue<String> 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 <username> <password> <apiType> <method-name?> <params...>
+                \tSupported method types:
+                \t\tfindDevices
+                \t\tfindDeviceProperties <dsn>
+                \t\tfindDeltaInProperties <dsn>
+                \t\tmonitorProperty <dsn> <propertyName> <sleepTime?>
+                """);
+    }
+
+    @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<DeviceProperty<?>> 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<Device> 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<DeviceProperty<?>> 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();
+    }
+}