[tuya] Initial contribution

This is a port of the SmartHome/J binding by @J-N-K

Changes from that binding:
 * Use BaseDynamicDescriptionProvider from core, rather than
   SmartHome/J's commons.SimpleDynamicDescriptionProvider
 * Generated i18n properties file
 * Minor wording tweak in the README

Co-authored-by: Jan N. Klug <github@klug.nrw>
Co-authored-by: jsetton <jeremy.setton@gmail.com>
Co-authored-by: Jimmy Tanagra <jcode@tanagra.id.au>
Signed-off-by: Cody Cutrer <cody@cutrer.us>
This commit is contained in:
Cody Cutrer 2024-12-20 10:25:48 -07:00
parent 7a3380a020
commit a7b2f7882e
62 changed files with 99147 additions and 0 deletions

View File

@ -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

View File

@ -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>

View File

@ -0,0 +1,26 @@
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

View 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).

View 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>

View File

@ -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>

View File

@ -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();
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.binding.BaseDynamicCommandDescriptionProvider;
/**
* This class provides the list of valid commands for dynamic channels.
*
* @author Cody Cutrer - Initial contribution
*
*/
@NonNullByDefault
class TuyaDynamicCommandDescriptionProvider extends BaseDynamicCommandDescriptionProvider {
}

View File

@ -0,0 +1,104 @@
/**
* 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 StorageService storageService) {
this.httpClient = httpClientFactory.getCommonHttpClient();
this.dynamicCommandDescriptionProvider = new TuyaDynamicCommandDescriptionProvider();
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;
}
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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() + "}";
}
}

View File

@ -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 + "}";
}
}

View File

@ -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;
}
}
}

View File

@ -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 + "'}";
}
}

View File

@ -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));
}
}

View File

@ -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 + "'}";
}
}

View File

@ -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);
}
}

View File

@ -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 + "}";
}
}

View File

@ -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 + "'}";
}
}

View File

@ -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 + "}";
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}
}

View File

@ -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));
}
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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 + "'}";
}
}

View File

@ -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));
}
}

View File

@ -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);
}
}
}

View File

@ -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();
}
}
}

View File

@ -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 + "'}";
}
}

View File

@ -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 + "}";
}
}

View File

@ -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;
}

View File

@ -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 + "}";
}
}
}

View File

@ -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());
}
}

View File

@ -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);
}
}
}
}
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

@ -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);
}
}
}
}

View File

@ -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 {
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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("", "");
}
}

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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

View File

@ -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>

File diff suppressed because it is too large Load Diff

View 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;
});
});
});

View File

@ -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
*
* @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);
}
}

View File

@ -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);
}
}

View File

@ -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 a
*
* @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());
}
}

View File

@ -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
*
* @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));
}
}

View File

@ -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
*
* @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));
}
}

View File

@ -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
*
* @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(""));
}
}

View File

@ -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>