]> git.basschouten.com Git - openhab-addons.git/commitdiff
[boschshc] Add command to list SHC device mappings (#15060)
authorGerd Zanker <gerd.zanker@web.de>
Mon, 26 Feb 2024 20:04:04 +0000 (21:04 +0100)
committerGitHub <noreply@github.com>
Mon, 26 Feb 2024 20:04:04 +0000 (21:04 +0100)
* [boschshc] add command to list Bosch Smart Home Controller devices and mapping to openhab devices and related services

Signed-off-by: Gerd Zanker <gerd.zanker@web.de>
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/console/BoschShcCommandExtension.java [new file with mode: 0644]
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/BridgeHandler.java
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/Device.java
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/PublicInformation.java
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/Scenario.java
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/SoftwareUpdateState.java [new file with mode: 0644]
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/SubscribeResult.java
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/UserDefinedState.java
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/discovery/ThingDiscoveryService.java
bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/console/BoschShcCommandExtensionTest.java [new file with mode: 0644]

diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/console/BoschShcCommandExtension.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/console/BoschShcCommandExtension.java
new file mode 100644 (file)
index 0000000..822146b
--- /dev/null
@@ -0,0 +1,264 @@
+/**
+ * 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.boschshc.internal.console;
+
+import static org.openhab.binding.boschshc.internal.discovery.ThingDiscoveryService.DEVICEMODEL_TO_THINGTYPE_MAP;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants;
+import org.openhab.binding.boschshc.internal.devices.bridge.BridgeHandler;
+import org.openhab.binding.boschshc.internal.devices.bridge.dto.Device;
+import org.openhab.binding.boschshc.internal.devices.bridge.dto.PublicInformation;
+import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException;
+import org.openhab.core.io.console.Console;
+import org.openhab.core.io.console.ConsoleCommandCompleter;
+import org.openhab.core.io.console.StringsCompleter;
+import org.openhab.core.io.console.extensions.AbstractConsoleCommandExtension;
+import org.openhab.core.io.console.extensions.ConsoleCommandExtension;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingRegistry;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.FrameworkUtil;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * Console command to list Bosch SHC devices and openhab support.
+ * Use the SHC API to get all SHC devices and SHC services
+ * and tries to lookup openhab devices and implemented service classes.
+ * Prints each name and looked-up implementation on console.
+ *
+ * @author Gerd Zanker - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = ConsoleCommandExtension.class)
+public class BoschShcCommandExtension extends AbstractConsoleCommandExtension implements ConsoleCommandCompleter {
+
+    static final String SHOW_BINDINGINFO = "showBindingInfo";
+    static final String SHOW_DEVICES = "showDevices";
+    static final String SHOW_SERVICES = "showServices";
+
+    static final String GET_BRIDGEINFO = "bridgeInfo";
+    static final String GET_DEVICES = "deviceInfo";
+    private static final StringsCompleter SUBCMD_COMPLETER = new StringsCompleter(
+            List.of(SHOW_BINDINGINFO, SHOW_DEVICES, SHOW_SERVICES, GET_BRIDGEINFO, GET_DEVICES), false);
+
+    private final ThingRegistry thingRegistry;
+
+    @Activate
+    public BoschShcCommandExtension(final @Reference ThingRegistry thingRegistry) {
+        super(BoschSHCBindingConstants.BINDING_ID, "Interact with the Bosch Smart Home Controller.");
+        this.thingRegistry = thingRegistry;
+    }
+
+    /**
+     * Returns all implemented services of this Bosch SHC binding.
+     * This list shall contain all available services and needs to be extended when a new service is added.
+     * A unit tests checks if this list matches with the existing subfolders in
+     * "src/main/java/org/openhab/binding/boschshc/internal/services".
+     */
+    List<String> getAllBoschShcServices() {
+        return List.of("airqualitylevel", "batterylevel", "binaryswitch", "bypass", "cameranotification", "childlock",
+                "communicationquality", "hsbcoloractuator", "humiditylevel", "illuminance", "intrusion", "keypad",
+                "latestmotion", "multilevelswitch", "powermeter", "powerswitch", "privacymode", "roomclimatecontrol",
+                "shuttercontact", "shuttercontrol", "silentmode", "smokedetectorcheck", "temperaturelevel", "userstate",
+                "valvetappet");
+    }
+
+    @Override
+    public void execute(String[] args, Console console) {
+        if (args.length == 0) {
+            printUsage(console);
+            return;
+        }
+        try {
+            if (GET_BRIDGEINFO.equals(args[0])) {
+                console.print(buildBridgeInfo());
+                return;
+            }
+            if (GET_DEVICES.equals(args[0])) {
+                console.print(buildDeviceInfo());
+                return;
+            }
+            if (SHOW_BINDINGINFO.equals(args[0])) {
+                console.print(buildBindingInfo());
+                return;
+            }
+            if (SHOW_DEVICES.equals(args[0])) {
+                console.print(buildSupportedDeviceStatus());
+                return;
+            }
+            if (SHOW_SERVICES.equals(args[0])) {
+                console.print(buildSupportedServiceStatus());
+                return;
+            }
+        } catch (BoschSHCException | ExecutionException | TimeoutException e) {
+            console.print(String.format("Error %1s%n", e.getMessage()));
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+        }
+        // unsupported command, print usage
+        printUsage(console);
+    }
+
+    private List<BridgeHandler> getBridgeHandlers() {
+        List<BridgeHandler> bridges = new ArrayList<>();
+        for (Thing thing : thingRegistry.getAll()) {
+            ThingHandler thingHandler = thing.getHandler();
+            if (thingHandler instanceof BridgeHandler bridgeHandler) {
+                bridges.add(bridgeHandler);
+            }
+        }
+        return bridges;
+    }
+
+    String buildBridgeInfo() throws BoschSHCException, InterruptedException, ExecutionException, TimeoutException {
+        List<BridgeHandler> bridges = getBridgeHandlers();
+        StringBuilder builder = new StringBuilder();
+        for (BridgeHandler bridgeHandler : bridges) {
+            builder.append(String.format("Bridge: %1s%n", bridgeHandler.getThing().getLabel()));
+            builder.append(String.format("  access possible: %1s%n", bridgeHandler.checkBridgeAccess()));
+
+            PublicInformation publicInformation = bridgeHandler.getPublicInformation();
+            builder.append(String.format("  SHC Generation: %1s%n", publicInformation.shcGeneration));
+            builder.append(String.format("  IP Address: %1s%n", publicInformation.shcIpAddress));
+            builder.append(String.format("  API Versions: %1s%n", publicInformation.apiVersions));
+            builder.append(String.format("  Software Version: %1s%n",
+                    publicInformation.softwareUpdateState.swInstalledVersion));
+            builder.append(String.format("  Version Update State: %1s%n",
+                    publicInformation.softwareUpdateState.swUpdateState));
+            builder.append(String.format("  Available Version: %1s%n",
+                    publicInformation.softwareUpdateState.swUpdateAvailableVersion));
+            builder.append(String.format("%n"));
+        }
+        return builder.toString();
+    }
+
+    String buildDeviceInfo() throws InterruptedException {
+        StringBuilder builder = new StringBuilder();
+        for (Thing thing : thingRegistry.getAll()) {
+            ThingHandler thingHandler = thing.getHandler();
+            if (thingHandler instanceof BridgeHandler bridgeHandler) {
+                builder.append(String.format("thing: %1s%n", thing.getLabel()));
+                builder.append(String.format("  thingHandler: %1s%n", thingHandler.getClass().getName()));
+                builder.append(String.format("bridge access possible: %1s%n", bridgeHandler.checkBridgeAccess()));
+
+                List<Device> devices = bridgeHandler.getDevices();
+                builder.append(String.format("devices (%1d): %n", devices.size()));
+                for (Device device : devices) {
+                    builder.append(buildDeviceInfo(device));
+                    builder.append(String.format("%n"));
+                }
+            }
+        }
+        return builder.toString();
+    }
+
+    private String buildDeviceInfo(Device device) {
+        StringBuilder builder = new StringBuilder();
+        builder.append(String.format("  deviceID: %1s%n", device.id));
+        builder.append(String.format("      type: %1s -> ", device.deviceModel));
+        if (DEVICEMODEL_TO_THINGTYPE_MAP.containsKey(device.deviceModel)) {
+            builder.append(DEVICEMODEL_TO_THINGTYPE_MAP.get(device.deviceModel).getId());
+        } else {
+            builder.append("!UNSUPPORTED!");
+        }
+        builder.append(String.format("%n"));
+
+        builder.append(buildDeviceServices(device.deviceServiceIds));
+        return builder.toString();
+    }
+
+    private String buildDeviceServices(List<String> deviceServiceIds) {
+        StringBuilder builder = new StringBuilder();
+        List<String> existingServices = getAllBoschShcServices();
+        for (String serviceName : deviceServiceIds) {
+            builder.append(String.format("            service: %1s -> ", serviceName));
+
+            if (existingServices.stream().anyMatch(s -> s.equals(serviceName.toLowerCase()))) {
+                for (String existingService : existingServices) {
+                    if (existingService.equals(serviceName.toLowerCase())) {
+                        builder.append(existingService);
+                    }
+                }
+            } else {
+                builder.append("!UNSUPPORTED!");
+            }
+            builder.append(String.format("%n"));
+        }
+        return builder.toString();
+    }
+
+    String buildBindingInfo() {
+        StringBuilder builder = new StringBuilder();
+        builder.append(String.format("Bosch SHC Binding%n"));
+        Bundle bundle = FrameworkUtil.getBundle(getClass());
+        if (bundle != null) {
+            builder.append(String.format("  SymbolicName %1s%n", bundle.getSymbolicName()));
+            builder.append(String.format("  Version %1s%n", bundle.getVersion()));
+        }
+        return builder.toString();
+    }
+
+    String buildSupportedDeviceStatus() {
+        StringBuilder builder = new StringBuilder();
+        builder.append(String.format("Supported Devices (%1d):%n", DEVICEMODEL_TO_THINGTYPE_MAP.size()));
+        for (Map.Entry<String, ThingTypeUID> entry : DEVICEMODEL_TO_THINGTYPE_MAP.entrySet()) {
+            builder.append(
+                    String.format(" - %1s = %1s%n", entry.getKey(), DEVICEMODEL_TO_THINGTYPE_MAP.get(entry.getKey())));
+        }
+        return builder.toString();
+    }
+
+    String buildSupportedServiceStatus() {
+        StringBuilder builder = new StringBuilder();
+        List<String> supportedServices = getAllBoschShcServices();
+        builder.append(String.format("Supported Services (%1d):%n", supportedServices.size()));
+        for (String service : supportedServices) {
+            builder.append(String.format(" - %1s%n", service));
+        }
+        return builder.toString();
+    }
+
+    @Override
+    public List<String> getUsages() {
+        return List.of(buildCommandUsage(SHOW_BINDINGINFO, "list detailed information about this binding"),
+                buildCommandUsage(SHOW_DEVICES, "list all devices supported by this binding"),
+                buildCommandUsage(SHOW_SERVICES, "list all services supported by this binding"),
+                buildCommandUsage(GET_DEVICES, "get all Bosch SHC devices"),
+                buildCommandUsage(GET_BRIDGEINFO, "get detailed information from Bosch SHC"));
+    }
+
+    @Override
+    public @Nullable ConsoleCommandCompleter getCompleter() {
+        return this;
+    }
+
+    @Override
+    public boolean complete(String[] args, int cursorArgumentIndex, int cursorPosition, List<String> candidates) {
+        if (cursorArgumentIndex <= 0) {
+            return SUBCMD_COMPLETER.complete(args, cursorArgumentIndex, cursorPosition, candidates);
+        }
+        return false;
+    }
+}
index 0cd68c8a6dd0410b022e139e3c213f076b213c9d..bfcedd426e8c74742213d59340f27c950946e33b 100644 (file)
@@ -39,6 +39,7 @@ import org.openhab.binding.boschshc.internal.devices.BoschSHCHandler;
 import org.openhab.binding.boschshc.internal.devices.bridge.dto.Device;
 import org.openhab.binding.boschshc.internal.devices.bridge.dto.DeviceServiceData;
 import org.openhab.binding.boschshc.internal.devices.bridge.dto.LongPollResult;
