[tuya] Add support for protocol version 3.5 (#626) (#18707)

* [tuya] Add support for protocol version 3.5
---------

Signed-off-by: Andriy Yemets <cyborg.andy@gmail.com>
Signed-off-by: Mike Jagdis <mjagdis@eris-associates.co.uk>
pull/18708/head
mjagdis 2025-05-23 12:51:45 +01:00 committed by GitHub
parent d733a33339
commit 3f66952fa9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 446 additions and 30 deletions

View File

@ -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.DeviceListInfo;
import org.openhab.binding.tuya.internal.cloud.dto.DeviceSchema; import org.openhab.binding.tuya.internal.cloud.dto.DeviceSchema;
import org.openhab.binding.tuya.internal.handler.ProjectHandler; 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.binding.tuya.internal.util.SchemaDp;
import org.openhab.core.config.discovery.AbstractThingHandlerDiscoveryService; import org.openhab.core.config.discovery.AbstractThingHandlerDiscoveryService;
import org.openhab.core.config.discovery.DiscoveryResult; import org.openhab.core.config.discovery.DiscoveryResult;
@ -64,6 +65,9 @@ public class TuyaDiscoveryService extends AbstractThingHandlerDiscoveryService<P
private final Gson gson = new Gson(); private final Gson gson = new Gson();
private @NonNullByDefault({}) Storage<String> storage; private @NonNullByDefault({}) Storage<String> storage;
private @Nullable ScheduledFuture<?> discoveryJob; private @Nullable ScheduledFuture<?> discoveryJob;
private @Nullable ScheduledFuture<?> broadcastJob;
private final UdpDiscoverySender udpDiscoverySender = new UdpDiscoverySender();
public TuyaDiscoveryService() { public TuyaDiscoveryService() {
super(ProjectHandler.class, SUPPORTED_THING_TYPES, SEARCH_TIME); super(ProjectHandler.class, SUPPORTED_THING_TYPES, SEARCH_TIME);
@ -136,6 +140,11 @@ public class TuyaDiscoveryService extends AbstractThingHandlerDiscoveryService<P
@Override @Override
protected synchronized void stopScan() { protected synchronized void stopScan() {
ScheduledFuture<?> broadcastJob = this.broadcastJob;
if (broadcastJob != null) {
broadcastJob.cancel(true);
this.broadcastJob = null;
}
removeOlderResults(getTimestampOfLastScan()); removeOlderResults(getTimestampOfLastScan());
super.stopScan(); super.stopScan();
} }
@ -163,6 +172,12 @@ public class TuyaDiscoveryService extends AbstractThingHandlerDiscoveryService<P
if (discoveryJob == null || discoveryJob.isCancelled()) { if (discoveryJob == null || discoveryJob.isCancelled()) {
this.discoveryJob = scheduler.scheduleWithFixedDelay(this::startScan, 1, 5, TimeUnit.MINUTES); this.discoveryJob = scheduler.scheduleWithFixedDelay(this::startScan, 1, 5, TimeUnit.MINUTES);
} }
ScheduledFuture<?> broadcastJob = this.broadcastJob;
if (broadcastJob == null || broadcastJob.isDone() || broadcastJob.isCancelled()) {
this.broadcastJob = scheduler.scheduleWithFixedDelay(udpDiscoverySender::sendMessage, 5, 10,
TimeUnit.SECONDS);
}
} }
@Override @Override
@ -172,5 +187,10 @@ public class TuyaDiscoveryService extends AbstractThingHandlerDiscoveryService<P
discoveryJob.cancel(true); discoveryJob.cancel(true);
this.discoveryJob = null; this.discoveryJob = null;
} }
ScheduledFuture<?> broadcastJob = this.broadcastJob;
if (broadcastJob != null) {
broadcastJob.cancel(true);
this.broadcastJob = null;
}
} }
} }

View File

