mirror of
https://github.com/openhab/openhab-addons.git
synced 2025-01-10 07:02:02 +01:00
Merge a5a12a8df9
into a1fc3632e6
This commit is contained in:
commit
5c3ae6e39d
@ -393,6 +393,7 @@
|
||||
/bundles/org.openhab.binding.tplinksmarthome/ @Hilbrand
|
||||
/bundles/org.openhab.binding.tr064/ @J-N-K
|
||||
/bundles/org.openhab.binding.tradfri/ @cweitkamp @kaikreuzer
|
||||
/bundles/org.openhab.binding.tuya/ @J-N-K
|
||||
/bundles/org.openhab.binding.unifi/ @mgbowman @Hilbrand
|
||||
/bundles/org.openhab.binding.unifiedremote/ @GiviMAD
|
||||
/bundles/org.openhab.binding.upb/ @marcusb
|
||||
|
@ -1941,6 +1941,11 @@
|
||||
<artifactId>org.openhab.binding.tradfri</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.binding.tuya</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.binding.unifi</artifactId>
|
||||
|
43
bundles/org.openhab.binding.tuya/NOTICE
Normal file
43
bundles/org.openhab.binding.tuya/NOTICE
Normal file
@ -0,0 +1,43 @@
|
||||
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
|
||||
|
||||
|
||||
== Third-party Content
|
||||
|
||||
device schema (converted)
|
||||
* License: MIT License
|
||||
* Project: https://github.com/Apollon77/ioBroker.tuya
|
||||
* Source: https://github.com/Apollon77/ioBroker.tuya
|
||||
|
||||
TuyAPI (inspiration for the local protocol)
|
||||
* License: MIT License
|
||||
* Project: https://github.com/codetheweb/tuyapi
|
||||
* Source: https://github.com/codetheweb/tuyapi
|
||||
|
||||
Parts of this code have been forked from https://github.com/smarthomej/addons
|
||||
|
||||
Original license header of forked files was
|
||||
|
||||
/**
|
||||
* Copyright (c) 2021-2023 Contributors to the SmartHome/J 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
|
||||
*/
|
200
bundles/org.openhab.binding.tuya/README.md
Normal file
200
bundles/org.openhab.binding.tuya/README.md
Normal file
@ -0,0 +1,200 @@
|
||||
# Tuya Binding
|
||||
|
||||
This addon connects Tuya WiFi devices with openHAB or compatible systems.
|
||||
The control and status reporting is done on the local network.
|
||||
Cloud access is only needed for discovery and initial connection.
|
||||
|
||||
Devices need to be connected to a Tuya account (Tuya App or SmartLife App).
|
||||
Each device has a unique "local key" (password/secret) which needs to be added during thing creation.
|
||||
It is highly recommended to use the discovery feature for that, but you can also sniff the local key with a MITM proxy during pairing.
|
||||
|
||||
Please note that only one local connection is allowed per device.
|
||||
Using the app (or other tools like tuya-mqtt) and the binding in parallel is not supported by Tuya devices and will cause problems such as inability to discover the IP address and/or inability to control the devices.
|
||||
The other app (and/or tuya-mqtt) must be closed in order for this binding to operate properly.
|
||||
|
||||
## Supported Things
|
||||
|
||||
There are two things: `project` and `tuyadevice`.
|
||||
|
||||
The `project` thing represents a Tuya developer portal cloud project (see below).
|
||||
`project` things must be configured manually and are needed for discovery only.
|
||||
|
||||
`tuyadevice` things represent a single device.
|
||||
They can be configured manually or by discovery.
|
||||
|
||||
## Discovery
|
||||
|
||||
Discovery is supported for `tuyadevice` things.
|
||||
By using discovery all necessary setting of the device are retrieved from your cloud account.
|
||||
|
||||
## Thing Configuration
|
||||
|
||||
### `project`
|
||||
|
||||
First create and link a Tuya Develop Account:
|
||||
|
||||
- Go to `iot.tuya.com` (the Tuya developer portal) and create an account.
|
||||
You can choose any credentials (email/password) you like (it is not necessary that they are the same as in the app).
|
||||
After confirming your account, log in to your new account.
|
||||
- On the left navigation bar, select "Cloud", then "Create new Cloud project" (upper right corner).
|
||||
Enter a name (e.g. "My Smarthome"), select "Smart Home" for "Industry" and "Development Method".
|
||||
For security reasons, select only the "Data Center" that your app is connected to (you can change that later if you select the wrong one).
|
||||
Select "IoT Core", "Authorization" and "Device Status Notification" as APIs.
|
||||
- You should be redirected to the "Overview" tab of your project.
|
||||
Write down (or copy) "Access ID/Client ID" and "Access Secret/Client Secret" (you can always look it up in your account).
|
||||
- In the upper menu bar, select the "Devices" tab, then go to "Link Tuya App Account" and link you App account.
|
||||
|
||||
|
||||
The next steps are performed in openHAB's Main UI:
|
||||
|
||||
Add a `project` and enter your credentials (`username`/`password`, from the app - not your cloud account!) and the cloud project credentials (`accessId`/`accessSecret`).
|
||||
The `countryCode` is the international dial prefix of the country you registered your app in (e.g. `49` for Germany or `43` for Austria).
|
||||
Depending on the app you use, set `schema` to `tuyaSmart` (for the Tuya Smart app) or `smartLife` (for the Smart Life app).
|
||||
The `datacenter` needs to be set to the same value as in your IoT project.
|
||||
|
||||
The thing should come online immediately.
|
||||
|
||||
If the thing does not come online, check
|
||||
|
||||
- if you really used the app and not the developer portal credentials
|
||||
- if you entered the correct country code (check in the App if you accidentally choose a wrong country)
|
||||
- check if you selected the correct "Data Center" in your cloud project (you can select more than one for testing).
|
||||
|
||||
### `tuyaDevice`
|
||||
|
||||
The best way to configure a `tuyaDevice` is using the discovery service.
|
||||
|
||||
The mandatory parameters are `deviceId`, `productId` and `localKey`.
|
||||
The `deviceId` is used to identify the device, the `productId` identifies the type of the device and the `localKey` is a kind of password for access control.
|
||||
These parameters are set during discovery.
|
||||
If you want to manually configure the device, you can also read those values from the cloud project above.
|
||||
|
||||
For line powered device on the same subnet `ip` address and `protocol` version are automatically detected.
|
||||
Tuya devices announce their presence via UDP broadcast packets, which is usually not available in other subnets.
|
||||
Battery powered devices do not announce their presence at all.
|
||||
There is no clear rule how to determine if a device has protocol 3.3 or 3.1.
|
||||
It is recommended to start with 3.3 and watch the log file if it that works and use 3.1 otherwise.
|
||||
|
||||
Some devices do not automatically refresh channels (e.g. some power meters).
|
||||
The `pollingInterval` can be increased from the default value `0` (off) to a minimum of 10s or higher.
|
||||
The device is then requested to refresh its data channels and reports the status.
|
||||
|
||||
In case something is not working, please open an issue on [GitHub](https://github.com/openhab/openhab-addons/issues/new?title=[tuya]) and add TRACE level logs.
|
||||
|
||||
## Channels
|
||||
|
||||
Channels are added automatically based on device schemas on first startup.
|
||||
The binding first tries to get it from a database of known device schemas.
|
||||
If no schema is found a schema retrieved from the cloud during discovery is used (if applicable).
|
||||
|
||||
The device will change to OFFLINE status if no device schema could be determined.
|
||||
|
||||
Channels can also be added manually.
|
||||
The available channel-types are `color`, `dimmer`, `number`, `string` and `switch`.
|
||||
Depending on the channel one or more parameters are available.
|
||||
If a schema is available (which should be the case in most setups), these parameters are auto-configured.
|
||||
|
||||
All channels have at least the `dp` parameter which is used to identify the channel when communication with the device.
|
||||
|
||||
### Type `color`
|
||||
|
||||
The `color` channel has a second optional parameter `dp2`.
|
||||
This parameter identifies the ON/OFF switch that is usually available on color lights.
|
||||
|
||||
### Type `dimmer`
|
||||
|
||||
The `dimmer` channel has two additional mandatory parameters `min` and `max`, one optional parameter `dp2` and one advanced parameter `reversed`.
|
||||
The `min` and `max` parameters define the range allowed for controlling the brightness (most common are 0-255 or 10-1000).
|
||||
The `dp2` parameter identifies the ON/OFF switch that is usually available on dimmable lights.
|
||||
The `reversed` parameter changes the direction of the scale (e.g. 0 becomes 100, 100 becomes 0).
|
||||
It defaults to `false`.
|
||||
|
||||
### Type `number`
|
||||
|
||||
The `number` channel has two additional mandatory parameters `min` and `max`.
|
||||
The `min` and `max` parameters define the range allowed (e.g. 0-86400 for turn-off "countdown").
|
||||
|
||||
### Type `string`
|
||||
|
||||
The `string` channel has one additional optional parameter `range`.
|
||||
It contains a comma-separated list of command options for this channel (e.g. `white,colour,scene,music` for the "workMode" channel).
|
||||
|
||||
### Type `ir-code`
|
||||
|
||||
IR code types:
|
||||
|
||||
+ `Tuya DIY-mode` - use study codes from real remotes.
|
||||
|
||||
Make a virtual remote control in DIY, learn virtual buttons.
|
||||
|
||||
+ `Tuya Codes Library (check Advanced options)` - use codes from templates library.
|
||||
|
||||
Make a virtual remote control from pre-defined type of devices.
|
||||
|
||||
Select Advanced checkbox to configure other parameters:
|
||||
+ `irCode` - Decoding parameter
|
||||
+ `irSendDelay` - used as `Send delay` parameter
|
||||
+ `irCodeType` - used as `type library` parameter
|
||||
|
||||
+ `NEC` - IR Code in NEC format
|
||||
+ `Samsung` - IR Code in Samsung format.
|
||||
|
||||
**Additional options:**
|
||||
|
||||
* `Active Listening` - Device will be always in learning mode.
|
||||
After send command with key code device stays in the learning mode
|
||||
* `DP Study Key` - **Advanced**. DP number for study key. Uses for receive key code in learning mode. Change it own your
|
||||
risk.
|
||||
|
||||
|
||||
If linked item received a command with `Key Code` (Code Library Parameter) then device sends appropriate key code.
|
||||
|
||||
#### How to use IR Code in NEC format.
|
||||
|
||||
Example, from Tasmota you need to use **_Data_** parameter, it can be with or without **_0x_**
|
||||
|
||||
```json
|
||||
{"Time": "2023-07-05T18:17:42", "IrReceived": {"Protocol": "NEC", "Bits": 32, "Data": "0x10EFD02F"}}
|
||||
```
|
||||
|
||||
Another example, use **_hex_** parameter
|
||||
|
||||
```json
|
||||
{ "type": "nec", "uint32": 284151855, "address": 8, "data": 11, "hex": "10EFD02F" }
|
||||
```
|
||||
|
||||
#### How to get key codes without Tasmota and other
|
||||
|
||||
Channel can receive learning key (autodetect format and put autodetected code in channel).
|
||||
|
||||
To start learning codes add new channel with Type String and DP = 1 and Range with `send_ir,study,study_exit,study_key`.
|
||||
|
||||
Link Item to this added channel and send command `study`.
|
||||
|
||||
Device will be in learning mode and be able to receive codes from remote control.
|
||||
|
||||
Just press a button on the remote control and see key code in channel `ir-code`.
|
||||
|
||||
If type of channel `ir-code` is **_NEC_** or **_Samsung_** you will see just a hex code.
|
||||
|
||||
If type of channel `ir-code` is **_Tuya DIY-mode_** you will see a type of code format and a hex code.
|
||||
|
||||
Pressing buttons and copying codes, then assign codes with Item which control device (adjust State Description and Command Options you want).
|
||||
|
||||
After receiving the key code, the learning mode automatically continues until you send command `study_exit` or send key code by Item with code
|
||||
## Troubleshooting
|
||||
|
||||
- If the `project` thing is not coming `ONLINE` check if you see your devices in the cloud-account on `iot.tuya.com`.
|
||||
If the listis empty, most likely you selected a wrong datacenter.
|
||||
- Check if there are errors in the log and if you see messages like `Configuring IP address '192.168.1.100' for thing 'tuya:tuya:tuyaDevice:bf3122fba012345fc9pqa'`.
|
||||
If this is missing, try configuring the IP manually.
|
||||
The MAC of your device can be found in the auto-discovered thing properties (this helps to identify the device in your router).
|
||||
- Provide TRACE level logs.
|
||||
Type `log:set TRACE org.openhab.binding.tuya` on the Karaf console to enable TRACE logging.
|
||||
Use `log:tail` to display the log.
|
||||
You can revert to normal logging with `log:set DEFAULT org.openhab.binding.tuya`
|
||||
- At least disable/enable the thing when providing logs.
|
||||
For most details better remove the device, use discovery and re-add the device.
|
||||
Please use PasteBin or a similar service, do not use JPG or other images, they can't be analysed properly.
|
||||
Check that the log doesn't contain any credentials.
|
||||
- Add the thing configuration to your report (in the UI use the "Code" view).
|
30
bundles/org.openhab.binding.tuya/pom.xml
Normal file
30
bundles/org.openhab.binding.tuya/pom.xml
Normal file
@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
|
||||
<version>5.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>org.openhab.binding.tuya</artifactId>
|
||||
|
||||
<name>openHAB Add-ons :: Bundles :: Tuya Binding</name>
|
||||
|
||||
<properties>
|
||||
<dep.noembedding>netty-common</dep.noembedding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-common</artifactId>
|
||||
<version>${netty.version}</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<features name="org.openhab.binding.tuya-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
|
||||
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
|
||||
|
||||
<feature name="openhab-binding-tuya" description="Tuya Binding" version="${project.version}">
|
||||
<feature>openhab-runtime-base</feature>
|
||||
<feature>openhab.tp-httpclient</feature>
|
||||
<feature>openhab.tp-netty</feature>
|
||||
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.tuya/${project.version}</bundle>
|
||||
</feature>
|
||||
</features>
|
@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.tuya.internal.util.SchemaDp;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
import org.openhab.core.thing.type.ChannelTypeUID;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
|
||||
/**
|
||||
* The {@link TuyaBindingConstants} class defines common constants, which are
|
||||
* used across the whole binding.
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class TuyaBindingConstants {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(TuyaBindingConstants.class);
|
||||
private static final String BINDING_ID = "tuya";
|
||||
|
||||
// List of all Thing Type UIDs
|
||||
public static final ThingTypeUID THING_TYPE_PROJECT = new ThingTypeUID(BINDING_ID, "project");
|
||||
public static final ThingTypeUID THING_TYPE_TUYA_DEVICE = new ThingTypeUID(BINDING_ID, "tuyaDevice");
|
||||
|
||||
public static final String PROPERTY_CATEGORY = "category";
|
||||
public static final String PROPERTY_MAC = "mac";
|
||||
|
||||
public static final String CONFIG_LOCAL_KEY = "localKey";
|
||||
public static final String CONFIG_DEVICE_ID = "deviceId";
|
||||
public static final String CONFIG_PRODUCT_ID = "productId";
|
||||
|
||||
public static final ChannelTypeUID CHANNEL_TYPE_UID_COLOR = new ChannelTypeUID(BINDING_ID, "color");
|
||||
public static final ChannelTypeUID CHANNEL_TYPE_UID_DIMMER = new ChannelTypeUID(BINDING_ID, "dimmer");
|
||||
public static final ChannelTypeUID CHANNEL_TYPE_UID_NUMBER = new ChannelTypeUID(BINDING_ID, "number");
|
||||
public static final ChannelTypeUID CHANNEL_TYPE_UID_STRING = new ChannelTypeUID(BINDING_ID, "string");
|
||||
public static final ChannelTypeUID CHANNEL_TYPE_UID_SWITCH = new ChannelTypeUID(BINDING_ID, "switch");
|
||||
public static final ChannelTypeUID CHANNEL_TYPE_UID_IR_CODE = new ChannelTypeUID(BINDING_ID, "ir-code");
|
||||
|
||||
public static final int TCP_CONNECTION_HEARTBEAT_INTERVAL = 10; // in s
|
||||
public static final int TCP_CONNECTION_TIMEOUT = 60; // in s;
|
||||
public static final int TCP_CONNECTION_MAXIMUM_MISSED_HEARTBEATS = 3;
|
||||
|
||||
public static final Map<String, Map<String, SchemaDp>> SCHEMAS = getSchemas();
|
||||
|
||||
private static Map<String, Map<String, SchemaDp>> getSchemas() {
|
||||
InputStream resource = Thread.currentThread().getContextClassLoader().getResourceAsStream("schema.json");
|
||||
if (resource == null) {
|
||||
LOGGER.warn("Could not read resource file 'schema.json', discovery might fail");
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
try (InputStreamReader reader = new InputStreamReader(resource)) {
|
||||
Gson gson = new Gson();
|
||||
Type schemaListType = TypeToken.getParameterized(Map.class, String.class, SchemaDp.class).getType();
|
||||
Type schemaType = TypeToken.getParameterized(Map.class, String.class, schemaListType).getType();
|
||||
return Objects.requireNonNull(gson.fromJson(reader, schemaType));
|
||||
} catch (IOException e) {
|
||||
LOGGER.warn("Failed to read 'schema.json', discovery might fail");
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal;
|
||||
|
||||
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.CONFIG_DEVICE_ID;
|
||||
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.CONFIG_LOCAL_KEY;
|
||||
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.CONFIG_PRODUCT_ID;
|
||||
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.PROPERTY_CATEGORY;
|
||||
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.PROPERTY_MAC;
|
||||
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.THING_TYPE_TUYA_DEVICE;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
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.tuya.internal.cloud.TuyaOpenAPI;
|
||||
import org.openhab.binding.tuya.internal.cloud.dto.DeviceListInfo;
|
||||
import org.openhab.binding.tuya.internal.cloud.dto.DeviceSchema;
|
||||
import org.openhab.binding.tuya.internal.handler.ProjectHandler;
|
||||
import org.openhab.binding.tuya.internal.util.SchemaDp;
|
||||
import org.openhab.core.config.discovery.AbstractThingHandlerDiscoveryService;
|
||||
import org.openhab.core.config.discovery.DiscoveryResult;
|
||||
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
|
||||
import org.openhab.core.storage.Storage;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
import org.openhab.core.thing.ThingUID;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.osgi.service.component.annotations.ServiceScope;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
|
||||
/**
|
||||
* The {@link TuyaDiscoveryService} implements the discovery service for Tuya devices from the cloud
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@Component(scope = ServiceScope.PROTOTYPE, service = TuyaDiscoveryService.class)
|
||||
@NonNullByDefault
|
||||
public class TuyaDiscoveryService extends AbstractThingHandlerDiscoveryService<ProjectHandler> {
|
||||
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(THING_TYPE_TUYA_DEVICE);
|
||||
private static final int SEARCH_TIME = 5;
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(TuyaDiscoveryService.class);
|
||||
private final Gson gson = new Gson();
|
||||
private @NonNullByDefault({}) Storage<String> storage;
|
||||
private @Nullable ScheduledFuture<?> discoveryJob;
|
||||
|
||||
public TuyaDiscoveryService() {
|
||||
super(ProjectHandler.class, SUPPORTED_THING_TYPES, SEARCH_TIME);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void startScan() {
|
||||
TuyaOpenAPI api = thingHandler.getApi();
|
||||
if (!api.isConnected()) {
|
||||
logger.debug("Tried to start scan but API for bridge '{}' is not connected.",
|
||||
thingHandler.getThing().getUID());
|
||||
return;
|
||||
}
|
||||
|
||||
processDeviceResponse(List.of(), api, 0);
|
||||
}
|
||||
|
||||
private void processDeviceResponse(List<DeviceListInfo> deviceList, TuyaOpenAPI api, int page) {
|
||||
deviceList.forEach(device -> processDevice(device, api));
|
||||
if (page == 0 || deviceList.size() == 100) {
|
||||
int nextPage = page + 1;
|
||||
thingHandler.getAllDevices(nextPage)
|
||||
.thenAccept(nextDeviceList -> processDeviceResponse(nextDeviceList, api, nextPage));
|
||||
}
|
||||
}
|
||||
|
||||
private void processDevice(DeviceListInfo device, TuyaOpenAPI api) {
|
||||
api.getFactoryInformation(List.of(device.id)).thenAccept(fiList -> {
|
||||
ThingUID thingUid = new ThingUID(THING_TYPE_TUYA_DEVICE, device.id);
|
||||
String deviceMac = fiList.stream().filter(fi -> fi.id.equals(device.id)).findAny().map(fi -> fi.mac)
|
||||
.orElse("");
|
||||
|
||||
Map<String, Object> properties = new HashMap<>();
|
||||
properties.put(PROPERTY_CATEGORY, device.category);
|
||||
properties.put(PROPERTY_MAC, Objects.requireNonNull(deviceMac).replaceAll("(..)(?!$)", "$1:"));
|
||||
properties.put(CONFIG_LOCAL_KEY, device.localKey);
|
||||
properties.put(CONFIG_DEVICE_ID, device.id);
|
||||
properties.put(CONFIG_PRODUCT_ID, device.productId);
|
||||
|
||||
DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUid).withLabel(device.name)
|
||||
.withRepresentationProperty(CONFIG_DEVICE_ID).withProperties(properties).build();
|
||||
|
||||
api.getDeviceSchema(device.id).thenAccept(schema -> {
|
||||
List<SchemaDp> schemaDps = new ArrayList<>();
|
||||
schema.functions.forEach(description -> addUniqueSchemaDp(description, schemaDps));
|
||||
schema.status.forEach(description -> addUniqueSchemaDp(description, schemaDps));
|
||||
storage.put(device.id, gson.toJson(schemaDps));
|
||||
});
|
||||
|
||||
thingDiscovered(discoveryResult);
|
||||
});
|
||||
}
|
||||
|
||||
private void addUniqueSchemaDp(DeviceSchema.Description description, List<SchemaDp> schemaDps) {
|
||||
if (description.dp_id == 0 || schemaDps.stream().anyMatch(schemaDp -> schemaDp.id == description.dp_id)) {
|
||||
// dp is missing or already present, skip it
|
||||
return;
|
||||
}
|
||||
// some devices report the same function code for different dps
|
||||
// we add an index only if this is the case
|
||||
String originalCode = description.code;
|
||||
int index = 1;
|
||||
while (schemaDps.stream().anyMatch(schemaDp -> schemaDp.code.equals(description.code))) {
|
||||
description.code = originalCode + "_" + index;
|
||||
}
|
||||
|
||||
schemaDps.add(SchemaDp.fromRemoteSchema(gson, description));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected synchronized void stopScan() {
|
||||
removeOlderResults(getTimestampOfLastScan());
|
||||
super.stopScan();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize() {
|
||||
this.storage = thingHandler.getStorage();
|
||||
super.initialize();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
super.dispose();
|
||||
removeOlderResults(new Date().getTime());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<ThingTypeUID> getSupportedThingTypes() {
|
||||
return SUPPORTED_THING_TYPES;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startBackgroundDiscovery() {
|
||||
ScheduledFuture<?> discoveryJob = this.discoveryJob;
|
||||
if (discoveryJob == null || discoveryJob.isCancelled()) {
|
||||
this.discoveryJob = scheduler.scheduleWithFixedDelay(this::startScan, 1, 5, TimeUnit.MINUTES);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stopBackgroundDiscovery() {
|
||||
ScheduledFuture<?> discoveryJob = this.discoveryJob;
|
||||
if (discoveryJob != null) {
|
||||
discoveryJob.cancel(true);
|
||||
this.discoveryJob = null;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.core.events.EventPublisher;
|
||||
import org.openhab.core.thing.binding.BaseDynamicCommandDescriptionProvider;
|
||||
import org.openhab.core.thing.i18n.ChannelTypeI18nLocalizationService;
|
||||
import org.openhab.core.thing.link.ItemChannelLinkRegistry;
|
||||
import org.openhab.core.thing.type.DynamicCommandDescriptionProvider;
|
||||
import org.osgi.service.component.annotations.Activate;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.osgi.service.component.annotations.Reference;
|
||||
|
||||
/**
|
||||
* This class provides the list of valid commands for dynamic channels.
|
||||
*
|
||||
* @author Cody Cutrer - Initial contribution
|
||||
*
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@Component(service = { DynamicCommandDescriptionProvider.class, TuyaDynamicCommandDescriptionProvider.class })
|
||||
public class TuyaDynamicCommandDescriptionProvider extends BaseDynamicCommandDescriptionProvider {
|
||||
@Activate
|
||||
public TuyaDynamicCommandDescriptionProvider(final @Reference EventPublisher eventPublisher, //
|
||||
final @Reference ItemChannelLinkRegistry itemChannelLinkRegistry, //
|
||||
final @Reference ChannelTypeI18nLocalizationService channelTypeI18nLocalizationService) {
|
||||
this.eventPublisher = eventPublisher;
|
||||
this.itemChannelLinkRegistry = itemChannelLinkRegistry;
|
||||
this.channelTypeI18nLocalizationService = channelTypeI18nLocalizationService;
|
||||
}
|
||||
}
|
@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal;
|
||||
|
||||
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.THING_TYPE_PROJECT;
|
||||
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.THING_TYPE_TUYA_DEVICE;
|
||||
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.openhab.binding.tuya.internal.handler.ProjectHandler;
|
||||
import org.openhab.binding.tuya.internal.handler.TuyaDeviceHandler;
|
||||
import org.openhab.binding.tuya.internal.local.UdpDiscoveryListener;
|
||||
import org.openhab.binding.tuya.internal.util.SchemaDp;
|
||||
import org.openhab.core.io.net.http.HttpClientFactory;
|
||||
import org.openhab.core.storage.Storage;
|
||||
import org.openhab.core.storage.StorageService;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
import org.openhab.core.thing.binding.BaseDynamicCommandDescriptionProvider;
|
||||
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.annotations.Activate;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.osgi.service.component.annotations.Deactivate;
|
||||
import org.osgi.service.component.annotations.Reference;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
|
||||
import io.netty.channel.EventLoopGroup;
|
||||
import io.netty.channel.nio.NioEventLoopGroup;
|
||||
|
||||
/**
|
||||
* The {@link TuyaHandlerFactory} is responsible for creating things and thing
|
||||
* handlers.
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@Component(configurationPid = "binding.tuya", service = ThingHandlerFactory.class)
|
||||
@SuppressWarnings("unused")
|
||||
public class TuyaHandlerFactory extends BaseThingHandlerFactory {
|
||||
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_PROJECT,
|
||||
THING_TYPE_TUYA_DEVICE);
|
||||
private static final Type STORAGE_TYPE = TypeToken.getParameterized(List.class, SchemaDp.class).getType();
|
||||
|
||||
private final BaseDynamicCommandDescriptionProvider dynamicCommandDescriptionProvider;
|
||||
private final HttpClient httpClient;
|
||||
private final Gson gson = new Gson();
|
||||
private final UdpDiscoveryListener udpDiscoveryListener;
|
||||
private final EventLoopGroup eventLoopGroup;
|
||||
private final Storage<String> storage;
|
||||
|
||||
@Activate
|
||||
public TuyaHandlerFactory(@Reference HttpClientFactory httpClientFactory,
|
||||
@Reference TuyaDynamicCommandDescriptionProvider dynamicCommandDescriptionProvider,
|
||||
@Reference StorageService storageService) {
|
||||
this.httpClient = httpClientFactory.getCommonHttpClient();
|
||||
this.dynamicCommandDescriptionProvider = dynamicCommandDescriptionProvider;
|
||||
this.eventLoopGroup = new NioEventLoopGroup();
|
||||
this.udpDiscoveryListener = new UdpDiscoveryListener(eventLoopGroup);
|
||||
this.storage = storageService.getStorage("org.openhab.binding.tuya.Schema");
|
||||
}
|
||||
|
||||
@Deactivate
|
||||
public void deactivate() {
|
||||
udpDiscoveryListener.deactivate();
|
||||
eventLoopGroup.shutdownGracefully();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
|
||||
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @Nullable ThingHandler createHandler(Thing thing) {
|
||||
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
|
||||
|
||||
if (THING_TYPE_PROJECT.equals(thingTypeUID)) {
|
||||
return new ProjectHandler(thing, httpClient, storage, gson);
|
||||
} else if (THING_TYPE_TUYA_DEVICE.equals(thingTypeUID)) {
|
||||
return new TuyaDeviceHandler(thing, gson.fromJson(storage.get(thing.getUID().getId()), STORAGE_TYPE), gson,
|
||||
dynamicCommandDescriptionProvider, eventLoopGroup, udpDiscoveryListener);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.cloud;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link ApiStatusCallback} is an interface for reporting API call results
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public interface ApiStatusCallback {
|
||||
|
||||
/**
|
||||
* report the status of the connection if it changes
|
||||
*
|
||||
* @param status true -> established, false -> disconnected/failed
|
||||
*/
|
||||
void tuyaOpenApiStatus(boolean status);
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.cloud;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* The {@link ConnectionException} is thrown if a connection problem caused the request to fail
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class ConnectionException extends Exception {
|
||||
static final long serialVersionUID = 1L;
|
||||
|
||||
public ConnectionException(@Nullable String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.cloud;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jetty.client.api.Request;
|
||||
import org.eclipse.jetty.client.api.Response;
|
||||
import org.eclipse.jetty.client.api.Result;
|
||||
import org.eclipse.jetty.client.util.BufferingResponseListener;
|
||||
import org.eclipse.jetty.http.HttpField;
|
||||
import org.eclipse.jetty.http.HttpStatus;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link TuyaContentListener} is a {@link BufferingResponseListener} implementation for the Tuya Cloud
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class TuyaContentListener extends BufferingResponseListener {
|
||||
private final Logger logger = LoggerFactory.getLogger(TuyaContentListener.class);
|
||||
private final CompletableFuture<String> future;
|
||||
|
||||
public TuyaContentListener(CompletableFuture<String> future) {
|
||||
this.future = future;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete(Result result) {
|
||||
Response response = result.getResponse();
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("Received from '{}': {}", result.getRequest().getURI(), responseToLogString(response));
|
||||
}
|
||||
Request request = result.getRequest();
|
||||
if (result.isFailed()) {
|
||||
logger.debug("Requesting '{}' (method='{}', content='{}') failed: {}", request.getURI(),
|
||||
request.getMethod(), request.getContent(), result.getFailure().getMessage());
|
||||
future.completeExceptionally(new ConnectionException("Request failed " + result.getFailure().getMessage()));
|
||||
} else {
|
||||
switch (response.getStatus()) {
|
||||
case HttpStatus.OK_200:
|
||||
case HttpStatus.CREATED_201:
|
||||
case HttpStatus.ACCEPTED_202:
|
||||
case HttpStatus.NON_AUTHORITATIVE_INFORMATION_203:
|
||||
case HttpStatus.NO_CONTENT_204:
|
||||
case HttpStatus.RESET_CONTENT_205:
|
||||
case HttpStatus.PARTIAL_CONTENT_206:
|
||||
case HttpStatus.MULTI_STATUS_207:
|
||||
byte[] content = getContent();
|
||||
if (content != null) {
|
||||
future.complete(new String(content, StandardCharsets.UTF_8));
|
||||
} else {
|
||||
future.completeExceptionally(new ConnectionException("Content is null."));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
logger.debug("Requesting '{}' (method='{}', content='{}') failed: {} {}", request.getURI(),
|
||||
request.getMethod(), request.getContent(), response.getStatus(), response.getReason());
|
||||
future.completeExceptionally(
|
||||
new ConnectionException("Invalid status code " + response.getStatus()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String responseToLogString(Response response) {
|
||||
return "Code = {" + response.getStatus() + "}, Headers = {"
|
||||
+ response.getHeaders().stream().map(HttpField::toString).collect(Collectors.joining(", "))
|
||||
+ "}, Content = {" + getContentAsString() + "}";
|
||||
}
|
||||
}
|
@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.cloud;
|
||||
|
||||
import java.lang.reflect.Type;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.StreamSupport;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.eclipse.jetty.client.api.ContentProvider;
|
||||
import org.eclipse.jetty.client.api.Request;
|
||||
import org.eclipse.jetty.client.util.StringContentProvider;
|
||||
import org.eclipse.jetty.http.HttpField;
|
||||
import org.eclipse.jetty.http.HttpMethod;
|
||||
import org.openhab.binding.tuya.internal.cloud.dto.CommandRequest;
|
||||
import org.openhab.binding.tuya.internal.cloud.dto.DeviceListInfo;
|
||||
import org.openhab.binding.tuya.internal.cloud.dto.DeviceSchema;
|
||||
import org.openhab.binding.tuya.internal.cloud.dto.FactoryInformation;
|
||||
import org.openhab.binding.tuya.internal.cloud.dto.Login;
|
||||
import org.openhab.binding.tuya.internal.cloud.dto.ResultResponse;
|
||||
import org.openhab.binding.tuya.internal.cloud.dto.Token;
|
||||
import org.openhab.binding.tuya.internal.config.ProjectConfiguration;
|
||||
import org.openhab.binding.tuya.internal.util.CryptoUtil;
|
||||
import org.openhab.binding.tuya.internal.util.JoiningMapCollector;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
|
||||
/**
|
||||
* The {@link TuyaOpenAPI} is an implementation of the Tuya OpenApi specification
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class TuyaOpenAPI {
|
||||
private final Logger logger = LoggerFactory.getLogger(TuyaOpenAPI.class);
|
||||
private final ScheduledExecutorService scheduler;
|
||||
private ProjectConfiguration config = new ProjectConfiguration();
|
||||
private final HttpClient httpClient;
|
||||
|
||||
private final ApiStatusCallback callback;
|
||||
private final Gson gson;
|
||||
|
||||
private Token token = new Token();
|
||||
private @Nullable ScheduledFuture<?> refreshTokenJob;
|
||||
|
||||
public TuyaOpenAPI(ApiStatusCallback callback, ScheduledExecutorService scheduler, Gson gson,
|
||||
HttpClient httpClient) {
|
||||
this.callback = callback;
|
||||
this.gson = gson;
|
||||
this.httpClient = httpClient;
|
||||
this.scheduler = scheduler;
|
||||
}
|
||||
|
||||
public void setConfiguration(ProjectConfiguration configuration) {
|
||||
this.config = configuration;
|
||||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
return !token.accessToken.isEmpty() && System.currentTimeMillis() < token.expireTimestamp;
|
||||
}
|
||||
|
||||
private void refreshToken() {
|
||||
if (System.currentTimeMillis() > token.expireTimestamp) {
|
||||
logger.warn("Cannot refresh token after expiry. Trying to re-login.");
|
||||
login();
|
||||
} else {
|
||||
stopRefreshTokenJob();
|
||||
request(HttpMethod.GET, "/v1.0/token/" + token.refreshToken, Map.of(), null).exceptionally(t -> "")
|
||||
.thenAccept(this::processTokenResponse);
|
||||
}
|
||||
}
|
||||
|
||||
public void login() {
|
||||
Login login = Login.fromProjectConfiguration(config);
|
||||
|
||||
stopRefreshTokenJob();
|
||||
request(HttpMethod.POST, "/v1.0/iot-01/associated-users/actions/authorized-login", Map.of(), login)
|
||||
.exceptionally(t -> "").thenApply(this::processTokenResponse);
|
||||
}
|
||||
|
||||
public void disconnect() {
|
||||
stopRefreshTokenJob();
|
||||
token = new Token();
|
||||
}
|
||||
|
||||
private void stopRefreshTokenJob() {
|
||||
ScheduledFuture<?> refreshTokenJob = this.refreshTokenJob;
|
||||
if (refreshTokenJob != null) {
|
||||
refreshTokenJob.cancel(true);
|
||||
this.refreshTokenJob = null;
|
||||
}
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> processTokenResponse(String contentString) {
|
||||
if (contentString.isEmpty()) {
|
||||
this.token = new Token();
|
||||
callback.tuyaOpenApiStatus(false);
|
||||
return CompletableFuture.failedFuture(new ConnectionException("Failed to get token."));
|
||||
}
|
||||
|
||||
Type type = TypeToken.getParameterized(ResultResponse.class, Token.class).getType();
|
||||
ResultResponse<Token> result = Objects.requireNonNull(gson.fromJson(contentString, type));
|
||||
|
||||
if (result.success) {
|
||||
Token token = result.result;
|
||||
if (token != null) {
|
||||
token.expireTimestamp = result.timestamp + token.expire * 1000;
|
||||
logger.debug("Got token: {}", token);
|
||||
this.token = token;
|
||||
callback.tuyaOpenApiStatus(true);
|
||||
refreshTokenJob = scheduler.schedule(this::refreshToken, token.expire - 60, TimeUnit.SECONDS);
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn("Request failed: {}, no token received", result);
|
||||
this.token = new Token();
|
||||
callback.tuyaOpenApiStatus(false);
|
||||
return CompletableFuture.failedFuture(new ConnectionException("Failed to get token."));
|
||||
}
|
||||
|
||||
public CompletableFuture<List<FactoryInformation>> getFactoryInformation(List<String> deviceIds) {
|
||||
Map<String, String> params = Map.of("device_ids", String.join(",", deviceIds));
|
||||
return request(HttpMethod.GET, "/v1.0/iot-03/devices/factory-infos", params, null).thenCompose(
|
||||
s -> processResponse(s, TypeToken.getParameterized(List.class, FactoryInformation.class).getType()));
|
||||
}
|
||||
|
||||
public CompletableFuture<List<DeviceListInfo>> getDeviceList(int page) {
|
||||
Map<String, String> params = Map.of(//
|
||||
"from", "", //
|
||||
"page_no", String.valueOf(page), //
|
||||
"page_size", "100");
|
||||
return request(HttpMethod.GET, "/v1.0/users/" + token.uid + "/devices", params, null).thenCompose(
|
||||
s -> processResponse(s, TypeToken.getParameterized(List.class, DeviceListInfo.class).getType()));
|
||||
}
|
||||
|
||||
public CompletableFuture<DeviceSchema> getDeviceSchema(String deviceId) {
|
||||
return request(HttpMethod.GET, "/v1.1/devices/" + deviceId + "/specifications", Map.of(), null)
|
||||
.thenCompose(s -> processResponse(s, DeviceSchema.class));
|
||||
}
|
||||
|
||||
public CompletableFuture<Boolean> sendCommand(String deviceId, CommandRequest command) {
|
||||
return request(HttpMethod.POST, "/v1.0/iot-03/devices/" + deviceId + "/commands", Map.of(), command)
|
||||
.thenCompose(s -> processResponse(s, Boolean.class));
|
||||
}
|
||||
|
||||
private <T> CompletableFuture<T> processResponse(String contentString, Type type) {
|
||||
Type responseType = TypeToken.getParameterized(ResultResponse.class, type).getType();
|
||||
ResultResponse<T> resultResponse = Objects.requireNonNull(gson.fromJson(contentString, responseType));
|
||||
if (resultResponse.success) {
|
||||
return CompletableFuture.completedFuture(resultResponse.result);
|
||||
} else {
|
||||
if (resultResponse.code >= 1010 && resultResponse.code <= 1013) {
|
||||
logger.warn("Server reported invalid token. This should never happen. Trying to re-login.");
|
||||
callback.tuyaOpenApiStatus(false);
|
||||
return CompletableFuture.failedFuture(new ConnectionException(resultResponse.msg));
|
||||
}
|
||||
return CompletableFuture.failedFuture(new IllegalStateException(resultResponse.msg));
|
||||
}
|
||||
}
|
||||
|
||||
private CompletableFuture<String> request(HttpMethod method, String path, Map<String, String> params,
|
||||
@Nullable Object body) {
|
||||
CompletableFuture<String> future = new CompletableFuture<>();
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
String sign = signRequest(method, path, Map.of("client_id", config.accessId), List.of("client_id"), params,
|
||||
body, null, now);
|
||||
Map<String, String> headers = Map.of( //
|
||||
"client_id", config.accessId, //
|
||||
"t", Long.toString(now), //
|
||||
"Signature-Headers", "client_id", //
|
||||
"sign", sign, //
|
||||
"sign_method", "HMAC-SHA256", //
|
||||
"access_token", this.token.accessToken);
|
||||
|
||||
String fullUrl = config.dataCenter + signUrl(path, params);
|
||||
Request request = httpClient.newRequest(URI.create(fullUrl));
|
||||
request.method(method);
|
||||
headers.forEach(request::header);
|
||||
if (body != null) {
|
||||
request.content(new StringContentProvider(gson.toJson(body)));
|
||||
request.header("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("Sending to '{}': {}", fullUrl, requestToLogString(request));
|
||||
}
|
||||
|
||||
request.send(new TuyaContentListener(future));
|
||||
return future;
|
||||
}
|
||||
|
||||
// used for testing only
|
||||
void setToken(Token token) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
// package private to allow tests
|
||||
String signRequest(HttpMethod method, String path, Map<String, String> headers, List<String> signHeaders,
|
||||
Map<String, String> params, @Nullable Object body, @Nullable String nonce, long now) {
|
||||
String stringToSign = stringToSign(method, path, headers, signHeaders, params, body);
|
||||
String tokenToUse = path.startsWith("/v1.0/token") ? "" : this.token.accessToken;
|
||||
String fullStringToSign = this.config.accessId + tokenToUse + now + (nonce == null ? "" : nonce) + stringToSign;
|
||||
|
||||
return CryptoUtil.hmacSha256(fullStringToSign, config.accessSecret);
|
||||
}
|
||||
|
||||
private String stringToSign(HttpMethod method, String path, Map<String, String> headers, List<String> signHeaders,
|
||||
Map<String, String> params, @Nullable Object body) {
|
||||
String bodyString = CryptoUtil.sha256(body != null ? gson.toJson(body) : "");
|
||||
String headerString = headers.entrySet().stream().filter(e -> signHeaders.contains(e.getKey()))
|
||||
.sorted(Map.Entry.comparingByKey()).collect(JoiningMapCollector.joining(":", "\n"));
|
||||
String urlString = signUrl(path, params);
|
||||
// add extra \n after header string -> TUYAs documentation is wrong
|
||||
return method.asString() + "\n" + bodyString + "\n" + headerString + "\n\n" + urlString;
|
||||
}
|
||||
|
||||
private String signUrl(String path, Map<String, String> params) {
|
||||
String paramString = params.entrySet().stream().sorted(Map.Entry.comparingByKey())
|
||||
.collect(JoiningMapCollector.joining("=", "&"));
|
||||
if (paramString.isEmpty()) {
|
||||
return path;
|
||||
} else {
|
||||
return path + "?" + paramString;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* create a log string from a {@link Request}
|
||||
*
|
||||
* @param request the request to log
|
||||
* @return the string representing the request
|
||||
*/
|
||||
private String requestToLogString(Request request) {
|
||||
ContentProvider contentProvider = request.getContent();
|
||||
String contentString = contentProvider == null ? "null"
|
||||
: StreamSupport.stream(contentProvider.spliterator(), false)
|
||||
.map(b -> StandardCharsets.UTF_8.decode(b).toString()).collect(Collectors.joining(", "));
|
||||
|
||||
return "Method = {" + request.getMethod() + "}, Headers = {"
|
||||
+ request.getHeaders().stream().map(HttpField::toString).collect(Collectors.joining(", "))
|
||||
+ "}, Content = {" + contentString + "}";
|
||||
}
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.cloud.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link CommandRequest} represents a request to the cloud
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class CommandRequest {
|
||||
public List<Command<?>> commands;
|
||||
|
||||
public CommandRequest(List<Command<?>> commands) {
|
||||
this.commands = commands;
|
||||
}
|
||||
|
||||
public static class Command<T> {
|
||||
public String code;
|
||||
public T value;
|
||||
|
||||
public Command(String code, T value) {
|
||||
this.code = code;
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.cloud.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* The {@link DeviceListInfo} encapsulates the information in the device list
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@SuppressWarnings("unused")
|
||||
public class DeviceListInfo {
|
||||
public String id = "";
|
||||
public String uuid = "";
|
||||
public String uid = "";
|
||||
|
||||
@SerializedName("biz_type")
|
||||
public int bizType = -1;
|
||||
public String name = "";
|
||||
@SerializedName("time_zone")
|
||||
public String timeZone = "";
|
||||
public String ip = "";
|
||||
@SerializedName("local_key")
|
||||
public String localKey = "";
|
||||
@SerializedName("sub")
|
||||
public boolean subDevice = false;
|
||||
public String model = "";
|
||||
|
||||
@SerializedName("create_time")
|
||||
public long createTime = 0;
|
||||
@SerializedName("update_time")
|
||||
public long updateTime = 0;
|
||||
@SerializedName("active_time")
|
||||
public long activeTime = 0;
|
||||
|
||||
public List<StatusInfo> status = List.of();
|
||||
|
||||
@SerializedName("owner_id")
|
||||
public String ownerId = "";
|
||||
@SerializedName("product_id")
|
||||
public String productId = "";
|
||||
@SerializedName("product_name")
|
||||
public String productName = "";
|
||||
|
||||
public String category = "";
|
||||
public String icon = "";
|
||||
public boolean online = false;
|
||||
|
||||
@SerializedName("node_id")
|
||||
public String nodeId = "";
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "DeviceListInfo{" + "id='" + id + "', uuid='" + uuid + "', uid='" + uid + "', bizType=" + bizType
|
||||
+ ", name='" + name + "', timeZone='" + timeZone + "', ip='" + ip + "', localKey='" + localKey
|
||||
+ "', subDevice=" + subDevice + ", model='" + model + "', createTime=" + createTime + ", updateTime="
|
||||
+ updateTime + ", activeTime=" + activeTime + ", status=" + status + ", ownerId='" + ownerId
|
||||
+ "', productId='" + productId + "', productName='" + productName + "', category='" + category
|
||||
+ "', icon='" + icon + "', online=" + online + ", nodeId='" + nodeId + "'}";
|
||||
}
|
||||
}
|
@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.cloud.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link DeviceSchema} encapsulates the command and status specification of a device
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@SuppressWarnings("unused")
|
||||
public class DeviceSchema {
|
||||
public String category = "";
|
||||
public List<Description> functions = List.of();
|
||||
public List<Description> status = List.of();
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "DeviceSpecification{category='" + category + "', functions=" + functions + ", status=" + status + "}";
|
||||
}
|
||||
|
||||
public static class Description {
|
||||
public String code = "";
|
||||
public int dp_id = 0;
|
||||
public String type = "";
|
||||
public String values = "";
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Description{code='" + code + "', dp_id=" + dp_id + ", type='" + type + "', values='" + values
|
||||
+ "'}";
|
||||
}
|
||||
}
|
||||
|
||||
public static class EnumRange {
|
||||
public List<String> range = List.of();
|
||||
}
|
||||
|
||||
public static class NumericRange {
|
||||
public double min = Double.MIN_VALUE;
|
||||
public double max = Double.MAX_VALUE;
|
||||
}
|
||||
|
||||
public boolean hasFunction(String fcn) {
|
||||
return functions.stream().anyMatch(f -> fcn.equals(f.code));
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.cloud.dto;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link FactoryInformation} encapsulates the reported factory information
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class FactoryInformation {
|
||||
public String id = "";
|
||||
public String mac = "";
|
||||
public String uuid = "";
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "FactoryInformation{id='" + id + "', mac='" + mac + "', uuid='" + uuid + "'}";
|
||||
}
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.cloud.dto;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.tuya.internal.config.ProjectConfiguration;
|
||||
import org.openhab.binding.tuya.internal.util.CryptoUtil;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* The {@link Login} encapsulates login data
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@SuppressWarnings("unused")
|
||||
public class Login {
|
||||
public String username;
|
||||
public String password;
|
||||
|
||||
@SerializedName("country_code")
|
||||
public Integer countryCode;
|
||||
public String schema;
|
||||
|
||||
public Login(String username, String password, Integer countryCode, String schema) {
|
||||
this.username = username;
|
||||
this.password = CryptoUtil.md5(password);
|
||||
this.countryCode = countryCode;
|
||||
this.schema = schema;
|
||||
}
|
||||
|
||||
public static Login fromProjectConfiguration(ProjectConfiguration config) {
|
||||
return new Login(config.username, config.password, config.countryCode, config.schema);
|
||||
}
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.cloud.dto;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* The {@link ResultResponse} encapsulates the Tuya Cloud Response
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class ResultResponse<T> {
|
||||
public boolean success = false;
|
||||
public long code = 0;
|
||||
@SerializedName("t")
|
||||
public long timestamp = 0;
|
||||
|
||||
public @Nullable String msg;
|
||||
public @Nullable T result;
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Result{timestamp=" + timestamp + ", code=" + code + ", msg=" + msg + ", success=" + success
|
||||
+ ", result=" + result + "}";
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.cloud.dto;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link StatusInfo} encapsulates device status data
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class StatusInfo {
|
||||
public String code = "";
|
||||
public String value = "";
|
||||
public String t = "";
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "StatusInfo{" + "code='" + code + "', value='" + value + "', t='" + t + "'}";
|
||||
}
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.cloud.dto;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* The {@link Token} encapsulates the Access Tokens
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@SuppressWarnings("unused")
|
||||
public class Token {
|
||||
@SerializedName("access_token")
|
||||
public final String accessToken;
|
||||
@SerializedName("refresh_token")
|
||||
public final String refreshToken;
|
||||
public final String uid;
|
||||
@SerializedName("expire_time")
|
||||
public final long expire;
|
||||
|
||||
public transient long expireTimestamp = 0;
|
||||
|
||||
public Token() {
|
||||
this("", "", "", 0);
|
||||
}
|
||||
|
||||
public Token(String accessToken, String refreshToken, String uid, long expire) {
|
||||
this.accessToken = accessToken;
|
||||
this.refreshToken = refreshToken;
|
||||
this.uid = uid;
|
||||
this.expire = expire;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Token{accessToken='" + accessToken + "', refreshToken='" + refreshToken + "', uid='" + uid
|
||||
+ "', expire=" + expire + "', expireTimestamp=" + expireTimestamp + "}";
|
||||
}
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.config;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link ChannelConfiguration} holds the configuration of a single device channel
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class ChannelConfiguration {
|
||||
public int dp = 0;
|
||||
public int dp2 = 0;
|
||||
public int min = Integer.MIN_VALUE;
|
||||
public int max = Integer.MAX_VALUE;
|
||||
public boolean sendAsString = false;
|
||||
public boolean reversed = false;
|
||||
public String range = "";
|
||||
public String irCode = "";
|
||||
public int irSendDelay = 300;
|
||||
public int irCodeType = 0;
|
||||
public String irType = "";
|
||||
public boolean activeListen = false;
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.config;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link DeviceConfiguration} holds the configuration of a single device
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class DeviceConfiguration {
|
||||
public String productId = "";
|
||||
public String deviceId = "";
|
||||
public String localKey = "";
|
||||
|
||||
public String ip = "";
|
||||
public String protocol = "";
|
||||
|
||||
public int pollingInterval = 0;
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.config;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link ProjectConfiguration} class contains fields mapping bridge configuration parameters.
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class ProjectConfiguration {
|
||||
public String username = "";
|
||||
public String password = "";
|
||||
public String accessId = "";
|
||||
public String accessSecret = "";
|
||||
public Integer countryCode = 0;
|
||||
public String schema = "";
|
||||
public String dataCenter = "";
|
||||
|
||||
public boolean isValid() {
|
||||
return !username.isEmpty() && !password.isEmpty() && !accessId.isEmpty() && !accessSecret.isEmpty()
|
||||
&& countryCode != 0 && !schema.isEmpty() && !dataCenter.isEmpty();
|
||||
}
|
||||
}
|
@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.handler;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
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.tuya.internal.TuyaDiscoveryService;
|
||||
import org.openhab.binding.tuya.internal.cloud.ApiStatusCallback;
|
||||
import org.openhab.binding.tuya.internal.cloud.TuyaOpenAPI;
|
||||
import org.openhab.binding.tuya.internal.cloud.dto.DeviceListInfo;
|
||||
import org.openhab.binding.tuya.internal.cloud.dto.DeviceSchema;
|
||||
import org.openhab.binding.tuya.internal.config.ProjectConfiguration;
|
||||
import org.openhab.core.storage.Storage;
|
||||
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.binding.BaseThingHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandlerService;
|
||||
import org.openhab.core.types.Command;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
|
||||
/**
|
||||
* The {@link ProjectHandler} is responsible for handling communication
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class ProjectHandler extends BaseThingHandler implements ApiStatusCallback {
|
||||
private final TuyaOpenAPI api;
|
||||
private final Storage<String> storage;
|
||||
|
||||
private @Nullable ScheduledFuture<?> apiConnectFuture;
|
||||
|
||||
public ProjectHandler(Thing thing, HttpClient httpClient, Storage<String> storage, Gson gson) {
|
||||
super(thing);
|
||||
this.api = new TuyaOpenAPI(this, scheduler, gson, httpClient);
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleCommand(ChannelUID channelUID, Command command) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize() {
|
||||
ProjectConfiguration config = getConfigAs(ProjectConfiguration.class);
|
||||
|
||||
if (!config.isValid()) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
api.setConfiguration(config);
|
||||
updateStatus(ThingStatus.UNKNOWN);
|
||||
|
||||
stopApiConnectFuture();
|
||||
apiConnectFuture = scheduler.schedule(api::login, 0, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void tuyaOpenApiStatus(boolean status) {
|
||||
if (!status) {
|
||||
stopApiConnectFuture();
|
||||
apiConnectFuture = scheduler.schedule(api::login, 60, TimeUnit.SECONDS);
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
|
||||
} else {
|
||||
stopApiConnectFuture();
|
||||
updateStatus(ThingStatus.ONLINE);
|
||||
}
|
||||
}
|
||||
|
||||
public TuyaOpenAPI getApi() {
|
||||
return api;
|
||||
}
|
||||
|
||||
public Storage<String> getStorage() {
|
||||
return storage;
|
||||
}
|
||||
|
||||
public CompletableFuture<List<DeviceListInfo>> getAllDevices(int page) {
|
||||
if (api.isConnected()) {
|
||||
return api.getDeviceList(page);
|
||||
}
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("not connected"));
|
||||
}
|
||||
|
||||
public CompletableFuture<DeviceSchema> getDeviceSchema(String deviceId) {
|
||||
if (api.isConnected()) {
|
||||
return api.getDeviceSchema(deviceId);
|
||||
}
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("not connected"));
|
||||
}
|
||||
|
||||
private void stopApiConnectFuture() {
|
||||
ScheduledFuture<?> apiConnectFuture = this.apiConnectFuture;
|
||||
if (apiConnectFuture != null) {
|
||||
apiConnectFuture.cancel(true);
|
||||
this.apiConnectFuture = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
stopApiConnectFuture();
|
||||
api.disconnect();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Class<? extends ThingHandlerService>> getServices() {
|
||||
return Set.of(TuyaDiscoveryService.class);
|
||||
}
|
||||
}
|
@ -0,0 +1,658 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.handler;
|
||||
|
||||
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.CHANNEL_TYPE_UID_COLOR;
|
||||
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.CHANNEL_TYPE_UID_DIMMER;
|
||||
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.CHANNEL_TYPE_UID_IR_CODE;
|
||||
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.CHANNEL_TYPE_UID_NUMBER;
|
||||
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.CHANNEL_TYPE_UID_STRING;
|
||||
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.CHANNEL_TYPE_UID_SWITCH;
|
||||
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.SCHEMAS;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.tuya.internal.config.ChannelConfiguration;
|
||||
import org.openhab.binding.tuya.internal.config.DeviceConfiguration;
|
||||
import org.openhab.binding.tuya.internal.local.DeviceInfoSubscriber;
|
||||
import org.openhab.binding.tuya.internal.local.DeviceStatusListener;
|
||||
import org.openhab.binding.tuya.internal.local.TuyaDevice;
|
||||
import org.openhab.binding.tuya.internal.local.UdpDiscoveryListener;
|
||||
import org.openhab.binding.tuya.internal.local.dto.DeviceInfo;
|
||||
import org.openhab.binding.tuya.internal.local.dto.IrCode;
|
||||
import org.openhab.binding.tuya.internal.util.ConversionUtil;
|
||||
import org.openhab.binding.tuya.internal.util.IrUtils;
|
||||
import org.openhab.binding.tuya.internal.util.SchemaDp;
|
||||
import org.openhab.core.cache.ExpiringCache;
|
||||
import org.openhab.core.cache.ExpiringCacheMap;
|
||||
import org.openhab.core.config.core.Configuration;
|
||||
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.StringType;
|
||||
import org.openhab.core.thing.Channel;
|
||||
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.BaseDynamicCommandDescriptionProvider;
|
||||
import org.openhab.core.thing.binding.BaseThingHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandlerCallback;
|
||||
import org.openhab.core.thing.binding.builder.ChannelBuilder;
|
||||
import org.openhab.core.thing.binding.builder.ThingBuilder;
|
||||
import org.openhab.core.thing.type.ChannelTypeUID;
|
||||
import org.openhab.core.types.Command;
|
||||
import org.openhab.core.types.CommandOption;
|
||||
import org.openhab.core.types.State;
|
||||
import org.openhab.core.types.UnDefType;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonSyntaxException;
|
||||
|
||||
import io.netty.channel.EventLoopGroup;
|
||||
|
||||
/**
|
||||
* The {@link TuyaDeviceHandler} handles commands and state updates
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class TuyaDeviceHandler extends BaseThingHandler implements DeviceInfoSubscriber, DeviceStatusListener {
|
||||
private static final List<String> COLOUR_CHANNEL_CODES = List.of("colour_data");
|
||||
private static final List<String> DIMMER_CHANNEL_CODES = List.of("bright_value", "bright_value_1", "bright_value_2",
|
||||
"temp_value");
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(TuyaDeviceHandler.class);
|
||||
|
||||
private final Gson gson;
|
||||
private final UdpDiscoveryListener udpDiscoveryListener;
|
||||
private final BaseDynamicCommandDescriptionProvider dynamicCommandDescriptionProvider;
|
||||
private final EventLoopGroup eventLoopGroup;
|
||||
private DeviceConfiguration configuration = new DeviceConfiguration();
|
||||
private @Nullable TuyaDevice tuyaDevice;
|
||||
private final List<SchemaDp> schemaDps;
|
||||
private boolean oldColorMode = false;
|
||||
|
||||
private @Nullable ScheduledFuture<?> reconnectFuture;
|
||||
private @Nullable ScheduledFuture<?> pollingJob;
|
||||
private @Nullable ScheduledFuture<?> irLearnJob;
|
||||
private boolean disposing = false;
|
||||
|
||||
private final Map<Integer, String> dpToChannelId = new HashMap<>();
|
||||
private final Map<Integer, List<String>> dp2ToChannelId = new HashMap<>();
|
||||
private final Map<String, ChannelTypeUID> channelIdToChannelTypeUID = new HashMap<>();
|
||||
private final Map<String, ChannelConfiguration> channelIdToConfiguration = new HashMap<>();
|
||||
|
||||
private final ExpiringCacheMap<Integer, @Nullable Object> deviceStatusCache = new ExpiringCacheMap<>(
|
||||
Duration.ofSeconds(10));
|
||||
private final Map<String, State> channelStateCache = new HashMap<>();
|
||||
|
||||
public TuyaDeviceHandler(Thing thing, @Nullable List<SchemaDp> schemaDps, Gson gson,
|
||||
BaseDynamicCommandDescriptionProvider dynamicCommandDescriptionProvider, EventLoopGroup eventLoopGroup,
|
||||
UdpDiscoveryListener udpDiscoveryListener) {
|
||||
super(thing);
|
||||
this.gson = gson;
|
||||
this.udpDiscoveryListener = udpDiscoveryListener;
|
||||
this.eventLoopGroup = eventLoopGroup;
|
||||
this.dynamicCommandDescriptionProvider = dynamicCommandDescriptionProvider;
|
||||
this.schemaDps = Objects.requireNonNullElse(schemaDps, List.of());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processDeviceStatus(Map<Integer, Object> deviceStatus) {
|
||||
logger.trace("'{}' received status message '{}'", thing.getUID(), deviceStatus);
|
||||
|
||||
if (deviceStatus.isEmpty()) {
|
||||
// if status is empty -> need to use control method to request device status
|
||||
Map<Integer, @Nullable Object> commandRequest = new HashMap<>();
|
||||
dpToChannelId.keySet().forEach(dp -> commandRequest.put(dp, null));
|
||||
dp2ToChannelId.keySet().forEach(dp -> commandRequest.put(dp, null));
|
||||
|
||||
TuyaDevice tuyaDevice = this.tuyaDevice;
|
||||
if (tuyaDevice != null) {
|
||||
tuyaDevice.set(commandRequest);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
deviceStatus.forEach(this::addSingleExpiringCache);
|
||||
deviceStatus.forEach(this::processChannelStatus);
|
||||
}
|
||||
|
||||
private void processChannelStatus(Integer dp, Object value) {
|
||||
String channelId = dpToChannelId.get(dp);
|
||||
if (channelId != null) {
|
||||
ChannelConfiguration channelConfiguration = channelIdToConfiguration.get(channelId);
|
||||
ChannelTypeUID channelTypeUID = channelIdToChannelTypeUID.get(channelId);
|
||||
|
||||
if (channelConfiguration == null || channelTypeUID == null) {
|
||||
logger.warn("Could not find configuration or type for channel '{}' in thing '{}'", channelId,
|
||||
thing.getUID());
|
||||
return;
|
||||
}
|
||||
|
||||
if (Boolean.FALSE.equals(deviceStatusCache.get(channelConfiguration.dp2))) {
|
||||
// skip update if the channel is off!
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (value instanceof String && CHANNEL_TYPE_UID_COLOR.equals(channelTypeUID)) {
|
||||
oldColorMode = ((String) value).length() == 14;
|
||||
updateState(channelId, ConversionUtil.hexColorDecode((String) value));
|
||||
return;
|
||||
} else if (value instanceof String && CHANNEL_TYPE_UID_STRING.equals(channelTypeUID)) {
|
||||
updateState(channelId, new StringType((String) value));
|
||||
return;
|
||||
} else if (Double.class.isAssignableFrom(value.getClass())
|
||||
&& CHANNEL_TYPE_UID_DIMMER.equals(channelTypeUID)) {
|
||||
updateState(channelId,
|
||||
ConversionUtil.brightnessDecode((double) value, 0, channelConfiguration.max));
|
||||
return;
|
||||
} else if (Double.class.isAssignableFrom(value.getClass())
|
||||
&& CHANNEL_TYPE_UID_NUMBER.equals(channelTypeUID)) {
|
||||
updateState(channelId, new DecimalType((double) value));
|
||||
return;
|
||||
} else if (value instanceof String string && CHANNEL_TYPE_UID_NUMBER.equals(channelTypeUID)) {
|
||||
updateState(channelId, new DecimalType(string));
|
||||
} else if (Boolean.class.isAssignableFrom(value.getClass())
|
||||
&& CHANNEL_TYPE_UID_SWITCH.equals(channelTypeUID)) {
|
||||
updateState(channelId, OnOffType.from((boolean) value));
|
||||
return;
|
||||
} else if (value instanceof String && CHANNEL_TYPE_UID_IR_CODE.equals(channelTypeUID)) {
|
||||
if (channelConfiguration.dp == 2) {
|
||||
String decoded = convertBase64Code(channelConfiguration, (String) value);
|
||||
logger.info("thing {} received ir code: {}", thing.getUID(), decoded);
|
||||
updateState(channelId, new StringType(decoded));
|
||||
irStartLearning(channelConfiguration.activeListen);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
}
|
||||
logger.warn("Could not update channel '{}' of thing '{}' with value '{}'. Datatype incompatible.",
|
||||
channelId, getThing().getUID(), value);
|
||||
updateState(channelId, UnDefType.UNDEF);
|
||||
} else {
|
||||
// try additional channelDps, only OnOffType
|
||||
List<String> channelIds = dp2ToChannelId.get(dp);
|
||||
if (channelIds == null) {
|
||||
logger.debug("Could not find channel for dp '{}' in thing '{}'", dp, thing.getUID());
|
||||
} else {
|
||||
if (Boolean.class.isAssignableFrom(value.getClass())) {
|
||||
OnOffType state = OnOffType.from((boolean) value);
|
||||
channelIds.forEach(ch -> updateState(ch, state));
|
||||
return;
|
||||
}
|
||||
logger.warn("Could not update channel '{}' of thing '{}' with value {}. Datatype incompatible.",
|
||||
channelIds, getThing().getUID(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void connectionStatus(boolean status) {
|
||||
if (status) {
|
||||
updateStatus(ThingStatus.ONLINE);
|
||||
int pollingInterval = configuration.pollingInterval;
|
||||
TuyaDevice tuyaDevice = this.tuyaDevice;
|
||||
if (tuyaDevice != null && pollingInterval > 0) {
|
||||
pollingJob = scheduler.scheduleWithFixedDelay(tuyaDevice::refreshStatus, pollingInterval,
|
||||
pollingInterval, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
// start learning code if thing is online and presents 'ir-code' channel
|
||||
channelIdToChannelTypeUID.entrySet().stream().filter(e -> CHANNEL_TYPE_UID_IR_CODE.equals(e.getValue()))
|
||||
.map(Map.Entry::getKey).findAny().map(channelIdToConfiguration::get)
|
||||
.ifPresent(irCodeChannelConfig -> irStartLearning(irCodeChannelConfig.activeListen));
|
||||
} else {
|
||||
updateStatus(ThingStatus.OFFLINE);
|
||||
ScheduledFuture<?> pollingJob = this.pollingJob;
|
||||
if (pollingJob != null) {
|
||||
pollingJob.cancel(true);
|
||||
this.pollingJob = null;
|
||||
}
|
||||
TuyaDevice tuyaDevice = this.tuyaDevice;
|
||||
ScheduledFuture<?> reconnectFuture = this.reconnectFuture;
|
||||
// only re-connect if a device is present, we are not disposing the thing and either the reconnectFuture is
|
||||
// empty or already done
|
||||
if (tuyaDevice != null && !disposing && (reconnectFuture == null || reconnectFuture.isDone())) {
|
||||
this.reconnectFuture = scheduler.schedule(this::connectDevice, 5000, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
if (channelIdToChannelTypeUID.containsValue(CHANNEL_TYPE_UID_IR_CODE)) {
|
||||
irStopLearning();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleCommand(ChannelUID channelUID, Command command) {
|
||||
if (getThing().getStatus() != ThingStatus.ONLINE) {
|
||||
logger.warn("Channel '{}' received a command but device is not ONLINE. Discarding command.", channelUID);
|
||||
return;
|
||||
}
|
||||
|
||||
Map<Integer, @Nullable Object> commandRequest = new HashMap<>();
|
||||
|
||||
ChannelTypeUID channelTypeUID = channelIdToChannelTypeUID.get(channelUID.getId());
|
||||
ChannelConfiguration configuration = channelIdToConfiguration.get(channelUID.getId());
|
||||
if (channelTypeUID == null || configuration == null) {
|
||||
logger.warn("Could not determine channel type or configuration for channel '{}'. Discarding command.",
|
||||
channelUID);
|
||||
return;
|
||||
}
|
||||
|
||||
if (CHANNEL_TYPE_UID_COLOR.equals(channelTypeUID)) {
|
||||
if (command instanceof HSBType) {
|
||||
commandRequest.put(configuration.dp, ConversionUtil.hexColorEncode((HSBType) command, oldColorMode));
|
||||
ChannelConfiguration workModeConfig = channelIdToConfiguration.get("work_mode");
|
||||
if (workModeConfig != null) {
|
||||
commandRequest.put(workModeConfig.dp, "colour");
|
||||
}
|
||||
if (configuration.dp2 != 0) {
|
||||
commandRequest.put(configuration.dp2, ((HSBType) command).getBrightness().doubleValue() > 0.0);
|
||||
}
|
||||
} else if (command instanceof PercentType) {
|
||||
State oldState = channelStateCache.get(channelUID.getId());
|
||||
if (!(oldState instanceof HSBType)) {
|
||||
logger.debug("Discarding command '{}' to channel '{}', cannot determine old state", command,
|
||||
channelUID);
|
||||
return;
|
||||
}
|
||||
HSBType newState = new HSBType(((HSBType) oldState).getHue(), ((HSBType) oldState).getSaturation(),
|
||||
(PercentType) command);
|
||||
commandRequest.put(configuration.dp, ConversionUtil.hexColorEncode(newState, oldColorMode));
|
||||
ChannelConfiguration workModeConfig = channelIdToConfiguration.get("work_mode");
|
||||
if (workModeConfig != null) {
|
||||
commandRequest.put(workModeConfig.dp, "colour");
|
||||
}
|
||||
if (configuration.dp2 != 0) {
|
||||
commandRequest.put(configuration.dp2, ((PercentType) command).doubleValue() > 0.0);
|
||||
}
|
||||
} else if (command instanceof OnOffType) {
|
||||
if (configuration.dp2 != 0) {
|
||||
commandRequest.put(configuration.dp2, OnOffType.ON.equals(command));
|
||||
}
|
||||
}
|
||||
} else if (CHANNEL_TYPE_UID_DIMMER.equals(channelTypeUID)) {
|
||||
if (command instanceof PercentType) {
|
||||
int value = ConversionUtil.brightnessEncode((PercentType) command, 0, configuration.max);
|
||||
if (configuration.reversed) {
|
||||
value = configuration.max - value;
|
||||
}
|
||||
if (value >= configuration.min) {
|
||||
commandRequest.put(configuration.dp, value);
|
||||
}
|
||||
if (configuration.dp2 != 0) {
|
||||
commandRequest.put(configuration.dp2, value >= configuration.min);
|
||||
}
|
||||
ChannelConfiguration workModeConfig = channelIdToConfiguration.get("work_mode");
|
||||
if (workModeConfig != null) {
|
||||
commandRequest.put(workModeConfig.dp, "white");
|
||||
}
|
||||
} else if (command instanceof OnOffType) {
|
||||
if (configuration.dp2 != 0) {
|
||||
commandRequest.put(configuration.dp2, OnOffType.ON.equals(command));
|
||||
}
|
||||
}
|
||||
} else if (CHANNEL_TYPE_UID_STRING.equals(channelTypeUID)) {
|
||||
commandRequest.put(configuration.dp, command.toString());
|
||||
} else if (CHANNEL_TYPE_UID_NUMBER.equals(channelTypeUID)) {
|
||||
if (command instanceof DecimalType decimalType) {
|
||||
commandRequest.put(configuration.dp,
|
||||
configuration.sendAsString ? String.format("%d", decimalType.intValue())
|
||||
: decimalType.intValue());
|
||||
}
|
||||
} else if (CHANNEL_TYPE_UID_SWITCH.equals(channelTypeUID)) {
|
||||
if (command instanceof OnOffType) {
|
||||
commandRequest.put(configuration.dp, OnOffType.ON.equals(command));
|
||||
}
|
||||
} else if (CHANNEL_TYPE_UID_IR_CODE.equals(channelTypeUID)) {
|
||||
if (command instanceof StringType) {
|
||||
switch (configuration.irType) {
|
||||
case "base64" -> {
|
||||
commandRequest.put(1, "study_key");
|
||||
commandRequest.put(7, command.toString());
|
||||
}
|
||||
case "tuya-head" -> {
|
||||
if (!configuration.irCode.isBlank()) {
|
||||
commandRequest.put(1, "send_ir");
|
||||
commandRequest.put(3, configuration.irCode);
|
||||
commandRequest.put(4, command.toString());
|
||||
commandRequest.put(10, configuration.irSendDelay);
|
||||
commandRequest.put(13, configuration.irCodeType);
|
||||
} else {
|
||||
logger.warn("irCode is not set for channel {}", channelUID);
|
||||
}
|
||||
}
|
||||
case "nec" -> {
|
||||
long code = convertHexCode(command.toString());
|
||||
String base64Code = IrUtils.necToBase64(code);
|
||||
commandRequest.put(1, "study_key");
|
||||
commandRequest.put(7, base64Code);
|
||||
}
|
||||
case "samsung" -> {
|
||||
long code = convertHexCode(command.toString());
|
||||
String base64Code = IrUtils.samsungToBase64(code);
|
||||
commandRequest.put(1, "study_key");
|
||||
commandRequest.put(7, base64Code);
|
||||
}
|
||||
}
|
||||
irStopLearning();
|
||||
}
|
||||
}
|
||||
|
||||
TuyaDevice tuyaDevice = this.tuyaDevice;
|
||||
if (!commandRequest.isEmpty() && tuyaDevice != null) {
|
||||
tuyaDevice.set(commandRequest);
|
||||
}
|
||||
|
||||
if (CHANNEL_TYPE_UID_IR_CODE.equals(channelTypeUID)) {
|
||||
if (command instanceof StringType) {
|
||||
irStartLearning(configuration.activeListen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
disposing = true;
|
||||
ScheduledFuture<?> future = reconnectFuture;
|
||||
if (future != null) {
|
||||
future.cancel(true);
|
||||
}
|
||||
future = this.pollingJob;
|
||||
if (future != null) {
|
||||
future.cancel(true);
|
||||
}
|
||||
if (configuration.ip.isEmpty()) {
|
||||
// unregister listener only if IP is not fixed
|
||||
udpDiscoveryListener.unregisterListener(this);
|
||||
}
|
||||
TuyaDevice tuyaDevice = this.tuyaDevice;
|
||||
if (tuyaDevice != null) {
|
||||
tuyaDevice.dispose();
|
||||
this.tuyaDevice = null;
|
||||
}
|
||||
irStopLearning();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize() {
|
||||
// clear all maps
|
||||
dpToChannelId.clear();
|
||||
dp2ToChannelId.clear();
|
||||
channelIdToChannelTypeUID.clear();
|
||||
channelIdToConfiguration.clear();
|
||||
|
||||
configuration = getConfigAs(DeviceConfiguration.class);
|
||||
|
||||
// check if we have channels and add them if available
|
||||
if (thing.getChannels().isEmpty()) {
|
||||
// stored schemas are usually more complete
|
||||
Map<String, SchemaDp> schema = SCHEMAS.get(configuration.productId);
|
||||
if (schema == null) {
|
||||
if (!schemaDps.isEmpty()) {
|
||||
// fallback to retrieved schema
|
||||
schema = schemaDps.stream().collect(Collectors.toMap(s -> s.code, s -> s));
|
||||
} else {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||
"No channels added and schema not found.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
addChannels(schema);
|
||||
}
|
||||
|
||||
thing.getChannels().forEach(this::configureChannel);
|
||||
|
||||
if (!configuration.ip.isBlank()) {
|
||||
deviceInfoChanged(new DeviceInfo(configuration.ip, configuration.protocol));
|
||||
} else {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING, "Waiting for IP address");
|
||||
udpDiscoveryListener.registerListener(configuration.deviceId, this);
|
||||
}
|
||||
|
||||
disposing = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deviceInfoChanged(DeviceInfo deviceInfo) {
|
||||
logger.info("Configuring IP address '{}' for thing '{}'.", deviceInfo, thing.getUID());
|
||||
|
||||
TuyaDevice tuyaDevice = this.tuyaDevice;
|
||||
if (tuyaDevice != null) {
|
||||
tuyaDevice.dispose();
|
||||
}
|
||||
updateStatus(ThingStatus.UNKNOWN);
|
||||
|
||||
this.tuyaDevice = new TuyaDevice(gson, this, eventLoopGroup, configuration.deviceId,
|
||||
configuration.localKey.getBytes(StandardCharsets.UTF_8), deviceInfo.ip, deviceInfo.protocolVersion);
|
||||
}
|
||||
|
||||
private void addChannels(Map<String, SchemaDp> schema) {
|
||||
ThingBuilder thingBuilder = editThing();
|
||||
ThingUID thingUID = thing.getUID();
|
||||
ThingHandlerCallback callback = getCallback();
|
||||
|
||||
if (callback == null) {
|
||||
logger.warn("Thing callback not found. Cannot auto-detect thing '{}' channels.", thingUID);
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Channel> channels = new HashMap<>(schema.entrySet().stream().map(e -> {
|
||||
String channelId = e.getKey();
|
||||
SchemaDp schemaDp = e.getValue();
|
||||
|
||||
ChannelUID channelUID = new ChannelUID(thingUID, channelId);
|
||||
Map<String, @Nullable Object> configuration = new HashMap<>();
|
||||
configuration.put("dp", schemaDp.id);
|
||||
|
||||
ChannelTypeUID channeltypeUID;
|
||||
if (COLOUR_CHANNEL_CODES.contains(channelId)) {
|
||||
channeltypeUID = CHANNEL_TYPE_UID_COLOR;
|
||||
} else if (DIMMER_CHANNEL_CODES.contains(channelId)) {
|
||||
channeltypeUID = CHANNEL_TYPE_UID_DIMMER;
|
||||
configuration.put("min", schemaDp.min);
|
||||
configuration.put("max", schemaDp.max);
|
||||
} else if ("bool".equals(schemaDp.type)) {
|
||||
channeltypeUID = CHANNEL_TYPE_UID_SWITCH;
|
||||
} else if ("enum".equals(schemaDp.type)) {
|
||||
channeltypeUID = CHANNEL_TYPE_UID_STRING;
|
||||
List<String> range = Objects.requireNonNullElse(schemaDp.range, List.of());
|
||||
configuration.put("range", String.join(",", range));
|
||||
} else if ("string".equals(schemaDp.type)) {
|
||||
channeltypeUID = CHANNEL_TYPE_UID_STRING;
|
||||
} else if ("value".equals(schemaDp.type)) {
|
||||
channeltypeUID = CHANNEL_TYPE_UID_NUMBER;
|
||||
configuration.put("min", schemaDp.min);
|
||||
configuration.put("max", schemaDp.max);
|
||||
} else {
|
||||
// e.g. type "raw", add empty channel
|
||||
return Map.entry("", ChannelBuilder.create(channelUID).build());
|
||||
}
|
||||
|
||||
return Map.entry(channelId, callback.createChannelBuilder(channelUID, channeltypeUID).withLabel(channelId)
|
||||
.withConfiguration(new Configuration(configuration)).build());
|
||||
}).filter(c -> !c.getKey().isEmpty()).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)));
|
||||
|
||||
List<String> channelSuffixes = List.of("", "_1", "_2");
|
||||
List<String> switchChannels = List.of("switch_led", "led_switch");
|
||||
channelSuffixes.forEach(suffix -> switchChannels.forEach(channel -> {
|
||||
Channel switchChannel = channels.get(channel + suffix);
|
||||
if (switchChannel != null) {
|
||||
// remove switch channel if brightness or color is present and add to dp2 instead
|
||||
ChannelConfiguration config = switchChannel.getConfiguration().as(ChannelConfiguration.class);
|
||||
Channel colourChannel = channels.get("colour_data" + suffix);
|
||||
Channel brightChannel = channels.get("bright_value" + suffix);
|
||||
boolean remove = false;
|
||||
|
||||
if (colourChannel != null) {
|
||||
colourChannel.getConfiguration().put("dp2", config.dp);
|
||||
remove = true;
|
||||
}
|
||||
if (brightChannel != null) {
|
||||
brightChannel.getConfiguration().put("dp2", config.dp);
|
||||
remove = true;
|
||||
}
|
||||
|
||||
if (remove) {
|
||||
channels.remove(channel + suffix);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
channels.values().forEach(thingBuilder::withChannel);
|
||||
|
||||
updateThing(thingBuilder.build());
|
||||
}
|
||||
|
||||
private void configureChannel(Channel channel) {
|
||||
ChannelConfiguration configuration = channel.getConfiguration().as(ChannelConfiguration.class);
|
||||
ChannelTypeUID channelTypeUID = channel.getChannelTypeUID();
|
||||
|
||||
if (channelTypeUID == null) {
|
||||
logger.warn("Could not determine ChannelTypeUID for '{}'", channel.getUID());
|
||||
return;
|
||||
}
|
||||
|
||||
String channelId = channel.getUID().getId();
|
||||
|
||||
if (!configuration.range.isEmpty()) {
|
||||
List<CommandOption> commandOptions = toCommandOptionList(
|
||||
Arrays.stream(configuration.range.split(",")).collect(Collectors.toList()));
|
||||
dynamicCommandDescriptionProvider.setCommandOptions(channel.getUID(), commandOptions);
|
||||
}
|
||||
|
||||
dpToChannelId.put(configuration.dp, channelId);
|
||||
channelIdToConfiguration.put(channelId, configuration);
|
||||
channelIdToChannelTypeUID.put(channelId, channelTypeUID);
|
||||
|
||||
// check if we have additional DPs (these are switch DP for color or brightness only)
|
||||
if (configuration.dp2 != 0) {
|
||||
List<String> list = Objects
|
||||
.requireNonNull(dp2ToChannelId.computeIfAbsent(configuration.dp2, ArrayList::new));
|
||||
list.add(channelId);
|
||||
}
|
||||
if (CHANNEL_TYPE_UID_IR_CODE.equals(channelTypeUID)) {
|
||||
irStartLearning(configuration.activeListen);
|
||||
}
|
||||
}
|
||||
|
||||
private void connectDevice() {
|
||||
TuyaDevice tuyaDevice = this.tuyaDevice;
|
||||
if (tuyaDevice == null) {
|
||||
logger.warn("Cannot connect {} because the device is not set.", thing.getUID());
|
||||
return;
|
||||
}
|
||||
// clear the future here because timing issues can prevent the next attempt if we fail again
|
||||
reconnectFuture = null;
|
||||
tuyaDevice.connect();
|
||||
}
|
||||
|
||||
private List<CommandOption> toCommandOptionList(List<String> options) {
|
||||
return options.stream().map(c -> new CommandOption(c, c)).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private void addSingleExpiringCache(Integer key, Object value) {
|
||||
ExpiringCache<@Nullable Object> expiringCache = new ExpiringCache<>(Duration.ofSeconds(10), () -> null);
|
||||
expiringCache.putValue(value);
|
||||
deviceStatusCache.put(key, expiringCache);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateState(String channelId, State state) {
|
||||
channelStateCache.put(channelId, state);
|
||||
super.updateState(channelId, state);
|
||||
}
|
||||
|
||||
private long convertHexCode(String code) {
|
||||
String sCode = code.startsWith("0x") ? code.substring(2) : code;
|
||||
return Long.parseLong(sCode, 16);
|
||||
}
|
||||
|
||||
private String convertBase64Code(ChannelConfiguration channelConfig, String encoded) {
|
||||
String decoded = "";
|
||||
try {
|
||||
if (channelConfig.irType.equals("nec")) {
|
||||
decoded = IrUtils.base64ToNec(encoded);
|
||||
IrCode code = Objects.requireNonNull(gson.fromJson(decoded, IrCode.class));
|
||||
decoded = "0x" + code.hex;
|
||||
} else if (channelConfig.irType.equals("samsung")) {
|
||||
decoded = IrUtils.base64ToSamsung(encoded);
|
||||
IrCode code = Objects.requireNonNull(gson.fromJson(decoded, IrCode.class));
|
||||
decoded = "0x" + code.hex;
|
||||
} else {
|
||||
if (encoded.length() > 68) {
|
||||
decoded = IrUtils.base64ToNec(encoded);
|
||||
if (decoded.isEmpty()) {
|
||||
decoded = IrUtils.base64ToSamsung(encoded);
|
||||
}
|
||||
IrCode code = Objects.requireNonNull(gson.fromJson(decoded, IrCode.class));
|
||||
decoded = code.type + ": 0x" + code.hex;
|
||||
} else {
|
||||
decoded = encoded;
|
||||
}
|
||||
}
|
||||
} catch (JsonSyntaxException e) {
|
||||
logger.warn("Incorrect json response: {}", e.getMessage());
|
||||
decoded = encoded;
|
||||
} catch (RuntimeException e) {
|
||||
logger.warn("Unable decode key code'{}', reason: {}", decoded, e.getMessage());
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
private void repeatStudyCode() {
|
||||
Map<Integer, @Nullable Object> commandRequest = new HashMap<>();
|
||||
commandRequest.put(1, "study");
|
||||
TuyaDevice tuyaDevice = this.tuyaDevice;
|
||||
if (tuyaDevice != null) {
|
||||
tuyaDevice.set(commandRequest);
|
||||
}
|
||||
}
|
||||
|
||||
private void irStopLearning() {
|
||||
logger.debug("[tuya:ir-controller] stop ir learning");
|
||||
ScheduledFuture<?> feature = irLearnJob;
|
||||
if (feature != null) {
|
||||
feature.cancel(true);
|
||||
this.irLearnJob = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void irStartLearning(boolean available) {
|
||||
irStopLearning();
|
||||
if (available) {
|
||||
logger.debug("[tuya:ir-controller] start ir learning");
|
||||
irLearnJob = scheduler.scheduleWithFixedDelay(this::repeatStudyCode, 200, 29000, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.local;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link CommandType} maps the numeric command types to an enum
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public enum CommandType {
|
||||
UDP(0),
|
||||
AP_CONFIG(1),
|
||||
ACTIVE(2),
|
||||
SESS_KEY_NEG_START(3),
|
||||
SESS_KEY_NEG_RESPONSE(4),
|
||||
SESS_KEY_NEG_FINISH(5),
|
||||
UNBIND(6),
|
||||
CONTROL(7),
|
||||
STATUS(8),
|
||||
HEART_BEAT(9),
|
||||
DP_QUERY(10),
|
||||
QUERY_WIFI(11),
|
||||
TOKEN_BIND(12),
|
||||
CONTROL_NEW(13),
|
||||
ENABLE_WIFI(14),
|
||||
DP_QUERY_NEW(16),
|
||||
SCENE_EXECUTE(17),
|
||||
DP_REFRESH(18),
|
||||
UDP_NEW(19),
|
||||
AP_CONFIG_NEW(20),
|
||||
BROADCAST_LPV34(35),
|
||||
LAN_EXT_STREAM(40),
|
||||
LAN_GW_ACTIVE(240),
|
||||
LAN_SUB_DEV_REQUEST(241),
|
||||
LAN_DELETE_SUB_DEV(242),
|
||||
LAN_REPORT_SUB_DEV(243),
|
||||
LAN_SCENE(244),
|
||||
LAN_PUBLISH_CLOUD_CONFIG(245),
|
||||
LAN_PUBLISH_APP_CONFIG(246),
|
||||
LAN_EXPORT_APP_CONFIG(247),
|
||||
LAN_PUBLISH_SCENE_PANEL(248),
|
||||
LAN_REMOVE_GW(249),
|
||||
LAN_CHECK_GW_UPDATE(250),
|
||||
LAN_GW_UPDATE(251),
|
||||
LAN_SET_GW_CHANNEL(252),
|
||||
DP_QUERY_NOT_SUPPORTED(-1); // this is an internal value
|
||||
|
||||
private final int code;
|
||||
|
||||
CommandType(int code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public static CommandType fromCode(int code) {
|
||||
return Arrays.stream(values()).filter(t -> t.code == code).findAny()
|
||||
.orElseThrow(() -> new IllegalArgumentException("Unknown code " + code));
|
||||
}
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.local;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.tuya.internal.local.dto.DeviceInfo;
|
||||
|
||||
/**
|
||||
* The {@link DeviceInfoSubscriber} is an interface to report new device information
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public interface DeviceInfoSubscriber {
|
||||
void deviceInfoChanged(DeviceInfo deviceInfo);
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.local;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link DeviceStatusListener} encapsulates device status data
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public interface DeviceStatusListener {
|
||||
void processDeviceStatus(Map<Integer, Object> deviceStatus);
|
||||
|
||||
void connectionStatus(boolean status);
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.local;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link MessageWrapper} wraps command type and message content
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class MessageWrapper<T> {
|
||||
public CommandType commandType;
|
||||
public T content;
|
||||
|
||||
public MessageWrapper(CommandType commandType, T content) {
|
||||
this.commandType = commandType;
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "MessageWrapper{commandType=" + commandType + ", content='" + content + "'}";
|
||||
}
|
||||
}
|
@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.local;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link ProtocolVersion} maps the protocol version String to
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public enum ProtocolVersion {
|
||||
V3_1("3.1"),
|
||||
V3_3("3.3"),
|
||||
V3_4("3.4");
|
||||
|
||||
private final String versionString;
|
||||
|
||||
ProtocolVersion(String versionString) {
|
||||
this.versionString = versionString;
|
||||
}
|
||||
|
||||
public byte[] getBytes() {
|
||||
return versionString.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public String getString() {
|
||||
return versionString;
|
||||
}
|
||||
|
||||
public static ProtocolVersion fromString(String version) {
|
||||
return Arrays.stream(values()).filter(t -> t.versionString.equals(version)).findAny()
|
||||
.orElseThrow(() -> new IllegalArgumentException("Unknown version " + version));
|
||||
}
|
||||
}
|
@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.local;
|
||||
|
||||
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.TCP_CONNECTION_HEARTBEAT_INTERVAL;
|
||||
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.TCP_CONNECTION_TIMEOUT;
|
||||
import static org.openhab.binding.tuya.internal.local.CommandType.CONTROL;
|
||||
import static org.openhab.binding.tuya.internal.local.CommandType.CONTROL_NEW;
|
||||
import static org.openhab.binding.tuya.internal.local.CommandType.DP_QUERY;
|
||||
import static org.openhab.binding.tuya.internal.local.CommandType.DP_REFRESH;
|
||||
import static org.openhab.binding.tuya.internal.local.CommandType.SESS_KEY_NEG_START;
|
||||
import static org.openhab.binding.tuya.internal.local.ProtocolVersion.V3_4;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.tuya.internal.local.handlers.HeartbeatHandler;
|
||||
import org.openhab.binding.tuya.internal.local.handlers.TuyaDecoder;
|
||||
import org.openhab.binding.tuya.internal.local.handlers.TuyaEncoder;
|
||||
import org.openhab.binding.tuya.internal.local.handlers.TuyaMessageHandler;
|
||||
import org.openhab.binding.tuya.internal.local.handlers.UserEventHandler;
|
||||
import org.openhab.binding.tuya.internal.util.CryptoUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
|
||||
import io.netty.bootstrap.Bootstrap;
|
||||
import io.netty.channel.Channel;
|
||||
import io.netty.channel.ChannelFuture;
|
||||
import io.netty.channel.ChannelFutureListener;
|
||||
import io.netty.channel.ChannelInitializer;
|
||||
import io.netty.channel.ChannelOption;
|
||||
import io.netty.channel.ChannelPipeline;
|
||||
import io.netty.channel.EventLoopGroup;
|
||||
import io.netty.channel.socket.SocketChannel;
|
||||
import io.netty.channel.socket.nio.NioSocketChannel;
|
||||
import io.netty.handler.timeout.IdleStateHandler;
|
||||
import io.netty.util.AttributeKey;
|
||||
|
||||
/**
|
||||
* The {@link TuyaDevice} handles the device connection
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class TuyaDevice implements ChannelFutureListener {
|
||||
public static final AttributeKey<String> DEVICE_ID_ATTR = AttributeKey.valueOf("deviceId");
|
||||
public static final AttributeKey<ProtocolVersion> PROTOCOL_ATTR = AttributeKey.valueOf("protocol");
|
||||
public static final AttributeKey<byte[]> SESSION_RANDOM_ATTR = AttributeKey.valueOf("sessionRandom");
|
||||
public static final AttributeKey<byte[]> SESSION_KEY_ATTR = AttributeKey.valueOf("sessionKey");
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(TuyaDevice.class);
|
||||
|
||||
private final Bootstrap bootstrap = new Bootstrap();
|
||||
private final DeviceStatusListener deviceStatusListener;
|
||||
private final String deviceId;
|
||||
private final byte[] deviceKey;
|
||||
|
||||
private final String address;
|
||||
private final ProtocolVersion protocolVersion;
|
||||
private @Nullable Channel channel;
|
||||
|
||||
public TuyaDevice(Gson gson, DeviceStatusListener deviceStatusListener, EventLoopGroup eventLoopGroup,
|
||||
String deviceId, byte[] deviceKey, String address, String protocolVersion) {
|
||||
this.address = address;
|
||||
this.deviceId = deviceId;
|
||||
this.deviceKey = deviceKey;
|
||||
this.deviceStatusListener = deviceStatusListener;
|
||||
this.protocolVersion = ProtocolVersion.fromString(protocolVersion);
|
||||
bootstrap.group(eventLoopGroup).channel(NioSocketChannel.class);
|
||||
bootstrap.option(ChannelOption.TCP_NODELAY, true).option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2000);
|
||||
bootstrap.handler(new ChannelInitializer<SocketChannel>() {
|
||||
@Override
|
||||
protected void initChannel(SocketChannel ch) throws Exception {
|
||||
ChannelPipeline pipeline = ch.pipeline();
|
||||
pipeline.addLast("idleStateHandler",
|
||||
new IdleStateHandler(TCP_CONNECTION_TIMEOUT, TCP_CONNECTION_HEARTBEAT_INTERVAL, 0));
|
||||
pipeline.addLast("messageEncoder", new TuyaEncoder(gson));
|
||||
pipeline.addLast("messageDecoder", new TuyaDecoder(gson));
|
||||
pipeline.addLast("heartbeatHandler", new HeartbeatHandler());
|
||||
pipeline.addLast("deviceHandler", new TuyaMessageHandler(deviceStatusListener));
|
||||
pipeline.addLast("userEventHandler", new UserEventHandler());
|
||||
}
|
||||
});
|
||||
connect();
|
||||
}
|
||||
|
||||
public void connect() {
|
||||
bootstrap.connect(address, 6668).addListener(this);
|
||||
}
|
||||
|
||||
private void disconnect() {
|
||||
Channel channel = this.channel;
|
||||
if (channel != null) { // if channel == null we are not connected anyway
|
||||
channel.pipeline().fireUserEventTriggered(new UserEventHandler.DisposeEvent());
|
||||
this.channel = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void set(Map<Integer, @Nullable Object> command) {
|
||||
CommandType commandType = (protocolVersion == V3_4) ? CONTROL_NEW : CONTROL;
|
||||
MessageWrapper<?> m = new MessageWrapper<>(commandType, Map.of("dps", command));
|
||||
Channel channel = this.channel;
|
||||
if (channel != null) {
|
||||
channel.writeAndFlush(m);
|
||||
} else {
|
||||
logger.warn("{}: Setting {} failed. Device is not connected.", deviceId, command);
|
||||
}
|
||||
}
|
||||
|
||||
public void requestStatus() {
|
||||
MessageWrapper<?> m = new MessageWrapper<>(DP_QUERY, Map.of("dps", Map.of()));
|
||||
Channel channel = this.channel;
|
||||
if (channel != null) {
|
||||
channel.writeAndFlush(m);
|
||||
} else {
|
||||
logger.warn("{}: Querying status failed. Device is not connected.", deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
public void refreshStatus() {
|
||||
MessageWrapper<?> m = new MessageWrapper<>(DP_REFRESH, Map.of("dpId", List.of(4, 5, 6, 18, 19, 20)));
|
||||
Channel channel = this.channel;
|
||||
if (channel != null) {
|
||||
channel.writeAndFlush(m);
|
||||
} else {
|
||||
logger.warn("{}: Refreshing status failed. Device is not connected.", deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
public void dispose() {
|
||||
disconnect();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void operationComplete(@NonNullByDefault({}) ChannelFuture channelFuture) throws Exception {
|
||||
if (channelFuture.isSuccess()) {
|
||||
Channel channel = channelFuture.channel();
|
||||
channel.attr(DEVICE_ID_ATTR).set(deviceId);
|
||||
channel.attr(PROTOCOL_ATTR).set(protocolVersion);
|
||||
// session key is device key before negotiation
|
||||
channel.attr(SESSION_KEY_ATTR).set(deviceKey);
|
||||
|
||||
if (protocolVersion == V3_4) {
|
||||
byte[] sessionRandom = CryptoUtil.generateRandom(16);
|
||||
channel.attr(SESSION_RANDOM_ATTR).set(sessionRandom);
|
||||
this.channel = channel;
|
||||
|
||||
// handshake for session key required
|
||||
MessageWrapper<?> m = new MessageWrapper<>(SESS_KEY_NEG_START, sessionRandom);
|
||||
channel.writeAndFlush(m);
|
||||
} else {
|
||||
this.channel = channel;
|
||||
|
||||
// no handshake for 3.1/3.3
|
||||
requestStatus();
|
||||
}
|
||||
} else {
|
||||
logger.debug("{}{}: Failed to connect: {}", deviceId,
|
||||
Objects.requireNonNullElse(channelFuture.channel().remoteAddress(), ""),
|
||||
channelFuture.cause().getMessage());
|
||||
this.channel = null;
|
||||
deviceStatusListener.connectionStatus(false);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.local;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.tuya.internal.local.dto.DeviceInfo;
|
||||
import org.openhab.binding.tuya.internal.local.handlers.DatagramToByteBufDecoder;
|
||||
import org.openhab.binding.tuya.internal.local.handlers.DiscoveryMessageHandler;
|
||||
import org.openhab.binding.tuya.internal.local.handlers.TuyaDecoder;
|
||||
import org.openhab.binding.tuya.internal.local.handlers.UserEventHandler;
|
||||
import org.openhab.binding.tuya.internal.util.CryptoUtil;
|
||||
import org.openhab.core.util.HexUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
|
||||
import io.netty.bootstrap.Bootstrap;
|
||||
import io.netty.channel.Channel;
|
||||
import io.netty.channel.ChannelFuture;
|
||||
import io.netty.channel.ChannelFutureListener;
|
||||
import io.netty.channel.ChannelInitializer;
|
||||
import io.netty.channel.ChannelOption;
|
||||
import io.netty.channel.ChannelPipeline;
|
||||
import io.netty.channel.EventLoopGroup;
|
||||
import io.netty.channel.socket.DatagramChannel;
|
||||
import io.netty.channel.socket.nio.NioDatagramChannel;
|
||||
|
||||
/**
|
||||
* The {@link UdpDiscoveryListener} handles UDP device discovery message
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class UdpDiscoveryListener implements ChannelFutureListener {
|
||||
private static final byte[] TUYA_UDP_KEY = HexUtils.hexToBytes(CryptoUtil.md5("yGAdlopoPVldABfn"));
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(UdpDiscoveryListener.class);
|
||||
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
private final Map<String, DeviceInfo> deviceInfos = new HashMap<>();
|
||||
private final Map<String, DeviceInfoSubscriber> deviceListeners = new HashMap<>();
|
||||
|
||||
private @NonNullByDefault({}) Channel encryptedChannel;
|
||||
private @NonNullByDefault({}) Channel rawChannel;
|
||||
private final EventLoopGroup group;
|
||||
private boolean deactivate = false;
|
||||
|
||||
public UdpDiscoveryListener(EventLoopGroup group) {
|
||||
this.group = group;
|
||||
activate();
|
||||
}
|
||||
|
||||
private void activate() {
|
||||
try {
|
||||
Bootstrap b = new Bootstrap();
|
||||
b.group(group).channel(NioDatagramChannel.class).option(ChannelOption.SO_BROADCAST, true)
|
||||
.handler(new ChannelInitializer<DatagramChannel>() {
|
||||
@Override
|
||||
protected void initChannel(DatagramChannel ch) throws Exception {
|
||||
ChannelPipeline pipeline = ch.pipeline();
|
||||
pipeline.addLast("udpDecoder", new DatagramToByteBufDecoder());
|
||||
pipeline.addLast("messageDecoder", new TuyaDecoder(gson));
|
||||
pipeline.addLast("discoveryHandler",
|
||||
new DiscoveryMessageHandler(deviceInfos, deviceListeners));
|
||||
pipeline.addLast("userEventHandler", new UserEventHandler());
|
||||
}
|
||||
});
|
||||
|
||||
ChannelFuture futureEncrypted = b.bind(6667).addListener(this).sync();
|
||||
encryptedChannel = futureEncrypted.channel();
|
||||
encryptedChannel.attr(TuyaDevice.DEVICE_ID_ATTR).set("udpListener");
|
||||
encryptedChannel.attr(TuyaDevice.PROTOCOL_ATTR).set(ProtocolVersion.V3_1);
|
||||
encryptedChannel.attr(TuyaDevice.SESSION_KEY_ATTR).set(TUYA_UDP_KEY);
|
||||
|
||||
ChannelFuture futureRaw = b.bind(6666).addListener(this).sync();
|
||||
rawChannel = futureRaw.channel();
|
||||
rawChannel.attr(TuyaDevice.DEVICE_ID_ATTR).set("udpListener");
|
||||
rawChannel.attr(TuyaDevice.PROTOCOL_ATTR).set(ProtocolVersion.V3_1);
|
||||
rawChannel.attr(TuyaDevice.SESSION_KEY_ATTR).set(TUYA_UDP_KEY);
|
||||
|
||||
} catch (InterruptedException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public void deactivate() {
|
||||
deactivate = true;
|
||||
encryptedChannel.pipeline().fireUserEventTriggered(new UserEventHandler.DisposeEvent());
|
||||
rawChannel.pipeline().fireUserEventTriggered(new UserEventHandler.DisposeEvent());
|
||||
try {
|
||||
encryptedChannel.closeFuture().sync();
|
||||
rawChannel.closeFuture().sync();
|
||||
} catch (InterruptedException e) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
public void registerListener(String deviceId, DeviceInfoSubscriber subscriber) {
|
||||
if (deviceListeners.put(deviceId, subscriber) != null) {
|
||||
logger.warn("Registered a second listener for '{}'.", deviceId);
|
||||
}
|
||||
DeviceInfo deviceInfo = deviceInfos.get(deviceId);
|
||||
if (deviceInfo != null) {
|
||||
subscriber.deviceInfoChanged(deviceInfo);
|
||||
}
|
||||
}
|
||||
|
||||
public void unregisterListener(DeviceInfoSubscriber deviceInfoSubscriber) {
|
||||
if (!deviceListeners.entrySet().removeIf(e -> deviceInfoSubscriber.equals(e.getValue()))) {
|
||||
logger.warn("Tried to unregister a listener for '{}' but no registration found.", deviceInfoSubscriber);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void operationComplete(@NonNullByDefault({}) ChannelFuture channelFuture) throws Exception {
|
||||
if (!channelFuture.isSuccess() && !deactivate) {
|
||||
// if we are not disposing, restart listener after an error
|
||||
deactivate();
|
||||
activate();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.local.dto;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* The {@link DeviceInfo} holds information for the device communication
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class DeviceInfo {
|
||||
public final String ip;
|
||||
public final String protocolVersion;
|
||||
|
||||
public DeviceInfo(String ip, String protocolVersion) {
|
||||
this.ip = ip;
|
||||
this.protocolVersion = protocolVersion;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(@Nullable Object o) {
|
||||
if (this == o)
|
||||
return true;
|
||||
if (o == null || getClass() != o.getClass())
|
||||
return false;
|
||||
DeviceInfo that = (DeviceInfo) o;
|
||||
return ip.equals(that.ip) && protocolVersion.equals(that.protocolVersion);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(ip, protocolVersion);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "DeviceInfo{ip='" + ip + "', version='" + protocolVersion + "'}";
|
||||
}
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.local.dto;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* The {@link DiscoveryMessage} represents the UDP discovery messages sent by Tuya devices
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class DiscoveryMessage {
|
||||
public String ip = "";
|
||||
@SerializedName("gwId")
|
||||
public String deviceId = "";
|
||||
public int active = 0;
|
||||
@SerializedName(value = "ability", alternate = { "ablilty" })
|
||||
public int ability = 0;
|
||||
public int mode = 0;
|
||||
public boolean encrypt = true;
|
||||
public String productKey = "";
|
||||
public String version = "";
|
||||
|
||||
public boolean token = true;
|
||||
public boolean wf_cfg = true;
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "DiscoveryMessage{ip='" + ip + "', deviceId='" + deviceId + "', active=" + active + ", ability="
|
||||
+ ability + ", mode=" + mode + ", encrypt=" + encrypt + ", productKey='" + productKey + "', version='"
|
||||
+ version + "', token= " + token + ", wf_cfg=" + wf_cfg + "}";
|
||||
}
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.local.dto;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link IrCode} represents the IR code decoded messages sent by Tuya devices
|
||||
*
|
||||
* @author Dmitry Pyatykh - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class IrCode {
|
||||
public String type = "";
|
||||
public String hex = "";
|
||||
public Integer address = 0;
|
||||
public Integer data = 0;
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.local.dto;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link TcpStatusPayload} encapsulates the payload of a TCP status message
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class TcpStatusPayload {
|
||||
public int protocol = -1;
|
||||
public String devId = "";
|
||||
public String gwId = "";
|
||||
public String uid = "";
|
||||
public long t = 0;
|
||||
public Map<Integer, Object> dps = Map.of();
|
||||
public Data data = new Data();
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "TcpStatusPayload{protocol=" + protocol + ", devId='" + devId + "', gwId='" + gwId + "', uid='" + uid
|
||||
+ "', t=" + t + ", dps=" + dps + ", data=" + data + "}";
|
||||
}
|
||||
|
||||
public static class Data {
|
||||
public Map<Integer, Object> dps = Map.of();
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Data{dps=" + dps + "}";
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.local.handlers;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.channel.socket.DatagramPacket;
|
||||
import io.netty.handler.codec.MessageToMessageDecoder;
|
||||
|
||||
/**
|
||||
* The {@link DatagramToByteBufDecoder} is a Netty Decoder for UDP messages
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class DatagramToByteBufDecoder extends MessageToMessageDecoder<DatagramPacket> {
|
||||
|
||||
@Override
|
||||
protected void decode(@Nullable ChannelHandlerContext ctx, DatagramPacket msg,
|
||||
@NonNullByDefault({}) List<Object> out) throws Exception {
|
||||
out.add(msg.content().copy());
|
||||
}
|
||||
}
|
@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.local.handlers;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.tuya.internal.local.CommandType;
|
||||
import org.openhab.binding.tuya.internal.local.DeviceInfoSubscriber;
|
||||
import org.openhab.binding.tuya.internal.local.MessageWrapper;
|
||||
import org.openhab.binding.tuya.internal.local.dto.DeviceInfo;
|
||||
import org.openhab.binding.tuya.internal.local.dto.DiscoveryMessage;
|
||||
|
||||
import io.netty.channel.ChannelDuplexHandler;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
|
||||
/**
|
||||
* The {@link DiscoveryMessageHandler} is used for handling UDP discovery messages
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class DiscoveryMessageHandler extends ChannelDuplexHandler {
|
||||
private final Map<String, DeviceInfo> deviceInfos;
|
||||
private final Map<String, DeviceInfoSubscriber> deviceListeners;
|
||||
|
||||
public DiscoveryMessageHandler(Map<String, DeviceInfo> deviceInfos,
|
||||
Map<String, DeviceInfoSubscriber> deviceListeners) {
|
||||
this.deviceInfos = deviceInfos;
|
||||
this.deviceListeners = deviceListeners;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelRead(@NonNullByDefault({}) ChannelHandlerContext ctx, @NonNullByDefault({}) Object msg)
|
||||
throws Exception {
|
||||
if (msg instanceof MessageWrapper<?>) {
|
||||
MessageWrapper<?> messageWrapper = (MessageWrapper<?>) msg;
|
||||
if ((messageWrapper.commandType == CommandType.UDP_NEW || messageWrapper.commandType == CommandType.UDP
|
||||
|| messageWrapper.commandType == CommandType.BROADCAST_LPV34)) {
|
||||
DiscoveryMessage discoveryMessage = (DiscoveryMessage) Objects.requireNonNull(messageWrapper.content);
|
||||
DeviceInfo deviceInfo = new DeviceInfo(discoveryMessage.ip, discoveryMessage.version);
|
||||
if (!deviceInfo.equals(deviceInfos.put(discoveryMessage.deviceId, deviceInfo))) {
|
||||
DeviceInfoSubscriber subscriber = deviceListeners.get(discoveryMessage.deviceId);
|
||||
|
||||
if (subscriber != null) {
|
||||
subscriber.deviceInfoChanged(deviceInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.local.handlers;
|
||||
|
||||
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.TCP_CONNECTION_MAXIMUM_MISSED_HEARTBEATS;
|
||||
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.TCP_CONNECTION_TIMEOUT;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.tuya.internal.local.CommandType;
|
||||
import org.openhab.binding.tuya.internal.local.MessageWrapper;
|
||||
import org.openhab.binding.tuya.internal.local.TuyaDevice;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import io.netty.channel.ChannelDuplexHandler;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.handler.timeout.IdleState;
|
||||
import io.netty.handler.timeout.IdleStateEvent;
|
||||
|
||||
/**
|
||||
* The {@link HeartbeatHandler} is responsible for sending and receiving heartbeat messages
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class HeartbeatHandler extends ChannelDuplexHandler {
|
||||
private final Logger logger = LoggerFactory.getLogger(HeartbeatHandler.class);
|
||||
private int heartBeatMissed = 0;
|
||||
|
||||
@Override
|
||||
public void userEventTriggered(@NonNullByDefault({}) ChannelHandlerContext ctx, @NonNullByDefault({}) Object evt)
|
||||
throws Exception {
|
||||
if (!ctx.channel().hasAttr(TuyaDevice.DEVICE_ID_ATTR)) {
|
||||
logger.warn("{}: Failed to retrieve deviceId from ChannelHandlerContext. This is a bug.",
|
||||
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""));
|
||||
return;
|
||||
}
|
||||
String deviceId = ctx.channel().attr(TuyaDevice.DEVICE_ID_ATTR).get();
|
||||
|
||||
if (evt instanceof IdleStateEvent e) {
|
||||
if (IdleState.READER_IDLE.equals(e.state())) {
|
||||
logger.warn("{}{}: Did not receive a message from for {} seconds. Connection seems to be dead.",
|
||||
deviceId, Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""),
|
||||
TCP_CONNECTION_TIMEOUT);
|
||||
ctx.close();
|
||||
} else if (IdleState.WRITER_IDLE.equals(e.state())) {
|
||||
heartBeatMissed++;
|
||||
if (heartBeatMissed > TCP_CONNECTION_MAXIMUM_MISSED_HEARTBEATS) {
|
||||
logger.warn("{}{}: Missed more than {} heartbeat responses. Connection seems to be dead.", deviceId,
|
||||
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""),
|
||||
TCP_CONNECTION_MAXIMUM_MISSED_HEARTBEATS);
|
||||
ctx.close();
|
||||
} else {
|
||||
logger.trace("{}{}: Sending ping", deviceId,
|
||||
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""));
|
||||
ctx.channel().writeAndFlush(new MessageWrapper<>(CommandType.HEART_BEAT, Map.of("dps", "")));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
super.userEventTriggered(ctx, evt);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelRead(@NonNullByDefault({}) ChannelHandlerContext ctx, @NonNullByDefault({}) Object msg)
|
||||
throws Exception {
|
||||
if (!ctx.channel().hasAttr(TuyaDevice.DEVICE_ID_ATTR)) {
|
||||
logger.warn("{}: Failed to retrieve deviceId from ChannelHandlerContext. This is a bug.",
|
||||
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""));
|
||||
return;
|
||||
}
|
||||
String deviceId = ctx.channel().attr(TuyaDevice.DEVICE_ID_ATTR).get();
|
||||
|
||||
if (msg instanceof MessageWrapper<?> m) {
|
||||
if (CommandType.HEART_BEAT.equals(m.commandType)) {
|
||||
logger.trace("{}{}: Received pong", deviceId,
|
||||
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""));
|
||||
heartBeatMissed = 0;
|
||||
// do not forward HEART_BEAT messages
|
||||
ctx.fireChannelReadComplete();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// forward to next handler
|
||||
ctx.fireChannelRead(msg);
|
||||
}
|
||||
}
|
@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.local.handlers;
|
||||
|
||||
import static org.openhab.binding.tuya.internal.local.CommandType.BROADCAST_LPV34;
|
||||
import static org.openhab.binding.tuya.internal.local.CommandType.DP_QUERY;
|
||||
import static org.openhab.binding.tuya.internal.local.CommandType.DP_QUERY_NOT_SUPPORTED;
|
||||
import static org.openhab.binding.tuya.internal.local.CommandType.STATUS;
|
||||
import static org.openhab.binding.tuya.internal.local.CommandType.UDP;
|
||||
import static org.openhab.binding.tuya.internal.local.CommandType.UDP_NEW;
|
||||
import static org.openhab.binding.tuya.internal.local.ProtocolVersion.V3_3;
|
||||
import static org.openhab.binding.tuya.internal.local.ProtocolVersion.V3_4;
|
||||
import static org.openhab.binding.tuya.internal.local.TuyaDevice.*;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.tuya.internal.local.CommandType;
|
||||
import org.openhab.binding.tuya.internal.local.MessageWrapper;
|
||||
import org.openhab.binding.tuya.internal.local.ProtocolVersion;
|
||||
import org.openhab.binding.tuya.internal.local.dto.DiscoveryMessage;
|
||||
import org.openhab.binding.tuya.internal.local.dto.TcpStatusPayload;
|
||||
import org.openhab.binding.tuya.internal.util.CryptoUtil;
|
||||
import org.openhab.core.util.HexUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonSyntaxException;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.handler.codec.ByteToMessageDecoder;
|
||||
|
||||
/**
|
||||
* The {@link TuyaDecoder} is a Netty Decoder for encoding Tuya Local messages
|
||||
*
|
||||
* Parts of this code are inspired by the TuyAPI project (see notice file)
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class TuyaDecoder extends ByteToMessageDecoder {
|
||||
private final Logger logger = LoggerFactory.getLogger(TuyaDecoder.class);
|
||||
|
||||
private final Gson gson;
|
||||
|
||||
public TuyaDecoder(Gson gson) {
|
||||
this.gson = gson;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void decode(@NonNullByDefault({}) ChannelHandlerContext ctx, @NonNullByDefault({}) ByteBuf in,
|
||||
@NonNullByDefault({}) List<Object> out) throws Exception {
|
||||
if (in.readableBytes() < 24) {
|
||||
// minimum packet size is 16 bytes header + 8 bytes suffix
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctx.channel().hasAttr(DEVICE_ID_ATTR) || !ctx.channel().hasAttr(PROTOCOL_ATTR)
|
||||
|| !ctx.channel().hasAttr(SESSION_KEY_ATTR)) {
|
||||
logger.warn(
|
||||
"{}: Failed to retrieve deviceId, protocol or sessionKey from ChannelHandlerContext. This is a bug.",
|
||||
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""));
|
||||
return;
|
||||
}
|
||||
String deviceId = ctx.channel().attr(DEVICE_ID_ATTR).get();
|
||||
ProtocolVersion protocol = ctx.channel().attr(PROTOCOL_ATTR).get();
|
||||
byte[] sessionKey = ctx.channel().attr(SESSION_KEY_ATTR).get();
|
||||
|
||||
// we need to take a copy first so the buffer stays intact if we exit early
|
||||
ByteBuf inCopy = in.copy();
|
||||
byte[] bytes = new byte[inCopy.readableBytes()];
|
||||
inCopy.readBytes(bytes);
|
||||
inCopy.release();
|
||||
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("{}{}: Received encoded '{}'", deviceId,
|
||||
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""), HexUtils.bytesToHex(bytes));
|
||||
}
|
||||
|
||||
ByteBuffer buffer = ByteBuffer.wrap(bytes);
|
||||
int prefix = buffer.getInt();
|
||||
int sequenceNumber = buffer.getInt();
|
||||
CommandType commandType = CommandType.fromCode(buffer.getInt());
|
||||
int payloadLength = buffer.getInt();
|
||||
|
||||
//
|
||||
if (buffer.limit() < payloadLength + 16) {
|
||||
// there are less bytes than needed, exit early
|
||||
logger.trace("Did not receive enough bytes from '{}', exiting early", deviceId);
|
||||
return;
|
||||
} else {
|
||||
// we have enough bytes, skip them from the input buffer and proceed processing
|
||||
in.skipBytes(payloadLength + 16);
|
||||
}
|
||||
|
||||
int returnCode = buffer.getInt();
|
||||
|
||||
byte[] payload;
|
||||
if ((returnCode & 0xffffff00) != 0) {
|
||||
// rewind if no return code is present
|
||||
buffer.position(buffer.position() - 4);
|
||||
payload = protocol == V3_4 ? new byte[payloadLength - 32] : new byte[payloadLength - 8];
|
||||
} else {
|
||||
payload = protocol == V3_4 ? new byte[payloadLength - 32 - 8] : new byte[payloadLength - 8 - 4];
|
||||
}
|
||||
|
||||
buffer.get(payload);
|
||||
|
||||
if (protocol == V3_4 && commandType != UDP && commandType != UDP_NEW) {
|
||||
byte[] fullMessage = new byte[buffer.position()];
|
||||
buffer.position(0);
|
||||
buffer.get(fullMessage);
|
||||
byte[] expectedHmac = new byte[32];
|
||||
buffer.get(expectedHmac);
|
||||
byte[] calculatedHmac = CryptoUtil.hmac(fullMessage, sessionKey);
|
||||
if (!Arrays.equals(expectedHmac, calculatedHmac)) {
|
||||
logger.warn("{}{}: Checksum failed for message: calculated {}, found {}", deviceId,
|
||||
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""),
|
||||
calculatedHmac != null ? HexUtils.bytesToHex(calculatedHmac) : "<null>",
|
||||
HexUtils.bytesToHex(expectedHmac));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
int crc = buffer.getInt();
|
||||
// header + payload without suffix and checksum
|
||||
int calculatedCrc = CryptoUtil.calculateChecksum(bytes, 0, 16 + payloadLength - 8);
|
||||
if (calculatedCrc != crc) {
|
||||
logger.warn("{}{}: Checksum failed for message: calculated {}, found {}", deviceId,
|
||||
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""), calculatedCrc, crc);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
int suffix = buffer.getInt();
|
||||
if (prefix != 0x000055aa || suffix != 0x0000aa55) {
|
||||
logger.warn("{}{}: Decoding failed: Prefix or suffix invalid.", deviceId,
|
||||
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""));
|
||||
return;
|
||||
}
|
||||
|
||||
if (Arrays.equals(Arrays.copyOfRange(payload, 0, protocol.getBytes().length), protocol.getBytes())) {
|
||||
if (protocol == V3_3) {
|
||||
// Remove 3.3 header
|
||||
payload = Arrays.copyOfRange(payload, 15, payload.length);
|
||||
} else {
|
||||
payload = Base64.getDecoder().decode(Arrays.copyOfRange(payload, 19, payload.length));
|
||||
}
|
||||
}
|
||||
|
||||
MessageWrapper<?> m;
|
||||
if (commandType == UDP) {
|
||||
// UDP is unencrypted
|
||||
m = new MessageWrapper<>(commandType,
|
||||
Objects.requireNonNull(gson.fromJson(new String(payload), DiscoveryMessage.class)));
|
||||
} else {
|
||||
byte[] decodedMessage = protocol == V3_4 ? CryptoUtil.decryptAesEcb(payload, sessionKey, true)
|
||||
: CryptoUtil.decryptAesEcb(payload, sessionKey, false);
|
||||
if (decodedMessage == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Arrays.equals(Arrays.copyOfRange(decodedMessage, 0, protocol.getBytes().length), protocol.getBytes())) {
|
||||
if (protocol == V3_4) {
|
||||
// Remove 3.4 header
|
||||
decodedMessage = Arrays.copyOfRange(decodedMessage, 15, decodedMessage.length);
|
||||
}
|
||||
}
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("{}{}: Decoded raw payload: {}", deviceId,
|
||||
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""),
|
||||
HexUtils.bytesToHex(decodedMessage));
|
||||
}
|
||||
|
||||
try {
|
||||
String decodedString = new String(decodedMessage).trim();
|
||||
if (commandType == DP_QUERY && "json obj data unvalid".equals(decodedString)) {
|
||||
// "json obj data unvalid" would also result in a JSONSyntaxException but is a known error when
|
||||
// DP_QUERY is not supported by the device. Using a CONTROL message with null values is a known
|
||||
// workaround, cf. https://github.com/codetheweb/tuyapi/blob/master/index.js#L156
|
||||
logger.info("{}{}: DP_QUERY not supported. Trying to request with CONTROL.", deviceId,
|
||||
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""));
|
||||
m = new MessageWrapper<>(DP_QUERY_NOT_SUPPORTED, Map.of());
|
||||
} else if (commandType == STATUS || commandType == DP_QUERY) {
|
||||
m = new MessageWrapper<>(commandType,
|
||||
Objects.requireNonNull(gson.fromJson(decodedString, TcpStatusPayload.class)));
|
||||
} else if (commandType == UDP_NEW || commandType == BROADCAST_LPV34) {
|
||||
m = new MessageWrapper<>(commandType,
|
||||
Objects.requireNonNull(gson.fromJson(decodedString, DiscoveryMessage.class)));
|
||||
} else {
|
||||
m = new MessageWrapper<>(commandType, decodedMessage);
|
||||
}
|
||||
} catch (JsonSyntaxException e) {
|
||||
logger.warn("{}{} failed to parse JSON: {}", deviceId,
|
||||
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""), e.getMessage());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("{}{}: Received {}", deviceId, Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""), m);
|
||||
out.add(m);
|
||||
}
|
||||
}
|
@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.local.handlers;
|
||||
|
||||
import static org.openhab.binding.tuya.internal.local.CommandType.DP_QUERY;
|
||||
import static org.openhab.binding.tuya.internal.local.CommandType.DP_QUERY_NEW;
|
||||
import static org.openhab.binding.tuya.internal.local.CommandType.DP_REFRESH;
|
||||
import static org.openhab.binding.tuya.internal.local.CommandType.HEART_BEAT;
|
||||
import static org.openhab.binding.tuya.internal.local.CommandType.SESS_KEY_NEG_FINISH;
|
||||
import static org.openhab.binding.tuya.internal.local.CommandType.SESS_KEY_NEG_START;
|
||||
import static org.openhab.binding.tuya.internal.local.ProtocolVersion.V3_3;
|
||||
import static org.openhab.binding.tuya.internal.local.ProtocolVersion.V3_4;
|
||||
import static org.openhab.binding.tuya.internal.local.TuyaDevice.*;
|
||||
import static org.openhab.binding.tuya.internal.local.TuyaDevice.SESSION_KEY_ATTR;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.jose4j.base64url.Base64;
|
||||
import org.openhab.binding.tuya.internal.local.CommandType;
|
||||
import org.openhab.binding.tuya.internal.local.MessageWrapper;
|
||||
import org.openhab.binding.tuya.internal.local.ProtocolVersion;
|
||||
import org.openhab.binding.tuya.internal.util.CryptoUtil;
|
||||
import org.openhab.core.util.HexUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.handler.codec.MessageToByteEncoder;
|
||||
|
||||
/**
|
||||
* The {@link TuyaEncoder} is a Netty Encoder for encoding Tuya Local messages
|
||||
*
|
||||
* Parts of this code are inspired by the TuyAPI project (see notice file)
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class TuyaEncoder extends MessageToByteEncoder<MessageWrapper<?>> {
|
||||
private final Logger logger = LoggerFactory.getLogger(TuyaEncoder.class);
|
||||
|
||||
private final Gson gson;
|
||||
|
||||
private int sequenceNo = 0;
|
||||
|
||||
public TuyaEncoder(Gson gson) {
|
||||
this.gson = gson;
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public void encode(@NonNullByDefault({}) ChannelHandlerContext ctx, MessageWrapper<?> msg,
|
||||
@NonNullByDefault({}) ByteBuf out) throws Exception {
|
||||
if (!ctx.channel().hasAttr(DEVICE_ID_ATTR) || !ctx.channel().hasAttr(PROTOCOL_ATTR)
|
||||
|| !ctx.channel().hasAttr(SESSION_KEY_ATTR)) {
|
||||
logger.warn(
|
||||
"{}: Failed to retrieve deviceId, protocol or sessionKey from ChannelHandlerContext. This is a bug.",
|
||||
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""));
|
||||
return;
|
||||
}
|
||||
String deviceId = ctx.channel().attr(DEVICE_ID_ATTR).get();
|
||||
ProtocolVersion protocol = ctx.channel().attr(PROTOCOL_ATTR).get();
|
||||
byte[] sessionKey = ctx.channel().attr(SESSION_KEY_ATTR).get();
|
||||
|
||||
byte[] payloadBytes;
|
||||
|
||||
// prepare payload
|
||||
if (msg.content == null || msg.content instanceof Map<?, ?>) {
|
||||
Map<String, Object> content = (Map<String, Object>) msg.content;
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
if (protocol == V3_4) {
|
||||
payload.put("protocol", 5);
|
||||
payload.put("t", System.currentTimeMillis() / 1000);
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("cid", deviceId);
|
||||
data.put("ctype", 0);
|
||||
if (content != null) {
|
||||
data.putAll(content);
|
||||
}
|
||||
payload.put("data", data);
|
||||
} else {
|
||||
payload.put("devId", deviceId);
|
||||
payload.put("gwId", deviceId);
|
||||
payload.put("uid", deviceId);
|
||||
payload.put("t", System.currentTimeMillis() / 1000);
|
||||
if (content != null) {
|
||||
payload.putAll(content);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("{}{}: Sending {}, payload {}", deviceId,
|
||||
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""), msg.commandType, payload);
|
||||
|
||||
String json = gson.toJson(payload);
|
||||
payloadBytes = json.getBytes(StandardCharsets.UTF_8);
|
||||
} else if (msg.content instanceof byte[]) {
|
||||
byte[] contentBytes = Objects.requireNonNull((byte[]) msg.content);
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("{}{}: Sending payload {}", deviceId,
|
||||
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""),
|
||||
HexUtils.bytesToHex(contentBytes));
|
||||
}
|
||||
payloadBytes = contentBytes.clone();
|
||||
} else {
|
||||
logger.warn("Can't determine payload type for '{}', discarding.", msg.content);
|
||||
return;
|
||||
}
|
||||
|
||||
Optional<byte[]> bufferOptional = protocol == V3_4 ? encode34(msg.commandType, payloadBytes, sessionKey)
|
||||
: encodePre34(msg.commandType, payloadBytes, sessionKey, protocol);
|
||||
|
||||
bufferOptional.ifPresentOrElse(buffer -> {
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("{}{}: Sending encoded '{}'", deviceId, ctx.channel().remoteAddress(),
|
||||
HexUtils.bytesToHex(buffer));
|
||||
}
|
||||
|
||||
out.writeBytes(buffer);
|
||||
}, () -> logger.debug("{}{}: Encoding returned an empty buffer", deviceId, ctx.channel().remoteAddress()));
|
||||
}
|
||||
|
||||
private Optional<byte[]> encodePre34(CommandType commandType, byte[] payload, byte[] deviceKey,
|
||||
ProtocolVersion protocol) {
|
||||
byte[] payloadBytes = payload;
|
||||
if (protocol == V3_3) {
|
||||
// Always encrypted
|
||||
payloadBytes = CryptoUtil.encryptAesEcb(payloadBytes, deviceKey, true);
|
||||
if (payloadBytes == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
if (commandType != DP_QUERY && commandType != CommandType.DP_REFRESH) {
|
||||
// Add 3.3 header
|
||||
ByteBuffer buffer = ByteBuffer.allocate(payloadBytes.length + 15);
|
||||
buffer.put("3.3".getBytes(StandardCharsets.UTF_8));
|
||||
buffer.position(15);
|
||||
buffer.put(payloadBytes);
|
||||
payloadBytes = buffer.array();
|
||||
}
|
||||
} else if (CommandType.CONTROL.equals(commandType)) {
|
||||
// Protocol 3.1 and below, only encrypt data if necessary
|
||||
byte[] encryptedPayload = CryptoUtil.encryptAesEcb(payloadBytes, deviceKey, true);
|
||||
if (encryptedPayload == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String payloadStr = Base64.encode(encryptedPayload);
|
||||
String hash = CryptoUtil
|
||||
.md5("data=" + payloadStr + "||lpv=" + protocol.getString() + "||" + new String(deviceKey));
|
||||
|
||||
// Create byte buffer from hex data
|
||||
payloadBytes = (protocol + hash.substring(8, 24) + payloadStr).getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
// Allocate buffer with room for payload + 24 bytes for
|
||||
// prefix, sequence, command, length, crc, and suffix
|
||||
ByteBuffer buffer = ByteBuffer.allocate(payloadBytes.length + 24);
|
||||
|
||||
// Add prefix, command, and length
|
||||
buffer.putInt(0x000055AA);
|
||||
buffer.putInt(++sequenceNo);
|
||||
buffer.putInt(commandType.getCode());
|
||||
buffer.putInt(payloadBytes.length + 8);
|
||||
|
||||
// Add payload
|
||||
buffer.put(payloadBytes);
|
||||
|
||||
// Calculate and add checksum
|
||||
int calculatedCrc = CryptoUtil.calculateChecksum(buffer.array(), 0, payloadBytes.length + 16);
|
||||
buffer.putInt(calculatedCrc);
|
||||
|
||||
// Add postfix
|
||||
buffer.putInt(0x0000AA55);
|
||||
|
||||
return Optional.of(buffer.array());
|
||||
}
|
||||
|
||||
private Optional<byte[]> encode34(CommandType commandType, byte[] payloadBytes, byte[] sessionKey) {
|
||||
byte[] rawPayload = payloadBytes;
|
||||
|
||||
if (commandType != DP_QUERY && commandType != HEART_BEAT && commandType != DP_QUERY_NEW
|
||||
&& commandType != SESS_KEY_NEG_START && commandType != SESS_KEY_NEG_FINISH
|
||||
&& commandType != DP_REFRESH) {
|
||||
rawPayload = new byte[payloadBytes.length + 15];
|
||||
System.arraycopy("3.4".getBytes(StandardCharsets.UTF_8), 0, rawPayload, 0, 3);
|
||||
System.arraycopy(payloadBytes, 0, rawPayload, 15, payloadBytes.length);
|
||||
}
|
||||
|
||||
byte padding = (byte) (0x10 - (rawPayload.length & 0xf));
|
||||
byte[] padded = new byte[rawPayload.length + padding];
|
||||
Arrays.fill(padded, padding);
|
||||
System.arraycopy(rawPayload, 0, padded, 0, rawPayload.length);
|
||||
|
||||
byte[] encryptedPayload = CryptoUtil.encryptAesEcb(padded, sessionKey, false);
|
||||
if (encryptedPayload == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
ByteBuffer buffer = ByteBuffer.allocate(encryptedPayload.length + 52);
|
||||
|
||||
// Add prefix, command, and length
|
||||
buffer.putInt(0x000055AA);
|
||||
buffer.putInt(++sequenceNo);
|
||||
buffer.putInt(commandType.getCode());
|
||||
buffer.putInt(encryptedPayload.length + 0x24);
|
||||
|
||||
// Add payload
|
||||
buffer.put(encryptedPayload);
|
||||
|
||||
// Calculate and add checksum
|
||||
byte[] checksumContent = new byte[encryptedPayload.length + 16];
|
||||
System.arraycopy(buffer.array(), 0, checksumContent, 0, encryptedPayload.length + 16);
|
||||
byte[] checksum = CryptoUtil.hmac(checksumContent, sessionKey);
|
||||
if (checksum == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
buffer.put(checksum);
|
||||
|
||||
// Add postfix
|
||||
buffer.putInt(0x0000AA55);
|
||||
|
||||
return Optional.of(buffer.array());
|
||||
}
|
||||
}
|
@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.local.handlers;
|
||||
|
||||
import static org.openhab.binding.tuya.internal.local.TuyaDevice.SESSION_KEY_ATTR;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.tuya.internal.local.CommandType;
|
||||
import org.openhab.binding.tuya.internal.local.DeviceStatusListener;
|
||||
import org.openhab.binding.tuya.internal.local.MessageWrapper;
|
||||
import org.openhab.binding.tuya.internal.local.TuyaDevice;
|
||||
import org.openhab.binding.tuya.internal.local.dto.TcpStatusPayload;
|
||||
import org.openhab.binding.tuya.internal.util.CryptoUtil;
|
||||
import org.openhab.core.util.HexUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import io.netty.channel.ChannelDuplexHandler;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
|
||||
/**
|
||||
* The {@link TuyaMessageHandler} is a Netty channel handler
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class TuyaMessageHandler extends ChannelDuplexHandler {
|
||||
private final Logger logger = LoggerFactory.getLogger(TuyaMessageHandler.class);
|
||||
|
||||
private final DeviceStatusListener deviceStatusListener;
|
||||
|
||||
public TuyaMessageHandler(DeviceStatusListener deviceStatusListener) {
|
||||
this.deviceStatusListener = deviceStatusListener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelActive(@NonNullByDefault({}) ChannelHandlerContext ctx) throws Exception {
|
||||
if (!ctx.channel().hasAttr(TuyaDevice.DEVICE_ID_ATTR) || !ctx.channel().hasAttr(SESSION_KEY_ATTR)) {
|
||||
logger.warn("{}: Failed to retrieve deviceId or sessionKey from ChannelHandlerContext. This is a bug.",
|
||||
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""));
|
||||
return;
|
||||
}
|
||||
String deviceId = ctx.channel().attr(TuyaDevice.DEVICE_ID_ATTR).get();
|
||||
|
||||
logger.debug("{}{}: Connection established.", deviceId,
|
||||
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""));
|
||||
deviceStatusListener.connectionStatus(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelInactive(@NonNullByDefault({}) ChannelHandlerContext ctx) throws Exception {
|
||||
if (!ctx.channel().hasAttr(TuyaDevice.DEVICE_ID_ATTR) || !ctx.channel().hasAttr(SESSION_KEY_ATTR)) {
|
||||
logger.warn("{}: Failed to retrieve deviceId or sessionKey from ChannelHandlerContext. This is a bug.",
|
||||
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""));
|
||||
return;
|
||||
}
|
||||
String deviceId = ctx.channel().attr(TuyaDevice.DEVICE_ID_ATTR).get();
|
||||
|
||||
logger.debug("{}{}: Connection terminated.", deviceId,
|
||||
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""));
|
||||
deviceStatusListener.connectionStatus(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public void channelRead(@NonNullByDefault({}) ChannelHandlerContext ctx, @NonNullByDefault({}) Object msg)
|
||||
throws Exception {
|
||||
if (!ctx.channel().hasAttr(TuyaDevice.DEVICE_ID_ATTR) || !ctx.channel().hasAttr(SESSION_KEY_ATTR)) {
|
||||
logger.warn("{}: Failed to retrieve deviceId or sessionKey from ChannelHandlerContext. This is a bug.",
|
||||
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""));
|
||||
return;
|
||||
}
|
||||
String deviceId = ctx.channel().attr(TuyaDevice.DEVICE_ID_ATTR).get();
|
||||
|
||||
if (msg instanceof MessageWrapper<?> m) {
|
||||
if (m.commandType == CommandType.DP_QUERY || m.commandType == CommandType.STATUS) {
|
||||
Map<Integer, Object> stateMap = null;
|
||||
if (m.content instanceof TcpStatusPayload) {
|
||||
TcpStatusPayload payload = (TcpStatusPayload) Objects.requireNonNull(m.content);
|
||||
stateMap = payload.protocol == 4 ? payload.data.dps : payload.dps;
|
||||
}
|
||||
|
||||
if (stateMap != null && !stateMap.isEmpty()) {
|
||||
deviceStatusListener.processDeviceStatus(stateMap);
|
||||
}
|
||||
} else if (m.commandType == CommandType.DP_QUERY_NOT_SUPPORTED) {
|
||||
deviceStatusListener.processDeviceStatus(Map.of());
|
||||
} else if (m.commandType == CommandType.SESS_KEY_NEG_RESPONSE) {
|
||||
if (!ctx.channel().hasAttr(TuyaDevice.SESSION_KEY_ATTR)
|
||||
|| !ctx.channel().hasAttr(TuyaDevice.SESSION_RANDOM_ATTR)) {
|
||||
logger.warn("{}{}: Session key negotiation failed because device key or session random is not set.",
|
||||
deviceId, Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""));
|
||||
return;
|
||||
}
|
||||
byte[] sessionKey = ctx.channel().attr(TuyaDevice.SESSION_KEY_ATTR).get();
|
||||
byte[] sessionRandom = ctx.channel().attr(TuyaDevice.SESSION_RANDOM_ATTR).get();
|
||||
byte[] localKeyHmac = CryptoUtil.hmac(sessionRandom, sessionKey);
|
||||
byte[] localKeyExpectedHmac = Arrays.copyOfRange((byte[]) m.content, 16, 16 + 32);
|
||||
|
||||
if (!Arrays.equals(localKeyHmac, localKeyExpectedHmac)) {
|
||||
logger.warn(
|
||||
"{}{}: Session key negotiation failed during Hmac validation: calculated {}, expected {}",
|
||||
deviceId, Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""),
|
||||
localKeyHmac != null ? HexUtils.bytesToHex(localKeyHmac) : "<null>",
|
||||
HexUtils.bytesToHex(localKeyExpectedHmac));
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] remoteKey = Arrays.copyOf((byte[]) m.content, 16);
|
||||
byte[] remoteKeyHmac = CryptoUtil.hmac(remoteKey, sessionKey);
|
||||
MessageWrapper<?> response = new MessageWrapper<>(CommandType.SESS_KEY_NEG_FINISH, remoteKeyHmac);
|
||||
|
||||
ctx.channel().writeAndFlush(response);
|
||||
|
||||
byte[] newSessionKey = CryptoUtil.generateSessionKey(sessionRandom, remoteKey, sessionKey);
|
||||
if (newSessionKey == null) {
|
||||
logger.warn("{}{}: Session key negotiation failed because session key is null.", deviceId,
|
||||
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""));
|
||||
return;
|
||||
}
|
||||
ctx.channel().attr(TuyaDevice.SESSION_KEY_ATTR).set(newSessionKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.local.handlers;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.tuya.internal.local.TuyaDevice;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import io.netty.channel.ChannelDuplexHandler;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
|
||||
/**
|
||||
* The {@link UserEventHandler} is a Netty handler for events (used for closing the connection)
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class UserEventHandler extends ChannelDuplexHandler {
|
||||
private final Logger logger = LoggerFactory.getLogger(UserEventHandler.class);
|
||||
|
||||
@Override
|
||||
public void userEventTriggered(@NonNullByDefault({}) ChannelHandlerContext ctx, @NonNullByDefault({}) Object evt) {
|
||||
if (!ctx.channel().hasAttr(TuyaDevice.DEVICE_ID_ATTR)) {
|
||||
logger.warn("Failed to retrieve deviceId from ChannelHandlerContext. This is a bug.");
|
||||
return;
|
||||
}
|
||||
String deviceId = ctx.channel().attr(TuyaDevice.DEVICE_ID_ATTR).get();
|
||||
|
||||
if (evt instanceof DisposeEvent) {
|
||||
logger.debug("{}{}: Received DisposeEvent, closing channel", deviceId,
|
||||
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""));
|
||||
ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void exceptionCaught(@NonNullByDefault({}) ChannelHandlerContext ctx, @NonNullByDefault({}) Throwable cause)
|
||||
throws Exception {
|
||||
if (!ctx.channel().hasAttr(TuyaDevice.DEVICE_ID_ATTR)) {
|
||||
logger.warn("{}: Failed to retrieve deviceId from ChannelHandlerContext. This is a bug.",
|
||||
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""));
|
||||
ctx.close();
|
||||
return;
|
||||
}
|
||||
String deviceId = ctx.channel().attr(TuyaDevice.DEVICE_ID_ATTR).get();
|
||||
|
||||
if (cause instanceof IOException) {
|
||||
logger.debug("{}{}: IOException caught, closing channel.", deviceId,
|
||||
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""), cause);
|
||||
logger.debug("IOException caught: ", cause);
|
||||
} else {
|
||||
logger.warn("{}{}: {} caught, closing the channel", deviceId,
|
||||
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""), cause.getClass(), cause);
|
||||
}
|
||||
ctx.close();
|
||||
}
|
||||
|
||||
public static class DisposeEvent {
|
||||
}
|
||||
}
|
@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.util;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.core.library.types.DecimalType;
|
||||
import org.openhab.core.library.types.HSBType;
|
||||
import org.openhab.core.library.types.PercentType;
|
||||
|
||||
/**
|
||||
* The {@link ConversionUtil} is a set of helper methods to convert data types
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class ConversionUtil {
|
||||
|
||||
private ConversionUtil() {
|
||||
// prevent instantiation
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Tuya color string in hexadecimal notation to {@link HSBType}
|
||||
*
|
||||
* @param hexColor the input string
|
||||
* @return the corresponding state
|
||||
*/
|
||||
public static HSBType hexColorDecode(String hexColor) {
|
||||
if (hexColor.length() == 12) {
|
||||
// 2 bytes H: 0-360, 2 bytes each S,B, 0-1000
|
||||
double h = Integer.parseInt(hexColor.substring(0, 4), 16);
|
||||
double s = Integer.parseInt(hexColor.substring(4, 8), 16) / 10.0;
|
||||
double b = Integer.parseInt(hexColor.substring(8, 12), 16) / 10.0;
|
||||
if (h == 360) {
|
||||
h = 0;
|
||||
}
|
||||
|
||||
return new HSBType(new DecimalType(h), new PercentType(new BigDecimal(s)),
|
||||
new PercentType(new BigDecimal(b)));
|
||||
} else if (hexColor.length() == 14) {
|
||||
// 1 byte each RGB: 0-255, 2 byte H: 0-360, 1 byte each SB: 0-255
|
||||
int r = Integer.parseInt(hexColor.substring(0, 2), 16);
|
||||
int g = Integer.parseInt(hexColor.substring(2, 4), 16);
|
||||
int b = Integer.parseInt(hexColor.substring(4, 6), 16);
|
||||
|
||||
return HSBType.fromRGB(r, g, b);
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unknown color format");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a {@link HSBType} to a Tuya color string in hexadecimal notation
|
||||
*
|
||||
* @param hsb The input state
|
||||
* @return the corresponding hexadecimal String
|
||||
*/
|
||||
public static String hexColorEncode(HSBType hsb, boolean oldColorMode) {
|
||||
if (!oldColorMode) {
|
||||
return String.format("%04x%04x%04x", hsb.getHue().intValue(),
|
||||
(int) (hsb.getSaturation().doubleValue() * 10), (int) (hsb.getBrightness().doubleValue() * 10));
|
||||
} else {
|
||||
return String.format("%02x%02x%02x%04x%02x%02x", (int) (hsb.getRed().doubleValue() * 2.55),
|
||||
(int) (hsb.getGreen().doubleValue() * 2.55), (int) (hsb.getBlue().doubleValue() * 2.55),
|
||||
hsb.getHue().intValue(), (int) (hsb.getSaturation().doubleValue() * 2.55),
|
||||
(int) (hsb.getBrightness().doubleValue() * 2.55));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the brightness value from Tuya to {@link PercentType}
|
||||
*
|
||||
* @param value the input value
|
||||
* @param min the minimum value (usually 0 or 10)
|
||||
* @param max the maximum value (usually 255 or 1000)
|
||||
* @return the corresponding PercentType (PercentType.ZERO if value is <= min)
|
||||
*/
|
||||
public static PercentType brightnessDecode(double value, double min, double max) {
|
||||
if (value <= min) {
|
||||
return PercentType.ZERO;
|
||||
} else if (value >= max) {
|
||||
return PercentType.HUNDRED;
|
||||
} else {
|
||||
return new PercentType(new BigDecimal(100.0 * value / (max - min)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a {@link PercentType} to a Tuya brightness value
|
||||
*
|
||||
* @param value the input value
|
||||
* @param min the minimum value (usually 0 or 10)
|
||||
* @param max the maximum value (usually 255 or 1000)
|
||||
* @return the int closest to the converted value
|
||||
*/
|
||||
public static int brightnessEncode(PercentType value, double min, double max) {
|
||||
return (int) Math.round(value.doubleValue() * (max - min) / 100.0);
|
||||
}
|
||||
}
|
@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.util;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidAlgorithmParameterException;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Random;
|
||||
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.core.util.HexUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link CryptoUtil} is a support class for encrypting/decrypting messages
|
||||
*
|
||||
* Parts of this code are inspired by the TuyAPI project (see notice file)
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class CryptoUtil {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(CryptoUtil.class);
|
||||
|
||||
private static final int[] CRC_32_TABLE = { 0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f,
|
||||
0xe963a535, 0x9e6495a3, 0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988, 0x09b64c2b, 0x7eb17cbd, 0xe7b82d07,
|
||||
0x90bf1d91, 0x1db71064, 0x6ab020f2, 0xf3b97148, 0x84be41de, 0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7,
|
||||
0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec, 0x14015c4f, 0x63066cd9, 0xfa0f3d63, 0x8d080df5, 0x3b6e20c8,
|
||||
0x4c69105e, 0xd56041e4, 0xa2677172, 0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, 0x35b5a8fa, 0x42b2986c,
|
||||
0xdbbbc9d6, 0xacbcf940, 0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59, 0x26d930ac, 0x51de003a, 0xc8d75180,
|
||||
0xbfd06116, 0x21b4f4b5, 0x56b3c423, 0xcfba9599, 0xb8bda50f, 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924,
|
||||
0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d, 0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a, 0x71b18589,
|
||||
0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433, 0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, 0x7f6a0dbb, 0x086d3d2d,
|
||||
0x91646c97, 0xe6635c01, 0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e, 0x6c0695ed, 0x1b01a57b, 0x8208f4c1,
|
||||
0xf50fc457, 0x65b0d9c6, 0x12b7e950, 0x8bbeb8ea, 0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65,
|
||||
0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2, 0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb, 0x4369e96a,
|
||||
0x346ed9fc, 0xad678846, 0xda60b8d0, 0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9, 0x5005713c, 0x270241aa,
|
||||
0xbe0b1010, 0xc90c2086, 0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f, 0x5edef90e, 0x29d9c998, 0xb0d09822,
|
||||
0xc7d7a8b4, 0x59b33d17, 0x2eb40d81, 0xb7bd5c3b, 0xc0ba6cad, 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a,
|
||||
0xead54739, 0x9dd277af, 0x04db2615, 0x73dc1683, 0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8, 0xe40ecf0b,
|
||||
0x9309ff9d, 0x0a00ae27, 0x7d079eb1, 0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe, 0xf762575d, 0x806567cb,
|
||||
0x196c3671, 0x6e6b06e7, 0xfed41b76, 0x89d32be0, 0x10da7a5a, 0x67dd4acc, 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43,
|
||||
0x60b08ed5, 0xd6d6a3e8, 0xa1d1937e, 0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b,
|
||||
0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60, 0xdf60efc3, 0xa867df55, 0x316e8eef, 0x4669be79, 0xcb61b38c,
|
||||
0xbc66831a, 0x256fd2a0, 0x5268e236, 0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f, 0xc5ba3bbe, 0xb2bd0b28,
|
||||
0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7, 0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d, 0x9b64c2b0, 0xec63f226, 0x756aa39c,
|
||||
0x026d930a, 0x9c0906a9, 0xeb0e363f, 0x72076785, 0x05005713, 0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38,
|
||||
0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21, 0x86d3d2d4, 0xf1d4e242, 0x68ddb3f8, 0x1fda836e, 0x81be16cd,
|
||||
0xf6b9265b, 0x6fb077e1, 0x18b74777, 0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, 0x8f659eff, 0xf862ae69,
|
||||
0x616bffd3, 0x166ccf45, 0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2, 0xa7672661, 0xd06016f7, 0x4969474d,
|
||||
0x3e6e77db, 0xaed16a4a, 0xd9d65adc, 0x40df0b66, 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9,
|
||||
0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605, 0xcdd70693, 0x54de5729, 0x23d967bf, 0xb3667a2e,
|
||||
0xc4614ab8, 0x5d681b02, 0x2a6f2b94, 0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d };
|
||||
private static final int GCM_TAG_LENGTH = 16;
|
||||
|
||||
private static final Random SECURE_RNG = new SecureRandom();
|
||||
|
||||
private CryptoUtil() {
|
||||
// prevent instantiation
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a Tuya compatible checksum
|
||||
*
|
||||
* @param bytes an {@link byte[]} containing the input data
|
||||
* @param start the start position of the checksum calculation
|
||||
* @param end the end position of the checksum position
|
||||
* @return the calculated checksum
|
||||
*/
|
||||
public static int calculateChecksum(byte[] bytes, int start, int end) {
|
||||
int crc = 0xffffffff;
|
||||
|
||||
for (int i = start; i < end; i++) {
|
||||
crc = (crc >>> 8) ^ CRC_32_TABLE[(crc ^ bytes[i]) & 0xff];
|
||||
}
|
||||
|
||||
return ~crc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate an SHA-256 hash of the input data
|
||||
*
|
||||
* @param data input data as String
|
||||
* @return the resulting SHA-256 hash as hexadecimal String
|
||||
*/
|
||||
public static String sha256(String data) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
digest.update(data.getBytes(StandardCharsets.UTF_8));
|
||||
return HexUtils.bytesToHex(digest.digest()).toLowerCase();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
LOGGER.warn("Algorithm SHA-256 not found. This should never happen. Check your Java setup.");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate a MD5 hash of the input data
|
||||
*
|
||||
* @param data input data as String
|
||||
* @return the resulting MD5 hash as hexadecimal String
|
||||
*/
|
||||
public static String md5(String data) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("MD5");
|
||||
digest.update(data.getBytes(StandardCharsets.UTF_8));
|
||||
return HexUtils.bytesToHex(digest.digest()).toLowerCase();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
LOGGER.warn("Algorithm MD5 not found. This should never happen. Check your Java setup.");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate an SHA-256 MAC of the input data with a given secret
|
||||
*
|
||||
* @param data input data as String
|
||||
* @param secret the secret to be used
|
||||
* @return the resulting MAC as hexadecimal String
|
||||
*/
|
||||
public static String hmacSha256(String data, String secret) {
|
||||
try {
|
||||
Mac sha256HMAC = Mac.getInstance("HmacSHA256");
|
||||
SecretKeySpec secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
|
||||
sha256HMAC.init(secretKey);
|
||||
|
||||
return HexUtils.bytesToHex(sha256HMAC.doFinal(data.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
|
||||
//
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt an AES-GCM encoded message
|
||||
*
|
||||
* @param msg the message as Base64 encoded string
|
||||
* @param password the password as string
|
||||
* @param t the timestamp of the message (used as AAD)
|
||||
* @return the decrypted message as String (or null if decryption failed)
|
||||
*/
|
||||
public static @Nullable String decryptAesGcm(String msg, String password, long t) {
|
||||
try {
|
||||
byte[] rawBuffer = Base64.getDecoder().decode(msg);
|
||||
// first four bytes are IV length
|
||||
int ivLength = rawBuffer[0] << 24 | (rawBuffer[1] & 0xFF) << 16 | (rawBuffer[2] & 0xFF) << 8
|
||||
| (rawBuffer[3] & 0xFF);
|
||||
// data length is full length without IV length and IV
|
||||
int dataLength = rawBuffer.length - 4 - ivLength;
|
||||
SecretKey secretKey = new SecretKeySpec(password.getBytes(StandardCharsets.UTF_8), 8, 16, "AES");
|
||||
final Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, rawBuffer, 4, ivLength);
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec);
|
||||
cipher.updateAAD(Long.toString(t).getBytes(StandardCharsets.UTF_8));
|
||||
byte[] decoded = cipher.doFinal(rawBuffer, 4 + ivLength, dataLength);
|
||||
return new String(decoded);
|
||||
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException
|
||||
| InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
|
||||
LOGGER.warn("Decryption of MQ failed: {}", e.getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt an AES-ECB encoded message
|
||||
*
|
||||
* @param data the message as array of bytes
|
||||
* @param key the key as array of bytes
|
||||
* @param unpad remove padding (for protocol 3.4)
|
||||
* @return the decrypted message as String (or null if decryption failed)
|
||||
*/
|
||||
public static byte @Nullable [] decryptAesEcb(byte[] data, byte[] key, boolean unpad) {
|
||||
if (data.length == 0) {
|
||||
return data.clone();
|
||||
}
|
||||
try {
|
||||
SecretKey secretKey = new SecretKeySpec(key, "AES");
|
||||
final Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey);
|
||||
byte[] decrypted = cipher.doFinal(data);
|
||||
if (unpad) {
|
||||
int padlength = decrypted[decrypted.length - 1];
|
||||
return Arrays.copyOf(decrypted, decrypted.length - padlength);
|
||||
}
|
||||
return decrypted;
|
||||
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | IllegalBlockSizeException
|
||||
| BadPaddingException e) {
|
||||
LOGGER.warn("Decryption of MQ failed: {}", e.getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt an AES-ECB encoded message
|
||||
*
|
||||
* @param data the message as array of bytes
|
||||
* @param key the key as array of bytes
|
||||
* @return the encrypted message as array of bytes (or null if decryption failed)
|
||||
*/
|
||||
public static byte @Nullable [] encryptAesEcb(byte[] data, byte[] key, boolean padding) {
|
||||
try {
|
||||
SecretKey secretKey = new SecretKeySpec(key, "AES");
|
||||
final Cipher cipher = padding ? Cipher.getInstance("AES/ECB/PKCS5Padding")
|
||||
: Cipher.getInstance("AES/ECB/NoPadding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
|
||||
return cipher.doFinal(data);
|
||||
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | IllegalBlockSizeException
|
||||
| BadPaddingException e) {
|
||||
LOGGER.warn("Encryption of MQ failed: {}", e.getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static byte @Nullable [] hmac(byte[] data, byte[] key) {
|
||||
try {
|
||||
Mac sha256_HMAC = Mac.getInstance("HmacSHA256");
|
||||
SecretKeySpec secret_key = new SecretKeySpec(key, "HmacSHA256");
|
||||
sha256_HMAC.init(secret_key);
|
||||
|
||||
return sha256_HMAC.doFinal(data);
|
||||
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
|
||||
LOGGER.warn("Creating HMAC hash failed: {}", e.getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a {@link byte[]} with the given size
|
||||
*
|
||||
* @param size the size in bytes
|
||||
* @return the array filled with random bytes.
|
||||
*/
|
||||
public static byte[] generateRandom(int size) {
|
||||
byte[] random = new byte[size];
|
||||
SECURE_RNG.nextBytes(random);
|
||||
return random;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a protocol 3.4 session key from local and remote key for a device
|
||||
*
|
||||
* @param localKey the randomly generated local key
|
||||
* @param remoteKey the provided remote key
|
||||
* @param deviceKey the (constant) device key
|
||||
* @return the session key for these keys
|
||||
*/
|
||||
public static byte @Nullable [] generateSessionKey(byte[] localKey, byte[] remoteKey, byte[] deviceKey) {
|
||||
byte[] sessionKey = localKey.clone();
|
||||
for (int i = 0; i < sessionKey.length; i++) {
|
||||
sessionKey[i] = (byte) (sessionKey[i] ^ remoteKey[i]);
|
||||
}
|
||||
|
||||
return CryptoUtil.encryptAesEcb(sessionKey, deviceKey, false);
|
||||
}
|
||||
}
|
@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.util;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link IrUtils} is a support class for decode/encode infra-red codes
|
||||
* <p>
|
||||
* Based on https://github.com/jasonacox/tinytuya/blob/master/tinytuya/Contrib/IRRemoteControlDevice.py
|
||||
*
|
||||
* @author Dmitry Pyatykh - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class IrUtils {
|
||||
private static final Logger logger = LoggerFactory.getLogger(IrUtils.class);
|
||||
|
||||
private IrUtils() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Base64 code format from Tuya to nec-format.
|
||||
*
|
||||
* @param base64Code the base64 code format from Tuya
|
||||
* @return the nec-format code
|
||||
*/
|
||||
public static String base64ToNec(String base64Code) {
|
||||
List<Integer> pulses = base64ToPulse(base64Code);
|
||||
if (!pulses.isEmpty()) {
|
||||
List<String> res = pulsesToNec(pulses);
|
||||
if (!res.isEmpty()) {
|
||||
return res.get(0);
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("No pulses found or conversion result is empty.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Base64 code format from Tuya to samsung-format.
|
||||
*
|
||||
* @param base64Code the base64 code format from Tuya
|
||||
* @return the samsung-format code
|
||||
*/
|
||||
public static String base64ToSamsung(String base64Code) {
|
||||
List<Integer> pulses = base64ToPulse(base64Code);
|
||||
if (!pulses.isEmpty()) {
|
||||
List<String> res = pulsesToSamsung(pulses);
|
||||
if (!res.isEmpty()) {
|
||||
return res.get(0);
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("No pulses found or conversion result is empty.");
|
||||
}
|
||||
|
||||
private static List<Integer> base64ToPulse(String base64Code) {
|
||||
List<Integer> pulses = new ArrayList<>();
|
||||
String key = (base64Code.length() % 4 == 1 && base64Code.startsWith("1")) ? base64Code.substring(1)
|
||||
: base64Code;
|
||||
byte[] raw_bytes = Base64.getDecoder().decode(key.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
int i = 0;
|
||||
try {
|
||||
while (i < raw_bytes.length) {
|
||||
int word = ((raw_bytes[i] & 0xFF) + (raw_bytes[i + 1] & 0xFF) * 256) & 0xFFFF;
|
||||
pulses.add(word);
|
||||
i += 2;
|
||||
|
||||
// dirty hack because key not aligned by 4 byte ?
|
||||
if (i >= raw_bytes.length) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (ArrayIndexOutOfBoundsException e) {
|
||||
logger.warn("Failed to convert base64 key code to pulses: {}", e.getMessage());
|
||||
}
|
||||
return pulses;
|
||||
}
|
||||
|
||||
private static List<Long> pulsesToWidthEncoded(List<Integer> pulses, Integer startMark) {
|
||||
List<Long> ret = new ArrayList<>();
|
||||
if (pulses.size() < 68) {
|
||||
throw new IllegalArgumentException("Not enough pulses");
|
||||
}
|
||||
|
||||
while (pulses.size() >= 68 && (pulses.get(0) < (startMark * 0.75) || pulses.get(0) > (startMark * 1.25))) {
|
||||
pulses.remove(0);
|
||||
}
|
||||
|
||||
while (pulses.size() >= 68) {
|
||||
if (pulses.get(0) < startMark * 0.75 || pulses.get(0) > startMark * 1.25) {
|
||||
throw new IllegalArgumentException(
|
||||
"Pulse length is less than 3/4 startMark or more than 5/4 startMark");
|
||||
}
|
||||
|
||||
// remove two first elements
|
||||
pulses.remove(0);
|
||||
pulses.remove(0);
|
||||
|
||||
int res = 0;
|
||||
long x = 0L;
|
||||
|
||||
for (int i = 31; i >= 0; i--) {
|
||||
res = pulses.get(1) >= (Integer) 1125 ? 1 : 0;
|
||||
|
||||
x |= (long) (res) << i;
|
||||
|
||||
// remove two first elements
|
||||
pulses.remove(0);
|
||||
pulses.remove(0);
|
||||
}
|
||||
|
||||
if (!ret.contains(x)) {
|
||||
ret.add(x);
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
private static List<Long> widthEncodedToPulses(long data, PulseParams param) {
|
||||
List<Long> pulses = new ArrayList<>();
|
||||
pulses.add(param.startMark);
|
||||
pulses.add(param.startSpace);
|
||||
|
||||
for (int i = 31; i >= 0; i--) {
|
||||
if ((data & (1L << i)) > 0L) {
|
||||
pulses.add(param.pulseOne);
|
||||
pulses.add(param.spaceOne);
|
||||
} else {
|
||||
pulses.add(param.pulseZero);
|
||||
pulses.add(param.spaceZero);
|
||||
}
|
||||
}
|
||||
pulses.add(param.trailingPulse);
|
||||
pulses.add(param.trailingSpace);
|
||||
return pulses;
|
||||
}
|
||||
|
||||
private static long mirrorBits(long data) {
|
||||
int shift = 8 - 1;
|
||||
long out = 0;
|
||||
|
||||
for (int i = 0; i < 8; i++) {
|
||||
if ((data & (1L << i)) > 0L) {
|
||||
out |= 1L << shift;
|
||||
}
|
||||
shift -= 1;
|
||||
}
|
||||
return out & 0xFF;
|
||||
}
|
||||
|
||||
private static List<String> pulsesToNec(List<Integer> pulses) {
|
||||
List<String> ret = new ArrayList<>();
|
||||
List<Long> res = pulsesToWidthEncoded(pulses, 9000);
|
||||
if (res.isEmpty()) {
|
||||
throw new IllegalArgumentException("[tuya:ir-controller] No ir key-code detected");
|
||||
}
|
||||
for (Long code : res) {
|
||||
long addr = mirrorBits((code >> 24) & 0xFF);
|
||||
long addrNot = mirrorBits((code >> 16) & 0xFF);
|
||||
long data = mirrorBits((code >> 8) & 0xFF);
|
||||
long dataNot = mirrorBits(code & 0xFF);
|
||||
|
||||
if (addr != (addrNot ^ 0xFF)) {
|
||||
addr = (addr << 8) | addrNot;
|
||||
}
|
||||
String d = String.format(
|
||||
"{ \"type\": \"nec\", \"uint32\": %d, \"address\": None, \"data\": None, \"hex\": \"%08X\" }", code,
|
||||
code);
|
||||
if (data == (dataNot ^ 0xFF)) {
|
||||
d = String.format(
|
||||
"{ \"type\": \"nec\", \"uint32\": %d, \"address\": %d, \"data\": %d, \"hex\": \"%08X\" }", code,
|
||||
addr, data, code);
|
||||
}
|
||||
ret.add(d);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
private static List<Long> necToPulses(long address) {
|
||||
return widthEncodedToPulses(address, new PulseParams());
|
||||
}
|
||||
|
||||
private static String pulsesToBase64(List<Long> pulses) {
|
||||
byte[] bytes = new byte[pulses.size() * 2];
|
||||
|
||||
final Integer[] i = { 0 };
|
||||
|
||||
pulses.forEach(p -> {
|
||||
int val = p.shortValue();
|
||||
bytes[i[0]] = (byte) (val & 0xFF);
|
||||
bytes[i[0] + 1] = (byte) ((val >> 8) & 0xFF);
|
||||
i[0] = i[0] + 2;
|
||||
});
|
||||
|
||||
return new String(Base64.getEncoder().encode(bytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Nec-format code to base64-format code from Tuya
|
||||
*
|
||||
* @param code nec-format code
|
||||
* @return the string
|
||||
*/
|
||||
public static String necToBase64(long code) {
|
||||
List<Long> pulses = necToPulses(code);
|
||||
return pulsesToBase64(pulses);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Samsung-format code to base64-format code from Tuya
|
||||
*
|
||||
* @param code samsung-format code
|
||||
* @return the string
|
||||
*/
|
||||
public static String samsungToBase64(long code) {
|
||||
List<Long> pulses = samsungToPulses(code);
|
||||
return pulsesToBase64(pulses);
|
||||
}
|
||||
|
||||
private static List<Long> samsungToPulses(long address) {
|
||||
return widthEncodedToPulses(address, new PulseParams());
|
||||
}
|
||||
|
||||
private static List<String> pulsesToSamsung(List<Integer> pulses) {
|
||||
List<String> ret = new ArrayList<>();
|
||||
List<Long> res = pulsesToWidthEncoded(pulses, 4500);
|
||||
for (Long code : res) {
|
||||
long addr = (code >> 24) & 0xFF;
|
||||
long addrNot = (code >> 16) & 0xFF;
|
||||
long data = (code >> 8) & 0xFF;
|
||||
long dataNot = code & 0xFF;
|
||||
|
||||
String d = String.format(
|
||||
"{ \"type\": \"samsung\", \"uint32\": %d, \"address\": None, \"data\": None, \"hex\": \"%08X\" }",
|
||||
code, code);
|
||||
if (addr == addrNot && data == (dataNot ^ 0xFF)) {
|
||||
addr = mirrorBits(addr);
|
||||
data = mirrorBits(data);
|
||||
d = String.format(
|
||||
"{ \"type\": \"samsung\", \"uint32\": %d, \"address\": %d, \"data\": %d, \"hex\": \"%08X\" }",
|
||||
code, addr, data, code);
|
||||
}
|
||||
ret.add(d);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
private static class PulseParams {
|
||||
/**
|
||||
* The Start mark.
|
||||
*/
|
||||
public long startMark = 9000;
|
||||
/**
|
||||
* The Start space.
|
||||
*/
|
||||
public long startSpace = 4500;
|
||||
/**
|
||||
* The Pulse one.
|
||||
*/
|
||||
public long pulseOne = 563;
|
||||
/**
|
||||
* The Pulse zero.
|
||||
*/
|
||||
public long pulseZero = 563;
|
||||
/**
|
||||
* The Space one.
|
||||
*/
|
||||
public long spaceOne = 1688;
|
||||
/**
|
||||
* The Space zero.
|
||||
*/
|
||||
public long spaceZero = 563;
|
||||
/**
|
||||
* The Trailing pulse.
|
||||
*/
|
||||
public long trailingPulse = 563;
|
||||
/**
|
||||
* The Trailing space.
|
||||
*/
|
||||
public long trailingSpace = 30000;
|
||||
}
|
||||
}
|
@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.util;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.BinaryOperator;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collector;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link JoiningMapCollector} allows joining all entries of a {@link java.util.stream.Stream<Map.Entry>} with or
|
||||
* without delimiters
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class JoiningMapCollector implements Collector<Map.Entry<String, String>, List<String>, String> {
|
||||
private final String valueDelimiter;
|
||||
private final String entryDelimiter;
|
||||
|
||||
private JoiningMapCollector(String valueDelimiter, String entryDelimiter) {
|
||||
this.valueDelimiter = valueDelimiter;
|
||||
this.entryDelimiter = entryDelimiter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Supplier<List<String>> supplier() {
|
||||
return ArrayList::new;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BiConsumer<List<String>, Map.Entry<String, String>> accumulator() {
|
||||
return (list, entry) -> list.add(entry.getKey() + valueDelimiter + entry.getValue());
|
||||
}
|
||||
|
||||
@Override
|
||||
public BinaryOperator<List<String>> combiner() {
|
||||
return (list1, list2) -> {
|
||||
list1.addAll(list2);
|
||||
return list1;
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public Function<List<String>, String> finisher() {
|
||||
return (list) -> String.join(entryDelimiter, list);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNullByDefault({}) Set<Characteristics> characteristics() {
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a collector for joining all @link Map.Entry} with the given delimiters
|
||||
*
|
||||
* @param valueDelimiter the delimiter used to join key and value of each entry
|
||||
* @param entryDelimiter the delimiter used to join entries
|
||||
* @return the joined {@link java.util.stream.Stream<Map.Entry>} as {@link String}
|
||||
*/
|
||||
public static JoiningMapCollector joining(String valueDelimiter, String entryDelimiter) {
|
||||
return new JoiningMapCollector(valueDelimiter, entryDelimiter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a collector for joining all {@link Map.Entry} without delimiters at all
|
||||
*
|
||||
* @return the joined {@link java.util.stream.Stream<Map.Entry>} as {@link String}
|
||||
*/
|
||||
public static JoiningMapCollector joining() {
|
||||
return new JoiningMapCollector("", "");
|
||||
}
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.util;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.tuya.internal.cloud.dto.DeviceSchema;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
|
||||
/**
|
||||
* The {@link SchemaDp} is a wrapper for the information of a single datapoint
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class SchemaDp {
|
||||
private static final Map<String, String> REMOTE_LOCAL_TYPE_MAP = Map.of( //
|
||||
"Boolean", "bool", //
|
||||
"Enum", "enum", //
|
||||
"Integer", "value", //
|
||||
"Json", "string");
|
||||
|
||||
public int id = 0;
|
||||
public String code = "";
|
||||
public String type = "";
|
||||
public @Nullable Double min;
|
||||
public @Nullable Double max;
|
||||
public @Nullable List<String> range;
|
||||
|
||||
public static SchemaDp fromRemoteSchema(Gson gson, DeviceSchema.Description function) {
|
||||
SchemaDp schemaDp = new SchemaDp();
|
||||
schemaDp.code = function.code.replace("_v2", "");
|
||||
schemaDp.id = function.dp_id;
|
||||
schemaDp.type = REMOTE_LOCAL_TYPE_MAP.getOrDefault(function.type, "raw"); // fallback to raw
|
||||
|
||||
if ("enum".equalsIgnoreCase(schemaDp.type) && function.values.contains("range")) {
|
||||
schemaDp.range = Objects.requireNonNull(
|
||||
gson.fromJson(function.values.replaceAll("\\\\", ""), DeviceSchema.EnumRange.class)).range;
|
||||
} else if ("value".equalsIgnoreCase(schemaDp.type) && function.values.contains("min")
|
||||
&& function.values.contains("max")) {
|
||||
DeviceSchema.NumericRange numericRange = Objects.requireNonNull(
|
||||
gson.fromJson(function.values.replaceAll("\\\\", ""), DeviceSchema.NumericRange.class));
|
||||
schemaDp.min = numericRange.min;
|
||||
schemaDp.max = numericRange.max;
|
||||
}
|
||||
|
||||
return schemaDp;
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<addon:addon id="tuya" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
|
||||
xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
|
||||
|
||||
<type>binding</type>
|
||||
<name>Tuya Binding</name>
|
||||
<description>This is the binding for Tuya.</description>
|
||||
<connection>local</connection>
|
||||
|
||||
</addon:addon>
|
@ -0,0 +1,92 @@
|
||||
# add-on
|
||||
|
||||
addon.tuya.name = Tuya Binding
|
||||
addon.tuya.description = This is the binding for Tuya.
|
||||
|
||||
# thing types
|
||||
|
||||
thing-type.tuya.project.label = Tuya Cloud Project
|
||||
thing-type.tuya.project.description = This thing represents a single cloud project. Needed for discovery.
|
||||
thing-type.tuya.tuyaDevice.label = Generic Tuya Device
|
||||
thing-type.tuya.tuyaDevice.description = A generic Tuya device. Can be extended with channels.
|
||||
|
||||
# thing types config
|
||||
|
||||
thing-type.config.tuya.project.accessId.label = Access-ID
|
||||
thing-type.config.tuya.project.accessId.description = Access ID/Client ID of the Cloud project.
|
||||
thing-type.config.tuya.project.accessSecret.label = Access Secret
|
||||
thing-type.config.tuya.project.accessSecret.description = Access Secret/Client Secret of the Cloud project.
|
||||
thing-type.config.tuya.project.countryCode.label = Country Code
|
||||
thing-type.config.tuya.project.countryCode.description = The (telephone) country code used when registering in the app.
|
||||
thing-type.config.tuya.project.dataCenter.label = Data Center
|
||||
thing-type.config.tuya.project.dataCenter.description = The data center for for your Tuya account
|
||||
thing-type.config.tuya.project.dataCenter.option.https\://openapi.tuyacn.com = China
|
||||
thing-type.config.tuya.project.dataCenter.option.https\://openapi.tuyaus.com = Western America
|
||||
thing-type.config.tuya.project.dataCenter.option.https\://openapi-ueaz.tuyaus.com = Eastern America (Azure/MS)
|
||||
thing-type.config.tuya.project.dataCenter.option.https\://openapi.tuyaeu.com = Central Europe
|
||||
thing-type.config.tuya.project.dataCenter.option.https\://openapi-weaz.tuyaeu.com = Western Europe (Azure/MS)
|
||||
thing-type.config.tuya.project.dataCenter.option.https\://openapi.tuyain.com = India
|
||||
thing-type.config.tuya.project.password.label = Password
|
||||
thing-type.config.tuya.project.password.description = Password in Tuya Smart/Smart Life app.
|
||||
thing-type.config.tuya.project.schema.label = App Type
|
||||
thing-type.config.tuya.project.schema.description = The app type (Tuya Smart or SmartLife).
|
||||
thing-type.config.tuya.project.schema.option.tuyaSmart = Tuya Smart
|
||||
thing-type.config.tuya.project.schema.option.smartLife = Smart Life
|
||||
thing-type.config.tuya.project.username.label = Username
|
||||
thing-type.config.tuya.project.username.description = Username in Tuya Smart/Smart Life app.
|
||||
thing-type.config.tuya.tuyaDevice.deviceId.label = Device ID
|
||||
thing-type.config.tuya.tuyaDevice.ip.label = IP Address
|
||||
thing-type.config.tuya.tuyaDevice.ip.description = Auto-detected if device is on same subnet or broadcast forwarding configured.
|
||||
thing-type.config.tuya.tuyaDevice.localKey.label = Device Local Key
|
||||
thing-type.config.tuya.tuyaDevice.pollingInterval.label = Polling Interval
|
||||
thing-type.config.tuya.tuyaDevice.pollingInterval.option.0 = disabled
|
||||
thing-type.config.tuya.tuyaDevice.productId.label = Product ID
|
||||
thing-type.config.tuya.tuyaDevice.protocol.label = Protocol Version
|
||||
thing-type.config.tuya.tuyaDevice.protocol.option.3.1 = 3.1
|
||||
thing-type.config.tuya.tuyaDevice.protocol.option.3.3 = 3.3
|
||||
thing-type.config.tuya.tuyaDevice.protocol.option.3.4 = 3.4
|
||||
|
||||
# channel types
|
||||
|
||||
channel-type.tuya.color.label = Color
|
||||
channel-type.tuya.dimmer.label = Dimmer
|
||||
channel-type.tuya.ir-code.label = IR Code
|
||||
channel-type.tuya.ir-code.description = Supported codes: tuya base64 codes diy mode, nec-format codes, samsung-format codes
|
||||
channel-type.tuya.number.label = Number
|
||||
channel-type.tuya.string.label = String
|
||||
channel-type.tuya.switch.label = Switch
|
||||
|
||||
# channel types config
|
||||
|
||||
channel-type.config.tuya.color.dp.label = Color DP
|
||||
channel-type.config.tuya.color.dp2.label = Switch DP
|
||||
channel-type.config.tuya.dimmer.dp.label = Value DP
|
||||
channel-type.config.tuya.dimmer.dp2.label = Switch DP
|
||||
channel-type.config.tuya.dimmer.dp2.description = Set only on brightness channels.
|
||||
channel-type.config.tuya.dimmer.max.label = Maximum
|
||||
channel-type.config.tuya.dimmer.min.label = Minimum
|
||||
channel-type.config.tuya.dimmer.reversed.label = Reversed
|
||||
channel-type.config.tuya.dimmer.reversed.description = Changes the direction of the scale (e.g. 0 becomes 100, 100 becomes 0).
|
||||
channel-type.config.tuya.ir-code.activeListen.label = Active Listening
|
||||
channel-type.config.tuya.ir-code.activeListen.description = Device will be always in learning mode. After send command with key code device stays in the learning mode
|
||||
channel-type.config.tuya.ir-code.dp.label = DP Study Key
|
||||
channel-type.config.tuya.ir-code.dp.description = DP number for study key. Uses for receive key code in learning mode
|
||||
channel-type.config.tuya.ir-code.irCode.label = IR Code
|
||||
channel-type.config.tuya.ir-code.irCode.description = Only for Tuya Codes Library: Decoding parameter
|
||||
channel-type.config.tuya.ir-code.irCodeType.label = Type
|
||||
channel-type.config.tuya.ir-code.irCodeType.description = Only for Tuya Codes Library: Code library label
|
||||
channel-type.config.tuya.ir-code.irSendDelay.label = Send delay
|
||||
channel-type.config.tuya.ir-code.irSendDelay.description = Only for Tuya Codes Library: Send delay
|
||||
channel-type.config.tuya.ir-code.irType.label = IR Code format
|
||||
channel-type.config.tuya.ir-code.irType.option.base64 = Tuya DIY-mode
|
||||
channel-type.config.tuya.ir-code.irType.option.tuya-head = Tuya Codes Library (check Advanced options)
|
||||
channel-type.config.tuya.ir-code.irType.option.nec = NEC
|
||||
channel-type.config.tuya.ir-code.irType.option.samsung = Samsung
|
||||
channel-type.config.tuya.number.dp.label = DP
|
||||
channel-type.config.tuya.number.max.label = Maximum
|
||||
channel-type.config.tuya.number.min.label = Minimum
|
||||
channel-type.config.tuya.number.sendAdString.label = Send As String
|
||||
channel-type.config.tuya.number.sendAdString.description = Send the value as string instead of number.
|
||||
channel-type.config.tuya.string.dp.label = DP
|
||||
channel-type.config.tuya.string.range.label = Range
|
||||
channel-type.config.tuya.switch.dp.label = DP
|
@ -0,0 +1,248 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<thing:thing-descriptions bindingId="tuya"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
|
||||
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
|
||||
|
||||
<!-- Cloud project -->
|
||||
<thing-type id="project">
|
||||
<label>Tuya Cloud Project</label>
|
||||
<description>This thing represents a single cloud project. Needed for discovery.</description>
|
||||
|
||||
<config-description>
|
||||
<parameter name="username" type="text" required="true">
|
||||
<label>Username</label>
|
||||
<description>Username in Tuya Smart/Smart Life app.</description>
|
||||
</parameter>
|
||||
<parameter name="password" type="text" required="true">
|
||||
<context>password</context>
|
||||
<label>Password</label>
|
||||
<description>Password in Tuya Smart/Smart Life app.</description>
|
||||
</parameter>
|
||||
<parameter name="accessId" type="text" required="true">
|
||||
<label>Access-ID</label>
|
||||
<description>Access ID/Client ID of the Cloud project.</description>
|
||||
</parameter>
|
||||
<parameter name="accessSecret" type="text" required="true">
|
||||
<context>password</context>
|
||||
<label>Access Secret</label>
|
||||
<description>Access Secret/Client Secret of the Cloud project.</description>
|
||||
</parameter>
|
||||
<parameter name="countryCode" type="integer" required="true">
|
||||
<label>Country Code</label>
|
||||
<description>The (telephone) country code used when registering in the app.</description>
|
||||
</parameter>
|
||||
<parameter name="schema" type="text" required="true">
|
||||
<label>App Type</label>
|
||||
<description>The app type (Tuya Smart or SmartLife).</description>
|
||||
<options>
|
||||
<option value="tuyaSmart">Tuya Smart</option>
|
||||
<option value="smartLife">Smart Life</option>
|
||||
</options>
|
||||
<limitToOptions>true</limitToOptions>
|
||||
</parameter>
|
||||
<parameter name="dataCenter" type="text" required="true">
|
||||
<label>Data Center</label>
|
||||
<description>The data center for for your Tuya account</description>
|
||||
<options>
|
||||
<option value="https://openapi.tuyacn.com">China</option>
|
||||
<option value="https://openapi.tuyaus.com">Western America</option>
|
||||
<option value="https://openapi-ueaz.tuyaus.com">Eastern America (Azure/MS)</option>
|
||||
<option value="https://openapi.tuyaeu.com">Central Europe</option>
|
||||
<option value="https://openapi-weaz.tuyaeu.com">Western Europe (Azure/MS)</option>
|
||||
<option value="https://openapi.tuyain.com">India</option>
|
||||
</options>
|
||||
<limitToOptions>true</limitToOptions>
|
||||
</parameter>
|
||||
</config-description>
|
||||
</thing-type>
|
||||
|
||||
<!-- Generic Tuya device -->
|
||||
<thing-type id="tuyaDevice" extensible="color,switch,dimmer,number,string,ir-code">
|
||||
<label>Generic Tuya Device</label>
|
||||
<description>A generic Tuya device. Can be extended with channels.</description>
|
||||
|
||||
<config-description>
|
||||
<parameter name="deviceId" type="text" required="true">
|
||||
<label>Device ID</label>
|
||||
</parameter>
|
||||
<parameter name="localKey" type="text" required="true">
|
||||
<label>Device Local Key</label>
|
||||
<context>password</context>
|
||||
</parameter>
|
||||
<parameter name="productId" type="text" required="true">
|
||||
<label>Product ID</label>
|
||||
</parameter>
|
||||
<parameter name="ip" type="text">
|
||||
<label>IP Address</label>
|
||||
<description>Auto-detected if device is on same subnet or broadcast forwarding configured.</description>
|
||||
<advanced>true</advanced>
|
||||
</parameter>
|
||||
<parameter name="protocol" type="text">
|
||||
<label>Protocol Version</label>
|
||||
<options>
|
||||
<option value="3.1">3.1</option>
|
||||
<option value="3.3">3.3</option>
|
||||
<option value="3.4">3.4</option>
|
||||
</options>
|
||||
<limitToOptions>true</limitToOptions>
|
||||
<advanced>true</advanced>
|
||||
</parameter>
|
||||
<parameter name="pollingInterval" type="integer" min="10" unit="s">
|
||||
<label>Polling Interval</label>
|
||||
<options>
|
||||
<option value="0">disabled</option>
|
||||
</options>
|
||||
<default>0</default>
|
||||
<limitToOptions>false</limitToOptions>
|
||||
<advanced>true</advanced>
|
||||
</parameter>
|
||||
</config-description>
|
||||
</thing-type>
|
||||
|
||||
<channel-type id="color">
|
||||
<item-type>Color</item-type>
|
||||
<label>Color</label>
|
||||
|
||||
<category>ColorLight</category>
|
||||
|
||||
<config-description>
|
||||
<parameter name="dp" type="integer" required="true">
|
||||
<label>Color DP</label>
|
||||
</parameter>
|
||||
<parameter name="dp2" type="integer">
|
||||
<label>Switch DP</label>
|
||||
</parameter>
|
||||
</config-description>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="switch">
|
||||
<item-type>Switch</item-type>
|
||||
<label>Switch</label>
|
||||
|
||||
<category>Switch</category>
|
||||
|
||||
<config-description>
|
||||
<parameter name="dp" type="integer" required="true">
|
||||
<label>DP</label>
|
||||
</parameter>
|
||||
</config-description>
|
||||
</channel-type>
|
||||
|
||||
|
||||
<channel-type id="dimmer">
|
||||
<item-type>Dimmer</item-type>
|
||||
<label>Dimmer</label>
|
||||
|
||||
<category>Light</category>
|
||||
|
||||
<config-description>
|
||||
<parameter name="dp" type="integer" required="true">
|
||||
<label>Value DP</label>
|
||||
</parameter>
|
||||
<parameter name="dp2" type="integer">
|
||||
<label>Switch DP</label>
|
||||
<description>Set only on brightness channels.</description>
|
||||
</parameter>
|
||||
<parameter name="min" type="integer">
|
||||
<label>Minimum</label>
|
||||
</parameter>
|
||||
<parameter name="max" type="integer">
|
||||
<label>Maximum</label>
|
||||
</parameter>
|
||||
<parameter name="reversed" type="boolean">
|
||||
<label>Reversed</label>
|
||||
<description>Changes the direction of the scale (e.g. 0 becomes 100, 100 becomes 0).</description>
|
||||
<default>false</default>
|
||||
<advanced>true</advanced>
|
||||
</parameter>
|
||||
</config-description>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="number">
|
||||
<item-type>Number</item-type>
|
||||
<label>Number</label>
|
||||
|
||||
<category>Number</category>
|
||||
|
||||
<config-description>
|
||||
<parameter name="dp" type="integer" required="true">
|
||||
<label>DP</label>
|
||||
</parameter>
|
||||
<parameter name="min" type="integer">
|
||||
<label>Minimum</label>
|
||||
</parameter>
|
||||
<parameter name="max" type="integer">
|
||||
<label>Maximum</label>
|
||||
</parameter>
|
||||
<parameter name="sendAdString" type="boolean">
|
||||
<label>Send As String</label>
|
||||
<description>Send the value as string instead of number.</description>
|
||||
<default>false</default>
|
||||
<advanced>true</advanced>
|
||||
</parameter>
|
||||
</config-description>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="string">
|
||||
<item-type>String</item-type>
|
||||
<label>String</label>
|
||||
|
||||
<config-description>
|
||||
<parameter name="dp" type="integer" required="true">
|
||||
<label>DP</label>
|
||||
</parameter>
|
||||
<parameter name="range" type="text">
|
||||
<label>Range</label>
|
||||
</parameter>
|
||||
</config-description>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="ir-code">
|
||||
<item-type>String</item-type>
|
||||
<label>IR Code</label>
|
||||
<description>Supported codes: tuya base64 codes diy mode, nec-format codes, samsung-format codes</description>
|
||||
|
||||
<config-description>
|
||||
<parameter name="irType" type="text" required="true">
|
||||
<label>IR Code format</label>
|
||||
<options>
|
||||
<option value="base64">Tuya DIY-mode</option>
|
||||
<option value="tuya-head">Tuya Codes Library (check Advanced options)</option>
|
||||
<option value="nec">NEC</option>
|
||||
<option value="samsung">Samsung</option>
|
||||
</options>
|
||||
<limitToOptions>true</limitToOptions>
|
||||
</parameter>
|
||||
<parameter name="activeListen" type="boolean" required="false">
|
||||
<label>Active Listening</label>
|
||||
<description>Device will be always in learning mode. After send command with key code device stays in the learning
|
||||
mode</description>
|
||||
</parameter>
|
||||
<parameter name="irCode" type="text" required="false">
|
||||
<label>IR Code</label>
|
||||
<description>Only for Tuya Codes Library: Decoding parameter</description>
|
||||
<advanced>true</advanced>
|
||||
</parameter>
|
||||
<parameter name="irSendDelay" type="integer" required="false">
|
||||
<label>Send delay</label>
|
||||
<description>Only for Tuya Codes Library: Send delay</description>
|
||||
<default>300</default>
|
||||
<advanced>true</advanced>
|
||||
</parameter>
|
||||
<parameter name="irCodeType" type="integer" required="false">
|
||||
<label>Type</label>
|
||||
<description>Only for Tuya Codes Library: Code library label</description>
|
||||
<default>0</default>
|
||||
<advanced>true</advanced>
|
||||
</parameter>
|
||||
<parameter name="dp" type="integer" required="false">
|
||||
<label>DP Study Key</label>
|
||||
<description>DP number for study key. Uses for receive key code in learning mode</description>
|
||||
<default>2</default>
|
||||
<advanced>true</advanced>
|
||||
</parameter>
|
||||
</config-description>
|
||||
</channel-type>
|
||||
|
||||
</thing:thing-descriptions>
|
93484
bundles/org.openhab.binding.tuya/src/main/resources/schema.json
Normal file
93484
bundles/org.openhab.binding.tuya/src/main/resources/schema.json
Normal file
File diff suppressed because it is too large
Load Diff
67
bundles/org.openhab.binding.tuya/src/main/tool/convert.js
Normal file
67
bundles/org.openhab.binding.tuya/src/main/tool/convert.js
Normal file
@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Converts the device-specific data from ioBroker.tuya to a binding compatible JSON
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
const http = require('https');
|
||||
const fs = require('fs');
|
||||
|
||||
const schemaJson = fs.createWriteStream("../../../target/in-schema.json");
|
||||
http.get("https://raw.githubusercontent.com/Apollon77/ioBroker.tuya/master/lib/schema.json", function(response) {
|
||||
response.setEncoding('utf8');
|
||||
response.pipe(schemaJson);
|
||||
schemaJson.on('finish', () => {
|
||||
schemaJson.close();
|
||||
|
||||
const knownSchemas = require('../../../target/in-schema.json');
|
||||
|
||||
let productKey, value;
|
||||
let convertedSchemas = {};
|
||||
|
||||
for (productKey in knownSchemas) {
|
||||
try {
|
||||
let schema = JSON.parse(knownSchemas[productKey].schema);
|
||||
let convertedSchema = {};
|
||||
for (value in schema) {
|
||||
let entry = schema[value];
|
||||
let convertedEntry;
|
||||
if (entry.type === 'raw') {
|
||||
convertedEntry = {id: entry.id, type: entry.type};
|
||||
} else {
|
||||
convertedEntry = {id: entry.id, type: entry.property.type};
|
||||
if (convertedEntry.type === 'enum') {
|
||||
convertedEntry['range'] = entry.property.range;
|
||||
}
|
||||
if (convertedEntry.type === 'value' && entry.property.min !== null && entry.property.max !== null) {
|
||||
convertedEntry['min'] = entry.property.min;
|
||||
convertedEntry['max'] = entry.property.max;
|
||||
}
|
||||
}
|
||||
convertedSchema[entry.code] = convertedEntry;
|
||||
}
|
||||
if (Object.keys(convertedSchema).length > 0) {
|
||||
convertedSchemas[productKey] = convertedSchema;
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Parse Error in Schema for ' + productKey + ': ' + err);
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFile('../resources/schema.json', JSON.stringify(convertedSchemas, null, '\t'), (err) => {
|
||||
if (err) throw err;
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openhab.binding.tuya.internal.util.JoiningMapCollector;
|
||||
|
||||
/**
|
||||
* The {@link JoiningMapCollectorTest} is a test class for the {@link JoiningMapCollector} class
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class JoiningMapCollectorTest {
|
||||
private static final Map<String, String> INPUT = Map.of( //
|
||||
"key1", "value1", //
|
||||
"key3", "value3", //
|
||||
"key2", "value2");
|
||||
|
||||
@Test
|
||||
public void defaultTest() {
|
||||
String result = INPUT.entrySet().stream().sorted(Map.Entry.comparingByKey())
|
||||
.collect(JoiningMapCollector.joining());
|
||||
Assertions.assertEquals("key1value1key2value2key3value3", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void urlTest() {
|
||||
String result = INPUT.entrySet().stream().sorted(Map.Entry.comparingByKey())
|
||||
.collect(JoiningMapCollector.joining("=", "&"));
|
||||
Assertions.assertEquals("key1=value1&key2=value2&key3=value3", result);
|
||||
}
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.cloud;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.openhab.binding.tuya.internal.util.ConversionUtil;
|
||||
import org.openhab.core.library.types.HSBType;
|
||||
import org.openhab.core.test.java.JavaTest;
|
||||
|
||||
/**
|
||||
* The {@link ConversionUtilTest} is a test class for the {@link ConversionUtil} class
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
public class ConversionUtilTest extends JavaTest {
|
||||
|
||||
@Test
|
||||
public void hexColorDecodeTestNew() {
|
||||
String hex = "00b403e803e8";
|
||||
HSBType hsb = ConversionUtil.hexColorDecode(hex);
|
||||
|
||||
Assertions.assertEquals(new HSBType("180,100,100"), hsb);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void hexColorDecodeTestOld() {
|
||||
String hex = "00008000f0ff8b";
|
||||
HSBType hsb = ConversionUtil.hexColorDecode(hex);
|
||||
|
||||
Assertions.assertEquals(new HSBType("240,100,50.196"), hsb);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void hexColorEncodeTestNew() {
|
||||
HSBType hsb = new HSBType("180,100,100");
|
||||
String hex = ConversionUtil.hexColorEncode(hsb, false);
|
||||
|
||||
Assertions.assertEquals("00b403e803e8", hex);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void hexColorEncodeTestOld() {
|
||||
HSBType hsb = new HSBType("240,100,50");
|
||||
String hex = ConversionUtil.hexColorEncode(hsb, true);
|
||||
|
||||
Assertions.assertEquals("00007f00f0fe7f", hex);
|
||||
}
|
||||
}
|
@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.cloud;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.eclipse.jetty.http.HttpMethod;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.openhab.binding.tuya.internal.cloud.dto.DeviceSchema;
|
||||
import org.openhab.binding.tuya.internal.cloud.dto.Token;
|
||||
import org.openhab.binding.tuya.internal.config.ProjectConfiguration;
|
||||
import org.openhab.binding.tuya.internal.util.CryptoUtil;
|
||||
import org.openhab.core.test.java.JavaTest;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
|
||||
/**
|
||||
* The {@link TuyaOpenAPITest} is atest class for the {@link TuyaOpenAPI} class
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
public class TuyaOpenAPITest extends JavaTest {
|
||||
private @Mock @NonNullByDefault({}) HttpClient httpClient;
|
||||
private @Mock @NonNullByDefault({}) ApiStatusCallback callback;
|
||||
private @Mock @NonNullByDefault({}) ScheduledExecutorService scheduler;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
private final String clientId = "1KAD46OrT9HafiKdsXeg";
|
||||
private final String secret = "4OHBOnWOqaEC1mWXOpVL3yV50s0qGSRC";
|
||||
private final String accessToken = "3f4eda2bdec17232f67c0b188af3eec1";
|
||||
private final long now = 1588925778000L;
|
||||
private final String nonce = "5138cc3a9033d69856923fd07b491173";
|
||||
private final Map<String, String> headers = Map.of(//
|
||||
"area_id", "29a33e8796834b1efa6", //
|
||||
"call_id", "8afdb70ab2ed11eb85290242ac130003", //
|
||||
"client_id", clientId);
|
||||
private final List<String> signHeaders = List.of("area_id", "call_id");
|
||||
|
||||
@Test
|
||||
public void signTokenRequest() {
|
||||
String path = "/v1.0/token";
|
||||
Map<String, String> params = Map.of( //
|
||||
"grant_type", "1");
|
||||
|
||||
ProjectConfiguration configuration = new ProjectConfiguration();
|
||||
configuration.accessId = clientId;
|
||||
configuration.accessSecret = secret;
|
||||
TuyaOpenAPI api = new TuyaOpenAPI(callback, scheduler, gson, httpClient);
|
||||
api.setConfiguration(configuration);
|
||||
String signedString = api.signRequest(HttpMethod.GET, path, headers, signHeaders, params, null, nonce, now);
|
||||
|
||||
Assertions.assertEquals("9E48A3E93B302EEECC803C7241985D0A34EB944F40FB573C7B5C2A82158AF13E", signedString);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void signServiceRequest() {
|
||||
String path = "/v2.0/apps/schema/users";
|
||||
Map<String, String> params = Map.of( //
|
||||
"page_no", "1", //
|
||||
"page_size", "50");
|
||||
|
||||
Token token = new Token(accessToken, "", "", 0);
|
||||
|
||||
ProjectConfiguration configuration = new ProjectConfiguration();
|
||||
configuration.accessId = clientId;
|
||||
configuration.accessSecret = secret;
|
||||
TuyaOpenAPI api = new TuyaOpenAPI(callback, scheduler, gson, httpClient);
|
||||
api.setConfiguration(configuration);
|
||||
api.setToken(token);
|
||||
|
||||
String signedString = api.signRequest(HttpMethod.GET, path, headers, signHeaders, params, null, nonce, now);
|
||||
|
||||
Assertions.assertEquals("AE4481C692AA80B25F3A7E12C3A5FD9BBF6251539DD78E565A1A72A508A88784", signedString);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void decryptTest() {
|
||||
String data = "AAAADF3anfyV36xCpZWsTDMtD0q0fsd5VXfX16x7lKc7yA8QFDnGixeCpmfE8OYFDWEx+8+pcn6JrjIXGHMLAXpeHamsUpnms8bBjfBj4KC8N4UUkT2WW15bwpAi1uQiY5j3XCrKb+VnHmG1cXL3yTi02URvwPfCBNoBB1X7ABsHNaPC6zJhYEcTwEc0Rmlk72qr4pEoweQxlZbhGsTb7VQAvPhjUV8Pzycms8kl9pt1fc/rMDc58vDP0ieThScQiYn4+3pbNKq+amzRdKIYmbI8aS9D97QmduRlqimeh6ve1KH9egtEvaigbAtcpHWyw6FB9ApCqoYuGBig8rO8GDlKdA==";
|
||||
String password = "8699163a36d6cecc04df6000b7a580f5";
|
||||
long t = 1636568272;
|
||||
|
||||
String decryptResult = CryptoUtil.decryptAesGcm(data, password, t);
|
||||
Assertions.assertNotNull(decryptResult);
|
||||
|
||||
// data contains 4-byte length, 12 byte IV, 128bits AuthTag
|
||||
Assertions.assertEquals(227, Objects.requireNonNull(decryptResult).length());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void schemaDecodeRange() {
|
||||
String value = "{\\\"range\\\":[\\\"white\\\",\\\"colour\\\",\\\"scene\\\",\\\"music\\\"]}";
|
||||
DeviceSchema.EnumRange range = Objects
|
||||
.requireNonNull(gson.fromJson(value.replaceAll("\\\\", ""), DeviceSchema.EnumRange.class));
|
||||
|
||||
Assertions.assertEquals(4, range.range.size());
|
||||
}
|
||||
}
|
@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.local.handlers;
|
||||
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.hasSize;
|
||||
import static org.hamcrest.Matchers.is;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.openhab.binding.tuya.internal.local.TuyaDevice.*;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.openhab.binding.tuya.internal.local.MessageWrapper;
|
||||
import org.openhab.binding.tuya.internal.local.ProtocolVersion;
|
||||
import org.openhab.core.util.HexUtils;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
|
||||
import io.netty.buffer.Unpooled;
|
||||
import io.netty.channel.Channel;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.util.Attribute;
|
||||
|
||||
/**
|
||||
* The {@link TuyaDecoderTest} is a test class for the {@link TuyaDecoder} class
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@NonNullByDefault
|
||||
public class TuyaDecoderTest {
|
||||
|
||||
private final Gson gson = new Gson();
|
||||
private @Mock @NonNullByDefault({}) ChannelHandlerContext ctxMock;
|
||||
private @Mock @NonNullByDefault({}) Channel channelMock;
|
||||
private @Mock @NonNullByDefault({}) Attribute<String> deviceIdAttrMock;
|
||||
private @Mock @NonNullByDefault({}) Attribute<ProtocolVersion> protocolAttrMock;
|
||||
private @Mock @NonNullByDefault({}) Attribute<byte[]> sessionKeyAttrMock;
|
||||
|
||||
@Test
|
||||
public void decode34Test() throws Exception {
|
||||
when(ctxMock.channel()).thenReturn(channelMock);
|
||||
|
||||
when(channelMock.hasAttr(DEVICE_ID_ATTR)).thenReturn(true);
|
||||
when(channelMock.attr(DEVICE_ID_ATTR)).thenReturn(deviceIdAttrMock);
|
||||
when(deviceIdAttrMock.get()).thenReturn("");
|
||||
|
||||
when(channelMock.hasAttr(PROTOCOL_ATTR)).thenReturn(true);
|
||||
when(channelMock.attr(PROTOCOL_ATTR)).thenReturn(protocolAttrMock);
|
||||
when(protocolAttrMock.get()).thenReturn(ProtocolVersion.V3_4);
|
||||
|
||||
when(channelMock.hasAttr(SESSION_KEY_ATTR)).thenReturn(true);
|
||||
when(channelMock.attr(SESSION_KEY_ATTR)).thenReturn(sessionKeyAttrMock);
|
||||
when(sessionKeyAttrMock.get()).thenReturn("5c8c3ccc1f0fbdbb".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
byte[] packet = HexUtils.hexToBytes(
|
||||
"000055aa0000fc6c0000000400000068000000004b578f442ec0802f26ca6794389ce4ebf57f94561e9367569b0ff90afebe08765460b35678102c0a96b666a6f6a3aabf9328e42ea1f29fd0eca40999ab964927c340dba68f847cb840b473c19572f8de9e222de2d5b1793dc7d4888a8b4f11b00000aa55");
|
||||
byte[] expectedResult = HexUtils.hexToBytes(
|
||||
"3965333963353564643232333163336605ca4f27a567a763d0df1ed6c34fa5bb334a604d900cc86b8085eef6acd0193d");
|
||||
|
||||
List<Object> out = new ArrayList<>();
|
||||
|
||||
TuyaDecoder decoder = new TuyaDecoder(gson);
|
||||
decoder.decode(ctxMock, Unpooled.copiedBuffer(packet), out);
|
||||
|
||||
assertThat(out, hasSize(1));
|
||||
MessageWrapper<?> result = (MessageWrapper<?>) out.get(0);
|
||||
assertThat(result.content, is(expectedResult));
|
||||
}
|
||||
}
|
@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.local.handlers;
|
||||
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.is;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.openhab.binding.tuya.internal.local.TuyaDevice.*;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.openhab.binding.tuya.internal.local.CommandType;
|
||||
import org.openhab.binding.tuya.internal.local.MessageWrapper;
|
||||
import org.openhab.binding.tuya.internal.local.ProtocolVersion;
|
||||
import org.openhab.core.util.HexUtils;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.channel.Channel;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.util.Attribute;
|
||||
|
||||
/**
|
||||
* The {@link TuyaEncoderTest} is a test class for the {@link TuyaEncoder} class
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@NonNullByDefault
|
||||
public class TuyaEncoderTest {
|
||||
|
||||
private final Gson gson = new Gson();
|
||||
private @Mock @NonNullByDefault({}) ChannelHandlerContext ctxMock;
|
||||
private @Mock @NonNullByDefault({}) Channel channelMock;
|
||||
private @Mock @NonNullByDefault({}) Attribute<String> deviceIdAttrMock;
|
||||
private @Mock @NonNullByDefault({}) Attribute<ProtocolVersion> protocolAttrMock;
|
||||
private @Mock @NonNullByDefault({}) Attribute<byte[]> sessionKeyAttrMock;
|
||||
private @Mock @NonNullByDefault({}) ByteBuf out;
|
||||
|
||||
@Test
|
||||
public void testEncoding34() throws Exception {
|
||||
when(ctxMock.channel()).thenReturn(channelMock);
|
||||
|
||||
when(channelMock.hasAttr(DEVICE_ID_ATTR)).thenReturn(true);
|
||||
when(channelMock.attr(DEVICE_ID_ATTR)).thenReturn(deviceIdAttrMock);
|
||||
when(deviceIdAttrMock.get()).thenReturn("");
|
||||
|
||||
when(channelMock.hasAttr(PROTOCOL_ATTR)).thenReturn(true);
|
||||
when(channelMock.attr(PROTOCOL_ATTR)).thenReturn(protocolAttrMock);
|
||||
when(protocolAttrMock.get()).thenReturn(ProtocolVersion.V3_4);
|
||||
|
||||
when(channelMock.hasAttr(SESSION_KEY_ATTR)).thenReturn(true);
|
||||
when(channelMock.attr(SESSION_KEY_ATTR)).thenReturn(sessionKeyAttrMock);
|
||||
when(sessionKeyAttrMock.get()).thenReturn("5c8c3ccc1f0fbdbb".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
byte[] payload = HexUtils.hexToBytes("47f877066f5983df0681e1f08be9f1a1");
|
||||
byte[] expectedResult = HexUtils.hexToBytes(
|
||||
"000055aa000000010000000300000044af06484eb01c2272666a10953aaa23e89328e42ea1f29fd0eca40999ab964927c99646647abb2ab242062a7e911953195ae99b2ee79fa00a95da8cc67e0b42e20000aa55");
|
||||
|
||||
MessageWrapper<?> msg = new MessageWrapper<>(CommandType.SESS_KEY_NEG_START, payload);
|
||||
|
||||
TuyaEncoder encoder = new TuyaEncoder(gson);
|
||||
encoder.encode(ctxMock, msg, out);
|
||||
|
||||
ArgumentCaptor<Object> captor = ArgumentCaptor.forClass(Object.class);
|
||||
|
||||
verify(out).writeBytes((byte[]) captor.capture());
|
||||
byte[] result = (byte[]) captor.getValue();
|
||||
assertThat(result.length, is(expectedResult.length));
|
||||
assertThat(result, is(expectedResult));
|
||||
}
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.tuya.internal.util;
|
||||
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.is;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openhab.core.util.HexUtils;
|
||||
|
||||
/**
|
||||
* The {@link CryptoUtilTest} is a test class for the {@link CryptoUtil} class
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class CryptoUtilTest {
|
||||
|
||||
@Test
|
||||
public void testSessionKeyGeneration() {
|
||||
byte[] deviceKey = "5c8c3ccc1f0fbdbb".getBytes(StandardCharsets.UTF_8);
|
||||
byte[] localKey = HexUtils.hexToBytes("db7b8a7ea8fa28be568531c6e22a2d7e");
|
||||
byte[] remoteKey = HexUtils.hexToBytes("30633665666638323536343733353036");
|
||||
byte[] expectedSessionKey = HexUtils.hexToBytes("afe2349b17e2cc833247ccb1a52e8aae");
|
||||
|
||||
byte[] sessionKey = CryptoUtil.generateSessionKey(localKey, remoteKey, deviceKey);
|
||||
|
||||
assertThat(sessionKey, is(expectedSessionKey));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void hmac() {
|
||||
byte[] deviceKey = "5c8c3ccc1f0fbdbb".getBytes(StandardCharsets.UTF_8);
|
||||
byte[] localKey = HexUtils.hexToBytes("2F4311CF69649F40166D4B98E7F9ABAA");
|
||||
byte[] hmac = CryptoUtil.hmac(localKey, deviceKey);
|
||||
// assertThat(HexUtils.bytesToHex(Objects.requireNonNull(hmac)), is(""));
|
||||
}
|
||||
}
|
@ -426,6 +426,7 @@
|
||||
<module>org.openhab.binding.tplinksmarthome</module>
|
||||
<module>org.openhab.binding.tr064</module>
|
||||
<module>org.openhab.binding.tradfri</module>
|
||||
<module>org.openhab.binding.tuya</module>
|
||||
<module>org.openhab.binding.unifi</module>
|
||||
<module>org.openhab.binding.unifiedremote</module>
|
||||
<module>org.openhab.binding.upnpcontrol</module>
|
||||
|
Loading…
Reference in New Issue
Block a user