+import org.openhab.binding.boschshc.internal.devices.bridge.dto.PublicInformation;
 import org.openhab.binding.boschshc.internal.devices.bridge.dto.Room;
 import org.openhab.binding.boschshc.internal.devices.bridge.dto.Scenario;
 import org.openhab.binding.boschshc.internal.devices.bridge.dto.UserDefinedState;
@@ -432,6 +433,23 @@ public class BridgeHandler extends BaseBridgeHandler {
         }
     }
 
+    /**
+     * Get public information from Bosch SHC.
+     */
+    public PublicInformation getPublicInformation()
+            throws InterruptedException, BoschSHCException, ExecutionException, TimeoutException {
+        @Nullable
+        BoschHttpClient localHttpClient = this.httpClient;
+        if (localHttpClient == null) {
+            throw new BoschSHCException(HTTP_CLIENT_NOT_INITIALIZED);
+        }
+
+        String url = localHttpClient.getPublicInformationUrl();
+        Request request = localHttpClient.createRequest(url, GET);
+
+        return localHttpClient.sendRequest(request, PublicInformation.class, PublicInformation::isValid, null);
+    }
+
     public boolean registerDiscoveryListener(ThingDiscoveryService listener) {
         if (thingDiscoveryService == null) {
             thingDiscoveryService = listener;
@@ -604,7 +622,7 @@ public class BridgeHandler extends BaseBridgeHandler {
         @Nullable
         BoschHttpClient localHttpClient = this.httpClient;
         if (localHttpClient == null) {
-            throw new BoschSHCException("HTTP client not initialized");
+            throw new BoschSHCException(HTTP_CLIENT_NOT_INITIALIZED);
         }
 
         String url = localHttpClient.getBoschSmartHomeUrl(String.format("devices/%s", deviceId));
@@ -634,7 +652,7 @@ public class BridgeHandler extends BaseBridgeHandler {
         @Nullable
         BoschHttpClient locaHttpClient = this.httpClient;
         if (locaHttpClient == null) {
-            throw new BoschSHCException("HTTP client not initialized");
+            throw new BoschSHCException(HTTP_CLIENT_NOT_INITIALIZED);
         }
 
         String url = locaHttpClient.getBoschSmartHomeUrl(String.format("userdefinedstates/%s", stateId));
index 845d2af72474f2c3e6e7625587e34e0b5ef9be9b..2dbb3128a89b067078ca81e557a10c6ab031b9eb 100644 (file)
@@ -55,7 +55,7 @@ public class Device {
     public String status;
     public List<String> childDeviceIds;
 
-    public static Boolean isValid(Device obj) {
+    public static boolean isValid(Device obj) {
         return obj != null && obj.id != null;
     }
 
index a049f649718b5db74b474cb3fe0715dd86fe674f..f102dd1ee063275baacde37cbb589ffa5a047ed9 100644 (file)
@@ -42,4 +42,10 @@ public class PublicInformation {
     public List<String> apiVersions;
     public String shcIpAddress;
     public String shcGeneration;
+    public SoftwareUpdateState softwareUpdateState;
+
+    public static boolean isValid(PublicInformation obj) {
+        return obj != null && obj.shcIpAddress != null && obj.shcGeneration != null && obj.apiVersions != null
+                && SoftwareUpdateState.isValid(obj.softwareUpdateState);
+    }
 }
index a67fc8171217d231228e0e96af73cee4db53b2f8..d6b6157153d99ac8b67a1f61ffef7203b95b8155 100644 (file)
@@ -48,17 +48,13 @@ public class Scenario extends BoschSHCServiceState {
         return scenario;
     }
 
-    public static Boolean isValid(Scenario[] scenarios) {
+    public static boolean isValid(Scenario[] scenarios) {
         return Arrays.stream(scenarios).allMatch(scenario -> (scenario.id != null));
     }
 
     @Override
     public String toString() {
-        final StringBuilder sb = new StringBuilder("Scenario{");
-        sb.append("name='").append(name).append("'");
-        sb.append(", id='").append(id).append("'");
-        sb.append(", lastTimeTriggered='").append(lastTimeTriggered).append("'");
-        sb.append('}');
-        return sb.toString();
+        return "Scenario{" + "name='" + name + "'" + ", id='" + id + "'" + ", lastTimeTriggered='" + lastTimeTriggered
+                + "'" + '}';
     }
 }
diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/SoftwareUpdateState.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/SoftwareUpdateState.java
new file mode 100644 (file)
index 0000000..a7e9409
--- /dev/null
@@ -0,0 +1,30 @@
+/**
+ * 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.boschshc.internal.devices.bridge.dto;
+
+/**
+ * Software Update State is part of PublicInformation.
+ * 
+ * @author Gerd Zanker - Initial contribution
+ */
+public class SoftwareUpdateState {
+
+    public String swUpdateState;
+    public String swInstalledVersion;
+    public String swUpdateAvailableVersion;
+
+    public static boolean isValid(SoftwareUpdateState obj) {
+        return obj != null && obj.swUpdateState != null && obj.swInstalledVersion != null
+                && obj.swUpdateAvailableVersion != null;
+    }
+}
index 55df8a0db95264a9c77eb0aeac83107e7179c2b8..c74933b5c0d73eebb87192a15b685b85ab6ff6b0 100644 (file)
@@ -31,7 +31,7 @@ public class SubscribeResult {
         return this.jsonrpc;
     }
 
-    public static Boolean isValid(SubscribeResult obj) {
+    public static boolean isValid(SubscribeResult obj) {
         return obj != null && obj.result != null && obj.jsonrpc != null;
     }
 }
index 60ac6d37dd500345dc016d94d8238033e243c6e4..ff1615b4801a95cbe34d3590a1a4048e24fdb455 100644 (file)
@@ -70,7 +70,7 @@ public class UserDefinedState extends BoschSHCServiceState {
                 + type + '\'' + '}';
     }
 
-    public static Boolean isValid(UserDefinedState obj) {
+    public static boolean isValid(UserDefinedState obj) {
         return obj != null && obj.id != null;
     }
 }
index 0562dcacf25fbe9ac03b9c4016666a7290f3a2ba..4e0246e778f28032c0d5ddd370fc488d208346c5 100644 (file)
@@ -68,7 +68,7 @@ public class ThingDiscoveryService extends AbstractThingHandlerDiscoveryService<
             BoschSHCBindingConstants.THING_TYPE_SMOKE_DETECTOR);
 
     // @formatter:off
-    protected static final Map<String, ThingTypeUID> DEVICEMODEL_TO_THINGTYPE_MAP = Map.ofEntries(
+    public static final Map<String, ThingTypeUID> DEVICEMODEL_TO_THINGTYPE_MAP = Map.ofEntries(
             new AbstractMap.SimpleEntry<>("BBL", BoschSHCBindingConstants.THING_TYPE_SHUTTER_CONTROL),
             new AbstractMap.SimpleEntry<>("TWINGUARD", BoschSHCBindingConstants.THING_TYPE_TWINGUARD),
             new AbstractMap.SimpleEntry<>("BSM", BoschSHCBindingConstants.THING_TYPE_INWALL_SWITCH),
diff --git a/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/console/BoschShcCommandExtensionTest.java b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/console/BoschShcCommandExtensionTest.java
new file mode 100644 (file)
index 0000000..85f7ea3
--- /dev/null
@@ -0,0 +1,228 @@
+/**
+ * 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.boschshc.internal.console;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.allOf;
+import static org.hamcrest.Matchers.containsString;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.openhab.binding.boschshc.internal.devices.bridge.BridgeHandler;
+import org.openhab.binding.boschshc.internal.devices.bridge.dto.Device;
+import org.openhab.binding.boschshc.internal.devices.bridge.dto.PublicInformation;
+import org.openhab.binding.boschshc.internal.devices.bridge.dto.SoftwareUpdateState;
+import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException;
+import org.openhab.core.io.console.Console;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingRegistry;
+
+/**
+ * Unit tests for Console command to list Bosch SHC devices and openhab support.
+ *
+ * @author Gerd Zanker - Initial contribution
+ */
+@ExtendWith(MockitoExtension.class)
+@NonNullByDefault
+class BoschShcCommandExtensionTest {
+
+    private @NonNullByDefault({}) BoschShcCommandExtension fixture;
+
+    private @Mock @NonNullByDefault({}) ThingRegistry thingRegistry;
+
+    @BeforeEach
+    void setUp() {
+        fixture = new BoschShcCommandExtension(thingRegistry);
+    }
+
+    @Test
+    void execute() {
+        // only sanity checks, content is tested with the functions called by execute
+        Console consoleMock = mock(Console.class);
+        when(thingRegistry.getAll()).thenReturn(Collections.emptyList());
+
+        fixture.execute(new String[] {}, consoleMock);
+        verify(consoleMock, times(5)).printUsage(any());
+        fixture.execute(new String[] { "" }, consoleMock);
+        verify(consoleMock, times(10)).printUsage(any());
+
+        fixture.execute(new String[] { BoschShcCommandExtension.SHOW_BINDINGINFO }, consoleMock);
+        verify(consoleMock, atLeastOnce()).print(any());
+        fixture.execute(new String[] { BoschShcCommandExtension.SHOW_DEVICES }, consoleMock);
+        verify(consoleMock, atLeastOnce()).print(any());
+        fixture.execute(new String[] { BoschShcCommandExtension.SHOW_SERVICES }, consoleMock);
+        verify(consoleMock, atLeastOnce()).print(any());
+
+        fixture.execute(new String[] { BoschShcCommandExtension.GET_BRIDGEINFO }, consoleMock);
+        verify(consoleMock, atLeastOnce()).print(any());
+        fixture.execute(new String[] { BoschShcCommandExtension.GET_DEVICES }, consoleMock);
+        verify(consoleMock, atLeastOnce()).print(any());
+    }
+
+    @Test
+    void getCompleter() {
+        assertThat(fixture.getCompleter(), is(fixture));
+    }
+
+    @Test
+    void getUsages() {
+        List<String> strings = fixture.getUsages();
+        assertThat(strings.size(), is(5));
+        assertThat(strings.get(0), is("boschshc showBindingInfo - list detailed information about this binding"));
+        assertThat(strings.get(1), is("boschshc showDevices - list all devices supported by this binding"));
+    }
+
+    @Test
+    void complete() {
+        ArrayList<String> candidates = new ArrayList<>();
+        assertThat(fixture.complete(new String[] { "" }, 1, 0, candidates), is(false));
+        assertThat(fixture.complete(new String[] { "" }, 0, 0, candidates), is(true));
+        // for empty arguments, the completer suggest all usage commands
+        assertThat(candidates.size(), is(fixture.getUsages().size()));
+    }
+
+    @Test
+    void printBridgeInfo() throws BoschSHCException, ExecutionException, InterruptedException, TimeoutException {
+        // no bridge
+        when(thingRegistry.getAll()).thenReturn(Collections.emptyList());
+        assertThat(fixture.buildBridgeInfo(), is(""));
+
+        // one bridge
+        PublicInformation publicInformation = new PublicInformation();
+        publicInformation.shcGeneration = "Gen-T";
+        publicInformation.shcIpAddress = "1.2.3.4";
+        publicInformation.softwareUpdateState = new SoftwareUpdateState();
+        Bridge mockBridge = mock(Bridge.class);
+        when(mockBridge.getLabel()).thenReturn("TestLabel");
+        BridgeHandler mockBridgeHandler = mock(BridgeHandler.class);
+        when(mockBridgeHandler.getThing()).thenReturn(mockBridge);
+        when(mockBridgeHandler.getPublicInformation()).thenReturn(publicInformation);
+        Thing mockBridgeThing = mock(Thing.class);
+        when(mockBridgeThing.getHandler()).thenReturn(mockBridgeHandler);
+        when(thingRegistry.getAll()).thenReturn(Collections.singletonList(mockBridgeThing));
+        assertThat(fixture.buildBridgeInfo(),
+                allOf(containsString("Bridge: TestLabel"), containsString("access possible: false"),
+                        containsString("SHC Generation: Gen-T"), containsString("IP Address: 1.2.3.4")));
+
+        // two bridges
+        PublicInformation publicInformation2 = new PublicInformation();
+        publicInformation2.shcGeneration = "Gen-U";
+        publicInformation2.shcIpAddress = "11.22.33.44";
+        publicInformation2.softwareUpdateState = new SoftwareUpdateState();
+        Bridge mockBridge2 = mock(Bridge.class);
+        when(mockBridge2.getLabel()).thenReturn("Bridge  2");
+        BridgeHandler mockBridgeHandler2 = mock(BridgeHandler.class);
+        when(mockBridgeHandler2.getThing()).thenReturn(mockBridge2);
+        when(mockBridgeHandler2.getPublicInformation()).thenReturn(publicInformation2);
+        Thing mockBridgeThing2 = mock(Thing.class);
+        when(mockBridgeThing2.getHandler()).thenReturn(mockBridgeHandler2);
+        when(thingRegistry.getAll()).thenReturn(Arrays.asList(mockBridgeThing, mockBridgeThing2));
+        assertThat(fixture.buildBridgeInfo(),
+                allOf(containsString("Bridge: TestLabel"), containsString("access possible: false"),
+                        containsString("SHC Generation: Gen-T"), containsString("IP Address: 1.2.3.4"),
+                        containsString("Bridge: Bridge  2"), containsString("access possible: false"),
+                        containsString("SHC Generation: Gen-U"), containsString("IP Address: 11.22.33.44")));
+    }
+
+    @Test
+    void printDeviceInfo() throws InterruptedException {
+        // no bridge
+        when(thingRegistry.getAll()).thenReturn(Collections.emptyList());
+        assertThat(fixture.buildDeviceInfo(), is(""));
+
+        // One bridge, No device
+        BridgeHandler mockBridgeHandler = mock(BridgeHandler.class);
+        Thing mockBridgeThing = mock(Thing.class);
+        when(mockBridgeThing.getLabel()).thenReturn("TestLabel");
+        when(mockBridgeThing.getHandler()).thenReturn(mockBridgeHandler);
+        when(thingRegistry.getAll()).thenReturn(Collections.singletonList(mockBridgeThing));
+        assertThat(fixture.buildDeviceInfo(), allOf(containsString("thing: TestLabel"), containsString("devices (0)")));
+
+        // One bridge, One UNsupported device
+        Device mockShcDevice = mock(Device.class);
+        mockShcDevice.deviceModel = "";
+        mockShcDevice.deviceServiceIds = Collections.emptyList();
+        when(mockBridgeHandler.getDevices()).thenReturn(List.of(mockShcDevice));
+        assertThat(fixture.buildDeviceInfo(), allOf(containsString("thing: TestLabel"), containsString("devices (1)"),
+                containsString("!UNSUPPORTED!")));
+
+        // One bridge, One supported device
+        mockShcDevice.deviceModel = "TWINGUARD";
+        mockShcDevice.deviceServiceIds = Collections.emptyList();
+        when(mockBridgeHandler.getDevices()).thenReturn(List.of(mockShcDevice));
+        assertThat(fixture.buildDeviceInfo(), allOf(containsString("thing: TestLabel"), containsString("devices (1)"),
+                containsString("TWINGUARD -> twinguard")));
+
+        // One bridge, One supported device with services
+        mockShcDevice.deviceModel = "TWINGUARD";
+        mockShcDevice.deviceServiceIds = List.of("unknownService", "batterylevel");
+        when(mockBridgeHandler.getDevices()).thenReturn(List.of(mockShcDevice));
+        assertThat(fixture.buildDeviceInfo(), allOf(containsString("thing: TestLabel"), containsString("devices (1)"),
+                containsString("TWINGUARD -> twinguard"), containsString("service: unknownService -> !UNSUPPORTED!"),
+                containsString("batterylevel -> batterylevel")));
+    }
+
+    @Test
+    void printBindingInfo() {
+        assertThat(fixture.buildBindingInfo(), containsString("Bosch SHC Binding"));
+    }
+
+    @Test
+    void printSupportedDevices() {
+        assertThat(fixture.buildSupportedDeviceStatus(),
+                allOf(containsString("Supported Devices"), containsString("BBL = boschshc:shutter-control")));
+    }
+
+    @Test
+    void printSupportedServices() {
+        assertThat(fixture.buildSupportedServiceStatus(),
+                allOf(containsString("Supported Services"), containsString("airqualitylevel")));
+    }
+
+    /**
+     * The list of services returned by getAllBoschShcServices() shall match
+     * the implemented services in org.openhab.bindings.boschshc.internal.services.
+     * Because reflection doesn't return all services classes during runtime
+     * this test supports consistency between the lists of services and the implemented services.
+     */
+    @Test
+    void getAllBoschShcServices() throws IOException {
+        List<String> services = Files
+                .walk(Paths.get("src/main/java/org/openhab/binding/boschshc/internal/services").toAbsolutePath(), 1)
+                .filter(Files::isDirectory).map(Path::getFileName).map(Path::toString)
+                // exclude folders which no service implementation
+                .filter(name -> !name.equals("dto")).filter(name -> !name.equals("services")).sorted()
+                .collect(Collectors.toList());
+        assertThat(services, is(fixture.getAllBoschShcServices()));
+    }
+}