@ -44,6 +44,7 @@ public enum CommandType {
UDP_NEW(19), UDP_NEW(19),
AP_CONFIG_NEW(20), AP_CONFIG_NEW(20),
BROADCAST_LPV34(35), BROADCAST_LPV34(35),
REQ_DEVINFO(37),
LAN_EXT_STREAM(40), LAN_EXT_STREAM(40),
LAN_GW_ACTIVE(240), LAN_GW_ACTIVE(240),
LAN_SUB_DEV_REQUEST(241), LAN_SUB_DEV_REQUEST(241),

View File

@ -26,7 +26,8 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
public enum ProtocolVersion { public enum ProtocolVersion {
V3_1("3.1"), V3_1("3.1"),
V3_3("3.3"), V3_3("3.3"),
V3_4("3.4"); V3_4("3.4"),
V3_5("3.5");
private final String versionString; private final String versionString;

View File

@ -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.DP_REFRESH;
import static org.openhab.binding.tuya.internal.local.CommandType.SESS_KEY_NEG_START; 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_4;
import static org.openhab.binding.tuya.internal.local.ProtocolVersion.V3_5;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -112,7 +113,7 @@ public class TuyaDevice implements ChannelFutureListener {
} }
public void set(Map<Integer, @Nullable Object> command) { public void set(Map<Integer, @Nullable Object> 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)); MessageWrapper<?> m = new MessageWrapper<>(commandType, Map.of("dps", command));
Channel channel = this.channel; Channel channel = this.channel;
if (channel != null) { if (channel != null) {
@ -156,7 +157,7 @@ public class TuyaDevice implements ChannelFutureListener {
// session key is device key before negotiation // session key is device key before negotiation
channel.attr(SESSION_KEY_ATTR).set(deviceKey); channel.attr(SESSION_KEY_ATTR).set(deviceKey);
if (protocolVersion == V3_4) { if (protocolVersion == V3_4 || protocolVersion == V3_5) {
byte[] sessionRandom = CryptoUtil.generateRandom(16); byte[] sessionRandom = CryptoUtil.generateRandom(16);
channel.attr(SESSION_RANDOM_ATTR).set(sessionRandom); channel.attr(SESSION_RANDOM_ATTR).set(sessionRandom);
this.channel = channel; this.channel = channel;

View File

@ -56,6 +56,7 @@ public class UdpDiscoveryListener implements ChannelFutureListener {
private final Map<String, DeviceInfoSubscriber> deviceListeners = new HashMap<>(); private final Map<String, DeviceInfoSubscriber> deviceListeners = new HashMap<>();
private @NonNullByDefault({}) Channel encryptedChannel; private @NonNullByDefault({}) Channel encryptedChannel;
private @NonNullByDefault({}) Channel encryptedChannel35;
private @NonNullByDefault({}) Channel rawChannel; private @NonNullByDefault({}) Channel rawChannel;
private final EventLoopGroup group; private final EventLoopGroup group;
private boolean deactivate = false; 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(); ChannelFuture futureEncrypted = b.bind(6667).addListener(this).sync();
encryptedChannel = futureEncrypted.channel(); encryptedChannel = futureEncrypted.channel();
encryptedChannel.attr(TuyaDevice.DEVICE_ID_ATTR).set("udpListener"); encryptedChannel.attr(TuyaDevice.DEVICE_ID_ATTR).set("udpListener");
@ -95,9 +102,11 @@ public class UdpDiscoveryListener implements ChannelFutureListener {
public void deactivate() { public void deactivate() {
deactivate = true; deactivate = true;
encryptedChannel.pipeline().fireUserEventTriggered(new UserEventHandler.DisposeEvent()); encryptedChannel.pipeline().fireUserEventTriggered(new UserEventHandler.DisposeEvent());
encryptedChannel35.pipeline().fireUserEventTriggered(new UserEventHandler.DisposeEvent());
rawChannel.pipeline().fireUserEventTriggered(new UserEventHandler.DisposeEvent()); rawChannel.pipeline().fireUserEventTriggered(new UserEventHandler.DisposeEvent());
try { try {
encryptedChannel.closeFuture().sync(); encryptedChannel.closeFuture().sync();
encryptedChannel35.closeFuture().sync();
rawChannel.closeFuture().sync(); rawChannel.closeFuture().sync();
} catch (InterruptedException e) { } catch (InterruptedException e) {
// do nothing // do nothing

View File

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

View File

@ -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.CommandType.UDP_NEW;
import static org.openhab.binding.tuya.internal.local.ProtocolVersion.V3_3; 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_4;
import static org.openhab.binding.tuya.internal.local.ProtocolVersion.V3_5;
import static org.openhab.binding.tuya.internal.local.TuyaDevice.*; import static org.openhab.binding.tuya.internal.local.TuyaDevice.*;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
@ -92,33 +93,59 @@ public class TuyaDecoder extends ByteToMessageDecoder {
if (logger.isTraceEnabled()) { if (logger.isTraceEnabled()) {
logger.trace("{}{}: Received encoded '{}'", deviceId, logger.trace("{}{}: Received encoded '{}'", deviceId,
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""), HexUtils.bytesToHex(bytes)); 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); ByteBuffer buffer = ByteBuffer.wrap(bytes);
int prefix = buffer.getInt(); 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. // this method call is necessary to correctly move the pointer within the buffer.
buffer.getInt(); buffer.getInt();
CommandType commandType = CommandType.fromCode(buffer.getInt()); CommandType commandType = CommandType.fromCode(buffer.getInt());
int payloadLength = 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 // there are less bytes than needed, exit early
logger.trace("Did not receive enough bytes from '{}', exiting early", deviceId); logger.trace("Did not receive enough bytes from '{}', exiting early", deviceId);
return; return;
} else { } else {
// we have enough bytes, skip them from the input buffer and proceed processing // 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; byte[] payload;
if ((returnCode & 0xffffff00) != 0) {
// rewind if no return code is present if (protocol == V3_5) {
buffer.position(buffer.position() - 4); payload = new byte[payloadLength];
payload = protocol == V3_4 ? new byte[payloadLength - 32] : new byte[payloadLength - 8];
} else { } 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); buffer.get(payload);
@ -137,7 +164,7 @@ public class TuyaDecoder extends ByteToMessageDecoder {
HexUtils.bytesToHex(expectedHmac)); HexUtils.bytesToHex(expectedHmac));
return; return;
} }
} else { } else if (protocol != V3_5) {
int crc = buffer.getInt(); int crc = buffer.getInt();
// header + payload without suffix and checksum // header + payload without suffix and checksum
int calculatedCrc = CryptoUtil.calculateChecksum(bytes, 0, 16 + payloadLength - 8); int calculatedCrc = CryptoUtil.calculateChecksum(bytes, 0, 16 + payloadLength - 8);
@ -149,7 +176,7 @@ public class TuyaDecoder extends ByteToMessageDecoder {
} }
int suffix = buffer.getInt(); 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, logger.warn("{}{}: Decoding failed: Prefix or suffix invalid.", deviceId,
Objects.requireNonNullElse(ctx.channel().remoteAddress(), "")); Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""));
return; return;
@ -170,18 +197,27 @@ public class TuyaDecoder extends ByteToMessageDecoder {
m = new MessageWrapper<>(commandType, m = new MessageWrapper<>(commandType,
Objects.requireNonNull(gson.fromJson(new String(payload), DiscoveryMessage.class))); Objects.requireNonNull(gson.fromJson(new String(payload), DiscoveryMessage.class)));
} else { } else {
byte[] decodedMessage = protocol == V3_4 ? CryptoUtil.decryptAesEcb(payload, sessionKey, true) byte[] decodedMessage = switch (protocol) {
: CryptoUtil.decryptAesEcb(payload, sessionKey, false); 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) { if (decodedMessage == null) {
return; 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 (Arrays.equals(Arrays.copyOfRange(decodedMessage, 0, protocol.getBytes().length), protocol.getBytes())) {
if (protocol == V3_4) { if (protocol == V3_4 || protocol == V3_5) {
// Remove 3.4 header // Remove 3.4 or 3.5 header
decodedMessage = Arrays.copyOfRange(decodedMessage, 15, decodedMessage.length); decodedMessage = Arrays.copyOfRange(decodedMessage, 15, decodedMessage.length);
} }
} }
if (logger.isTraceEnabled()) { if (logger.isTraceEnabled()) {
logger.trace("{}{}: Decoded raw payload: {}", deviceId, logger.trace("{}{}: Decoded raw payload: {}", deviceId,
Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""), Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""),

View File

@ -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_QUERY_NEW;
import static org.openhab.binding.tuya.internal.local.CommandType.DP_REFRESH; 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.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_FINISH;
import static org.openhab.binding.tuya.internal.local.CommandType.SESS_KEY_NEG_START; 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_3;
import static org.openhab.binding.tuya.internal.local.ProtocolVersion.V3_4; 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 static org.openhab.binding.tuya.internal.local.TuyaDevice.*;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
@ -86,7 +88,11 @@ public class TuyaEncoder extends MessageToByteEncoder<MessageWrapper<?>> {
if (msg.content == null || msg.content instanceof Map<?, ?>) { if (msg.content == null || msg.content instanceof Map<?, ?>) {
Map<String, Object> content = (Map<String, Object>) msg.content; Map<String, Object> content = (Map<String, Object>) msg.content;
Map<String, Object> payload = new HashMap<>(); Map<String, Object> 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("protocol", 5);
payload.put("t", System.currentTimeMillis() / 1000); payload.put("t", System.currentTimeMillis() / 1000);
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
@ -123,8 +129,11 @@ public class TuyaEncoder extends MessageToByteEncoder<MessageWrapper<?>> {
return; return;
} }
Optional<byte[]> bufferOptional = protocol == V3_4 ? encode34(msg.commandType, payloadBytes, sessionKey) Optional<byte[]> bufferOptional = switch (protocol) {
: encodePre34(msg.commandType, payloadBytes, sessionKey, 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 -> { bufferOptional.ifPresentOrElse(buffer -> {
if (logger.isTraceEnabled()) { if (logger.isTraceEnabled()) {
@ -237,4 +246,44 @@ public class TuyaEncoder extends MessageToByteEncoder<MessageWrapper<?>> {
return Optional.of(buffer.array()); return Optional.of(buffer.array());
} }
private Optional<byte[]> 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());
}
} }

View File

@ -12,6 +12,7 @@
*/ */
package org.openhab.binding.tuya.internal.local.handlers; 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 static org.openhab.binding.tuya.internal.local.TuyaDevice.SESSION_KEY_ATTR;
import java.util.Arrays; 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.CommandType;
import org.openhab.binding.tuya.internal.local.DeviceStatusListener; import org.openhab.binding.tuya.internal.local.DeviceStatusListener;
import org.openhab.binding.tuya.internal.local.MessageWrapper; 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.TuyaDevice;
import org.openhab.binding.tuya.internal.local.dto.TcpStatusPayload; import org.openhab.binding.tuya.internal.local.dto.TcpStatusPayload;
import org.openhab.binding.tuya.internal.util.CryptoUtil; import org.openhab.binding.tuya.internal.util.CryptoUtil;
@ -79,12 +81,15 @@ public class TuyaMessageHandler extends ChannelDuplexHandler {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public void channelRead(@NonNullByDefault({}) ChannelHandlerContext ctx, @NonNullByDefault({}) Object msg) public void channelRead(@NonNullByDefault({}) ChannelHandlerContext ctx, @NonNullByDefault({}) Object msg)
throws Exception { throws Exception {
if (!ctx.channel().hasAttr(TuyaDevice.DEVICE_ID_ATTR) || !ctx.channel().hasAttr(SESSION_KEY_ATTR)) { 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.", || !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(), "")); Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""));
return; return;
} }
String deviceId = ctx.channel().attr(TuyaDevice.DEVICE_ID_ATTR).get(); 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 (msg instanceof MessageWrapper<?> m) {
if (m.commandType == CommandType.DP_QUERY || m.commandType == CommandType.STATUS) { if (m.commandType == CommandType.DP_QUERY || m.commandType == CommandType.STATUS) {
@ -125,7 +130,7 @@ public class TuyaMessageHandler extends ChannelDuplexHandler {
ctx.channel().writeAndFlush(response); ctx.channel().writeAndFlush(response);
byte[] newSessionKey = CryptoUtil.generateSessionKey(sessionRandom, remoteKey, sessionKey); byte[] newSessionKey = CryptoUtil.generateSessionKey(sessionRandom, remoteKey, sessionKey, protocol);
if (newSessionKey == null) { if (newSessionKey == null) {
logger.warn("{}{}: Session key negotiation failed because session key is null.", deviceId, logger.warn("{}{}: Session key negotiation failed because session key is null.", deviceId,
Objects.requireNonNullElse(ctx.channel().remoteAddress(), "")); Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""));

View File

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

View File

@ -20,6 +20,7 @@ import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Arrays; import java.util.Arrays;
import java.util.Base64; import java.util.Base64;
import java.util.Objects;
import java.util.Random; import java.util.Random;
import javax.crypto.BadPaddingException; import javax.crypto.BadPaddingException;
@ -33,6 +34,7 @@ import javax.crypto.spec.SecretKeySpec;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.tuya.internal.local.ProtocolVersion;
import org.openhab.core.util.HexUtils; import org.openhab.core.util.HexUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -78,6 +80,8 @@ public class CryptoUtil {
0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605, 0xcdd70693, 0x54de5729, 0x23d967bf, 0xb3667a2e, 0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605, 0xcdd70693, 0x54de5729, 0x23d967bf, 0xb3667a2e,
0xc4614ab8, 0x5d681b02, 0x2a6f2b94, 0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d }; 0xc4614ab8, 0x5d681b02, 0x2a6f2b94, 0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d };
private static final int GCM_TAG_LENGTH = 16; 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(); private static final Random SECURE_RNG = new SecureRandom();
@ -188,6 +192,35 @@ public class CryptoUtil {
return null; 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 * Decrypt an AES-ECB encoded message
* *
@ -218,6 +251,44 @@ public class CryptoUtil {
return null; 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 * 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 localKey the randomly generated local key
* @param remoteKey the provided remote key * @param remoteKey the provided remote key
* @param deviceKey the (constant) device 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(); byte[] sessionKey = localKey.clone();
for (int i = 0; i < sessionKey.length; i++) { for (int i = 0; i < sessionKey.length; i++) {
sessionKey[i] = (byte) (sessionKey[i] ^ remoteKey[i]); sessionKey[i] = (byte) (sessionKey[i] ^ remoteKey[i]);
} }
byte[] result = new byte[SESSION_KEY_LENGTH];
return CryptoUtil.encryptAesEcb(sessionKey, deviceKey, false); 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;
} }
} }

View File

@ -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<NetworkInterface> networkInterfaces = NetworkInterface.getNetworkInterfaces();
while (networkInterfaces.hasMoreElements()) {
NetworkInterface netInterface = networkInterfaces.nextElement();
if (!netInterface.isLoopback() && netInterface.isUp()) {
Enumeration<InetAddress> 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 "";
}
}

View File

@ -84,6 +84,7 @@
<option value="3.1">3.1</option> <option value="3.1">3.1</option>
<option value="3.3">3.3</option> <option value="3.3">3.3</option>
<option value="3.4">3.4</option> <option value="3.4">3.4</option>
<option value="3.5">3.5</option>
</options> </options>
<limitToOptions>true</limitToOptions> <limitToOptions>true</limitToOptions>
<advanced>true</advanced> <advanced>true</advanced>

View File

@ -20,6 +20,7 @@ import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.openhab.binding.tuya.internal.local.ProtocolVersion;
import org.openhab.core.util.HexUtils; import org.openhab.core.util.HexUtils;
/** /**
@ -37,7 +38,7 @@ public class CryptoUtilTest {
byte[] remoteKey = HexUtils.hexToBytes("30633665666638323536343733353036"); byte[] remoteKey = HexUtils.hexToBytes("30633665666638323536343733353036");
byte[] expectedSessionKey = HexUtils.hexToBytes("afe2349b17e2cc833247ccb1a52e8aae"); 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)); assertThat(sessionKey, is(expectedSessionKey));
} }