From 612afd2e0747d5cea81c67e82adf0022c8abcf00 Mon Sep 17 00:00:00 2001 From: Christian Wild <40909464+wildcs@users.noreply.github.com> Date: Sun, 28 Nov 2021 15:29:21 +0100 Subject: [PATCH] [Tapocontrol] Binding to control Tapo (by TP-Link) Devices (#11111) * [tapocontrol] New Source Upload Signed-off-by: Christian Wild * [tapocontrol] Delete bundles/org.openhab.binding.tapocontrol directory Signed-off-by: Christian Wild * [tapocontrol] Snapshot 3.2 Signed-off-by: Christian Wild * [tapocontrol] Update CODEOWNERS Fixed bindingname Signed-off-by: Christian Wild * [tapocontrol] Update README.md Signed-off-by: Christian Wild * [tapocontrol] new "Bridge-Version" Credentials (TapoCloud) where now set in a bridge device. Things now had to be attached to a bridge. Signed-off-by: Christian Wild * [tapocontrol] fixed device discovery bug fixed device discovery bug added bridge to thing-types.xml Signed-off-by: Christian Wild * [tapocontrol] Update bundles/org.openhab.binding.tapocontrol/README.md Co-authored-by: Fabian Wolter Signed-off-by: Christian Wild * [tapocontrol] code cleanup and optimization - general code cleanup and optimization - limited max connections and queued requests to 10 per destination - device error handling revised - review remarks of pull request processed Signed-off-by: Christian Wild * [tapocontrol] solved review requests Signed-off-by: Christian Wild * [tapocontrol] LightStrip L900 basicly supported Signed-off-by: Christian Wild * [tapocontrol] fixed review requests Signed-off-by: Christian Wild * [tapocontrol] fixed compiler warnings Signed-off-by: Christian Wild Co-authored-by: Fabian Wolter --- CODEOWNERS | 1 + bom/openhab-addons/pom.xml | 5 + .../org.openhab.binding.tapocontrol/NOTICE | 13 + .../org.openhab.binding.tapocontrol/README.md | 114 ++++ .../org.openhab.binding.tapocontrol/pom.xml | 15 + .../src/main/feature/feature.xml | 9 + .../internal/TapoControlHandlerFactory.java | 116 ++++ .../internal/TapoDiscoveryService.java | 230 +++++++ .../internal/api/TapoCloudConnector.java | 238 ++++++++ .../internal/api/TapoDeviceConnector.java | 384 ++++++++++++ .../internal/api/TapoDeviceHttpApi.java | 564 ++++++++++++++++++ .../constants/TapoBindingSettings.java | 56 ++ .../constants/TapoErrorConstants.java | 157 +++++ .../constants/TapoThingConstants.java | 153 +++++ .../internal/device/TapoBridgeHandler.java | 301 ++++++++++ .../internal/device/TapoDevice.java | 479 +++++++++++++++ .../internal/device/TapoLightStrip.java | 230 +++++++ .../internal/device/TapoSmartBulb.java | 169 ++++++ .../internal/device/TapoSmartPlug.java | 92 +++ .../internal/device/TapoUniversalDevice.java | 234 ++++++++ .../internal/helpers/MimeEncode.java | 42 ++ .../internal/helpers/PayloadBuilder.java | 90 +++ .../internal/helpers/TapoCipher.java | 144 +++++ .../internal/helpers/TapoCredentials.java | 220 +++++++ .../internal/helpers/TapoErrorHandler.java | 264 ++++++++ .../internal/helpers/TapoUtils.java | 348 +++++++++++ .../structures/TapoBridgeConfiguration.java | 76 +++ .../structures/TapoDeviceConfiguration.java | 63 ++ .../internal/structures/TapoDeviceInfo.java | 224 +++++++ .../internal/structures/TapoLightEffect.java | 141 +++++ .../main/resources/OH-INF/binding/binding.xml | 7 + .../main/resources/OH-INF/config/config.xml | 44 ++ .../resources/OH-INF/thing/L510_Series.xml | 23 + .../resources/OH-INF/thing/L530_Series.xml | 23 + .../src/main/resources/OH-INF/thing/L900.xml | 23 + .../src/main/resources/OH-INF/thing/P100.xml | 23 + .../src/main/resources/OH-INF/thing/P105.xml | 23 + .../main/resources/OH-INF/thing/bridge.xml | 12 + .../main/resources/OH-INF/thing/channels.xml | 197 ++++++ .../tapocontrol/internal/api/TapoUDP.java | 138 +++++ .../internal/device/TapoBridgeHandler.java | 317 ++++++++++ .../internal/device/TapoUniversalDevice.java | 234 ++++++++ .../structures/TapoBridgeConfiguration.java | 77 +++ .../internal/structures/TapoDeviceInfo.java | 242 ++++++++ .../internal/structures/TapoLightEffect.java | 149 +++++ .../test/resources/OH-INF/config/config.xml | 58 ++ .../resources/OH-INF/thing/testdevice.xml | 57 ++ bundles/pom.xml | 1 + 48 files changed, 6820 insertions(+) create mode 100644 bundles/org.openhab.binding.tapocontrol/NOTICE create mode 100644 bundles/org.openhab.binding.tapocontrol/README.md create mode 100644 bundles/org.openhab.binding.tapocontrol/pom.xml create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/feature/feature.xml create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/TapoControlHandlerFactory.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/TapoDiscoveryService.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/api/TapoCloudConnector.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/api/TapoDeviceConnector.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/api/TapoDeviceHttpApi.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/constants/TapoBindingSettings.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/constants/TapoErrorConstants.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/constants/TapoThingConstants.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/device/TapoBridgeHandler.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/device/TapoDevice.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/device/TapoLightStrip.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/device/TapoSmartBulb.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/device/TapoSmartPlug.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/device/TapoUniversalDevice.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/helpers/MimeEncode.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/helpers/PayloadBuilder.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/helpers/TapoCipher.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/helpers/TapoCredentials.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/helpers/TapoErrorHandler.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/helpers/TapoUtils.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/structures/TapoBridgeConfiguration.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/structures/TapoDeviceConfiguration.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/structures/TapoDeviceInfo.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/structures/TapoLightEffect.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/resources/OH-INF/binding/binding.xml create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/resources/OH-INF/config/config.xml create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/resources/OH-INF/thing/L510_Series.xml create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/resources/OH-INF/thing/L530_Series.xml create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/resources/OH-INF/thing/L900.xml create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/resources/OH-INF/thing/P100.xml create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/resources/OH-INF/thing/P105.xml create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/resources/OH-INF/thing/bridge.xml create mode 100644 bundles/org.openhab.binding.tapocontrol/src/main/resources/OH-INF/thing/channels.xml create mode 100644 bundles/org.openhab.binding.tapocontrol/src/test/java/org/openhab/binding/tapocontrol/internal/api/TapoUDP.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/test/java/org/openhab/binding/tapocontrol/internal/device/TapoBridgeHandler.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/test/java/org/openhab/binding/tapocontrol/internal/device/TapoUniversalDevice.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/test/java/org/openhab/binding/tapocontrol/internal/structures/TapoBridgeConfiguration.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/test/java/org/openhab/binding/tapocontrol/internal/structures/TapoDeviceInfo.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/test/java/org/openhab/binding/tapocontrol/internal/structures/TapoLightEffect.java create mode 100644 bundles/org.openhab.binding.tapocontrol/src/test/resources/OH-INF/config/config.xml create mode 100644 bundles/org.openhab.binding.tapocontrol/src/test/resources/OH-INF/thing/testdevice.xml diff --git a/CODEOWNERS b/CODEOWNERS index beaf69a9962..44df2624eaa 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -295,6 +295,7 @@ /bundles/org.openhab.binding.tacmi/ @twendt @Wolfgang1966 @marvkis /bundles/org.openhab.binding.tado/ @dfrommi /bundles/org.openhab.binding.tankerkoenig/ @dolic @JueBag +/bundles/org.openhab.binding.tapocontrol/ @wildcs /bundles/org.openhab.binding.telegram/ @ZzetT /bundles/org.openhab.binding.teleinfo/ @Nokyyz @olivierkeke /bundles/org.openhab.binding.tellstick/ @openhab/add-ons-maintainers diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index 0e49501a5ad..d672adddd53 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -1466,6 +1466,11 @@ org.openhab.binding.tankerkoenig ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.tapocontrol + ${project.version} + org.openhab.addons.bundles org.openhab.binding.telegram diff --git a/bundles/org.openhab.binding.tapocontrol/NOTICE b/bundles/org.openhab.binding.tapocontrol/NOTICE new file mode 100644 index 00000000000..38d625e3492 --- /dev/null +++ b/bundles/org.openhab.binding.tapocontrol/NOTICE @@ -0,0 +1,13 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons diff --git a/bundles/org.openhab.binding.tapocontrol/README.md b/bundles/org.openhab.binding.tapocontrol/README.md new file mode 100644 index 00000000000..77074f476d8 --- /dev/null +++ b/bundles/org.openhab.binding.tapocontrol/README.md @@ -0,0 +1,114 @@ +# TapoControl Binding + +This binding adds support to control Tapo (Copyright © TP-Link Corporation Limited) Smart Home Devices from your local openHAB system. + +## Supported Things + +The following Tapo-Devices are supported + +### P100/P105 SmartPlug (WiFi) + +* Power On/Off +* Wi-Fi signal (SignalStrength) +* On-Time (Time in seconds device is switched on) + +### L510_Series dimmable SmartBulb (WiFi) + +* Light On/Off +* Brightnes (Dimmer) 0-100 % +* ColorTemperature (Number) 2500-6500 K +* Wi-Fi signal (SignalStrength) +* On-Time (Time in seconds device is switched on) + +### L530_Series MultiColor SmartBulb (WiFi) + +* Light On/Off +* Brightnes (Dimmer) 0-100 % +* ColorTemperature (Number) 2500-6500 K +* Color (Color) +* Wi-Fi signal (SignalStrength) +* On-Time (Time in seconds device is switched on) + +### L900 MultiColor LightStrip (WiFi) + +* Light On/Off +* Brightnes (Dimmer) 0-100 % +* ColorTemperature (Number) 2500-6500 K +* Color (Color) +* Wi-Fi signal (SignalStrength) +* On-Time (Time in seconds device is switched on) + + +## Prerequisites + +Before using Smart Plugs with openHAB the devices must be connected to the Wi-Fi network. +This can be done using the Tapo provided mobile app. +You need to setup a bridge (Cloud-Login) to commiunicate with your devices. + +## Discovery + +Discovery is done by connecting to the Tapo-Cloud Service. +All devices stored in your cloud account will be detected even if they are not in your network. +You need to know the IP-Adress of your device. This must be set manually in the thing configuration + +## Bridge Configuration + +The bridge needs to be configured with by `username` and `password` (Tapo-Cloud login) . +This is used for device discovery and to create a handshake (cookie) to act with your devices over the local network. + +The thing has the following configuration parameters: + +| Parameter | Description | +|--------------------|----------------------------------------------------------------------| +| username | Username (eMail) of your Tapo-Cloud | +| password | Password of your Tapo-Cloud | + +## Thing Configuration + +The thing needs to be configured with `ipAddress`. + +The thing has the following configuration parameters: + +| Parameter | Description | +|--------------------|----------------------------------------------------------------------| +| ipAddress | IP Address of the device. | +| pollingInterval | Refresh interval in seconds. Optional. The default is 30 seconds | + + +## Channels + +All devices support some of the following channels: + +| group | channel |type | description | things supporting this channel | +|-----------|----------------- |------------------------|------------------------------|---------------------------------| +| actuator | output | Switch | Power device on or off | P100, P105,L510, L530, L900 | +| | brightness | Dimmer | Brightness 0-100% | L510, L530, L900 | +| | colorTemperature | Number | White-Color-Temp 2500-6500K | L510, L530, L900 | +| | color | Color | Color | L530, L900 | +| device | wifiSignal | system.signal-strength | WiFi-quality-level | P100, P105, L510, L530, L900 | +| | onTime | Number:Time | seconds output is on | P100, P105, L510, L530, L900 | + + +## Channel Refresh + +When the thing receives a `RefreshType` command the thing will send a new refreshRequest over http. +To minimize network traffic the default refresh-rate is set to 30 seconds. This can be reduced down to 10 seconds in advanced settings of the device. If any command was sent to a channel, it will do an immediately refresh of the whole device. + + +## Full Example + +### tapocontrol.things: + +``` +tapocontrol:bridge:myTapoBridge "Cloud-Login" [ username="you@yourpovider.com", password="verysecret" ] +tapocontrol:P100:myTapoBridge:mySocket "My-Socket" [ ipAddress="192.168.178.150", pollingInterval=30 ] +tapocontrol:L510_Series:myTapoBridge:whiteBulb "white-light" [ ipAddress="192.168.178.151", pollingInterval=30 ] +tapocontrol:L530_Series:myTapoBridge:colorBulb "color-light" [ ipAddress="192.168.178.152", pollingInterval=30 ] +tapocontrol:L900:myTapoBridge:myLightStrip "light-strip" [ ipAddress="192.168.178.153", pollingInterval=30 ] +``` + +### tapocontrol.items: + +``` +Switch TAPO_SOCKET "socket" { channel="tapocontrol:P100:myTapoBridge:mySocket:actuator#output" } +``` diff --git a/bundles/org.openhab.binding.tapocontrol/pom.xml b/bundles/org.openhab.binding.tapocontrol/pom.xml new file mode 100644 index 00000000000..2e71e41e4d6 --- /dev/null +++ b/bundles/org.openhab.binding.tapocontrol/pom.xml @@ -0,0 +1,15 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 3.2.0-SNAPSHOT + + + org.openhab.binding.tapocontrol + openHAB Add-ons :: Bundles :: TapoControl Binding + diff --git a/bundles/org.openhab.binding.tapocontrol/src/main/feature/feature.xml b/bundles/org.openhab.binding.tapocontrol/src/main/feature/feature.xml new file mode 100644 index 00000000000..5cc25ab0614 --- /dev/null +++ b/bundles/org.openhab.binding.tapocontrol/src/main/feature/feature.xml @@ -0,0 +1,9 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.binding.tapocontrol/${project.version} + + diff --git a/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/TapoControlHandlerFactory.java b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/TapoControlHandlerFactory.java new file mode 100644 index 00000000000..e24a5d5d796 --- /dev/null +++ b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/TapoControlHandlerFactory.java @@ -0,0 +1,116 @@ +/** + * Copyright (c) 2010-2021 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.tapocontrol.internal; + +import static org.openhab.binding.tapocontrol.internal.constants.TapoBindingSettings.*; +import static org.openhab.binding.tapocontrol.internal.constants.TapoThingConstants.*; + +import java.util.HashSet; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.openhab.binding.tapocontrol.internal.device.TapoBridgeHandler; +import org.openhab.binding.tapocontrol.internal.device.TapoLightStrip; +import org.openhab.binding.tapocontrol.internal.device.TapoSmartBulb; +import org.openhab.binding.tapocontrol.internal.device.TapoSmartPlug; +import org.openhab.binding.tapocontrol.internal.device.TapoUniversalDevice; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.binding.BaseThingHandlerFactory; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerFactory; +import org.osgi.service.component.ComponentContext; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link TapoControlHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Christian Wild - Initial contribution + */ +@Component(service = ThingHandlerFactory.class, configurationPid = "binding.tapocontrol") +@NonNullByDefault +public class TapoControlHandlerFactory extends BaseThingHandlerFactory { + private final Logger logger = LoggerFactory.getLogger(TapoControlHandlerFactory.class); + private final Set accountHandlers = new HashSet<>(); + private final HttpClient httpClient; + + @Activate + public TapoControlHandlerFactory() { + // create new httpClient + httpClient = new HttpClient(new SslContextFactory.Client()); + httpClient.setFollowRedirects(false); + httpClient.setMaxConnectionsPerDestination(HTTP_MAX_CONNECTIONS); + httpClient.setMaxRequestsQueuedPerDestination(HTTP_MAX_QUEUED_REQUESTS); + try { + httpClient.start(); + } catch (Exception e) { + logger.error("cannot start httpClient"); + } + } + + @Deactivate + @Override + protected void deactivate(ComponentContext componentContext) { + super.deactivate(componentContext); + try { + httpClient.stop(); + } catch (Exception e) { + logger.debug("unable to stop httpClient"); + } + } + + /** + * Provides the supported thing types + */ + @Override + public boolean supportsThingType(ThingTypeUID thingTypeUID) { + if (thingTypeUID.equals(UNIVERSAL_THING_TYPE)) { + return true; + } + return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID); + } + + /** + * Create handler of things. + */ + @Override + protected @Nullable ThingHandler createHandler(Thing thing) { + ThingTypeUID thingTypeUID = thing.getThingTypeUID(); + + if (SUPPORTED_BRIDGE_UIDS.contains(thingTypeUID)) { + TapoBridgeHandler bridgeHandler = new TapoBridgeHandler((Bridge) thing, httpClient); + accountHandlers.add(bridgeHandler); + return bridgeHandler; + } else if (SUPPORTED_SMART_PLUG_UIDS.contains(thingTypeUID)) { + return new TapoSmartPlug(thing); + } else if (SUPPORTED_WHITE_BULB_UIDS.contains(thingTypeUID)) { + return new TapoSmartBulb(thing); + } else if (SUPPORTED_COLOR_BULB_UIDS.contains(thingTypeUID)) { + return new TapoSmartBulb(thing); + } else if (SUPPORTED_LIGHT_STRIP_UIDS.contains(thingTypeUID)) { + return new TapoLightStrip(thing); + } else if (thingTypeUID.equals(UNIVERSAL_THING_TYPE)) { + return new TapoUniversalDevice(thing); + } + return null; + } +} diff --git a/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/TapoDiscoveryService.java b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/TapoDiscoveryService.java new file mode 100644 index 00000000000..12bd4ea07a4 --- /dev/null +++ b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/TapoDiscoveryService.java @@ -0,0 +1,230 @@ +/** + * Copyright (c) 2010-2021 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.tapocontrol.internal; + +import static org.openhab.binding.tapocontrol.internal.constants.TapoBindingSettings.*; +import static org.openhab.binding.tapocontrol.internal.constants.TapoThingConstants.*; +import static org.openhab.binding.tapocontrol.internal.helpers.TapoUtils.*; + +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.tapocontrol.internal.device.TapoBridgeHandler; +import org.openhab.binding.tapocontrol.internal.structures.TapoBridgeConfiguration; +import org.openhab.core.config.discovery.AbstractDiscoveryService; +import org.openhab.core.config.discovery.DiscoveryResult; +import org.openhab.core.config.discovery.DiscoveryResultBuilder; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +/** + * Handler class for TAPO Smart Home thing discovery + * + * @author Christian Wild - Initial contribution + */ +@NonNullByDefault +public class TapoDiscoveryService extends AbstractDiscoveryService implements ThingHandlerService { + private final Logger logger = LoggerFactory.getLogger(TapoDiscoveryService.class); + protected @NonNullByDefault({}) TapoBridgeHandler bridge; + + /*********************************** + * + * INITIALIZATION + * + ************************************/ + + /** + * INIT CLASS + * + * @param bridgeHandler + */ + public TapoDiscoveryService() { + super(SUPPORTED_THING_TYPES_UIDS, TAPO_DISCOVERY_TIMEOUT_S, false); + } + + /** + * deactivate + */ + @Override + public void activate() { + TapoBridgeConfiguration config = bridge.getBridgeConfig(); + if (config.cloudDiscoveryEnabled || config.udpDiscoveryEnabled) { + startBackgroundDiscovery(); + } + } + + /** + * deactivate + */ + @Override + public void deactivate() { + super.deactivate(); + } + + @Override + public void setThingHandler(@Nullable ThingHandler handler) { + if (handler instanceof TapoBridgeHandler) { + TapoBridgeHandler tapoBridge = (TapoBridgeHandler) handler; + tapoBridge.setDiscoveryService(this); + this.bridge = tapoBridge; + } + } + + @Override + public @Nullable ThingHandler getThingHandler() { + return this.bridge; + } + + /*********************************** + * + * SCAN HANDLING + * + ************************************/ + + /** + * Start scan manually + */ + @Override + public void startScan() { + removeOlderResults(getTimestampOfLastScan()); + if (bridge != null) { + JsonArray jsonArray = bridge.getDeviceList(); + handleCloudDevices(jsonArray); + } + } + + /*********************************** + * + * handle Results + * + ************************************/ + + /** + * CREATE DISCOVERY RESULT + * creates discoveryResult (Thing) from JsonObject got from Cloud + * + * @param device JsonObject with device information + * @return DiscoveryResult-Object + */ + public DiscoveryResult createResult(JsonObject device) { + TapoBridgeHandler tapoBridge = this.bridge; + String deviceModel = getDeviceModel(device); + String label = getDeviceLabel(device); + String deviceMAC = device.get(CLOUD_PROPERTY_MAC).getAsString(); + ThingTypeUID thingTypeUID = new ThingTypeUID(BINDING_ID, deviceModel); + + /* create properties */ + Map properties = new HashMap<>(); + properties.put(Thing.PROPERTY_VENDOR, DEVICE_VENDOR); + properties.put(Thing.PROPERTY_MAC_ADDRESS, formatMac(deviceMAC, MAC_DIVISION_CHAR)); + properties.put(Thing.PROPERTY_FIRMWARE_VERSION, device.get(CLOUD_PROPERTY_FW).getAsString()); + properties.put(Thing.PROPERTY_HARDWARE_VERSION, device.get(CLOUD_PROPERTY_HW).getAsString()); + properties.put(Thing.PROPERTY_MODEL_ID, deviceModel); + properties.put(Thing.PROPERTY_SERIAL_NUMBER, device.get(CLOUD_PROPERTY_ID).getAsString()); + + logger.debug("device {} discovered", deviceModel); + if (tapoBridge != null) { + ThingUID bridgeUID = tapoBridge.getUID(); + ThingUID thingUID = new ThingUID(thingTypeUID, bridgeUID, deviceMAC); + return DiscoveryResultBuilder.create(thingUID).withProperties(properties) + .withRepresentationProperty(DEVICE_REPRASENTATION_PROPERTY).withBridge(bridgeUID).withLabel(label) + .build(); + } else { + ThingUID thingUID = new ThingUID(BINDING_ID, deviceMAC); + return DiscoveryResultBuilder.create(thingUID).withProperties(properties) + .withRepresentationProperty(DEVICE_REPRASENTATION_PROPERTY).withLabel(label).build(); + } + } + + /** + * work with result from get devices from cloud devices + * + * @param deviceList + */ + protected void handleCloudDevices(JsonArray deviceList) { + try { + for (JsonElement deviceElement : deviceList) { + if (deviceElement.isJsonObject()) { + JsonObject device = deviceElement.getAsJsonObject(); + String deviceModel = getDeviceModel(device); + ThingTypeUID thingTypeUID = new ThingTypeUID(BINDING_ID, deviceModel); + + /* create thing */ + if (SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID)) { + DiscoveryResult discoveryResult = createResult(device); + thingDiscovered(discoveryResult); + } + } + } + } catch (Exception e) { + logger.debug("error handlling CloudDevices", e); + } + } + + /** + * GET DEVICEMODEL + * + * @param device JsonObject with deviceData + * @return String with DeviceModel + */ + protected String getDeviceModel(JsonObject device) { + try { + String deviceModel = device.get(CLOUD_PROPERTY_MODEL).getAsString(); + deviceModel = deviceModel.replaceAll("\\(.*\\)", ""); // replace (DE) + deviceModel = deviceModel.replace("Tapo", ""); + deviceModel = deviceModel.trim(); + deviceModel = deviceModel.replace(" ", "_"); + return deviceModel; + } catch (Exception e) { + logger.debug("error getDeviceModel", e); + return ""; + } + } + + /** + * GET DEVICE LABEL + * + * @param device JsonObject with deviceData + * @return String with DeviceLabel + */ + protected String getDeviceLabel(JsonObject device) { + try { + String deviceLabel = ""; + String deviceModel = getDeviceModel(device); + ThingTypeUID deviceUID = new ThingTypeUID(BINDING_ID, deviceModel); + + if (SUPPORTED_SMART_PLUG_UIDS.contains(deviceUID)) { + deviceLabel = DEVICE_DESCRIPTION_SMART_PLUG; + } else if (SUPPORTED_WHITE_BULB_UIDS.contains(deviceUID)) { + deviceLabel = DEVICE_DESCRIPTION_WHITE_BULB; + } else if (SUPPORTED_COLOR_BULB_UIDS.contains(deviceUID)) { + deviceLabel = DEVICE_DESCRIPTION_COLOR_BULB; + } + return DEVICE_VENDOR + " " + deviceModel + " " + deviceLabel; + } catch (Exception e) { + logger.debug("error getDeviceLabel", e); + return ""; + } + } +} diff --git a/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/api/TapoCloudConnector.java b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/api/TapoCloudConnector.java new file mode 100644 index 00000000000..30e7bdc049e --- /dev/null +++ b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/api/TapoCloudConnector.java @@ -0,0 +1,238 @@ +/** + * Copyright (c) 2010-2021 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.tapocontrol.internal.api; + +import static org.openhab.binding.tapocontrol.internal.constants.TapoBindingSettings.*; +import static org.openhab.binding.tapocontrol.internal.constants.TapoErrorConstants.*; + +import java.util.concurrent.TimeoutException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.client.util.StringContentProvider; +import org.eclipse.jetty.http.HttpMethod; +import org.openhab.binding.tapocontrol.internal.device.TapoBridgeHandler; +import org.openhab.binding.tapocontrol.internal.helpers.PayloadBuilder; +import org.openhab.binding.tapocontrol.internal.helpers.TapoErrorHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +/** + * Handler class for TAPO-Cloud connections. + * + * @author Christian Wild - Initial contribution + */ +@NonNullByDefault +public class TapoCloudConnector { + private final Logger logger = LoggerFactory.getLogger(TapoCloudConnector.class); + private final TapoBridgeHandler bridge; + private final Gson gson = new Gson(); + private final HttpClient httpClient; + + private String token = ""; + private String url = TAPO_CLOUD_URL; + private String uid; + + /** + * INIT CLASS + * + */ + public TapoCloudConnector(TapoBridgeHandler bridge, HttpClient httpClient) { + this.bridge = bridge; + this.httpClient = httpClient; + this.uid = bridge.getUID().getAsString(); + } + + /** + * handle error + * + * @param tapoError TapoErrorHandler + */ + protected void handleError(TapoErrorHandler tapoError) { + this.bridge.setError(tapoError); + } + + /*********************************** + * + * HTTP (Cloud)-Actions + * + ************************************/ + + /** + * LOGIN TO CLOUD (get Token) + * + * @param username unencrypted username + * @param password unencrypted password + * @return true if login was successfull + */ + public Boolean login(String username, String password) { + this.token = getToken(username, password, TAPO_TERMINAL_UUID); + this.url = TAPO_CLOUD_URL + "?token=" + token; + return !this.token.isBlank(); + } + + /** + * logout + */ + public void logout() { + this.token = ""; + } + + /** + * GET TOKEN FROM TAPO-CLOUD + * + * @param email + * @param password + * @param terminalUUID + * @return + */ + private String getToken(String email, String password, String terminalUUID) { + String token = ""; + + /* create login payload */ + PayloadBuilder plBuilder = new PayloadBuilder(); + plBuilder.method = "login"; + plBuilder.addParameter("appType", TAPO_APP_TYPE); + plBuilder.addParameter("cloudUserName", email); + plBuilder.addParameter("cloudPassword", password); + plBuilder.addParameter("terminalUUID", terminalUUID); + String payload = plBuilder.getPayload(); + + ContentResponse response = sendCloudRequest(TAPO_CLOUD_URL, payload); + if (response != null) { + token = getTokenFromResponse(response); + } + return token; + } + + private String getTokenFromResponse(ContentResponse response) { + /* work with response */ + if (response.getStatus() == 200) { + String rBody = response.getContentAsString(); + JsonObject jsonObject = gson.fromJson(rBody, JsonObject.class); + if (jsonObject != null) { + Integer errorCode = jsonObject.get("error_code").getAsInt(); + if (errorCode == 0) { + token = jsonObject.getAsJsonObject("result").get("token").getAsString(); + } else { + /* return errorcode from device */ + String msg = jsonObject.get("msg").getAsString(); + handleError(new TapoErrorHandler(errorCode, msg)); + logger.trace("cloud returns error: '{}'", rBody); + } + } else { + handleError(new TapoErrorHandler(ERR_JSON_DECODE_FAIL)); + logger.trace("unexpected json-response '{}'", rBody); + } + } else { + handleError(new TapoErrorHandler(ERR_HTTP_RESPONSE, ERR_HTTP_RESPONSE_MSG)); + logger.warn("invalid response while login"); + token = ""; + } + return token; + } + + /** + * + * @return JsonArray with deviceList + */ + public JsonArray getDeviceList() { + /* create payload */ + PayloadBuilder plBuilder = new PayloadBuilder(); + plBuilder.method = "getDeviceList"; + String payload = plBuilder.getPayload(); + + ContentResponse response = sendCloudRequest(this.url, payload); + if (response != null) { + return getDeviceListFromResponse(response); + } + return new JsonArray(); + } + + /** + * get DeviceList from Contenresponse + * + * @param response + * @return + */ + private JsonArray getDeviceListFromResponse(ContentResponse response) { + /* work with response */ + if (response.getStatus() == 200) { + String rBody = response.getContentAsString(); + JsonObject jsonObject = gson.fromJson(rBody, JsonObject.class); + if (jsonObject != null) { + /* get errocode (0=success) */ + Integer errorCode = jsonObject.get("error_code").getAsInt(); + if (errorCode == 0) { + JsonObject result = jsonObject.getAsJsonObject("result"); + return result.getAsJsonArray("deviceList"); + } else { + /* return errorcode from device */ + handleError(new TapoErrorHandler(errorCode, "device answers with errorcode")); + logger.trace("cloud returns error: '{}'", rBody); + } + } else { + logger.trace("enexpected json-response '{}'", rBody); + } + } else { + logger.trace("response error '{}'", response.getContentAsString()); + } + return new JsonArray(); + } + + /*********************************** + * + * HTTP-ACTIONS + * + ************************************/ + /** + * SEND SYNCHRON HTTP-REQUEST + * + * @param url url request is sent to + * @param payload payload (String) to send + * @return ContentResponse of request + */ + @Nullable + protected ContentResponse sendCloudRequest(String url, String payload) { + Request httpRequest = httpClient.newRequest(url).method(HttpMethod.POST.toString()); + + /* set header */ + httpRequest.header("content-type", CONTENT_TYPE_JSON); + httpRequest.header("Accept", CONTENT_TYPE_JSON); + + /* add request body */ + httpRequest.content(new StringContentProvider(payload, CONTENT_CHARSET), CONTENT_TYPE_JSON); + + try { + ContentResponse httpResponse = httpRequest.send(); + return httpResponse; + } catch (InterruptedException e) { + logger.debug("({}) sending request interrupted: {}", uid, e.toString()); + handleError(new TapoErrorHandler(e)); + } catch (TimeoutException e) { + logger.debug("({}) sending request timeout: {}", uid, e.toString()); + handleError(new TapoErrorHandler(ERR_CONNECT_TIMEOUT, e.toString())); + } catch (Exception e) { + logger.debug("({}) sending request failed: {}", uid, e.toString()); + handleError(new TapoErrorHandler(e)); + } + return null; + } +} diff --git a/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/api/TapoDeviceConnector.java b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/api/TapoDeviceConnector.java new file mode 100644 index 00000000000..bb0f5d9fce5 --- /dev/null +++ b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/api/TapoDeviceConnector.java @@ -0,0 +1,384 @@ +/** + * Copyright (c) 2010-2021 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.tapocontrol.internal.api; + +import static org.openhab.binding.tapocontrol.internal.constants.TapoBindingSettings.*; +import static org.openhab.binding.tapocontrol.internal.constants.TapoErrorConstants.*; +import static org.openhab.binding.tapocontrol.internal.helpers.TapoUtils.*; + +import java.net.InetAddress; +import java.util.HashMap; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.tapocontrol.internal.device.TapoBridgeHandler; +import org.openhab.binding.tapocontrol.internal.device.TapoDevice; +import org.openhab.binding.tapocontrol.internal.helpers.PayloadBuilder; +import org.openhab.binding.tapocontrol.internal.helpers.TapoErrorHandler; +import org.openhab.binding.tapocontrol.internal.structures.TapoDeviceInfo; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; + +/** + * Handler class for TAPO Smart Home device connections. + * This class uses asynchronous HttpClient-Requests + * + * @author Christian Wild - Initial contribution + */ +@NonNullByDefault +public class TapoDeviceConnector extends TapoDeviceHttpApi { + private final Logger logger = LoggerFactory.getLogger(TapoDeviceConnector.class); + private final String uid; + private final TapoDevice device; + private TapoDeviceInfo deviceInfo; + private Gson gson; + private long lastQuery = 0L; + private long lastSent = 0L; + private long lastLogin = 0L; + + /** + * INIT CLASS + * + * @param config TapoControlConfiguration class + */ + public TapoDeviceConnector(TapoDevice device, TapoBridgeHandler bridgeThingHandler) { + super(device, bridgeThingHandler); + this.device = device; + this.gson = new Gson(); + this.deviceInfo = new TapoDeviceInfo(); + this.uid = device.getThingUID().getAsString(); + } + + /*********************************** + * + * LOGIN FUNCTIONS + * + ************************************/ + /** + * login + * + * @return true if success + */ + public boolean login() { + if (this.pingDevice()) { + logger.trace("({}) sending login to url '{}'", uid, deviceURL); + + long now = System.currentTimeMillis(); + if (now > this.lastLogin + TAPO_LOGIN_MIN_GAP_MS) { + this.lastLogin = now; + unsetToken(); + unsetCookie(); + + /* create ssl-handschake (cookie) */ + String cookie = createHandshake(); + if (!cookie.isBlank()) { + setCookie(cookie); + String token = queryToken(); + setToken(token); + } + } else { + logger.trace("({}) not done cause of min_gap '{}'", uid, TAPO_LOGIN_MIN_GAP_MS); + } + return this.loggedIn(); + } else { + logger.debug("({}) no ping while login '{}'", uid, this.ipAddress); + handleError(new TapoErrorHandler(ERR_DEVICE_OFFLINE, "no ping while login")); + return false; + } + } + + /*********************************** + * + * DEVICE ACTIONS + * + ************************************/ + + /** + * send custom command to device + * + * @param plBuilder Payloadbuilder with unencrypted payload + */ + public void sendCustomQuery(String queryMethod) { + /* create payload */ + PayloadBuilder plBuilder = new PayloadBuilder(); + plBuilder.method = queryMethod; + sendCustomPayload(plBuilder); + } + + /** + * send custom command to device + * + * @param plBuilder Payloadbuilder with unencrypted payload + */ + public void sendCustomPayload(PayloadBuilder plBuilder) { + long now = System.currentTimeMillis(); + if (now > this.lastSent + TAPO_SEND_MIN_GAP_MS) { + String payload = plBuilder.getPayload(); + sendSecurePasstrhroug(payload, DEVICE_CMD_CUSTOM); + } else { + logger.debug("({}) command not sent becauso of min_gap: {}", uid, now + " <- " + lastSent); + } + } + + /** + * send "set_device_info" command to device + * + * @param name Name of command to send + * @param value Value to send to control + */ + public void sendDeviceCommand(String name, Object value) { + long now = System.currentTimeMillis(); + if (now > this.lastSent + TAPO_SEND_MIN_GAP_MS) { + this.lastSent = now; + + /* create payload */ + PayloadBuilder plBuilder = new PayloadBuilder(); + plBuilder.method = DEVICE_CMD_SETINFO; + plBuilder.addParameter(name, value); + String payload = plBuilder.getPayload(); + + sendSecurePasstrhroug(payload, DEVICE_CMD_SETINFO); + } else { + logger.debug("({}) command not sent becauso of min_gap: {}", uid, now + " <- " + lastSent); + } + } + + /** + * send multiple "set_device_info" commands to device + * + * @param map HashMap (name, value of parameter) + */ + public void sendDeviceCommands(HashMap map) { + long now = System.currentTimeMillis(); + if (now > this.lastSent + TAPO_SEND_MIN_GAP_MS) { + this.lastSent = now; + + /* create payload */ + PayloadBuilder plBuilder = new PayloadBuilder(); + plBuilder.method = DEVICE_CMD_SETINFO; + for (HashMap.Entry entry : map.entrySet()) { + plBuilder.addParameter(entry.getKey(), entry.getValue()); + } + String payload = plBuilder.getPayload(); + + sendSecurePasstrhroug(payload, DEVICE_CMD_SETINFO); + } else { + logger.debug("({}) command not sent becauso of min_gap: {}", uid, now + " <- " + lastSent); + } + } + + /** + * Query Info from Device adn refresh deviceInfo + */ + public void queryInfo() { + queryInfo(false); + } + + /** + * Query Info from Device adn refresh deviceInfo + * + * @param ignoreGap ignore gap to last query. query anyway + */ + public void queryInfo(boolean ignoreGap) { + logger.trace("({}) DeviceConnetor_queryInfo from '{}'", uid, deviceURL); + long now = System.currentTimeMillis(); + if (ignoreGap || now > this.lastQuery + TAPO_SEND_MIN_GAP_MS) { + this.lastQuery = now; + + /* create payload */ + PayloadBuilder plBuilder = new PayloadBuilder(); + plBuilder.method = DEVICE_CMD_GETINFO; + String payload = plBuilder.getPayload(); + + sendSecurePasstrhroug(payload, DEVICE_CMD_GETINFO); + } else { + logger.debug("({}) command not sent becauso of min_gap: {}", uid, now + " <- " + lastQuery); + } + } + + /** + * SEND SECUREPASSTHROUGH + * encprypt payload and send to device + * + * @param payload payload sent to device + * @param command command executed - this will handle result + */ + protected void sendSecurePasstrhroug(String payload, String command) { + /* encrypt payload */ + String encryptedPayload = encryptPayload(payload); + + /* create secured payload */ + PayloadBuilder plBuilder = new PayloadBuilder(); + plBuilder.method = "securePassthrough"; + plBuilder.addParameter("request", encryptedPayload); + String securePassthroughPayload = plBuilder.getPayload(); + + sendAsyncRequest(deviceURL, securePassthroughPayload, command); + } + + /*********************************** + * + * HANDLE RESPONSES + * + ************************************/ + + /** + * Handle SuccessResponse (setDeviceInfo) + * + * @param responseBody String with responseBody from device + */ + @Override + protected void handleSuccessResponse(String responseBody) { + JsonObject jsnResult = getJsonFromResponse(responseBody); + Integer errorCode = jsonObjectToInt(jsnResult, "error_code", ERR_JSON_DECODE_FAIL); + if (errorCode != 0) { + logger.debug("({}) set deviceInfo not succesfull: {}", uid, jsnResult); + this.device.handleConnectionState(); + } + this.device.responsePasstrough(responseBody); + } + + /** + * handle JsonResponse (getDeviceInfo) + * + * @param responseBody String with responseBody from device + */ + @Override + protected void handleDeviceResult(String responseBody) { + JsonObject jsnResult = getJsonFromResponse(responseBody); + if (jsnResult.has("device_id")) { + this.deviceInfo = new TapoDeviceInfo(jsnResult); + this.device.setDeviceInfo(deviceInfo); + } else { + this.deviceInfo = new TapoDeviceInfo(); + this.device.handleConnectionState(); + } + this.device.responsePasstrough(responseBody); + } + + /** + * handle custom response + * + * @param responseBody String with responseBody from device + */ + @Override + protected void handleCustomResponse(String responseBody) { + this.device.responsePasstrough(responseBody); + } + + /** + * handle error + * + * @param te TapoErrorHandler + */ + @Override + protected void handleError(TapoErrorHandler tapoError) { + this.device.setError(tapoError); + } + + /** + * get Json from response + * + * @param responseBody + * @return JsonObject with result + */ + private JsonObject getJsonFromResponse(String responseBody) { + JsonObject jsonObject = gson.fromJson(responseBody, JsonObject.class); + /* get errocode (0=success) */ + if (jsonObject != null) { + Integer errorCode = jsonObjectToInt(jsonObject, "error_code"); + if (errorCode == 0) { + /* decrypt response */ + jsonObject = gson.fromJson(responseBody, JsonObject.class); + logger.trace("({}) received result: {}", uid, responseBody); + if (jsonObject != null) { + /* return result if set / else request was successfull */ + if (jsonObject.has("result")) { + return jsonObject.getAsJsonObject("result"); + } else { + return jsonObject; + } + } + } else { + /* return errorcode from device */ + TapoErrorHandler te = new TapoErrorHandler(errorCode, "device answers with errorcode"); + logger.debug("({}) device answers with errorcode {} - {}", uid, errorCode, te.getMessage()); + handleError(te); + return jsonObject; + } + } + logger.debug("({}) sendPayload exception {}", uid, responseBody); + handleError(new TapoErrorHandler(ERR_HTTP_RESPONSE)); + return new JsonObject(); + } + + /*********************************** + * + * GET RESULTS + * + ************************************/ + + /** + * Check if device is online + * + * @return true if device is online + */ + public Boolean isOnline() { + return isOnline(false); + } + + /** + * Check if device is online + * + * @param raiseError if true + * @return true if device is online + */ + public Boolean isOnline(Boolean raiseError) { + if (pingDevice()) { + return true; + } else { + logger.trace("({}) device is offline (no ping)", uid); + if (raiseError) { + handleError(new TapoErrorHandler(ERR_DEVICE_OFFLINE)); + } + logout(); + return false; + } + } + + /** + * IP-Adress + * + * @return String ipAdress + */ + public String getIP() { + return this.ipAddress; + } + + /** + * PING IP Adress + * + * @return true if ping successfull + */ + public Boolean pingDevice() { + try { + InetAddress address = InetAddress.getByName(this.ipAddress); + return address.isReachable(TAPO_PING_TIMEOUT_MS); + } catch (Exception e) { + logger.debug("({}) InetAdress throws: {}", uid, e.getMessage()); + return false; + } + } +} diff --git a/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/api/TapoDeviceHttpApi.java b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/api/TapoDeviceHttpApi.java new file mode 100644 index 00000000000..59bb9b55498 --- /dev/null +++ b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/api/TapoDeviceHttpApi.java @@ -0,0 +1,564 @@ +/** + * Copyright (c) 2010-2021 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.tapocontrol.internal.api; + +import static org.openhab.binding.tapocontrol.internal.constants.TapoBindingSettings.*; +import static org.openhab.binding.tapocontrol.internal.constants.TapoErrorConstants.*; +import static org.openhab.binding.tapocontrol.internal.helpers.TapoUtils.*; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpResponse; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.client.api.Result; +import org.eclipse.jetty.client.util.BufferingResponseListener; +import org.eclipse.jetty.client.util.StringContentProvider; +import org.eclipse.jetty.http.HttpMethod; +import org.openhab.binding.tapocontrol.internal.device.TapoBridgeHandler; +import org.openhab.binding.tapocontrol.internal.device.TapoDevice; +import org.openhab.binding.tapocontrol.internal.helpers.PayloadBuilder; +import org.openhab.binding.tapocontrol.internal.helpers.TapoCipher; +import org.openhab.binding.tapocontrol.internal.helpers.TapoErrorHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; + +/** + * Handler class for TAPO Smart Home device connections. + * This class uses synchronous HttpClient-Requests for login to device + * + * @author Christian Wild - Initial contribution + */ +@NonNullByDefault +public class TapoDeviceHttpApi { + private final Logger logger = LoggerFactory.getLogger(TapoDeviceHttpApi.class); + private final String uid; + private final TapoCipher tapoCipher; + private final TapoBridgeHandler bridge; + private Gson gson; + private String token = ""; + private String cookie = ""; + protected String deviceURL = ""; + protected String ipAddress = ""; + + /** + * INIT CLASS + * + * @param config TapoControlConfiguration class + */ + public TapoDeviceHttpApi(TapoDevice device, TapoBridgeHandler bridgeThingHandler) { + this.bridge = bridgeThingHandler; + this.tapoCipher = new TapoCipher(); + this.gson = new Gson(); + this.uid = device.getThingUID().getAsString(); + String ipAddress = device.getIpAddress(); + setDeviceURL(ipAddress); + } + + /*********************************** + * + * DELEGATING FUNCTIONS + * will normaly be delegated to extension-classes(TapoDeviceConnector) + * + ************************************/ + /** + * handle SuccessResponse (setDeviceInfo) + * + * @param responseBody String with responseBody from device + */ + protected void handleSuccessResponse(String responseBody) { + } + + /** + * handle JsonResponse (getDeviceInfo) + * + * @param responseBody String with responseBody from device + */ + protected void handleDeviceResult(String responseBody) { + } + + /** + * handle custom response + * + * @param responseBody String with responseBody from device + */ + protected void handleCustomResponse(String responseBody) { + } + + /** + * handle error + * + * @param te TapoErrorHandler + */ + protected void handleError(TapoErrorHandler tapoError) { + } + + /*********************************** + * + * LOGIN FUNCTIONS + * + ************************************/ + + /** + * Create Handshake and set cookie + * + * @return true if handshake (cookie) was created + */ + protected String createHandshake() { + String cookie = ""; + try { + /* create payload for handshake */ + PayloadBuilder plBuilder = new PayloadBuilder(); + plBuilder.method = "handshake"; + plBuilder.addParameter("key", bridge.getCredentials().getPublicKey()); // ?.decode("UTF-8") + String payload = plBuilder.getPayload(); + + /* send request (create ) */ + logger.trace("({}) create handhsake with payload: {}", uid, payload.toString()); + ContentResponse response = sendRequest(this.deviceURL, payload); + if (response != null && getErrorCode(response) == 0) { + String encryptedKey = getKeyFromResponse(response); + this.tapoCipher.setKey(encryptedKey, bridge.getCredentials()); + cookie = getCookieFromResponse(response); + } + } catch (Exception e) { + logger.debug("({}) could not createHandshake: {}", uid, e.toString()); + handleError(new TapoErrorHandler(ERR_HAND_SHAKE_FAILED, "could not createHandshake")); + } + return cookie; + } + + /** + * return encrypted key from 'handshake' request + * + * @param response ContentResponse from "handshake" method + * @return + */ + private String getKeyFromResponse(ContentResponse response) { + String rBody = response.getContentAsString(); + JsonObject jsonObj = gson.fromJson(rBody, JsonObject.class); + if (jsonObj != null) { + logger.trace("({}) received awnser: {}", uid, rBody); + return jsonObjectToString(jsonObj.getAsJsonObject("result"), "key"); + } else { + logger.warn("({}) could not getKeyFromResponse '{}'", uid, rBody); + handleError(new TapoErrorHandler(ERR_HAND_SHAKE_FAILED, "could not getKeyFromResponse")); + } + return ""; + } + + /** + * return cookie from 'handshake' request + * + * @param response ContentResponse from "handshake" metho + * @return + */ + private String getCookieFromResponse(ContentResponse response) { + String cookie = ""; + try { + cookie = response.getHeaders().get("Set-Cookie").split(";")[0]; + logger.trace("({}) got cookie: '{}'", uid, cookie); + } catch (Exception e) { + logger.warn("({}) could not getCookieFromResponse", uid); + handleError(new TapoErrorHandler(ERR_HAND_SHAKE_FAILED, "could not getCookieFromResponse")); + } + return cookie; + } + + /** + * Query Token from device + * + * @return String with token returned from device + */ + protected String queryToken() { + String token = ""; + try { + /* encrypt login credentials */ + PayloadBuilder plBuilder = new PayloadBuilder(); + plBuilder.method = "login_device"; + plBuilder.addParameter("username", bridge.getCredentials().getEncodedEmail()); + plBuilder.addParameter("password", bridge.getCredentials().getEncodedPassword()); + String payload = plBuilder.getPayload(); + String encryptedPayload = this.encryptPayload(payload); + + /* create secured login informations */ + plBuilder = new PayloadBuilder(); + plBuilder.method = "securePassthrough"; + plBuilder.addParameter("request", encryptedPayload); + String securePassthroughPayload = plBuilder.getPayload(); + + /* sendRequest and get Token */ + ContentResponse response = sendRequest(deviceURL, securePassthroughPayload); + token = getTokenFromResponse(response); + } catch (Exception e) { + logger.debug("({}) error building login payload: {}", uid, e.toString()); + handleError(new TapoErrorHandler(e, "error building login payload")); + } + return token; + } + + /** + * get Token from "login"-request + * + * @param response + * @return + */ + private String getTokenFromResponse(@Nullable ContentResponse response) { + String result = ""; + TapoErrorHandler tapoError = new TapoErrorHandler(); + if (response != null && response.getStatus() == 200) { + String rBody = response.getContentAsString(); + String decryptedResponse = this.decryptResponse(rBody); + logger.trace("({}) received result: {}", uid, decryptedResponse); + + /* get errocode (0=success) */ + JsonObject jsonObject = gson.fromJson(decryptedResponse, JsonObject.class); + if (jsonObject != null) { + Integer errorCode = jsonObjectToInt(jsonObject, "error_code", ERR_JSON_DECODE_FAIL); + if (errorCode == 0) { + /* return result if set / else request was successfull */ + result = jsonObjectToString(jsonObject.getAsJsonObject("result"), "token"); + } else { + /* return errorcode from device */ + tapoError.raiseError(errorCode, "could not get token"); + logger.debug("({}) login recieved errorCode {} - {}", uid, errorCode, tapoError.getMessage()); + } + } else { + logger.debug("({}) unexpected json-response '{}'", uid, decryptedResponse); + tapoError.raiseError(ERR_JSON_ENCODE_FAIL, "could not get token"); + } + } else { + logger.debug("({}) invalid response while login", uid); + tapoError.raiseError(ERR_HTTP_RESPONSE, "invalid response while login"); + } + /* handle error */ + if (tapoError.hasError()) { + handleError(tapoError); + } + return result; + } + + /*********************************** + * + * HTTP-ACTIONS + * + ************************************/ + /** + * SEND SYNCHRON HTTP-REQUEST + * + * @param url url request is sent to + * @param payload payload (String) to send + * @return ContentResponse of request + */ + @Nullable + protected ContentResponse sendRequest(String url, String payload) { + logger.trace("({}) sendRequest to '{}' with cookie '{}'", uid, url, this.cookie); + + Request httpRequest = bridge.getHttpClient().newRequest(url).method(HttpMethod.POST.toString()); + + /* set header */ + httpRequest = setHeaders(httpRequest); + httpRequest.timeout(TAPO_HTTP_TIMEOUT_MS, TimeUnit.MILLISECONDS); + + /* add request body */ + httpRequest.content(new StringContentProvider(payload, CONTENT_CHARSET), CONTENT_TYPE_JSON); + + try { + ContentResponse httpResponse = httpRequest.send(); + return httpResponse; + } catch (InterruptedException e) { + logger.debug("({}) sending request interrupted: {}", uid, e.toString()); + handleError(new TapoErrorHandler(e)); + } catch (TimeoutException e) { + logger.debug("({}) sending request timeout: {}", uid, e.toString()); + handleError(new TapoErrorHandler(ERR_CONNECT_TIMEOUT, e.toString())); + } catch (Exception e) { + logger.debug("({}) sending request failed: {}", uid, e.toString()); + handleError(new TapoErrorHandler(e)); + } + return null; + } + + /** + * SEND ASYNCHRONOUS HTTP-REQUEST + * (don't wait for awnser with programm code) + * + * @param url string url request is sent to + * @param payload data-payload + * @param command command executed - this will handle RepsonseType + */ + protected void sendAsyncRequest(String url, String payload, String command) { + logger.trace("({}) sendAsncRequest to '{}' with cookie '{}'", uid, url, this.cookie); + try { + Request httpRequest = bridge.getHttpClient().newRequest(url).method(HttpMethod.POST.toString()); + + /* set header */ + httpRequest = setHeaders(httpRequest); + + /* add request body */ + httpRequest.content(new StringContentProvider(payload, CONTENT_CHARSET), CONTENT_TYPE_JSON); + + httpRequest.timeout(TAPO_HTTP_TIMEOUT_MS, TimeUnit.MILLISECONDS).send(new BufferingResponseListener() { + @NonNullByDefault({}) + @Override + public void onComplete(Result result) { + final HttpResponse response = (HttpResponse) result.getResponse(); + if (result.getFailure() != null) { + /* handle result errors */ + Throwable e = result.getFailure(); + String errorMessage = getValueOrDefault(e.getMessage(), ""); + if (e instanceof TimeoutException) { + logger.debug("({}) sendAsyncRequest timeout'{}'", uid, errorMessage); + handleError(new TapoErrorHandler(ERR_CONNECT_TIMEOUT, errorMessage)); + } else { + logger.debug("({}) sendAsyncRequest failed'{}'", uid, errorMessage); + handleError(new TapoErrorHandler(new Exception(e), errorMessage)); + } + } else if (response.getStatus() != 200) { + logger.debug("({}) sendAsyncRequest response error'{}'", uid, response.getStatus()); + handleError(new TapoErrorHandler(ERR_HTTP_RESPONSE, getContentAsString())); + } else { + /* request succesfull */ + String rBody = getContentAsString(); + rBody = decryptResponse(rBody); + logger.trace("({}) requestCompleted '{}'", uid, rBody); + /* handle result */ + switch (command) { + case DEVICE_CMD_SETINFO: + handleSuccessResponse(rBody); + break; + case DEVICE_CMD_GETINFO: + handleDeviceResult(rBody); + break; + case DEVICE_CMD_CUSTOM: + handleCustomResponse(rBody); + break; + } + } + } + }); + } catch (Exception e) { + handleError(new TapoErrorHandler(e)); + } + } + + /** + * return error code from response + * + * @param response + * @return 0 if request was successfull + */ + protected Integer getErrorCode(@Nullable ContentResponse response) { + try { + if (response != null) { + String responseBody = response.getContentAsString(); + return getErrorCode(responseBody); + } else { + return ERR_HTTP_RESPONSE; + } + } catch (Exception e) { + return ERR_HTTP_RESPONSE; + } + } + + /** + * return error code from responseBody + * + * @param responseBody + * @return 0 if request was successfull + */ + protected Integer getErrorCode(String responseBody) { + try { + JsonObject jsonObject = gson.fromJson(responseBody, JsonObject.class); + /* get errocode (0=success) */ + Integer errorCode = jsonObjectToInt(jsonObject, "error_code", ERR_JSON_DECODE_FAIL); + if (errorCode == 0) { + return 0; + } else { + logger.debug("({}) device returns errorcode '{}'", uid, errorCode); + handleError(new TapoErrorHandler(errorCode)); + return errorCode; + } + } catch (Exception e) { + return ERR_HTTP_RESPONSE; + } + } + + /** + * SET HTTP-HEADERS + */ + private Request setHeaders(Request httpRequest) { + /* set header */ + httpRequest.header("content-type", CONTENT_TYPE_JSON); + httpRequest.header("Accept", CONTENT_TYPE_JSON); + if (!this.cookie.isEmpty()) { + httpRequest.header(HTTP_AUTH_TYPE_COOKIE, this.cookie); + } + return httpRequest; + } + + /*********************************** + * + * ENCRYPTION / CODING + * + ************************************/ + + /** + * Decrypt Response + * + * @param responseBody encrypted string from response-body + * @return String decrypted responseBody + */ + protected String decryptResponse(String responseBody) { + try { + JsonObject jsonObject = gson.fromJson(responseBody, JsonObject.class); + if (jsonObject != null) { + String encryptedResponse = jsonObjectToString(jsonObject.getAsJsonObject("result"), "response"); + return tapoCipher.decode(encryptedResponse); + } else { + handleError(new TapoErrorHandler(ERR_JSON_DECODE_FAIL)); + } + } catch (Exception ex) { + logger.debug("({}) exception '{}' decryptingResponse: '{}'", uid, ex.toString(), responseBody); + } + return responseBody; + } + + /** + * encrypt payload + * + * @param payload + * @return encrypted payload + */ + protected String encryptPayload(String payload) { + try { + return tapoCipher.encode(payload); + } catch (Exception ex) { + logger.debug("({}) exception encoding Payload '{}'", uid, ex.toString()); + return ""; + } + } + + /** + * perform logout (dispose cookie) + */ + public void logout() { + logger.trace("DeviceHttpApi_logout"); + unsetToken(); + unsetCookie(); + } + + /*********************************** + * + * GET RESULTS + * + ************************************/ + /** + * Logged In + * + * @return true if logged in + */ + public Boolean loggedIn() { + return loggedIn(false); + } + + /** + * Logged In + * + * @param raiseError if true + * @return true if logged in + */ + public Boolean loggedIn(Boolean raiseError) { + if (!this.token.isBlank() && !this.cookie.isBlank()) { + return true; + } else { + logger.trace("({}) not logged in", uid); + if (raiseError) { + handleError(new TapoErrorHandler(ERR_LOGIN)); + } + return false; + } + } + + /*********************************** + * + * SET VALUES + * + ************************************/ + + /** + * Set new ipAddress + * + * @param new ipAdress + */ + public void setDeviceURL(String ipAddress) { + this.ipAddress = ipAddress; + this.deviceURL = String.format(TAPO_DEVICE_URL, ipAddress); + } + + /** + * Set new ipAdresss with token + * + * @param ipAddress ipAddres of device + * @param token token from login-ressult + */ + public void setDeviceURL(String ipAddress, String token) { + this.ipAddress = ipAddress; + this.deviceURL = String.format(TAPO_DEVICE_URL, ipAddress); + } + + /** + * Set new token + * + * @param deviceURL + * @param token + */ + protected void setToken(String token) { + if (!token.isBlank()) { + String url = this.deviceURL.replaceAll("\\?token=\\w*", ""); + this.deviceURL = url + "?token=" + token; + } + this.token = token; + } + + /** + * Unset Token (device logout) + */ + protected void unsetToken() { + this.deviceURL = this.deviceURL.replaceAll("\\?token=\\w*", ""); + this.token = ""; + } + + /** + * Set new cookie + * + * @param cookie + */ + protected void setCookie(String cookie) { + this.cookie = cookie; + } + + /** + * Unset Cookie (device logout) + */ + protected void unsetCookie() { + bridge.getHttpClient().getCookieStore().removeAll(); + this.cookie = ""; + } +} diff --git a/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/constants/TapoBindingSettings.java b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/constants/TapoBindingSettings.java new file mode 100644 index 00000000000..dd4ff8f1ac7 --- /dev/null +++ b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/constants/TapoBindingSettings.java @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2010-2021 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.tapocontrol.internal.constants; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link TapoBindingSettings} class defines common constants, which are + * used across the whole binding. + * + * @author Christian Wild - Initial contribution + */ +@NonNullByDefault +public class TapoBindingSettings { + public static final String BINDING_ID = "tapocontrol"; + + // List of all constant configurations + public static final String HTTP_HEADER_AUTH = "Authorization"; + public static final String HTTP_AUTH_TYPE_BASIC = "Basic"; + public static final String HTTP_AUTH_TYPE_COOKIE = "cookie"; + public static final String CONTENT_CHARSET = "UTF-8"; + public static final String CONTENT_TYPE_JSON = "application/json"; + public static final String TAPO_CLOUD_URL = "https://eu-wap.tplinkcloud.com"; + public static final String TAPO_APP_TYPE = "Tapo_Ios"; + public static final String TAPO_TERMINAL_UUID = "0A950402-7224-46EB-A450-7362CDB902A2"; + public static final String TAPO_DEVICE_URL = "http://%s/app"; + public static final Integer HTTP_MAX_CONNECTIONS = 10; // setMaxConnectionsPerDestination for HTTP-Client + public static final Integer HTTP_MAX_QUEUED_REQUESTS = 10; // setMaxRequestsQueuedPerDestination for HTTP-Client + public static final Integer TAPO_HTTP_TIMEOUT_MS = 5000; // http request timeout + public static final Integer TAPO_PING_TIMEOUT_MS = 2000; // ping timeout + public static final Integer TAPO_REFRESH_MIN_GAP_MS = 5000; // min gap between sending refresh request + public static final Integer TAPO_SEND_MIN_GAP_MS = 1000; // min gap between sending command request + public static final Integer TAPO_LOGIN_MIN_GAP_MS = 5000; // min gap between sending login request + public static final Integer TAPO_LOGIN_MAX_GAP_M = 1440; // max minutes to relogin to device + public static final Integer TAPO_DISCOVERY_TIMEOUT_S = 6; // timout device discovery in seconds + public static final Integer POLLING_MIN_INTERVAL_S = 10; // min polling interval (settings) + + // FORMATING CONSTANTS + public static final String IPV4_REGEX = "(([0-1]?[0-9]{1,2}\\.)|(2[0-4][0-9]\\.)|(25[0-5]\\.)){3}(([0-1]?[0-9]{1,2})|(2[0-4][0-9])|(25[0-5]))"; + public static final char MAC_DIVISION_CHAR = '-'; + + // LIST OF DEVICE-COMMANDS + public static final String DEVICE_CMD_GETINFO = "get_device_info"; + public static final String DEVICE_CMD_SETINFO = "set_device_info"; + public static final String DEVICE_CMD_CUSTOM = "custom_command"; +} diff --git a/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/constants/TapoErrorConstants.java b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/constants/TapoErrorConstants.java new file mode 100644 index 00000000000..d2e8d8839bf --- /dev/null +++ b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/constants/TapoErrorConstants.java @@ -0,0 +1,157 @@ +/** + * Copyright (c) 2010-2021 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.tapocontrol.internal.constants; + +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link TapoErrorConstants} class defines error-message constants + * + * @author Christian Wild - Initial contribution + */ +@NonNullByDefault +public class TapoErrorConstants { + /**************************************** + * LIST OF ERROR CODES + ****************************************/ + // List of API-ErrorCodes + public static final Integer ERR_COMMON_FAILED = -1; + public static final Integer ERR_SESSION_TIMEOUT = 9999; + public static final Integer ERR_NULL_TRANSPORT = 1000; + public static final Integer ERR_REQUEST = 1002; + public static final Integer ERR_HAND_SHAKE_FAILED = 1100; + public static final Integer ERR_LOGIN_FAILED = 1111; + public static final Integer ERR_HTTP_TRANSPORT_FAILED = 1112; + public static final Integer ERR_MULTI_REQUEST_FAILED = 1200; + public static final Integer ERR_JSON_DECODE_FAIL = -1003; + public static final Integer ERR_JSON_ENCODE_FAIL = -1004; + public static final Integer ERR_AES_DECODE_FAIL = -1005; + public static final Integer ERR_REQUEST_LEN_ERROR = -1006; + public static final Integer ERR_CLOUD_FAILED = -1007; + public static final Integer ERR_PARAMS = -1008; + public static final Integer ERR_RSA_KEY_LENGTH = -1010; + public static final Integer ERR_SESSION_PARAM = -1101; + public static final Integer ERR_QUICK_SETUP = -1201; + public static final Integer ERR_DEVICE = -1301; + public static final Integer ERR_DEVICE_NEXT_EVENT = -1302; + public static final Integer ERR_FIRMWARE = -1401; + public static final Integer ERR_FIRMWARE_VER_ERROR = -1402; + public static final Integer ERR_LOGIN = -1501; + public static final Integer ERR_TIME = -1601; + public static final Integer ERR_TIME_SYS = -1602; + public static final Integer ERR_TIME_SAVE = -1603; + public static final Integer ERR_WIRELESS = -1701; + public static final Integer ERR_WIRELESS_UNSUPPORTED = -1702; + public static final Integer ERR_SCHEDULE = -1801; + public static final Integer ERR_SCHEDULE_FULL = -1802; + public static final Integer ERR_SCHEDULE_CONFLICT = -1803; + public static final Integer ERR_SCHEDULE_SAVE = -1804; + public static final Integer ERR_SCHEDULE_INDEX = -1805; + public static final Integer ERR_COUNTDOWN = -1901; + public static final Integer ERR_COUNTDOWN_CONFLICT = -1902; + public static final Integer ERR_COUNTDOWN_SAVE = -1903; + public static final Integer ERR_ANTITHEFT = -2001; + public static final Integer ERR_ANTITHEFT_CONFLICT = -2002; + public static final Integer ERR_ANTITHEFT_SAVE = -2003; + public static final Integer ERR_ACCOUNT = -2101; + public static final Integer ERR_STAT = -2201; + public static final Integer ERR_STAT_SAVE = -2202; + public static final Integer ERR_DST = -2301; + public static final Integer ERR_DST_SAVE = -2302; + // -20661 + + // List of Binding-ErrorCodes + public static final Integer ERR_HTTP_RESPONSE = 9001; + public static final Integer ERR_COOKIE = 9002; + public static final Integer ERR_CREDENTIALS = 9003; + public static final Integer ERR_DEVICE_OFFLINE = 9009; + public static final Integer ERR_CONNECT_TIMEOUT = 9010; + + // List of Config-ErrorCodes + public static final Integer ERR_CONF_IP = 10001; // ip not set + public static final Integer ERR_CONF_CREDENTIALS = 10002; // credentials not set + public static final Integer ERR_NO_BRIDGE = 10003; // no bridge configured + + /**************************************** + * LIST OF ERROR MESSAGES + ****************************************/ + // List of CLOUD-Error-Messages + public static final String ERR_COMMON_FAILED_MSG = ""; // -1; + public static final String ERR_SESSION_TIMEOUT_MSG = "Session Timeout"; // 9999; + public static final String ERR_NULL_TRANSPORT_MSG = ""; // 1000; + public static final String ERR_REQUEST_MSG = "Invalid request or command"; // 1002; + public static final String ERR_HAND_SHAKE_FAILED_MSG = "Can't create handshake"; // 1100; + public static final String ERR_LOGIN_FAILED_MSG = ""; // 1111; + public static final String ERR_HTTP_TRANSPORT_FAILED_MSG = ""; // 1112; + public static final String ERR_MULTI_REQUEST_FAILED_MSG = ""; // 1200; + public static final String ERR_JSON_DECODE_FAIL_MSG = "json decode failed"; // -1003; + public static final String ERR_JSON_ENCODE_FAIL_MSG = "json encode failed"; // -1004; + public static final String ERR_AES_DECODE_FAIL_MSG = ""; // -1005; + public static final String ERR_REQUEST_LEN_ERROR_MSG = ""; // -1006; + public static final String ERR_CLOUD_FAILED_MSG = ""; // -1007; + public static final String ERR_PARAMS_MSG = "received invalid parameter"; // -1008; + public static final String ERR_RSA_KEY_LENGTH_MSG = "Invalid Public Key Length"; // -1010; + public static final String ERR_SESSION_PARAM_MSG = ""; // -1101; + public static final String ERR_QUICK_SETUP_MSG = ""; // -1201; + public static final String ERR_DEVICE_MSG = ""; // -1301; + public static final String ERR_DEVICE_NEXT_EVENT_MSG = ""; // -1302; + public static final String ERR_FIRMWARE_MSG = ""; // -1401; + public static final String ERR_FIRMWARE_VER_ERROR_MSG = ""; // -1402; + public static final String ERR_LOGIN_MSG = "Login Error"; // -1501; + public static final String ERR_TIME_MSG = ""; // -1601; + public static final String ERR_TIME_SYS_MSG = ""; // -1602; + public static final String ERR_TIME_SAVE_MSG = ""; // -1603; + public static final String ERR_WIRELESS_MSG = ""; // -1701; + public static final String ERR_WIRELESS_UNSUPPORTED_MSG = ""; // -1702; + public static final String ERR_SCHEDULE_MSG = ""; // -1801; + public static final String ERR_SCHEDULE_FULL_MSG = ""; // -1802; + public static final String ERR_SCHEDULE_CONFLICT_MSG = ""; // -1803; + public static final String ERR_SCHEDULE_SAVE_MSG = ""; // -1804; + public static final String ERR_SCHEDULE_INDEX_MSG = ""; // -1805; + public static final String ERR_COUNTDOWN_MSG = ""; // -1901; + public static final String ERR_COUNTDOWN_CONFLICT_MSG = ""; // -1902; + public static final String ERR_COUNTDOWN_SAVE_MSG = ""; // -1903; + public static final String ERR_ANTITHEFT_MSG = ""; // -2001; + public static final String ERR_ANTITHEFT_CONFLICT_MSG = ""; // -2002; + public static final String ERR_ANTITHEFT_SAVE_MSG = ""; // -2003; + public static final String ERR_ACCOUNT_MSG = ""; // -2101; + public static final String ERR_STAT_MSG = ""; // -2201; + public static final String ERR_STAT_SAVE_MSG = ""; // -2202; + public static final String ERR_DST_MSG = ""; // -2301; + public static final String ERR_DST_SAVE_MSG = ""; // -2302; + + // List of Binding-Error-Messages + public static final String ERR_HTTP_RESPONSE_MSG = "Invalid HTTP-Response"; // 9001 + public static final String ERR_COOKIE_MSG = "Cookie Error"; // 9002 + public static final String ERR_DEVICE_OFFLINE_MSG = "Device Offline"; // 9009 + public static final String ERR_CREDENTIALS_MSG = "Invalid Request or Credentials"; + public static final String ERR_CONNECT_TIMEOUT_MSG = "Connection Timeout - device not reachable"; + + // List of Config-Error-Messages + public static final String ERR_CONF_IP_MSG = "IP-Address not valid"; // 10001; + public static final String ERR_CONF_CREDENTIALS_MSG = "credentials not set (bridge)"; // 10002; + public static final String ERR_NO_BRIDGE_MSG = "no brigde configured"; // 10003; + + /**************************************** + * ErrorTypes + ****************************************/ + // communication errors - set device to offline (retry connect) + public static final Set LIST_COMMUNICATION_ERRORS = Set.of(ERR_HTTP_RESPONSE, ERR_COOKIE, + ERR_DEVICE_OFFLINE, ERR_CONNECT_TIMEOUT); + // configuration errors - set device to state configuration error (don't retry) + public static final Set LIST_CONFIGURATION_ERRORS = Set.of(ERR_CREDENTIALS); + // reauthenticate errors (trying login immediatly) + public static final Set LIST_REAUTH_ERRORS = Set.of(ERR_SESSION_TIMEOUT, ERR_HAND_SHAKE_FAILED); +} diff --git a/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/constants/TapoThingConstants.java b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/constants/TapoThingConstants.java new file mode 100644 index 00000000000..faa22611a2f --- /dev/null +++ b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/constants/TapoThingConstants.java @@ -0,0 +1,153 @@ +/** + * Copyright (c) 2010-2021 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.tapocontrol.internal.constants; + +import static org.openhab.binding.tapocontrol.internal.constants.TapoBindingSettings.*; + +import java.util.Collections; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.ThingTypeUID; + +/** + * The {@link TapoBindingSettings} class defines common constants, which are + * used across the whole binding. + * + * @author Christian Wild - Initial contribution + ***/ +@NonNullByDefault +public class TapoThingConstants { + public static final String DEVICE_VENDOR = "Tapo"; + + /*** LIST OF SUPPORTED DEVICE NAMES ***/ + public static final String DEVICE_BRIDGE = "bridge"; + public static final String DEVICE_P100 = "P100"; + public static final String DEVICE_P105 = "P105"; + public static final String DEVICE_L510E = "L510_Series"; + public static final String DEVICE_L530E = "L530_Series"; + public static final String DEVICE_L900 = "L900"; + public static final String DEVICE_UNIVERSAL = "Test_Device"; + + /*** LIST OF SUPPORTED DEVICE DESCRIPTIONS ***/ + public static final String DEVICE_DESCRIPTION_BRIDGE = "TapoControl Cloud-Login"; + public static final String DEVICE_DESCRIPTION_SMART_PLUG = "SmartPlug"; + public static final String DEVICE_DESCRIPTION_WHITE_BULB = "White-Light-Bulb"; + public static final String DEVICE_DESCRIPTION_COLOR_BULB = "Color-Light-Bulb"; + public static final String DEVICE_DESCRIPTION_LIGHTSTRIP = "LightStrip"; + + /*** LIST OF SUPPORTED THING UIDS ***/ + public static final ThingTypeUID BRIDGE_THING_TYPE = new ThingTypeUID(BINDING_ID, DEVICE_BRIDGE); + public static final ThingTypeUID P100_THING_TYPE = new ThingTypeUID(BINDING_ID, DEVICE_P100); + public static final ThingTypeUID P105_THING_TYPE = new ThingTypeUID(BINDING_ID, DEVICE_P105); + public static final ThingTypeUID L510E_THING_TYPE = new ThingTypeUID(BINDING_ID, DEVICE_L510E); + public static final ThingTypeUID L530E_THING_TYPE = new ThingTypeUID(BINDING_ID, DEVICE_L530E); + public static final ThingTypeUID L900_THING_TYPE = new ThingTypeUID(BINDING_ID, DEVICE_L900); + public static final ThingTypeUID UNIVERSAL_THING_TYPE = new ThingTypeUID(BINDING_ID, DEVICE_UNIVERSAL); + + /*** SET OF SUPPORTED UIDS ***/ + public static final Set SUPPORTED_BRIDGE_UIDS = Set.of(BRIDGE_THING_TYPE); + public static final Set SUPPORTED_SMART_PLUG_UIDS = Set.of(P100_THING_TYPE, P105_THING_TYPE); + public static final Set SUPPORTED_WHITE_BULB_UIDS = Set.of(L510E_THING_TYPE); + public static final Set SUPPORTED_COLOR_BULB_UIDS = Set.of(L530E_THING_TYPE); + public static final Set SUPPORTED_LIGHT_STRIP_UIDS = Set.of(L900_THING_TYPE); + public static final Set SUPPORTED_THING_TYPES_UIDS = Collections + .unmodifiableSet(Stream + .of(SUPPORTED_BRIDGE_UIDS, SUPPORTED_SMART_PLUG_UIDS, SUPPORTED_WHITE_BULB_UIDS, + SUPPORTED_COLOR_BULB_UIDS, SUPPORTED_LIGHT_STRIP_UIDS) + .flatMap(Set::stream).collect(Collectors.toSet())); + + /*** THINGS WITH CHANNEL GROUPS ***/ + public static final Set CHANNEL_GROUP_THING_SET = Collections + .unmodifiableSet(Stream + .of(SUPPORTED_BRIDGE_UIDS, SUPPORTED_SMART_PLUG_UIDS, SUPPORTED_WHITE_BULB_UIDS, + SUPPORTED_COLOR_BULB_UIDS, SUPPORTED_LIGHT_STRIP_UIDS) + .flatMap(Set::stream).collect(Collectors.toSet())); + + /*** DEVICE PROPERTY STRINGS (CLOUD) ***/ + public static final String CLOUD_PROPERTY_ALIAS = "alias"; + public static final String CLOUD_PROPERTY_FW = "fwVer"; + public static final String CLOUD_PROPERTY_HW = "deviceHwVer"; + public static final String CLOUD_PROPERTY_ID = "deviceId"; + public static final String CLOUD_PROPERTY_MAC = "deviceMac"; + public static final String CLOUD_PROPERTY_MODEL = "deviceName"; // use name cause modell returns different values + public static final String CLOUD_PROPERTY_NAME = "deviceName"; + public static final String CLOUD_PROPERTY_REGION = "deviceRegion"; + public static final String CLOUD_PROPERTY_SERVER_URL = "appServerUrl"; + public static final String CLOUD_PROPERTY_TYPE = "deviceType"; + + /*** DEVICE PROPERTY STRINGS (DEVICE) ***/ + public static final String DEVICE_PROPERTY_BRIGHTNES = "brightness"; + public static final String DEVICE_PROPERTY_COLORTEMP = "color_temp"; + public static final String DEVICE_PROPERTY_FW = "fw_ver"; + public static final String DEVICE_PROPERTY_HUE = "hue"; + public static final String DEVICE_PROPERTY_HW = "hw_ver"; + public static final String DEVICE_PROPERTY_ID = "device_id"; + public static final String DEVICE_PROPERTY_IP = "ip"; + public static final String DEVICE_PROPERTY_MAC = "mac"; + public static final String DEVICE_PROPERTY_MODEL = "model"; + public static final String DEVICE_PROPERTY_NICKNAME = "nickname"; + public static final String DEVICE_PROPERTY_ON = "device_on"; + public static final String DEVICE_PROPERTY_ONTIME = "on_time"; + public static final String DEVICE_PROPERTY_OVERHEAT = "overheated"; + public static final String DEVICE_PROPERTY_REGION = "region"; + public static final String DEVICE_PROPERTY_SATURATION = "saturation"; + public static final String DEVICE_PROPERTY_SIGNAL = "signal_level"; + public static final String DEVICE_PROPERTY_SIGNAL_RSSI = "rssi"; + public static final String DEVICE_PROPERTY_TYPE = "type"; + public static final String DEVICE_PROPERTY_USAGE_7 = "time_usage_past7"; + public static final String DEVICE_PROPERTY_USAGE_30 = "time_usage_past30"; + public static final String DEVICE_PROPERTY_USAGE_TODAY = "time_usage_today"; + public static final String DEVICE_REPRASENTATION_PROPERTY = "macAddress"; + // lightning effects + public static final String DEVICE_PROPERTY_EFFECT = "lighting_effect"; + public static final String PROPERTY_LIGHTNING_EFFECT_BRIGHNTESS = "brightness"; + public static final String PROPERTY_LIGHTNING_EFFECT_COLORTEMPRANGE = "color_temp_range"; + public static final String PROPERTY_LIGHTNING_EFFECT_CUSTOM = "custom"; + public static final String PROPERTY_LIGHTNING_EFFECT_DISPLAYCOLORS = "displayColors"; + public static final String PROPERTY_LIGHTNING_EFFECT_ENABLE = "enable"; + public static final String PROPERTY_LIGHTNING_EFFECT_ID = "id"; + public static final String PROPERTY_LIGHTNING_EFFECT_NAME = "name"; + + /*** DEVICE SETTINGS ***/ + public static final Integer BULB_MIN_COLORTEMP = 2500; + public static final Integer BULB_MAX_COLORTEMP = 6500; + + /*** CHANNEL LISTS ***/ + // channel group actuator + public static final String CHANNEL_GROUP_ACTUATOR = "actuator"; + public static final String CHANNEL_BRIGHTNESS = "brightness"; + public static final String CHANNEL_COLOR = "color"; + public static final String CHANNEL_COLOR_TEMP = "colorTemperature"; + public static final String CHANNEL_OUTPUT = "output"; + public static final String CHANNEL_SWITCH = "switch"; + // channel group device + public static final String CHANNEL_GROUP_DEVICE = "device"; + public static final String CHANNEL_ONTIME = "onTime"; + public static final String CHANNEL_OVERHEAT = "overheated"; + public static final String CHANNEL_WIFI_STRENGTH = "wifiSignal"; + // channel group effect + public static final String CHANNEL_GROUP_EFFECTS = "effect"; + public static final String CHANNEL_FX_BRIGHTNESS = "brightness"; + public static final String CHANNEL_FX_COLORS = "displayColors"; + public static final String CHANNEL_FX_CUSTOM = "custom"; + public static final String CHANNEL_FX_ENABLE = "enable"; + public static final String CHANNEL_FX_NAME = "name"; + + /*** LIST OF PROPERTY NAMES ***/ + public static final String PROPERTY_FAMILY = "deviceFamily"; + public static final String PROPERTY_LOCATION = "location"; + public static final String PROPERTY_WIFI_LEVEL = "signal-strength"; +} diff --git a/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/device/TapoBridgeHandler.java b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/device/TapoBridgeHandler.java new file mode 100644 index 00000000000..7feb214fe25 --- /dev/null +++ b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/device/TapoBridgeHandler.java @@ -0,0 +1,301 @@ +/** + * Copyright (c) 2010-2021 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.tapocontrol.internal.device; + +import java.util.Collection; +import java.util.Collections; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.tapocontrol.internal.TapoDiscoveryService; +import org.openhab.binding.tapocontrol.internal.api.TapoCloudConnector; +import org.openhab.binding.tapocontrol.internal.helpers.TapoCredentials; +import org.openhab.binding.tapocontrol.internal.helpers.TapoErrorHandler; +import org.openhab.binding.tapocontrol.internal.structures.TapoBridgeConfiguration; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.binding.BaseBridgeHandler; +import org.openhab.core.thing.binding.ThingHandlerService; +import org.openhab.core.types.Command; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.JsonArray; + +/** + * The {@link TapoBridgeHandler} is responsible for handling commands, which are + * sent to one of the channels with a bridge. + * + * @author Christian Wild - Initial contribution + */ +@NonNullByDefault +public class TapoBridgeHandler extends BaseBridgeHandler { + private final Logger logger = LoggerFactory.getLogger(TapoBridgeHandler.class); + private final TapoErrorHandler bridgeError = new TapoErrorHandler(); + private final TapoBridgeConfiguration config; + private final HttpClient httpClient; + private @Nullable ScheduledFuture startupJob; + private @Nullable ScheduledFuture pollingJob; + private @Nullable ScheduledFuture discoveryJob; + private @NonNullByDefault({}) TapoCloudConnector cloudConnector; + private @NonNullByDefault({}) TapoDiscoveryService discoveryService; + private TapoCredentials credentials; + + private String uid; + + public TapoBridgeHandler(Bridge bridge, HttpClient httpClient) { + super(bridge); + Thing thing = getThing(); + this.cloudConnector = new TapoCloudConnector(this, httpClient); + this.config = new TapoBridgeConfiguration(thing); + this.credentials = new TapoCredentials(); + this.uid = thing.getUID().toString(); + this.httpClient = httpClient; + } + + /*********************************** + * + * BRIDGE INITIALIZATION + * + ************************************/ + @Override + /** + * INIT BRIDGE + * set credentials and login cloud + */ + public void initialize() { + this.config.loadSettings(); + this.credentials = new TapoCredentials(config.username, config.password); + activateBridge(); + } + + /** + * ACTIVATE BRIDGE + */ + private void activateBridge() { + // set the thing status to UNKNOWN temporarily and let the background task decide for the real status. + updateStatus(ThingStatus.UNKNOWN); + + // background initialization (delay it a little bit): + this.startupJob = scheduler.schedule(this::delayedStartUp, 1000, TimeUnit.MILLISECONDS); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + logger.debug("{} Bridge doesn't handle command: {}", this.uid, command); + } + + @Override + public void dispose() { + stopScheduler(this.startupJob); + stopScheduler(this.pollingJob); + stopScheduler(this.discoveryJob); + super.dispose(); + } + + /** + * ACTIVATE DISCOVERY SERVICE + */ + @Override + public Collection> getServices() { + return Collections.singleton(TapoDiscoveryService.class); + } + + /** + * Set DiscoveryService + * + * @param discoveryService + */ + public void setDiscoveryService(TapoDiscoveryService discoveryService) { + this.discoveryService = discoveryService; + } + + /*********************************** + * + * SCHEDULER + * + ************************************/ + + /** + * delayed OneTime StartupJob + */ + private void delayedStartUp() { + loginCloud(); + startCloudScheduler(); + startDiscoveryScheduler(); + } + + /** + * Start CloudLogin Scheduler + */ + protected void startCloudScheduler() { + Integer pollingInterval = config.cloudReconnectIntervalM; + if (pollingInterval > 0) { + logger.trace("{} starting bridge cloud sheduler", this.uid); + + this.pollingJob = scheduler.scheduleWithFixedDelay(this::loginCloud, pollingInterval, pollingInterval, + TimeUnit.MINUTES); + } else { + stopScheduler(this.pollingJob); + } + } + + /** + * Start DeviceDiscovery Scheduler + */ + protected void startDiscoveryScheduler() { + Integer pollingInterval = config.discoveryIntervalM; + if (config.cloudDiscoveryEnabled && pollingInterval > 0) { + logger.trace("{} starting bridge discovery sheduler", this.uid); + + this.discoveryJob = scheduler.scheduleWithFixedDelay(this::discoverDevices, 0, pollingInterval, + TimeUnit.MINUTES); + } else { + stopScheduler(this.discoveryJob); + } + } + + /** + * Stop scheduler + * + * @param scheduler ScheduledFeature which schould be stopped + */ + protected void stopScheduler(@Nullable ScheduledFuture scheduler) { + if (scheduler != null) { + scheduler.cancel(true); + scheduler = null; + } + } + + /*********************************** + * + * ERROR HANDLER + * + ************************************/ + /** + * return device Error + * + * @return + */ + public TapoErrorHandler getError() { + return this.bridgeError; + } + + /** + * set device error + * + * @param tapoError TapoErrorHandler-Object + */ + public void setError(TapoErrorHandler tapoError) { + this.bridgeError.set(tapoError); + } + + /*********************************** + * + * BRIDGE COMMUNICATIONS + * + ************************************/ + + /** + * Login to Cloud + * + * @return + */ + public boolean loginCloud() { + bridgeError.reset(); // reset ErrorHandler + if (!config.username.isBlank() && !config.password.isBlank()) { + logger.debug("{} login with user {}", this.uid, config.username); + if (cloudConnector.login(config.username, config.password)) { + updateStatus(ThingStatus.ONLINE); + return true; + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, bridgeError.getMessage()); + } + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "credentials not set"); + } + return false; + } + + /*********************************** + * + * DEVICE DISCOVERY + * + ************************************/ + + /** + * START DEVICE DISCOVERY + */ + public void discoverDevices() { + this.discoveryService.startScan(); + } + + /** + * GET DEVICELIST CONNECTED TO BRIDGE + * + * @return devicelist + */ + public JsonArray getDeviceList() { + JsonArray deviceList = new JsonArray(); + if (config.cloudDiscoveryEnabled) { + logger.trace("{} discover devicelist from cloud", this.uid); + deviceList = getDeviceListCloud(); + } + return deviceList; + } + + /** + * GET DEVICELIST FROM CLOUD + * returns all devices stored in cloud + * + * @return deviceList from cloud + */ + private JsonArray getDeviceListCloud() { + logger.trace("{} getDeviceList from cloud", this.uid); + bridgeError.reset(); // reset ErrorHandler + JsonArray deviceList = new JsonArray(); + if (loginCloud()) { + deviceList = this.cloudConnector.getDeviceList(); + } + return deviceList; + } + + /*********************************** + * + * BRIDGE GETTERS + * + ************************************/ + + public TapoCredentials getCredentials() { + return this.credentials; + } + + public HttpClient getHttpClient() { + return this.httpClient; + } + + public ThingUID getUID() { + return getThing().getUID(); + } + + public TapoBridgeConfiguration getBridgeConfig() { + return this.config; + } +} diff --git a/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/device/TapoDevice.java b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/device/TapoDevice.java new file mode 100644 index 00000000000..7c63ed9ae31 --- /dev/null +++ b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/device/TapoDevice.java @@ -0,0 +1,479 @@ +/** + * Copyright (c) 2010-2021 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.tapocontrol.internal.device; + +import static org.openhab.binding.tapocontrol.internal.constants.TapoBindingSettings.*; +import static org.openhab.binding.tapocontrol.internal.constants.TapoErrorConstants.*; +import static org.openhab.binding.tapocontrol.internal.constants.TapoThingConstants.*; +import static org.openhab.binding.tapocontrol.internal.helpers.TapoUtils.*; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.tapocontrol.internal.api.TapoDeviceConnector; +import org.openhab.binding.tapocontrol.internal.helpers.TapoErrorHandler; +import org.openhab.binding.tapocontrol.internal.structures.TapoDeviceConfiguration; +import org.openhab.binding.tapocontrol.internal.structures.TapoDeviceInfo; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.thing.binding.BridgeHandler; +import org.openhab.core.types.State; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Abstract class as base for TAPO-Device device implementations. + * + * @author Christian Wild - Initial contribution + */ +@NonNullByDefault +public abstract class TapoDevice extends BaseThingHandler { + private final Logger logger = LoggerFactory.getLogger(TapoDevice.class); + protected final TapoErrorHandler deviceError = new TapoErrorHandler(); + protected final String uid; + protected TapoDeviceConfiguration config; + protected TapoDeviceInfo deviceInfo; + protected @Nullable ScheduledFuture startupJob; + protected @Nullable ScheduledFuture pollingJob; + protected @NonNullByDefault({}) TapoDeviceConnector connector; + protected @NonNullByDefault({}) TapoBridgeHandler bridge; + + /** + * Constructor + * + * @param thing Thing object representing device + */ + protected TapoDevice(Thing thing) { + super(thing); + this.config = new TapoDeviceConfiguration(thing); + this.deviceInfo = new TapoDeviceInfo(); + this.uid = getThing().getUID().getAsString(); + } + + /*********************************** + * + * INIT AND SETTINGS + * + ************************************/ + + /** + * INITIALIZE DEVICE + */ + @Override + public void initialize() { + try { + this.config.loadSettings(); + Bridge bridgeThing = getBridge(); + if (bridgeThing != null) { + BridgeHandler bridgeHandler = bridgeThing.getHandler(); + if (bridgeHandler != null) { + this.bridge = (TapoBridgeHandler) bridgeHandler; + this.connector = new TapoDeviceConnector(this, bridge); + } + } + } catch (Exception e) { + logger.debug("({}) configuration error : {}", uid, e.getMessage()); + } + TapoErrorHandler configError = checkSettings(); + if (!configError.hasError()) { + activateDevice(); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, configError.getMessage()); + } + } + + /** + * DISPOSE + */ + @Override + public void dispose() { + try { + stopScheduler(this.startupJob); + stopScheduler(this.pollingJob); + connector.logout(); + } catch (Exception e) { + // handle exception + } + super.dispose(); + } + + /** + * ACTIVATE DEVICE + */ + private void activateDevice() { + // set the thing status to UNKNOWN temporarily and let the background task decide for the real status. + updateStatus(ThingStatus.UNKNOWN); + + // background initialization (delay it a little bit): + this.startupJob = scheduler.schedule(this::delayedStartUp, 2000, TimeUnit.MILLISECONDS); + startScheduler(); + } + + /** + * CHECK SETTINGS + * + * @return TapoErrorHandler with configuration-errors + */ + protected TapoErrorHandler checkSettings() { + TapoErrorHandler configErr = new TapoErrorHandler(); + + /* check bridge */ + if (bridge == null || !(bridge instanceof TapoBridgeHandler)) { + configErr.raiseError(ERR_NO_BRIDGE); + return configErr; + } + /* check ip-address */ + if (!config.ipAddress.matches(IPV4_REGEX)) { + configErr.raiseError(ERR_CONF_IP); + return configErr; + } + /* check credentials */ + if (!bridge.getCredentials().areSet()) { + configErr.raiseError(ERR_CONF_CREDENTIALS); + return configErr; + } + return configErr; + } + + /** + * Checks if the response object contains errors and if so throws an {@link IOException} when an error code was set. + * + * @throws IOException if an error code was set in the response object + */ + protected void checkErrors() throws IOException { + final Integer errorCode = deviceError.getCode(); + + if (errorCode != 0) { + throw new IOException("Error (" + errorCode + "): " + deviceError.getMessage()); + } + } + + /*********************************** + * + * SCHEDULER + * + ************************************/ + /** + * delayed OneTime StartupJob + */ + private void delayedStartUp() { + connect(); + } + + /** + * Start scheduler + */ + protected void startScheduler() { + Integer pollingInterval = this.config.pollingInterval; + + if (pollingInterval > 0) { + if (pollingInterval < POLLING_MIN_INTERVAL_S) { + pollingInterval = POLLING_MIN_INTERVAL_S; + } + logger.trace("({}) starScheduler: create job with interval : {}", uid, pollingInterval); + this.pollingJob = scheduler.scheduleWithFixedDelay(this::schedulerAction, pollingInterval, pollingInterval, + TimeUnit.SECONDS); + } else { + stopScheduler(this.pollingJob); + } + } + + /** + * Stop scheduler + * + * @param scheduler ScheduledFeature which schould be stopped + */ + protected void stopScheduler(@Nullable ScheduledFuture scheduler) { + if (scheduler != null) { + scheduler.cancel(true); + scheduler = null; + } + } + + /** + * Scheduler Action + */ + protected void schedulerAction() { + logger.trace("({}) schedulerAction", uid); + queryDeviceInfo(); + } + + /*********************************** + * + * ERROR HANDLER + * + ************************************/ + /** + * return device Error + * + * @return + */ + public TapoErrorHandler getError() { + return this.deviceError; + } + + /** + * set device error + * + * @param tapoError TapoErrorHandler-Object + */ + public void setError(TapoErrorHandler tapoError) { + this.deviceError.set(tapoError); + handleConnectionState(); + } + + /*********************************** + * + * THING + * + ************************************/ + + /*** + * Check if ThingType is model + * + * @param model + * @return + */ + protected Boolean isThingModel(String model) { + try { + ThingTypeUID foundType = new ThingTypeUID(BINDING_ID, model); + ThingTypeUID expectedType = getThing().getThingTypeUID(); + return expectedType.equals(foundType); + } catch (Exception e) { + logger.warn("({}) verify thing model throws : {}", uid, e.getMessage()); + return false; + } + } + + /** + * CHECK IF RECEIVED DATA ARE FROM THE EXPECTED DEVICE + * Compare MAC-Adress + * + * @param deviceInfo + * @return true if is the expected device + */ + protected Boolean isExpectedThing(TapoDeviceInfo deviceInfo) { + try { + String expectedThingUID = getThing().getProperties().get(DEVICE_REPRASENTATION_PROPERTY); + String foundThingUID = deviceInfo.getRepresentationProperty(); + String foundModel = deviceInfo.getModel(); + if (expectedThingUID == null || expectedThingUID.isBlank()) { + return isThingModel(foundModel); + } + /* sometimes received mac was with and sometimes without "-" from device */ + expectedThingUID = unformatMac(expectedThingUID); + foundThingUID = unformatMac(foundThingUID); + return expectedThingUID.equals(foundThingUID); + } catch (Exception e) { + logger.warn("({}) verify thing model throws : {}", uid, e.getMessage()); + return false; + } + } + + /** + * Return ThingUID + */ + public ThingUID getThingUID() { + return getThing().getUID(); + } + + /*********************************** + * + * DEVICE PROPERTIES + * + ************************************/ + + /** + * query device Properties + */ + public void queryDeviceInfo() { + queryDeviceInfo(false); + } + + /** + * query device Properties + * + * @param ignoreGap ignore gap to last query. query anyway (force) + */ + public void queryDeviceInfo(boolean ignoreGap) { + deviceError.reset(); + if (connector.loggedIn()) { + connector.queryInfo(ignoreGap); + } else { + logger.debug("({}) tried to query DeviceInfo but not loggedIn", uid); + connect(); + } + } + + /** + * SET DEVICE INFOs to device + * + * @param deviceInfo + */ + public void setDeviceInfo(TapoDeviceInfo deviceInfo) { + this.deviceInfo = deviceInfo; + if (isExpectedThing(deviceInfo)) { + devicePropertiesChanged(deviceInfo); + handleConnectionState(); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "found type:'" + deviceInfo.getModel() + "' with mac:'" + deviceInfo.getRepresentationProperty() + + "'. Check IP-Address"); + } + } + + /** + * Handle full responsebody received from connector + * + * @param responseBody + */ + public void responsePasstrough(String responseBody) { + } + + /** + * UPDATE PROPERTIES + * + * If only one property must be changed, there is also a convenient method + * updateProperty(String name, String value). + * + * @param TapoDeviceInfo + */ + protected void devicePropertiesChanged(TapoDeviceInfo deviceInfo) { + /* device properties */ + Map properties = editProperties(); + properties.put(Thing.PROPERTY_MAC_ADDRESS, deviceInfo.getMAC()); + properties.put(Thing.PROPERTY_FIRMWARE_VERSION, deviceInfo.getFirmwareVersion()); + properties.put(Thing.PROPERTY_HARDWARE_VERSION, deviceInfo.getHardwareVersion()); + properties.put(Thing.PROPERTY_MODEL_ID, deviceInfo.getModel()); + properties.put(Thing.PROPERTY_SERIAL_NUMBER, deviceInfo.getSerial()); + updateProperties(properties); + } + + /** + * update channel state + * + * @param channelID + * @param value + */ + public void publishState(String channelID, State value) { + updateState(channelID, value); + } + + /*********************************** + * + * CONNECTION + * + ************************************/ + + /** + * Connect (login) to device + * + */ + public Boolean connect() { + deviceError.reset(); + Boolean loginSuccess = false; + + try { + loginSuccess = connector.login(); + if (loginSuccess) { + connector.queryInfo(); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, deviceError.getMessage()); + } + } catch (Exception e) { + updateStatus(ThingStatus.UNKNOWN); + } + return loginSuccess; + } + + /** + * disconnect device + */ + public void disconnect() { + connector.logout(); + } + + /** + * handle device state by connector error + */ + public void handleConnectionState() { + ThingStatus deviceState = getThing().getStatus(); + Integer errorCode = deviceError.getCode(); + + if (errorCode == 0) { + if (deviceState != ThingStatus.ONLINE) { + updateStatus(ThingStatus.ONLINE); + } + } else if (LIST_REAUTH_ERRORS.contains(errorCode)) { + connect(); + } else if (LIST_COMMUNICATION_ERRORS.contains(errorCode)) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, deviceError.getMessage()); + disconnect(); + } else if (LIST_CONFIGURATION_ERRORS.contains(errorCode)) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, deviceError.getMessage()); + } else { + updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, deviceError.getMessage()); + } + } + + /** + * Return IP-Address of device + */ + public String getIpAddress() { + return this.config.ipAddress; + } + + /*********************************** + * + * CHANNELS + * + ************************************/ + /** + * Get ChannelID including group + * + * @param group String channel-group + * @param channel String channel-name + * @return String channelID + */ + protected String getChannelID(String group, String channel) { + ThingTypeUID thingTypeUID = thing.getThingTypeUID(); + if (CHANNEL_GROUP_THING_SET.contains(thingTypeUID) && group.length() > 0) { + return group + "#" + channel; + } + return channel; + } + + /** + * Get Channel from ChannelID + * + * @param channelID String channelID + * @return String channel-name + */ + protected String getChannelFromID(ChannelUID channelID) { + String channel = channelID.getIdWithoutGroup(); + channel = channel.replace(CHANNEL_GROUP_ACTUATOR + "#", ""); + channel = channel.replace(CHANNEL_GROUP_DEVICE + "#", ""); + channel = channel.replace(CHANNEL_GROUP_EFFECTS + "#", ""); + return channel; + } +} diff --git a/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/device/TapoLightStrip.java b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/device/TapoLightStrip.java new file mode 100644 index 00000000000..d1dfc137258 --- /dev/null +++ b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/device/TapoLightStrip.java @@ -0,0 +1,230 @@ +/** + * Copyright (c) 2010-2021 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.tapocontrol.internal.device; + +import static org.openhab.binding.tapocontrol.internal.constants.TapoThingConstants.*; +import static org.openhab.binding.tapocontrol.internal.helpers.TapoUtils.*; + +import java.util.HashMap; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.tapocontrol.internal.structures.TapoDeviceInfo; +import org.openhab.binding.tapocontrol.internal.structures.TapoLightEffect; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.HSBType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.PercentType; +import org.openhab.core.library.unit.Units; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.JsonObject; + +/** + * TAPO Smart-Plug-Device. + * + * @author Christian Wild - Initial contribution + */ +@NonNullByDefault +public class TapoLightStrip extends TapoDevice { + private final Logger logger = LoggerFactory.getLogger(TapoLightStrip.class); + + /** + * Constructor + * + * @param thing Thing object representing device + */ + public TapoLightStrip(Thing thing) { + super(thing); + } + + /** + * handle command sent to device + * + * @param channelUID channelUID command is sent to + * @param command command to be sent + */ + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + Boolean refreshInfo = false; + + String channel = channelUID.getIdWithoutGroup(); + String group = channelUID.getGroupId(); + if (command instanceof RefreshType) { + refreshInfo = true; + } else if (group == CHANNEL_GROUP_EFFECTS) { + setLightEffect(channel, command); + refreshInfo = true; + } else { + switch (channel) { + case CHANNEL_OUTPUT: + connector.sendDeviceCommand(DEVICE_PROPERTY_ON, command == OnOffType.ON); + refreshInfo = true; + break; + case CHANNEL_BRIGHTNESS: + if (command instanceof PercentType) { + Float percent = ((PercentType) command).floatValue(); + setBrightness(percent.intValue()); // 0..100% = 0..100 + refreshInfo = true; + } else if (command instanceof DecimalType) { + setBrightness(((DecimalType) command).intValue()); + refreshInfo = true; + } + break; + case CHANNEL_COLOR_TEMP: + if (command instanceof DecimalType) { + setColorTemp(((DecimalType) command).intValue()); + refreshInfo = true; + } + break; + case CHANNEL_COLOR: + if (command instanceof HSBType) { + setColor((HSBType) command); + refreshInfo = true; + } + break; + default: + logger.warn("({}) command type '{}' not supported for channel '{}'", uid, command.toString(), + channelUID.getId()); + } + } + + /* refreshInfo */ + if (refreshInfo) { + queryDeviceInfo(true); + } + } + + /** + * SET BRIGHTNESS + * + * @param newBrightness percentage 0-100 of new brightness + */ + protected void setBrightness(Integer newBrightness) { + /* switch off if 0 */ + if (newBrightness == 0) { + connector.sendDeviceCommand(DEVICE_PROPERTY_ON, false); + } else { + HashMap newState = new HashMap<>(); + newState.put(DEVICE_PROPERTY_ON, true); + newState.put(DEVICE_PROPERTY_BRIGHTNES, newBrightness); + connector.sendDeviceCommands(newState); + } + } + + /** + * SET COLOR + * + * @param command + */ + protected void setColor(HSBType command) { + HashMap newState = new HashMap<>(); + newState.put(DEVICE_PROPERTY_ON, true); + newState.put(DEVICE_PROPERTY_HUE, command.getHue()); + newState.put(DEVICE_PROPERTY_SATURATION, command.getSaturation()); + newState.put(DEVICE_PROPERTY_BRIGHTNES, command.getBrightness()); + connector.sendDeviceCommands(newState); + } + + /** + * SET COLORTEMP + * + * @param colorTemp (Integer) in Kelvin + */ + protected void setColorTemp(Integer colorTemp) { + HashMap newState = new HashMap<>(); + colorTemp = limitVal(colorTemp, BULB_MIN_COLORTEMP, BULB_MAX_COLORTEMP); + newState.put(DEVICE_PROPERTY_ON, true); + newState.put(DEVICE_PROPERTY_COLORTEMP, colorTemp); + connector.sendDeviceCommands(newState); + } + + /** + * set Light Effect from channel/command + * + * @param channel channel (effect) to set + * @param command command (value) to set + */ + protected void setLightEffect(String channel, Command command) { + TapoLightEffect lightEffect = deviceInfo.getLightEffect(); + switch (channel) { + case CHANNEL_FX_BRIGHTNESS: + if (command instanceof PercentType) { + Float percent = ((PercentType) command).floatValue(); + lightEffect.setBrightness(percent.intValue()); // 0..100% = 0..100 + } else if (command instanceof DecimalType) { + lightEffect.setBrightness(((DecimalType) command).intValue()); + } + break; + case CHANNEL_FX_COLORS: + // comming soon + break; + case CHANNEL_FX_NAME: + lightEffect.setName(command.toString()); + break; + case CHANNEL_FX_ENABLE: + lightEffect.setEnable(command == OnOffType.ON); + break; + } + setLightEffects(lightEffect); + } + + /** + * SET LIGHTNING EFFECTS + * + * @param lightEffect new lightEffect + */ + protected void setLightEffects(TapoLightEffect lightEffect) { + JsonObject newEffect = new JsonObject(); + newEffect.addProperty(PROPERTY_LIGHTNING_EFFECT_ENABLE, lightEffect.getEnable()); + newEffect.addProperty(PROPERTY_LIGHTNING_EFFECT_NAME, lightEffect.getName()); + newEffect.addProperty(PROPERTY_LIGHTNING_EFFECT_BRIGHNTESS, lightEffect.getBrightness()); + newEffect.addProperty(PROPERTY_LIGHTNING_EFFECT_COLORTEMPRANGE, lightEffect.getColorTempRange().toString()); + newEffect.addProperty(PROPERTY_LIGHTNING_EFFECT_DISPLAYCOLORS, lightEffect.getDisplayColors().toString()); + newEffect.addProperty(PROPERTY_LIGHTNING_EFFECT_CUSTOM, lightEffect.getCustom()); + + connector.sendDeviceCommand(DEVICE_PROPERTY_EFFECT, newEffect.toString()); + } + + /** + * UPDATE PROPERTIES + * + * @param TapoDeviceInfo + */ + @Override + protected void devicePropertiesChanged(TapoDeviceInfo deviceInfo) { + TapoLightEffect lightEffect = deviceInfo.getLightEffect(); + super.devicePropertiesChanged(deviceInfo); + publishState(getChannelID(CHANNEL_GROUP_ACTUATOR, CHANNEL_OUTPUT), getOnOffType(deviceInfo.isOn())); + publishState(getChannelID(CHANNEL_GROUP_ACTUATOR, CHANNEL_BRIGHTNESS), + getPercentType(deviceInfo.getBrightness())); + publishState(getChannelID(CHANNEL_GROUP_ACTUATOR, CHANNEL_COLOR_TEMP), + getDecimalType(deviceInfo.getColorTemp())); + publishState(getChannelID(CHANNEL_GROUP_ACTUATOR, CHANNEL_COLOR), deviceInfo.getHSB()); + publishState(getChannelID(CHANNEL_GROUP_DEVICE, CHANNEL_WIFI_STRENGTH), + getDecimalType(deviceInfo.getSignalLevel())); + publishState(getChannelID(CHANNEL_GROUP_DEVICE, CHANNEL_ONTIME), + getQuantityType(deviceInfo.getOnTime(), Units.SECOND)); + publishState(getChannelID(CHANNEL_GROUP_DEVICE, CHANNEL_OVERHEAT), getOnOffType(deviceInfo.isOverheated())); + // light effect + publishState(getChannelID(CHANNEL_GROUP_EFFECTS, CHANNEL_FX_BRIGHTNESS), + getPercentType(lightEffect.getBrightness())); + publishState(getChannelID(CHANNEL_GROUP_EFFECTS, CHANNEL_FX_NAME), getStringType(lightEffect.getName())); + publishState(getChannelID(CHANNEL_GROUP_EFFECTS, CHANNEL_FX_ENABLE), getOnOffType(lightEffect.getEnable())); + publishState(getChannelID(CHANNEL_GROUP_EFFECTS, CHANNEL_FX_CUSTOM), getOnOffType(lightEffect.getCustom())); + } +} diff --git a/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/device/TapoSmartBulb.java b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/device/TapoSmartBulb.java new file mode 100644 index 00000000000..e23aab3acb8 --- /dev/null +++ b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/device/TapoSmartBulb.java @@ -0,0 +1,169 @@ +/** + * Copyright (c) 2010-2021 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.tapocontrol.internal.device; + +import static org.openhab.binding.tapocontrol.internal.constants.TapoThingConstants.*; +import static org.openhab.binding.tapocontrol.internal.helpers.TapoUtils.*; + +import java.util.HashMap; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.tapocontrol.internal.structures.TapoDeviceInfo; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.HSBType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.PercentType; +import org.openhab.core.library.unit.Units; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * TAPO Smart-Plug-Device. + * + * @author Christian Wild - Initial contribution + */ +@NonNullByDefault +public class TapoSmartBulb extends TapoDevice { + private final Logger logger = LoggerFactory.getLogger(TapoSmartBulb.class); + + /** + * Constructor + * + * @param thing Thing object representing device + */ + public TapoSmartBulb(Thing thing) { + super(thing); + } + + /** + * handle command sent to device + * + * @param channelUID channelUID command is sent to + * @param command command to be sent + */ + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + Boolean refreshInfo = false; + + String channel = channelUID.getIdWithoutGroup(); + if (command instanceof RefreshType) { + refreshInfo = true; + } else { + switch (channel) { + case CHANNEL_OUTPUT: + connector.sendDeviceCommand(DEVICE_PROPERTY_ON, command == OnOffType.ON); + refreshInfo = true; + break; + case CHANNEL_BRIGHTNESS: + if (command instanceof PercentType) { + Float percent = ((PercentType) command).floatValue(); + setBrightness(percent.intValue()); // 0..100% = 0..100 + refreshInfo = true; + } else if (command instanceof DecimalType) { + setBrightness(((DecimalType) command).intValue()); + refreshInfo = true; + } + break; + case CHANNEL_COLOR_TEMP: + if (command instanceof DecimalType) { + setColorTemp(((DecimalType) command).intValue()); + refreshInfo = true; + } + break; + case CHANNEL_COLOR: + if (command instanceof HSBType) { + setColor((HSBType) command); + refreshInfo = true; + } + break; + default: + logger.warn("({}) command type '{}' not supported for channel '{}'", uid, command.toString(), + channelUID.getId()); + } + } + + /* refreshInfo */ + if (refreshInfo) { + queryDeviceInfo(true); + } + } + + /** + * SET BRIGHTNESS + * + * @param newBrightness percentage 0-100 of new brightness + */ + protected void setBrightness(Integer newBrightness) { + /* switch off if 0 */ + if (newBrightness == 0) { + connector.sendDeviceCommand(DEVICE_PROPERTY_ON, false); + } else { + HashMap newState = new HashMap<>(); + newState.put(DEVICE_PROPERTY_ON, true); + newState.put(DEVICE_PROPERTY_BRIGHTNES, newBrightness); + connector.sendDeviceCommands(newState); + } + } + + /** + * SET COLOR + * + * @param command + */ + protected void setColor(HSBType command) { + HashMap newState = new HashMap<>(); + newState.put(DEVICE_PROPERTY_ON, true); + newState.put(DEVICE_PROPERTY_HUE, command.getHue()); + newState.put(DEVICE_PROPERTY_SATURATION, command.getSaturation()); + newState.put(DEVICE_PROPERTY_BRIGHTNES, command.getBrightness()); + connector.sendDeviceCommands(newState); + } + + /** + * SET COLORTEMP + * + * @param colorTemp (Integer) in Kelvin + */ + protected void setColorTemp(Integer colorTemp) { + HashMap newState = new HashMap<>(); + colorTemp = limitVal(colorTemp, BULB_MIN_COLORTEMP, BULB_MAX_COLORTEMP); + newState.put(DEVICE_PROPERTY_ON, true); + newState.put(DEVICE_PROPERTY_COLORTEMP, colorTemp); + connector.sendDeviceCommands(newState); + } + + /** + * UPDATE PROPERTIES + * + * @param TapoDeviceInfo + */ + @Override + protected void devicePropertiesChanged(TapoDeviceInfo deviceInfo) { + super.devicePropertiesChanged(deviceInfo); + publishState(getChannelID(CHANNEL_GROUP_ACTUATOR, CHANNEL_OUTPUT), getOnOffType(deviceInfo.isOn())); + publishState(getChannelID(CHANNEL_GROUP_ACTUATOR, CHANNEL_BRIGHTNESS), + getPercentType(deviceInfo.getBrightness())); + publishState(getChannelID(CHANNEL_GROUP_ACTUATOR, CHANNEL_COLOR_TEMP), + getDecimalType(deviceInfo.getColorTemp())); + publishState(getChannelID(CHANNEL_GROUP_ACTUATOR, CHANNEL_COLOR), deviceInfo.getHSB()); + publishState(getChannelID(CHANNEL_GROUP_DEVICE, CHANNEL_WIFI_STRENGTH), + getDecimalType(deviceInfo.getSignalLevel())); + publishState(getChannelID(CHANNEL_GROUP_DEVICE, CHANNEL_ONTIME), + getQuantityType(deviceInfo.getOnTime(), Units.SECOND)); + publishState(getChannelID(CHANNEL_GROUP_DEVICE, CHANNEL_OVERHEAT), getOnOffType(deviceInfo.isOverheated())); + } +} diff --git a/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/device/TapoSmartPlug.java b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/device/TapoSmartPlug.java new file mode 100644 index 00000000000..170dd8ed4b4 --- /dev/null +++ b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/device/TapoSmartPlug.java @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2010-2021 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.tapocontrol.internal.device; + +import static org.openhab.binding.tapocontrol.internal.constants.TapoThingConstants.*; +import static org.openhab.binding.tapocontrol.internal.helpers.TapoUtils.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.tapocontrol.internal.structures.TapoDeviceInfo; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.unit.Units; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * TAPO Smart-Plug-Device. + * + * @author Christian Wild - Initial contribution + */ +@NonNullByDefault +public class TapoSmartPlug extends TapoDevice { + private final Logger logger = LoggerFactory.getLogger(TapoSmartPlug.class); + + /** + * Constructor + * + * @param thing Thing object representing device + */ + public TapoSmartPlug(Thing thing) { + super(thing); + } + + /** + * handle command sent to device + * + * @param channelUID channelUID command is sent to + * @param command command to be sent + */ + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + Boolean refreshInfo = false; + + /* perform actions */ + if (command instanceof RefreshType) { + refreshInfo = true; + } else if (command == OnOffType.ON) { + connector.sendDeviceCommand(DEVICE_PROPERTY_ON, true); + refreshInfo = true; + } else if (command == OnOffType.OFF) { + connector.sendDeviceCommand(DEVICE_PROPERTY_ON, false); + refreshInfo = true; + } else { + logger.warn("({}) command type '{}' not supported for channel '{}'", uid, command.toString(), + channelUID.getId()); + } + + /* refreshInfo */ + if (refreshInfo) { + queryDeviceInfo(true); + } + } + + /** + * UPDATE PROPERTIES + * + * @param TapoDeviceInfo + */ + @Override + protected void devicePropertiesChanged(TapoDeviceInfo deviceInfo) { + super.devicePropertiesChanged(deviceInfo); + publishState(getChannelID(CHANNEL_GROUP_ACTUATOR, CHANNEL_OUTPUT), getOnOffType(deviceInfo.isOn())); + publishState(getChannelID(CHANNEL_GROUP_DEVICE, CHANNEL_WIFI_STRENGTH), + getDecimalType(deviceInfo.getSignalLevel())); + publishState(getChannelID(CHANNEL_GROUP_DEVICE, CHANNEL_ONTIME), + getQuantityType(deviceInfo.getOnTime(), Units.SECOND)); + publishState(getChannelID(CHANNEL_GROUP_DEVICE, CHANNEL_OVERHEAT), getOnOffType(deviceInfo.isOverheated())); + } +} diff --git a/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/device/TapoUniversalDevice.java b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/device/TapoUniversalDevice.java new file mode 100644 index 00000000000..b45f105f380 --- /dev/null +++ b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/device/TapoUniversalDevice.java @@ -0,0 +1,234 @@ +/** + * Copyright (c) 2010-2021 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.tapocontrol.internal.device; + +import static org.openhab.binding.tapocontrol.internal.constants.TapoThingConstants.*; +import static org.openhab.binding.tapocontrol.internal.helpers.TapoUtils.*; + +import java.util.HashMap; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.tapocontrol.internal.structures.TapoDeviceInfo; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.HSBType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.PercentType; +import org.openhab.core.library.unit.Units; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * TAPO Universal-Device + * universal device for testing pruposes + * + * @author Christian Wild - Initial contribution + */ +@NonNullByDefault +public class TapoUniversalDevice extends TapoDevice { + private final Logger logger = LoggerFactory.getLogger(TapoUniversalDevice.class); + + // CHANNEL LIST + public static final String CHANNEL_GROUP_DEBUG = "debug"; + public static final String CHANNEL_RESPONSE = "deviceResponse"; + public static final String CHANNEL_COMMAND = "deviceCommand"; + + /** + * Constructor + * + * @param thing Thing object representing device + */ + public TapoUniversalDevice(Thing thing) { + super(thing); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + logger.debug("({}) handleCommand '{}' for channelUID {}", uid, command.toString(), channelUID.getId()); + Boolean refreshInfo = false; + + String channel = channelUID.getIdWithoutGroup(); + if (command instanceof RefreshType) { + refreshInfo = true; + } else { + switch (channel) { + case CHANNEL_OUTPUT: + connector.sendDeviceCommand(DEVICE_PROPERTY_ON, command == OnOffType.ON); + refreshInfo = true; + break; + case CHANNEL_BRIGHTNESS: + if (command instanceof PercentType) { + Float percent = ((PercentType) command).floatValue(); + setBrightness(percent.intValue()); // 0..100% = 0..100 + refreshInfo = true; + } else if (command instanceof DecimalType) { + setBrightness(((DecimalType) command).intValue()); + refreshInfo = true; + } + break; + case CHANNEL_COLOR_TEMP: + if (command instanceof DecimalType) { + setColorTemp(((DecimalType) command).intValue()); + refreshInfo = true; + } + break; + case CHANNEL_COLOR: + if (command instanceof HSBType) { + setColor((HSBType) command); + refreshInfo = true; + } + break; + case CHANNEL_COMMAND: + String[] cmd = command.toString().split(":"); + if (cmd.length == 1) { + connector.sendCustomQuery(cmd[0]); + } else if (cmd.length == 2) { + connector.sendDeviceCommand(cmd[0], cmd[1]); + } else { + logger.warn("({}) wrong command format '{}'", uid, command.toString()); + } + break; + default: + logger.warn("({}) command type '{}' not supported for channel '{}'", uid, command.toString(), + channelUID.getId()); + } + } + + /* refreshInfo */ + if (refreshInfo) { + queryDeviceInfo(); + } + } + + /** + * SET BRIGHTNESS + * + * @param newBrightness percentage 0-100 of new brightness + */ + protected void setBrightness(Integer newBrightness) { + /* switch off if 0 */ + if (newBrightness == 0) { + connector.sendDeviceCommand(DEVICE_PROPERTY_ON, false); + } else { + HashMap newState = new HashMap<>(); + newState.put(DEVICE_PROPERTY_ON, true); + newState.put(DEVICE_PROPERTY_BRIGHTNES, newBrightness); + connector.sendDeviceCommands(newState); + } + } + + /** + * SET COLOR + * + * @param command + */ + protected void setColor(HSBType command) { + HashMap newState = new HashMap<>(); + newState.put(DEVICE_PROPERTY_ON, true); + newState.put(DEVICE_PROPERTY_HUE, command.getHue()); + newState.put(DEVICE_PROPERTY_SATURATION, command.getSaturation()); + newState.put(DEVICE_PROPERTY_BRIGHTNES, command.getBrightness()); + connector.sendDeviceCommands(newState); + } + + /** + * SET COLORTEMP + * + * @param colorTemp (Integer) in Kelvin + */ + protected void setColorTemp(Integer colorTemp) { + HashMap newState = new HashMap<>(); + colorTemp = limitVal(colorTemp, BULB_MIN_COLORTEMP, BULB_MAX_COLORTEMP); + newState.put(DEVICE_PROPERTY_ON, true); + newState.put(DEVICE_PROPERTY_COLORTEMP, colorTemp); + connector.sendDeviceCommands(newState); + } + + /** + * SET DEVICE INFOs to device + * + * @param deviceInfo + */ + @Override + public void setDeviceInfo(TapoDeviceInfo deviceInfo) { + devicePropertiesChanged(deviceInfo); + handleConnectionState(); + } + + /** + * Handle full responsebody received from connector + * + * @param responseBody + */ + public void responsePasstrough(String responseBody) { + logger.debug("({}) received response {}", uid, responseBody); + publishState(getChannelID(CHANNEL_GROUP_DEBUG, CHANNEL_RESPONSE), getStringType(responseBody)); + } + + /** + * UPDATE PROPERTIES + * + * @param TapoDeviceInfo + */ + @Override + protected void devicePropertiesChanged(TapoDeviceInfo deviceInfo) { + super.devicePropertiesChanged(deviceInfo); + publishState(getChannelID(CHANNEL_GROUP_ACTUATOR, CHANNEL_OUTPUT), getOnOffType(deviceInfo.isOn())); + publishState(getChannelID(CHANNEL_GROUP_ACTUATOR, CHANNEL_BRIGHTNESS), + getPercentType(deviceInfo.getBrightness())); + publishState(getChannelID(CHANNEL_GROUP_ACTUATOR, CHANNEL_COLOR_TEMP), + getDecimalType(deviceInfo.getColorTemp())); + publishState(getChannelID(CHANNEL_GROUP_ACTUATOR, CHANNEL_COLOR), deviceInfo.getHSB()); + + publishState(getChannelID(CHANNEL_GROUP_DEVICE, CHANNEL_WIFI_STRENGTH), + getDecimalType(deviceInfo.getSignalLevel())); + publishState(getChannelID(CHANNEL_GROUP_DEVICE, CHANNEL_ONTIME), + getQuantityType(deviceInfo.getOnTime(), Units.SECOND)); + publishState(getChannelID(CHANNEL_GROUP_DEVICE, CHANNEL_OVERHEAT), + getDecimalType(deviceInfo.isOverheated() ? 1 : 0)); + } + + /*********************************** + * + * CHANNELS + * + ************************************/ + /** + * Get ChannelID including group + * + * @param group String channel-group + * @param channel String channel-name + * @return String channelID + */ + @Override + protected String getChannelID(String group, String channel) { + return group + "#" + channel; + } + + /** + * Get Channel from ChannelID + * + * @param channelID String channelID + * @return String channel-name + */ + protected String getChannelFromID(ChannelUID channelID) { + String channel = channelID.getIdWithoutGroup(); + channel = channel.replace(CHANNEL_GROUP_ACTUATOR + "#", ""); + channel = channel.replace(CHANNEL_GROUP_DEVICE + "#", ""); + channel = channel.replace(CHANNEL_GROUP_DEBUG + "#", ""); + return channel; + } +} diff --git a/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/helpers/MimeEncode.java b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/helpers/MimeEncode.java new file mode 100644 index 00000000000..9a6f6da68e7 --- /dev/null +++ b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/helpers/MimeEncode.java @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2010-2021 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.tapocontrol.internal.helpers; + +import static java.util.Base64.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * MimeEncoder + * + * @author Christian Wild - Initial contribution + */ +@NonNullByDefault +public class MimeEncode { + + public byte[] encode(byte[] src) { + return getMimeEncoder().encode(src); + } + + public String encodeToString(byte[] src) { + return getMimeEncoder().encodeToString(src); + } + + public byte[] decode(byte[] src) { + return getMimeDecoder().decode(src); + } + + public byte[] decode(String src) { + return getMimeDecoder().decode(src); + } +} diff --git a/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/helpers/PayloadBuilder.java b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/helpers/PayloadBuilder.java new file mode 100644 index 00000000000..aa3500200f4 --- /dev/null +++ b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/helpers/PayloadBuilder.java @@ -0,0 +1,90 @@ +/** + * Copyright (c) 2010-2021 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.tapocontrol.internal.helpers; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; + +/** + * PAYLOAD BUILDER + * Generates payload for TapoHttp request + * + * @author Christian Wild - Initial contribution + */ +@NonNullByDefault +public class PayloadBuilder { + public String method = ""; + private JsonObject parameters = new JsonObject(); + + /** + * Set Command + * + * @param command command (method) to send + */ + public void setCommand(String command) { + this.method = command; + } + + /** + * Add Parameter + * + * @param name parameter name + * @param value parameter value (typeOf Bool,Number or String) + */ + public void addParameter(String name, Object value) { + if (value instanceof Boolean) { + this.parameters.addProperty(name, (Boolean) value); + } else if (value instanceof Number) { + this.parameters.addProperty(name, (Number) value); + } else { + this.parameters.addProperty(name, value.toString()); + } + } + + /** + * Get JSON Payload (STRING) + * + * @return String JSON-Payload + */ + public String getPayload() { + Gson gson = new Gson(); + JsonObject payload = getJsonPayload(); + return gson.toJson(payload); + } + + /** + * Get JSON Payload (JSON-Object) + * + * @return JsonObject JSON-Payload + */ + public JsonObject getJsonPayload() { + JsonObject payload = new JsonObject(); + long timeMils = System.currentTimeMillis();// * 1000; + + payload.addProperty("method", this.method); + payload.add("params", this.parameters); + payload.addProperty("requestTimeMils", timeMils); + + return payload; + } + + /** + * Flush Parameters + * remove all parameters + */ + public void flushParameters(String command) { + this.parameters = new JsonObject(); + } +} diff --git a/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/helpers/TapoCipher.java b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/helpers/TapoCipher.java new file mode 100644 index 00000000000..7923ee1bc41 --- /dev/null +++ b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/helpers/TapoCipher.java @@ -0,0 +1,144 @@ +/** + * Copyright (c) 2010-2021 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.tapocontrol.internal.helpers; + +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; + +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * TAPO-CIPHER + * Based on K4CZP3R's p100-java-poc + * + * @author Christian Wild - Initial Initial contribution + */ +@NonNullByDefault +public class TapoCipher { + private final Logger logger = LoggerFactory.getLogger(TapoCipher.class); + protected static final String CIPHER_TRANSFORMATION = "AES/CBC/PKCS5Padding"; + protected static final String CIPHER_ALGORITHM = "AES"; + protected static final String CIPHER_CHARSET = "UTF-8"; + protected static final String HANDSHAKE_TRANSFORMATION = "RSA/ECB/PKCS1Padding"; + protected static final String HANDSHAKE_ALGORITHM = "RSA"; + protected static final String HANDSHAKE_CHARSET = "UTF-8"; + + @NonNullByDefault({}) + private Cipher encodeCipher; + @NonNullByDefault({}) + private Cipher decodeCipher; + @NonNullByDefault({}) + private MimeEncode mimeEncode; + + /** + * CREATE NEW EMPTY CIPHER + */ + public TapoCipher() { + } + + /** + * CREATE NEW CIPHER WITH KEY AND CREDENTIALS + * + * @param handshakeKey Key from Handshake-Request + * @param credentials TapoCredentials + * @throws Exception + */ + public TapoCipher(String handshakeKey, TapoCredentials credentials) { + setKey(handshakeKey, credentials); + } + + /** + * SET NEW KEY AND CREDENTIALS + * + * @param handshakeKey + * @param credentials + */ + public void setKey(String handshakeKey, TapoCredentials credentials) { + logger.trace("Init TapoCipher with key: {} ", handshakeKey); + MimeEncode mimeEncode = new MimeEncode(); + try { + byte[] decode = mimeEncode.decode(handshakeKey.getBytes(HANDSHAKE_CHARSET)); + byte[] decode2 = mimeEncode.decode(credentials.getPrivateKeyBytes()); + Cipher instance = Cipher.getInstance(HANDSHAKE_TRANSFORMATION); + KeyFactory kf = KeyFactory.getInstance(HANDSHAKE_ALGORITHM); + PrivateKey p = kf.generatePrivate(new PKCS8EncodedKeySpec(decode2)); + instance.init(Cipher.DECRYPT_MODE, p); + byte[] doFinal = instance.doFinal(decode); + byte[] bArr = new byte[16]; + byte[] bArr2 = new byte[16]; + System.arraycopy(doFinal, 0, bArr, 0, 16); + System.arraycopy(doFinal, 16, bArr2, 0, 16); + initCipher(bArr, bArr2); + } catch (Exception ex) { + logger.warn("Something went wrong: {}", ex.getMessage()); + } + } + + /** + * INIT ENCODE/DECDE-CIPHERS + * + * @param bArr + * @param bArr2 + * @throws Exception + */ + protected void initCipher(byte[] bArr, byte[] bArr2) throws Exception { + try { + mimeEncode = new MimeEncode(); + SecretKeySpec secretKeySpec = new SecretKeySpec(bArr, CIPHER_ALGORITHM); + IvParameterSpec ivParameterSpec = new IvParameterSpec(bArr2); + this.encodeCipher = Cipher.getInstance(CIPHER_TRANSFORMATION); + this.decodeCipher = Cipher.getInstance(CIPHER_TRANSFORMATION); + this.encodeCipher.init(1, secretKeySpec, ivParameterSpec); + this.decodeCipher.init(2, secretKeySpec, ivParameterSpec); + } catch (Exception e) { + logger.warn("initChiper failed: {}", e.getMessage()); + this.encodeCipher = null; + this.decodeCipher = null; + } + } + + /** + * ENCODE STRING + * + * @param str source string to encode + * @return encoded string + * @throws Exception + */ + public String encode(String str) throws Exception { + byte[] doFinal; + doFinal = this.encodeCipher.doFinal(str.getBytes(CIPHER_CHARSET)); + String encrypted = mimeEncode.encodeToString(doFinal); + return encrypted.replace("\r\n", ""); + } + + /** + * DECODE STRING + * + * @param str source string to decode + * @return decoded string + * @throws Exception + */ + public String decode(String str) throws Exception { + byte[] data = mimeEncode.decode(str.getBytes(CIPHER_CHARSET)); + byte[] doFinal; + doFinal = this.decodeCipher.doFinal(data); + return new String(doFinal); + } +} diff --git a/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/helpers/TapoCredentials.java b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/helpers/TapoCredentials.java new file mode 100644 index 00000000000..416905e5979 --- /dev/null +++ b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/helpers/TapoCredentials.java @@ -0,0 +1,220 @@ +/** + * Copyright (c) 2010-2021 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.tapocontrol.internal.helpers; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Handler class for TAPO Credentials + * + * @author Christian Wild - Initial contribution + */ +@NonNullByDefault +public class TapoCredentials { + + private final Logger logger = LoggerFactory.getLogger(TapoCredentials.class); + private MimeEncode mimeEncoder; + private String encodedPassword = ""; + private String encodedEmail = ""; + private String publicKey = ""; + private String privateKey = ""; + private String username = ""; + private String password = ""; + + /** + * INIT CLASS + * + */ + public TapoCredentials() { + this.mimeEncoder = new MimeEncode(); + } + + /** + * INIT CLASS + * + * @param email E-Mail-adress of Tapo Cloud + * @param passowrd Password of Tapo Cloud + */ + public TapoCredentials(String eMail, String password) { + this.mimeEncoder = new MimeEncode(); + setCredectials(eMail, password); + } + + /** + * set credentials. + * + * @param username username (eMail-adress) of Tapo Cloud + * @param passowrd Password of Tapo Cloud + */ + public void setCredectials(String eMail, String password) { + try { + this.username = eMail; + this.password = password; + encryptCredentials(eMail, password); + createKeyPair(); + } catch (Exception e) { + logger.warn("error init credential class '{}'", e.toString()); + } + } + + /** + * encrypt credentials. + * + * @param username username (eMail-adress) of Tapo Cloud + * @param passowrd Password of Tapo Cloud + */ + private void encryptCredentials(String username, String password) throws Exception { + logger.trace("encrypt credentials for '{}'", username); + + /* Password Encoding */ + byte[] byteWord = password.getBytes(); + this.encodedPassword = mimeEncoder.encodeToString(byteWord); + + /* User Encoding */ + String encodedUser = this.shaDigestUsername(username); + byteWord = encodedUser.getBytes("UTF-8"); + this.encodedEmail = mimeEncoder.encodeToString(byteWord); + } + + /** + * Create Key-Pairs + * + */ + public void createKeyPair() throws NoSuchAlgorithmException { + logger.trace("generating new keypair"); + KeyPairGenerator instance = KeyPairGenerator.getInstance("RSA"); + instance.initialize(1024, new SecureRandom()); + KeyPair generateKeyPair = instance.generateKeyPair(); + + this.publicKey = new String(mimeEncoder.encode(((RSAPublicKey) generateKeyPair.getPublic()).getEncoded())); + this.privateKey = new String(mimeEncoder.encode(((RSAPrivateKey) generateKeyPair.getPrivate()).getEncoded())); + logger.trace("new privateKey: '{}'", this.privateKey); + logger.trace("new ublicKey: '{}'", this.publicKey); + } + + /** + * shaDigest USERNAME + * + */ + private String shaDigestUsername(String str) throws NoSuchAlgorithmException { + byte[] bArr = str.getBytes(); + byte[] digest = MessageDigest.getInstance("SHA1").digest(bArr); + + StringBuilder sb = new StringBuilder(); + for (byte b : digest) { + String hexString = Integer.toHexString(b & 255); + if (hexString.length() == 1) { + sb.append("0"); + sb.append(hexString); + } else { + sb.append(hexString); + } + } + return sb.toString(); + } + + /** + * RETURN ENCODED PASSWORD + * + */ + public String getEncodedPassword() { + return encodedPassword; + } + + /** + * RETURN ENCODED E-MAIL + * + */ + public String getEncodedEmail() { + return encodedEmail; + } + + /** + * RETURN PASSWORD + * + */ + public String getPassword() { + return password; + } + + /** + * RETURN Username (E-MAIL) + * + */ + public String getUsername() { + return username; + } + + /** + * RETURN PRIVATE-KEY + * + * @return String -----BEGIN PRIVATE KEY-----\n%s\n-----END PRIVATE KEY----- + */ + public String getPrivateKey() { + return String.format("-----BEGIN PRIVATE KEY-----%n%s%n-----END PRIVATE KEY-----%n", privateKey); + } + + /** + * RETURN PUBLIC KEY + * + * @return String -----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY----- + */ + public String getPublicKey() { + return String.format("-----BEGIN PUBLIC KEY-----%n%s%n-----END PUBLIC KEY-----%n", publicKey); + } + + /** + * RETURN PRIVATE-KEY (BYTES) + * + * @return UTF-8 coded byte[] with private key + */ + public byte[] getPrivateKeyBytes() { + try { + return privateKey.getBytes("UTF-8"); + } catch (Exception e) { + return new byte[0]; + } + } + + /** + * RETURN PUBLIC-KEY (BYTES) + * + * @return UTF-8 coded byte[] with private key + */ + public byte[] getPublicKeyBytes() { + try { + return publicKey.getBytes("UTF-8"); + } catch (Exception e) { + return new byte[0]; + } + } + + /** + * CHECK IF CREDENTIALS ARE SET + * + * @return + */ + public Boolean areSet() { + return !(this.username.isEmpty() || this.password.isEmpty()); + } +} diff --git a/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/helpers/TapoErrorHandler.java b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/helpers/TapoErrorHandler.java new file mode 100644 index 00000000000..78f70ae1ea0 --- /dev/null +++ b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/helpers/TapoErrorHandler.java @@ -0,0 +1,264 @@ +/** + * Copyright (c) 2010-2021 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.tapocontrol.internal.helpers; + +import static org.openhab.binding.tapocontrol.internal.helpers.TapoUtils.*; + +import java.lang.reflect.Field; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.tapocontrol.internal.constants.TapoErrorConstants; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; + +/** + * Class Handling TapoErrors + * + * @author Christian Wild - Initial contribution + */ +@NonNullByDefault +public class TapoErrorHandler extends Exception { + private static final long serialVersionUID = 0L; + private Integer errorCode = 0; + private String errorMessage = ""; + private String infoMessage = ""; + private Gson gson = new Gson(); + + /** + * Constructor + * + */ + public TapoErrorHandler() { + } + + /** + * Constructor + * + * @param errorCode error code (number) + */ + public TapoErrorHandler(Integer errorCode) { + raiseError(errorCode); + } + + /** + * Constructor + * + * @param errorCode error code (number) + * @param infoMessage optional info-message + */ + public TapoErrorHandler(Integer errorCode, String infoMessage) { + raiseError(errorCode, infoMessage); + } + + /** + * Constructor + * + * @param exception Exception + */ + public TapoErrorHandler(Exception ex) { + raiseError(ex); + } + + /** + * Constructor + * + * @param exception Exception + * @param infoMessage optional info-message + */ + public TapoErrorHandler(Exception ex, String infoMessage) { + raiseError(ex, infoMessage); + } + + /*********************************** + * + * Private Functions + * + ************************************/ + + /** + * GET ERROR-MESSAGE + * + * @param errCode error Number (or constant ERR_CODE ) + * @return error-message if set constant ERR_CODE_MSG. if not name of ERR_CODE is returned + */ + private String getErrorMessage(Integer errCode) { + Field[] fields = TapoErrorConstants.class.getDeclaredFields(); + /* loop ErrorConstants and search for code in value */ + for (Field f : fields) { + String constName = f.getName(); + try { + Integer val = (Integer) f.get(this); + if (val != null && val.equals(errCode)) { + Field constantName = TapoErrorConstants.class.getDeclaredField(constName + "_MSG"); + String msg = getValueOrDefault(constantName.get(null), "").toString(); + if (msg.length() > 2) { + return msg; + } else { + return infoMessage + " (" + constName + ")"; + } + } + } catch (Exception e) { + // next loop + } + } + return infoMessage + " (" + errCode.toString() + ")"; + } + + /*********************************** + * + * Public Functions + * + ************************************/ + + /** + * Raises new error + * + * @param errorCode error code (number) + */ + public void raiseError(Integer errorCode) { + raiseError(errorCode, ""); + } + + /** + * Raises new error + * + * @param errorCode error code (number) + * @param infoMessage optional info-message + */ + public void raiseError(Integer errorCode, String infoMessage) { + this.errorCode = errorCode; + this.infoMessage = infoMessage; + this.errorMessage = getErrorMessage(errorCode); + } + + /** + * Raises new error + * + * @param exception Exception + */ + public void raiseError(Exception ex) { + raiseError(ex, ""); + } + + /** + * Raises new error + * + * @param exception Exception + * @param infoMessage optional info-message + */ + public void raiseError(Exception ex, String infoMessage) { + this.errorCode = ex.hashCode(); + this.infoMessage = infoMessage; + this.errorMessage = getValueOrDefault(ex.getMessage(), ex.toString()); + } + + /** + * Take over tapoError + * + * @param tapoError + */ + public void set(TapoErrorHandler tapoError) { + this.errorCode = tapoError.getNumber(); + this.infoMessage = tapoError.getExtendedInfo(); + this.errorMessage = getErrorMessage(this.errorCode); + } + + /** + * Reset Error + */ + public void reset() { + this.errorCode = 0; + this.errorMessage = ""; + this.infoMessage = ""; + } + + /*********************************** + * + * GET RESULTS + * + ************************************/ + + /** + * Get Error Message + * + * @return error text + */ + @Override + @Nullable + public String getMessage() { + return this.errorMessage; + } + + /** + * Get Error Message directly by error-number + * + * @param errorCode + * @return error message + */ + public String getMessage(Integer errorCode) { + return getErrorMessage(errorCode); + } + + /** + * Get Error Code + * + * @return error code (integer) + */ + public Integer getCode() { + return this.errorCode; + } + + /** + * Get Info Message + * + * @return error extended info + */ + public String getExtendedInfo() { + return this.infoMessage; + } + + /** + * Get Error Number + * + * @return error number + */ + public Integer getNumber() { + return this.errorCode; + } + + /** + * Check if has Error + * + * @return true if has error + */ + public Boolean hasError() { + return this.errorCode != 0; + } + + /** + * Get JSON-Object with errror + * + * @return JsonObject with error-informations + */ + public JsonObject getJson() { + JsonObject json; + json = gson.fromJson("{'error_code': '" + errorCode + "', 'error_message':'" + errorMessage + "'}", + JsonObject.class); + if (json == null) { + json = new JsonObject(); + } + return json; + } +} diff --git a/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/helpers/TapoUtils.java b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/helpers/TapoUtils.java new file mode 100644 index 00000000000..1364ec7cbd7 --- /dev/null +++ b/bundles/org.openhab.binding.tapocontrol/src/main/java/org/openhab/binding/tapocontrol/internal/helpers/TapoUtils.java @@ -0,0 +1,348 @@ +/** + * Copyright (c) 2010-2021 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.tapocontrol.internal.helpers; + +import javax.measure.Unit; +import javax.measure.quantity.Time; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.HSBType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.PercentType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.StringType; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; + +/** + * {@link TapoUtils} TapoUtils - + * Utility Helper Functions + * + * @author Christian Wild - Initial Initial contribution + */ +@NonNullByDefault +public class TapoUtils { + + /************************************ + * CALCULATION UTILS + ***********************************/ + /** + * Limit Value between limits + * + * @param value Integer + * @param lowerLimit + * @param upperLimit + * @return + */ + public static Integer limitVal(@Nullable Integer value, Integer lowerLimit, Integer upperLimit) { + if (value == null || value < lowerLimit) { + return lowerLimit; + } else if (value > upperLimit) { + return upperLimit; + } + return value; + } + + /************************************ + * FORMAT UTILS + ***********************************/ + /** + * return value or default val if it's null + * + * @param Type of value + * @param value value + * @param defaultValue defaut value + * @return + */ + public static T getValueOrDefault(@Nullable T value, T defaultValue) { + return value == null ? defaultValue : value; + } + + /** + * Format MAC-Address replacing old division chars and add new one + * + * @param mac unformated mac-Address + * @param newDivisionChar new division char (e.g. ":","-" ) + * @return new formated mac-Address + */ + public static String formatMac(String mac, char newDivisionChar) { + String unformatedMac = unformatMac(mac); + String formatedMac = unformatedMac.replaceAll("(.{2})", "$1" + newDivisionChar).substring(0, 17); + return formatedMac; + } + + /** + * unformat MAC-Address replace all division chars + * + * @param mac + * @return + */ + public static String unformatMac(String mac) { + mac = mac.replace("-", ""); + mac = mac.replace(":", ""); + mac = mac.replace(".", ""); + return mac; + } + + /** + * HEX-STRING to byte convertion + */ + public static byte[] hexStringToByteArray(String s) { + int len = s.length(); + byte[] data = new byte[len / 2]; + try { + for (int i = 0; i < len; i += 2) { + data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4) + Character.digit(s.charAt(i + 1), 16)); + } + } catch (Exception e) { + } + return data; + } + + /** + * Return Boolean from string + * + * @param s - string to be converted + * @param defVal - Default Value + */ + public Boolean stringToBool(@Nullable String s, boolean defVal) { + if (s == null) { + return defVal; + } + try { + return Boolean.parseBoolean(s); + } catch (Exception e) { + return defVal; + } + } + + /** + * Return Integer from string + * + * @param s - string to be converted + * @param defVal - Default Value + */ + public Integer stringToInteger(@Nullable String s, Integer defVal) { + if (s == null) { + return defVal; + } + try { + return Integer.valueOf(s); + } catch (Exception e) { + return defVal; + } + } + + /*********************************** + * JSON-FORMATER + ************************************/ + + public static boolean isValidJson(String json) { + try { + Gson gson = new Gson(); + JsonObject jsnObject = gson.fromJson(json, JsonObject.class); + return jsnObject != null; + } catch (Exception e) { + return false; + } + } + + /** + * + * @param name parameter name + * @param defVal - default value; + * @return string value + */ + public static String jsonObjectToString(@Nullable JsonObject jsonObject, String name, String defVal) { + if (jsonObject != null && jsonObject.has(name)) { + return jsonObject.get(name).getAsString(); + } else { + return defVal; + } + } + + /** + * + * @param name parameter name + * @return string value + */ + public static String jsonObjectToString(@Nullable JsonObject jsonObject, String name) { + return jsonObjectToString(jsonObject, name, ""); + } + + /** + * + * @param name parameter name + * @param defVal - default value; + * @return boolean value + */ + public static Boolean jsonObjectToBool(@Nullable JsonObject jsonObject, String name, Boolean defVal) { + if (jsonObject != null && jsonObject.has(name)) { + return jsonObject.get(name).getAsBoolean(); + } else { + return false; + } + } + + /** + * + * @param name parameter name + * @return boolean value + */ + public static Boolean jsonObjectToBool(@Nullable JsonObject jsonObject, String name) { + return jsonObjectToBool(jsonObject, name, false); + } + + /** + * + * @param name parameter name + * @param defVal - default value; + * @return integer value + */ + public static Integer jsonObjectToInt(@Nullable JsonObject jsonObject, String name, Integer defVal) { + if (jsonObject != null && jsonObject.has(name)) { + return jsonObject.get(name).getAsInt(); + } else { + return defVal; + } + } + + /** + * + * @param name parameter name + * @return integer value + */ + public static Integer jsonObjectToInt(@Nullable JsonObject jsonObject, String name) { + return jsonObjectToInt(jsonObject, name, 0); + } + + /** + * + * @param name parameter name + * @param defVal - default value; + * @return number value + */ + public static Number jsonObjectToNumber(@Nullable JsonObject jsonObject, String name, Number defVal) { + if (jsonObject != null && jsonObject.has(name)) { + return jsonObject.get(name).getAsNumber(); + } else { + return defVal; + } + } + + /** + * + * @param name parameter name + * @return number value + */ + public static Number jsonObjectToNumber(@Nullable JsonObject jsonObject, String name) { + return jsonObjectToNumber(jsonObject, name, 0); + } + + /************************************ + * TYPE UTILS + ***********************************/ + + /** + * Return OnOffType from bool + * + * @param boolVal + */ + public static OnOffType getOnOffType(@Nullable Boolean boolVal) { + return (boolVal != null ? boolVal ? OnOffType.ON : OnOffType.OFF : OnOffType.OFF); + } + + /** + * Return OnOffType from bool + * + * @param boolVal + */ + public static OnOffType getOnOffType(Integer intVal) { + return intVal == 0 ? OnOffType.OFF : OnOffType.ON; + } + + /** + * Return StringType from String + * + * @param strVal + */ + public static StringType getStringType(@Nullable String strVal) { + return new StringType(strVal != null ? strVal : ""); + } + + /** + * Return DecimalType from Double + * + * @param numVal + */ + public static DecimalType getDecimalType(@Nullable Double numVal) { + return new DecimalType((numVal != null ? numVal : 0)); + } + + /** + * Return DecimalType from Integer + * + * @param numVal + */ + public static DecimalType getDecimalType(@Nullable Integer numVal) { + return new DecimalType((numVal != null ? numVal : 0)); + } + + /** + * Return DecimalType from Long + * + * @param numVal + */ + public static DecimalType getDecimalTypel(@Nullable Long numVal) { + return new DecimalType((numVal != null ? numVal : 0)); + } + + /** + * + * @param numVal value 0-100 + * @return PercentType + */ + public static PercentType getPercentType(@Nullable Integer numVal) { + Integer val = limitVal(numVal, 0, 100); + return new PercentType(val); + } + + /** + * Return HSBType from integers + * + * @param hue integer hue-color + * @param saturation integer saturation + * @param brightness integer brightness + * @return HSBType + */ + public static HSBType getHSBType(Integer hue, Integer saturation, Integer brightness) { + DecimalType h = new DecimalType(hue); + PercentType s = new PercentType(saturation); + PercentType b = new PercentType(brightness); + return new HSBType(h, s, b); + } + + /** + * Return QuantityType with Time + * + * @param numVal Number with value + * @param unit TimeUnit (Unit