diff --git a/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/TuyaDiscoveryService.java b/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/TuyaDiscoveryService.java
index e7ca5f69e8d..53d93d797de 100644
--- a/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/TuyaDiscoveryService.java
+++ b/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/TuyaDiscoveryService.java
@@ -34,6 +34,7 @@ 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.local.UdpDiscoverySender;
import org.openhab.binding.tuya.internal.util.SchemaDp;
import org.openhab.core.config.discovery.AbstractThingHandlerDiscoveryService;
import org.openhab.core.config.discovery.DiscoveryResult;
@@ -64,6 +65,9 @@ public class TuyaDiscoveryService extends AbstractThingHandlerDiscoveryService
storage;
private @Nullable ScheduledFuture> discoveryJob;
+ private @Nullable ScheduledFuture> broadcastJob;
+
+ private final UdpDiscoverySender udpDiscoverySender = new UdpDiscoverySender();
public TuyaDiscoveryService() {
super(ProjectHandler.class, SUPPORTED_THING_TYPES, SEARCH_TIME);
@@ -136,6 +140,11 @@ public class TuyaDiscoveryService extends AbstractThingHandlerDiscoveryService
broadcastJob = this.broadcastJob;
+ if (broadcastJob != null) {
+ broadcastJob.cancel(true);
+ this.broadcastJob = null;
+ }
removeOlderResults(getTimestampOfLastScan());
super.stopScan();
}
@@ -163,6 +172,12 @@ public class TuyaDiscoveryService extends AbstractThingHandlerDiscoveryService
broadcastJob = this.broadcastJob;
+ if (broadcastJob == null || broadcastJob.isDone() || broadcastJob.isCancelled()) {
+ this.broadcastJob = scheduler.scheduleWithFixedDelay(udpDiscoverySender::sendMessage, 5, 10,
+ TimeUnit.SECONDS);
+ }
}
@Override
@@ -172,5 +187,10 @@ public class TuyaDiscoveryService extends AbstractThingHandlerDiscoveryService
broadcastJob = this.broadcastJob;
+ if (broadcastJob != null) {
+ broadcastJob.cancel(true);
+ this.broadcastJob = null;
+ }
}
}
diff --git a/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/CommandType.java b/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/CommandType.java
index 935489ca3a0..359b06e1669 100644
--- a/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/CommandType.java
+++ b/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/CommandType.java
@@ -44,6 +44,7 @@ public enum CommandType {
UDP_NEW(19),
AP_CONFIG_NEW(20),
BROADCAST_LPV34(35),
+ REQ_DEVINFO(37),
LAN_EXT_STREAM(40),
LAN_GW_ACTIVE(240),
LAN_SUB_DEV_REQUEST(241),
diff --git a/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/ProtocolVersion.java b/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/ProtocolVersion.java
index 6999f114c04..41c2ceeaee3 100644
--- a/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/ProtocolVersion.java
+++ b/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/ProtocolVersion.java
@@ -26,7 +26,8 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
public enum ProtocolVersion {
V3_1("3.1"),
V3_3("3.3"),
- V3_4("3.4");
+ V3_4("3.4"),
+ V3_5("3.5");
private final String versionString;
diff --git a/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/TuyaDevice.java b/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/TuyaDevice.java
index cec54af8996..3633f226a8c 100644
--- a/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/TuyaDevice.java
+++ b/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/TuyaDevice.java
@@ -20,6 +20,7 @@ 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 static org.openhab.binding.tuya.internal.local.ProtocolVersion.V3_5;
import java.util.List;
import java.util.Map;
@@ -112,7 +113,7 @@ public class TuyaDevice implements ChannelFutureListener {
}
public void set(Map command) {
- CommandType commandType = (protocolVersion == V3_4) ? CONTROL_NEW : CONTROL;
+ CommandType commandType = (protocolVersion == V3_4 || protocolVersion == V3_5) ? CONTROL_NEW : CONTROL;
MessageWrapper> m = new MessageWrapper<>(commandType, Map.of("dps", command));
Channel channel = this.channel;
if (channel != null) {
@@ -156,7 +157,7 @@ public class TuyaDevice implements ChannelFutureListener {
// session key is device key before negotiation
channel.attr(SESSION_KEY_ATTR).set(deviceKey);
- if (protocolVersion == V3_4) {
+ if (protocolVersion == V3_4 || protocolVersion == V3_5) {
byte[] sessionRandom = CryptoUtil.generateRandom(16);
channel.attr(SESSION_RANDOM_ATTR).set(sessionRandom);
this.channel = channel;
diff --git a/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/UdpDiscoveryListener.java b/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/UdpDiscoveryListener.java
index 056b4404e71..c6d97aa2a92 100644
--- a/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/UdpDiscoveryListener.java
+++ b/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/UdpDiscoveryListener.java
@@ -56,6 +56,7 @@ public class UdpDiscoveryListener implements ChannelFutureListener {
private final Map deviceListeners = new HashMap<>();
private @NonNullByDefault({}) Channel encryptedChannel;
+ private @NonNullByDefault({}) Channel encryptedChannel35;
private @NonNullByDefault({}) Channel rawChannel;
private final EventLoopGroup group;
private boolean deactivate = false;
@@ -79,6 +80,12 @@ public class UdpDiscoveryListener implements ChannelFutureListener {
}
});
+ ChannelFuture futureEncrypted35 = b.bind(7000).addListener(this).sync();
+ encryptedChannel35 = futureEncrypted35.channel();
+ encryptedChannel35.attr(TuyaDevice.DEVICE_ID_ATTR).set("udpListener");
+ encryptedChannel35.attr(TuyaDevice.PROTOCOL_ATTR).set(ProtocolVersion.V3_5);
+ encryptedChannel35.attr(TuyaDevice.SESSION_KEY_ATTR).set(TUYA_UDP_KEY);
+
ChannelFuture futureEncrypted = b.bind(6667).addListener(this).sync();
encryptedChannel = futureEncrypted.channel();
encryptedChannel.attr(TuyaDevice.DEVICE_ID_ATTR).set("udpListener");
@@ -95,9 +102,11 @@ public class UdpDiscoveryListener implements ChannelFutureListener {
public void deactivate() {
deactivate = true;
encryptedChannel.pipeline().fireUserEventTriggered(new UserEventHandler.DisposeEvent());
+ encryptedChannel35.pipeline().fireUserEventTriggered(new UserEventHandler.DisposeEvent());
rawChannel.pipeline().fireUserEventTriggered(new UserEventHandler.DisposeEvent());
try {
encryptedChannel.closeFuture().sync();
+ encryptedChannel35.closeFuture().sync();
rawChannel.closeFuture().sync();
} catch (InterruptedException e) {
// do nothing
diff --git a/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/UdpDiscoverySender.java b/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/UdpDiscoverySender.java
new file mode 100644
index 00000000000..a05fb5e2305
--- /dev/null
+++ b/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/UdpDiscoverySender.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2010-2025 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.local.CommandType.REQ_DEVINFO;
+
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.tuya.internal.local.handlers.TuyaEncoder;
+import org.openhab.binding.tuya.internal.local.handlers.UdpBroadcastHandler;
+import org.openhab.binding.tuya.internal.util.CryptoUtil;
+import org.openhab.binding.tuya.internal.util.NetworkUtil;
+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.nio.NioEventLoopGroup;
+import io.netty.channel.socket.DatagramChannel;
+import io.netty.channel.socket.nio.NioDatagramChannel;
+
+/**
+ * The {@link UdpDiscoverySender} sends device v3.5 discovery UDP broadcast message
+ *
+ * @author Andriy Yemets - Initial contribution
+ */
+@NonNullByDefault
+public class UdpDiscoverySender {
+ private static final byte[] TUYA_UDP_KEY = HexUtils.hexToBytes(CryptoUtil.md5("yGAdlopoPVldABfn"));
+
+ private final Logger logger = LoggerFactory.getLogger(UdpDiscoverySender.class);
+
+ private final Gson gson = new Gson();
+
+ private final String broadcastAddress = "255.255.255.255";
+ private final int broadcastPort = 7000;
+
+ public UdpDiscoverySender() {
+ //
+ }
+
+ public void sendMessage() {
+ EventLoopGroup group = new NioEventLoopGroup(1);
+ try {
+ Bootstrap b = new Bootstrap();
+ b.group(group).channel(NioDatagramChannel.class).option(ChannelOption.SO_BROADCAST, true)
+ .handler(new ChannelInitializer() {
+ @Override
+ protected void initChannel(DatagramChannel ch) throws Exception {
+ ChannelPipeline pipeline = ch.pipeline();
+ pipeline.addLast("broadcastHandler",
+ new UdpBroadcastHandler(broadcastAddress, broadcastPort));
+ pipeline.addLast("messageEncoder", new TuyaEncoder(gson));
+ }
+ });
+
+ ChannelFuture futureChannel = b.bind(0).sync();
+ Channel broadcastChannel = futureChannel.channel();
+ broadcastChannel.attr(TuyaDevice.DEVICE_ID_ATTR).set("udpDiscoverySender");
+ broadcastChannel.attr(TuyaDevice.PROTOCOL_ATTR).set(ProtocolVersion.V3_5);
+ broadcastChannel.attr(TuyaDevice.SESSION_KEY_ATTR).set(TUYA_UDP_KEY);
+
+ MessageWrapper> m = new MessageWrapper<>(REQ_DEVINFO,
+ Map.of("from", "app", "ip", NetworkUtil.getLocalIPAddress()));
+ broadcastChannel.writeAndFlush(m).addListener(ChannelFutureListener.CLOSE);
+ } catch (Exception e) {
+ logger.error("Error during sending UDP Discovery message. {}", e.getMessage());
+ } finally {
+ group.shutdownGracefully();
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/handlers/TuyaDecoder.java b/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/handlers/TuyaDecoder.java
index 3482faaf382..03234cac184 100644
--- a/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/handlers/TuyaDecoder.java
+++ b/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/handlers/TuyaDecoder.java
@@ -20,6 +20,7 @@ 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.ProtocolVersion.V3_5;
import static org.openhab.binding.tuya.internal.local.TuyaDevice.*;
import java.nio.ByteBuffer;
@@ -92,33 +93,59 @@ public class TuyaDecoder extends ByteToMessageDecoder {
if (logger.isTraceEnabled()) {
logger.trace("{}{}: Received encoded '{}'", deviceId,
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""), HexUtils.bytesToHex(bytes));
+ logger.trace("{}{}: Protocol version '{}'", deviceId,
+ Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""), protocol.getString());
}
ByteBuffer buffer = ByteBuffer.wrap(bytes);
int prefix = buffer.getInt();
+
+ if (prefix == 0x006699 && protocol != V3_5) {
+ protocol = V3_5;
+ logger.debug("Set protocol version to {}", protocol.getString());
+ }
+
+ int headerLength = protocol == V3_5 ? 22 : 16;
+
+ if (protocol == V3_5) {
+ // skip 2 unknown bytes in header
+ buffer.position(buffer.position() + 2);
+ }
+
// this method call is necessary to correctly move the pointer within the buffer.
buffer.getInt();
CommandType commandType = CommandType.fromCode(buffer.getInt());
int payloadLength = buffer.getInt();
- if (buffer.limit() < payloadLength + 16) {
+ byte[] header = new byte[14];
+ if (protocol == V3_5) {
+ // get header for GCM AAD
+ System.arraycopy(buffer.array(), 4, header, 0, 14);
+ }
+
+ if (buffer.limit() < payloadLength + headerLength) {
// 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);
+ in.skipBytes(payloadLength + headerLength);
}
- 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];
+
+ if (protocol == V3_5) {
+ payload = new byte[payloadLength];
} else {
- payload = protocol == V3_4 ? new byte[payloadLength - 32 - 8] : new byte[payloadLength - 8 - 4];
+ int returnCode = buffer.getInt();
+
+ 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);
@@ -137,7 +164,7 @@ public class TuyaDecoder extends ByteToMessageDecoder {
HexUtils.bytesToHex(expectedHmac));
return;
}
- } else {
+ } else if (protocol != V3_5) {
int crc = buffer.getInt();
// header + payload without suffix and checksum
int calculatedCrc = CryptoUtil.calculateChecksum(bytes, 0, 16 + payloadLength - 8);
@@ -149,7 +176,7 @@ public class TuyaDecoder extends ByteToMessageDecoder {
}
int suffix = buffer.getInt();
- if (prefix != 0x000055aa || suffix != 0x0000aa55) {
+ if ((prefix != 0x000055aa || suffix != 0x0000aa55) && (prefix != 0x00006699 || suffix != 0x00009966)) {
logger.warn("{}{}: Decoding failed: Prefix or suffix invalid.", deviceId,
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""));
return;
@@ -170,18 +197,27 @@ public class TuyaDecoder extends ByteToMessageDecoder {
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);
+ byte[] decodedMessage = switch (protocol) {
+ case V3_5 -> CryptoUtil.decryptAesGcm(payload, sessionKey, header, null);
+ case V3_4 -> CryptoUtil.decryptAesEcb(payload, sessionKey, true);
+ default -> CryptoUtil.decryptAesEcb(payload, sessionKey, false);
+ };
if (decodedMessage == null) {
return;
}
+ if (protocol == V3_5) {
+ // Remove return code
+ decodedMessage = Arrays.copyOfRange(decodedMessage, 4, decodedMessage.length);
+ }
+
if (Arrays.equals(Arrays.copyOfRange(decodedMessage, 0, protocol.getBytes().length), protocol.getBytes())) {
- if (protocol == V3_4) {
- // Remove 3.4 header
+ if (protocol == V3_4 || protocol == V3_5) {
+ // Remove 3.4 or 3.5 header
decodedMessage = Arrays.copyOfRange(decodedMessage, 15, decodedMessage.length);
}
}
+
if (logger.isTraceEnabled()) {
logger.trace("{}{}: Decoded raw payload: {}", deviceId,
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""),
diff --git a/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/handlers/TuyaEncoder.java b/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/handlers/TuyaEncoder.java
index 7c87ee43539..89dcdb64069 100644
--- a/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/handlers/TuyaEncoder.java
+++ b/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/handlers/TuyaEncoder.java
@@ -16,10 +16,12 @@ 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.REQ_DEVINFO;
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.ProtocolVersion.V3_5;
import static org.openhab.binding.tuya.internal.local.TuyaDevice.*;
import java.nio.ByteBuffer;
@@ -86,7 +88,11 @@ public class TuyaEncoder extends MessageToByteEncoder> {
if (msg.content == null || msg.content instanceof Map, ?>) {
Map content = (Map) msg.content;
Map payload = new HashMap<>();
- if (protocol == V3_4) {
+ if (msg.commandType == REQ_DEVINFO) {
+ if (content != null) {
+ payload.putAll(content);
+ }
+ } else if (protocol == V3_4 || protocol == V3_5) {
payload.put("protocol", 5);
payload.put("t", System.currentTimeMillis() / 1000);
Map data = new HashMap<>();
@@ -123,8 +129,11 @@ public class TuyaEncoder extends MessageToByteEncoder> {
return;
}
- Optional bufferOptional = protocol == V3_4 ? encode34(msg.commandType, payloadBytes, sessionKey)
- : encodePre34(msg.commandType, payloadBytes, sessionKey, protocol);
+ Optional bufferOptional = switch (protocol) {
+ case V3_5 -> encode35(msg.commandType, payloadBytes, sessionKey);
+ case V3_4 -> encode34(msg.commandType, payloadBytes, sessionKey);
+ default -> encodePre34(msg.commandType, payloadBytes, sessionKey, protocol);
+ };
bufferOptional.ifPresentOrElse(buffer -> {
if (logger.isTraceEnabled()) {
@@ -237,4 +246,44 @@ public class TuyaEncoder extends MessageToByteEncoder> {
return Optional.of(buffer.array());
}
+
+ private Optional encode35(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
+ && commandType != REQ_DEVINFO) {
+ rawPayload = new byte[payloadBytes.length + 15];
+ System.arraycopy("3.5".getBytes(StandardCharsets.UTF_8), 0, rawPayload, 0, 3);
+ System.arraycopy(payloadBytes, 0, rawPayload, 15, payloadBytes.length);
+ }
+
+ ByteBuffer buffer = ByteBuffer.allocate(rawPayload.length + 22 + 12 + 16);
+
+ // Add prefix
+ buffer.putInt(0x00006699);
+ // Add unknown 2 bytes
+ buffer.putShort((short) 0x0000);
+ // Add sequence number and command
+ buffer.putInt(++sequenceNo);
+ buffer.putInt(commandType.getCode());
+ // Add length: 12 byte IV/nonce + payload length + 16 byte GCM Tag
+ buffer.putInt(rawPayload.length + 12 + 16);
+ // Get header data for GCM AAD
+ byte[] header = new byte[14];
+ System.arraycopy(buffer.array(), 4, header, 0, 14);
+
+ byte[] encryptedPayload = CryptoUtil.encryptAesGcm(rawPayload, sessionKey, header, null);
+ if (encryptedPayload == null) {
+ return Optional.empty();
+ }
+
+ // Add encrypted payload
+ buffer.put(encryptedPayload);
+
+ // Add postfix
+ buffer.putInt(0x00009966);
+
+ return Optional.of(buffer.array());
+ }
}
diff --git a/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/handlers/TuyaMessageHandler.java b/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/handlers/TuyaMessageHandler.java
index 03d4c0d3ed5..767cfda6ac1 100644
--- a/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/handlers/TuyaMessageHandler.java
+++ b/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/handlers/TuyaMessageHandler.java
@@ -12,6 +12,7 @@
*/
package org.openhab.binding.tuya.internal.local.handlers;
+import static org.openhab.binding.tuya.internal.local.TuyaDevice.PROTOCOL_ATTR;
import static org.openhab.binding.tuya.internal.local.TuyaDevice.SESSION_KEY_ATTR;
import java.util.Arrays;
@@ -22,6 +23,7 @@ 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.ProtocolVersion;
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;
@@ -79,12 +81,15 @@ public class TuyaMessageHandler extends ChannelDuplexHandler {
@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.",
+ if (!ctx.channel().hasAttr(TuyaDevice.DEVICE_ID_ATTR) || !ctx.channel().hasAttr(SESSION_KEY_ATTR)
+ || !ctx.channel().hasAttr(PROTOCOL_ATTR)) {
+ logger.warn(
+ "{}: Failed to retrieve deviceId, sessionKey or protocol from ChannelHandlerContext. This is a bug.",
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""));
return;
}
String deviceId = ctx.channel().attr(TuyaDevice.DEVICE_ID_ATTR).get();
+ ProtocolVersion protocol = ctx.channel().attr(TuyaDevice.PROTOCOL_ATTR).get();
if (msg instanceof MessageWrapper> m) {
if (m.commandType == CommandType.DP_QUERY || m.commandType == CommandType.STATUS) {
@@ -125,7 +130,7 @@ public class TuyaMessageHandler extends ChannelDuplexHandler {
ctx.channel().writeAndFlush(response);
- byte[] newSessionKey = CryptoUtil.generateSessionKey(sessionRandom, remoteKey, sessionKey);
+ byte[] newSessionKey = CryptoUtil.generateSessionKey(sessionRandom, remoteKey, sessionKey, protocol);
if (newSessionKey == null) {
logger.warn("{}{}: Session key negotiation failed because session key is null.", deviceId,
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""));
diff --git a/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/handlers/UdpBroadcastHandler.java b/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/handlers/UdpBroadcastHandler.java
new file mode 100644
index 00000000000..96a1748dcda
--- /dev/null
+++ b/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/local/handlers/UdpBroadcastHandler.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2010-2025 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.net.InetSocketAddress;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import io.netty.buffer.ByteBuf;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelOutboundHandlerAdapter;
+import io.netty.channel.ChannelPromise;
+import io.netty.channel.socket.DatagramPacket;
+
+/**
+ * The {@link UdpBroadcastHandler} is a Netty handler for create UDP broadcast message
+ *
+ * @author Andriy Yemets - Initial contribution
+ */
+@NonNullByDefault
+public class UdpBroadcastHandler extends ChannelOutboundHandlerAdapter {
+
+ private final String broadcastAddress;
+ private final int broadcastPort;
+
+ public UdpBroadcastHandler(String broadcastAddress, int broadcastPort) {
+ this.broadcastAddress = broadcastAddress;
+ this.broadcastPort = broadcastPort;
+ }
+
+ @Override
+ public void write(@NonNullByDefault({}) ChannelHandlerContext ctx, @NonNullByDefault({}) Object msg,
+ @NonNullByDefault({}) ChannelPromise promise) throws Exception {
+ if (msg instanceof ByteBuf) {
+ ByteBuf buf = (ByteBuf) msg;
+ DatagramPacket packet = new DatagramPacket(buf, new InetSocketAddress(broadcastAddress, broadcastPort));
+ ctx.write(packet, promise);
+ } else {
+ super.write(ctx, msg, promise);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/util/CryptoUtil.java b/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/util/CryptoUtil.java
index 8419c79e47d..89d41f01ebe 100644
--- a/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/util/CryptoUtil.java
+++ b/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/util/CryptoUtil.java
@@ -20,6 +20,7 @@ import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
+import java.util.Objects;
import java.util.Random;
import javax.crypto.BadPaddingException;
@@ -33,6 +34,7 @@ import javax.crypto.spec.SecretKeySpec;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.tuya.internal.local.ProtocolVersion;
import org.openhab.core.util.HexUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -78,6 +80,8 @@ public class CryptoUtil {
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 int GCM_IV_LENGTH = 12;
+ private static final int SESSION_KEY_LENGTH = 16;
private static final Random SECURE_RNG = new SecureRandom();
@@ -188,6 +192,35 @@ public class CryptoUtil {
return null;
}
+ /**
+ * Decrypt an AES-GCM encoded message
+ *
+ * @param data the message as array of bytes
+ * @param key the key as array of bytes
+ * @param headerData optional, the header data as array of bytes (used as AAD)
+ * @param nonce optional, the IV/nonce as array of bytes (12 bytes)
+ * @return the decrypted message as String (or null if decryption failed)
+ */
+ public static byte @Nullable [] decryptAesGcm(byte[] data, byte[] key, byte @Nullable [] headerData,
+ byte @Nullable [] nonce) {
+ try {
+ byte[] iv = new byte[GCM_IV_LENGTH];
+ System.arraycopy(Objects.requireNonNullElse(nonce, data), 0, iv, 0, GCM_IV_LENGTH);
+ SecretKey secretKey = new SecretKeySpec(key, "AES");
+ final Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ GCMParameterSpec gcmIv = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv);
+ cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmIv);
+ if (headerData != null) {
+ cipher.updateAAD(headerData);
+ }
+ return cipher.doFinal(data, GCM_IV_LENGTH, data.length - GCM_IV_LENGTH);
+ } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException
+ | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
+ LOGGER.warn("Decryption of MQ failed: {}", e.getMessage());
+ }
+ return null;
+ }
+
/**
* Decrypt an AES-ECB encoded message
*
@@ -218,6 +251,44 @@ public class CryptoUtil {
return null;
}
+ /**
+ * Encrypt an AES-GCM encoded message
+ *
+ * @param data the message as array of bytes
+ * @param key the key as array of bytes
+ * @param headerData optional, the header data as array of bytes (used as AAD)
+ * @param nonce optional, the IV/nonce as array of bytes (12 bytes)
+ * @return the encrypted message as array of bytes (or null if encryption failed)
+ */
+ public static byte @Nullable [] encryptAesGcm(byte[] data, byte[] key, byte @Nullable [] headerData,
+ byte @Nullable [] nonce) {
+ try {
+ byte[] iv = new byte[GCM_IV_LENGTH];
+ if (nonce != null) {
+ System.arraycopy(nonce, 0, iv, 0, GCM_IV_LENGTH);
+ } else {
+ SECURE_RNG.nextBytes(iv);
+ }
+ SecretKey secretKey = new SecretKeySpec(key, "AES");
+ final Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv);
+ cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec);
+ if (headerData != null) {
+ cipher.updateAAD(headerData);
+ }
+ byte[] encryptedBytes = cipher.doFinal(data);
+ byte[] result = new byte[GCM_IV_LENGTH + encryptedBytes.length];
+ System.arraycopy(iv, 0, result, 0, GCM_IV_LENGTH);
+ System.arraycopy(encryptedBytes, 0, result, GCM_IV_LENGTH, encryptedBytes.length);
+ return result;
+ } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | IllegalBlockSizeException
+ | BadPaddingException | InvalidAlgorithmParameterException e) {
+ LOGGER.warn("Encryption of MQ failed: {}", e.getMessage());
+ }
+
+ return null;
+ }
+
/**
* Encrypt an AES-ECB encoded message
*
@@ -267,19 +338,31 @@ public class CryptoUtil {
}
/**
- * Generate a protocol 3.4 session key from local and remote key for a device
+ * Generate a protocol 3.4 and 3.5 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
+ * @param protocol the protocol version
+ * @return the session key for these keys and protocol
*/
- public static byte @Nullable [] generateSessionKey(byte[] localKey, byte[] remoteKey, byte[] deviceKey) {
+ public static byte @Nullable [] generateSessionKey(byte[] localKey, byte[] remoteKey, byte[] deviceKey,
+ ProtocolVersion protocol) {
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);
+ byte[] result = new byte[SESSION_KEY_LENGTH];
+ if (protocol == ProtocolVersion.V3_4) {
+ result = CryptoUtil.encryptAesEcb(sessionKey, deviceKey, false);
+ } else if (protocol == ProtocolVersion.V3_5) {
+ byte[] nonce = new byte[GCM_IV_LENGTH];
+ System.arraycopy(localKey, 0, nonce, 0, GCM_IV_LENGTH);
+ byte[] encrypted = CryptoUtil.encryptAesGcm(sessionKey, deviceKey, null, nonce);
+ if (encrypted != null) {
+ System.arraycopy(encrypted, GCM_IV_LENGTH, result, 0, SESSION_KEY_LENGTH);
+ }
+ }
+ return result;
}
}
diff --git a/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/util/NetworkUtil.java b/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/util/NetworkUtil.java
new file mode 100644
index 00000000000..3cd9b749153
--- /dev/null
+++ b/bundles/org.openhab.binding.tuya/src/main/java/org/openhab/binding/tuya/internal/util/NetworkUtil.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright (c) 2010-2025 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.net.Inet4Address;
+import java.net.InetAddress;
+import java.net.NetworkInterface;
+import java.util.Enumeration;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link NetworkUtil} is a support class for retrieving network related information.
+ *
+ * Parts of this code are inspired by the TuyAPI project (see notice file)
+ *
+ * @author Andriy Yemets - Initial contribution
+ */
+@NonNullByDefault
+public class NetworkUtil {
+ private static final Logger logger = LoggerFactory.getLogger(NetworkUtil.class);
+
+ private NetworkUtil() {
+ // prevent instantiation
+ }
+
+ /**
+ * Get host local IPv4 address
+ *
+ * @return the resulting IPv4 address as String
+ */
+ public static String getLocalIPAddress() {
+ try {
+ Enumeration networkInterfaces = NetworkInterface.getNetworkInterfaces();
+ while (networkInterfaces.hasMoreElements()) {
+ NetworkInterface netInterface = networkInterfaces.nextElement();
+ if (!netInterface.isLoopback() && netInterface.isUp()) {
+ Enumeration inetAddresses = netInterface.getInetAddresses();
+ while (inetAddresses.hasMoreElements()) {
+ InetAddress inetAddress = inetAddresses.nextElement();
+ if (inetAddress.isSiteLocalAddress() && inetAddress instanceof Inet4Address) {
+ logger.trace("Local IPv4 address is: {}", inetAddress.getHostAddress());
+ return inetAddress.getHostAddress();
+ }
+ }
+ }
+ }
+ } catch (Exception e) {
+ logger.warn("Unable to get local IPv4 address. {}", e.getMessage());
+ }
+ return "";
+ }
+}
diff --git a/bundles/org.openhab.binding.tuya/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.tuya/src/main/resources/OH-INF/thing/thing-types.xml
index 5c8f90e5389..cefa989760f 100644
--- a/bundles/org.openhab.binding.tuya/src/main/resources/OH-INF/thing/thing-types.xml
+++ b/bundles/org.openhab.binding.tuya/src/main/resources/OH-INF/thing/thing-types.xml
@@ -84,6 +84,7 @@
+
true
true
diff --git a/bundles/org.openhab.binding.tuya/src/test/java/org/openhab/binding/tuya/internal/util/CryptoUtilTest.java b/bundles/org.openhab.binding.tuya/src/test/java/org/openhab/binding/tuya/internal/util/CryptoUtilTest.java
index 90d8e934815..b308ecbd202 100644
--- a/bundles/org.openhab.binding.tuya/src/test/java/org/openhab/binding/tuya/internal/util/CryptoUtilTest.java
+++ b/bundles/org.openhab.binding.tuya/src/test/java/org/openhab/binding/tuya/internal/util/CryptoUtilTest.java
@@ -20,6 +20,7 @@ import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
+import org.openhab.binding.tuya.internal.local.ProtocolVersion;
import org.openhab.core.util.HexUtils;
/**
@@ -37,7 +38,7 @@ public class CryptoUtilTest {
byte[] remoteKey = HexUtils.hexToBytes("30633665666638323536343733353036");
byte[] expectedSessionKey = HexUtils.hexToBytes("afe2349b17e2cc833247ccb1a52e8aae");
- byte[] sessionKey = CryptoUtil.generateSessionKey(localKey, remoteKey, deviceKey);
+ byte[] sessionKey = CryptoUtil.generateSessionKey(localKey, remoteKey, deviceKey, ProtocolVersion.V3_4);
assertThat(sessionKey, is(expectedSessionKey));
}