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