* [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
parent
d733a33339
commit
3f66952fa9
|
@ -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<P
|
|||
private final Gson gson = new Gson();
|
||||
private @NonNullByDefault({}) Storage<String> 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<P
|
|||
|
||||
@Override
|
||||
protected synchronized void stopScan() {
|
||||
ScheduledFuture<?> 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<P
|
|||
if (discoveryJob == null || discoveryJob.isCancelled()) {
|
||||
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
|
||||
|
@ -172,5 +187,10 @@ public class TuyaDiscoveryService extends AbstractThingHandlerDiscoveryService<P
|
|||
discoveryJob.cancel(true);
|
||||
this.discoveryJob = null;
|
||||
}
|
||||
ScheduledFuture<?> broadcastJob = this.broadcastJob;
|
||||
if (broadcastJob != null) {
|
||||
broadcastJob.cancel(true);
|
||||
this.broadcastJob = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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<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));
|
||||
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;
|
||||
|
|
|
@ -56,6 +56,7 @@ public class UdpDiscoveryListener implements ChannelFutureListener {
|
|||
private final Map<String, DeviceInfoSubscriber> 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
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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(), ""),
|
||||
|
|
|
@ -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<MessageWrapper<?>> {
|
|||
if (msg.content == null || msg.content instanceof Map<?, ?>) {
|
||||
Map<String, Object> content = (Map<String, Object>) msg.content;
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
if (protocol == V3_4) {
|
||||
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<String, Object> data = new HashMap<>();
|
||||
|
@ -123,8 +129,11 @@ public class TuyaEncoder extends MessageToByteEncoder<MessageWrapper<?>> {
|
|||
return;
|
||||
}
|
||||
|
||||
Optional<byte[]> bufferOptional = protocol == V3_4 ? encode34(msg.commandType, payloadBytes, sessionKey)
|
||||
: encodePre34(msg.commandType, payloadBytes, sessionKey, protocol);
|
||||
Optional<byte[]> 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<MessageWrapper<?>> {
|
|||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(), ""));
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 "";
|
||||
}
|
||||
}
|
|
@ -84,6 +84,7 @@
|
|||
<option value="3.1">3.1</option>
|
||||
<option value="3.3">3.3</option>
|
||||
<option value="3.4">3.4</option>
|
||||
<option value="3.5">3.5</option>
|
||||
</options>
|
||||
<limitToOptions>true</limitToOptions>
|
||||
<advanced>true</advanced>
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue