diff --git a/.gitignore b/.gitignore
index dfcfd56..fec7628 100644
--- a/.gitignore
+++ b/.gitignore
@@ -348,3 +348,13 @@ MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
+
+src/.DS_Store
+
+.DS_Store
+
+src/TuyaNet.Console/Database.secret.json
+
+.idea/.idea.tuyanet.dir/.idea/
+
+src/.idea/.idea.TuyaNet/.idea/
diff --git a/TuyaParser.cs b/TuyaParser.cs
deleted file mode 100644
index f8cb80e..0000000
--- a/TuyaParser.cs
+++ /dev/null
@@ -1,183 +0,0 @@
-using Newtonsoft.Json.Linq;
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Security.Cryptography;
-using System.Text;
-
-namespace com.clusterrr.TuyaNet
-{
- ///
- /// Class to encode and decode data sent over local network.
- ///
- internal static class TuyaParser
- {
- private static byte[] PROTOCOL_VERSION_BYTES_31 = Encoding.ASCII.GetBytes("3.1");
- private static byte[] PROTOCOL_VERSION_BYTES_33 = Encoding.ASCII.GetBytes("3.3");
- private static byte[] PROTOCOL_33_HEADER = Enumerable.Concat(PROTOCOL_VERSION_BYTES_33, new byte[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }).ToArray();
- private static byte[] PREFIX = new byte[] { 0, 0, 0x55, 0xAA };
- private static byte[] SUFFIX = { 0, 0, 0xAA, 0x55 };
- private static uint SeqNo = 0;
-
- internal static IEnumerable BigEndian(IEnumerable seq) => BitConverter.IsLittleEndian ? seq.Reverse() : seq;
-
- internal static byte[] Encrypt(byte[] data, byte[] key)
- {
- var aes = new AesManaged()
- {
- Mode = CipherMode.ECB,
- Key = key
- };
- using (var ms = new MemoryStream())
- using (var cs = new CryptoStream(ms, aes.CreateEncryptor(), CryptoStreamMode.Write))
- {
- cs.Write(data, 0, data.Length);
- cs.Close();
- data = ms.ToArray(); // encrypt the data
- }
- return data;
- }
-
- internal static byte[] Decrypt(byte[] data, byte[] key)
- {
- if (data.Length == 0) return data;
- var aes = new AesManaged()
- {
- Mode = CipherMode.ECB,
- Key = key
- };
- using (var ms = new MemoryStream())
- using (var cs = new CryptoStream(ms, aes.CreateDecryptor(), CryptoStreamMode.Write))
- {
- cs.Write(data, 0, data.Length);
- cs.Close();
- data = ms.ToArray(); // dencrypt the data
- }
- return data;
- }
-
- internal static byte[] EncodeRequest(TuyaCommand command, string json, byte[] key, TuyaProtocolVersion protocolVersion = TuyaProtocolVersion.V33)
- {
- // Remove spaces and newlines
- var root = JObject.Parse(json);
- json = root.ToString(Newtonsoft.Json.Formatting.None);
-
- byte[] payload = Encoding.UTF8.GetBytes(json);
-
- if (protocolVersion == TuyaProtocolVersion.V33)
- {
- // Encrypt
- payload = Encrypt(payload, key);
- // Add protocol 3.3 header
- if ((command != TuyaCommand.DP_QUERY) && (command != TuyaCommand.UPDATE_DPS))
- payload = Enumerable.Concat(PROTOCOL_33_HEADER, payload).ToArray();
- }
- else if (command == TuyaCommand.CONTROL)
- {
- // Encrypt
- payload = Encrypt(payload, key);
- // Encode to base64
- string data64 = Convert.ToBase64String(payload);
- // Make string
- payload = Encoding.UTF8.GetBytes($"data={data64}||lpv=3.1||");
- using (var md5 = MD5.Create())
- using (var ms = new MemoryStream())
- {
- // Calculate MD5 of data
- ms.Write(payload, 0, payload.Length);
- // ...and encryption key
- ms.Write(key, 0, key.Length);
- string md5s =
- BitConverter.ToString( // Make string from MD5
- md5.ComputeHash(ms.ToArray()) // Calculate MD5
- )
- .Replace("-", string.Empty) // Remove '-'
- .Substring(8, 16) // Get part of it
- .ToLower(); // Lowercase
- // Data with protocol header, MD5 hash and data
- payload = Encoding.UTF8.GetBytes($"3.1{md5s}{data64}");
- }
- }
-
- using (var ms = new MemoryStream())
- {
- byte[] seqNo = BitConverter.GetBytes(SeqNo++);
- if (BitConverter.IsLittleEndian) Array.Reverse(seqNo); // Make big-endian
- byte[] dataLength = BitConverter.GetBytes(payload.Length + 8);
- if (BitConverter.IsLittleEndian) Array.Reverse(dataLength); // Make big-endian
-
- ms.Write(PREFIX, 0, 4); // Prefix
- ms.Write(seqNo, 0, 4); // Packet number
- ms.Write(new byte[] { 0, 0, 0, (byte)command }, 0, 4); // Command number
- ms.Write(dataLength, 0, 4); // Length of data + length of suffix
- ms.Write(payload, 0, payload.Length); // Data
- var crc32 = new Crc32();
- var crc = crc32.Get(ms.ToArray());
- byte[] crcBin = BitConverter.GetBytes(crc);
- if (BitConverter.IsLittleEndian) Array.Reverse(crcBin); // Make big-endian
- ms.Write(crcBin, 0, 4); // CRC32 checksum
- ms.Write(SUFFIX, 0, 4); // Suffix
- payload = ms.ToArray();
- }
-
- return payload;
- }
-
- internal static TuyaLocalResponse DecodeResponse(byte[] data, byte[] key, TuyaProtocolVersion protocolVersion = TuyaProtocolVersion.V33)
- {
- // Check length and prefix
- if (data.Length < 20 || !data.Take(PREFIX.Length).SequenceEqual(PREFIX))
- {
- throw new InvalidDataException("Invalid header/prefix");
- }
- // Check length
- int length = BitConverter.ToInt32(BigEndian(data.Skip(12).Take(4)).ToArray(), 0);
- if (data.Length != 16 + length)
- {
- throw new InvalidDataException("Invalid length");
- }
- // Check suffix
- if (!data.Skip(16 + length - SUFFIX.Length).Take(SUFFIX.Length).SequenceEqual(SUFFIX))
- {
- throw new InvalidDataException("Invalid suffix");
- }
-
- // Packet number
- // uint seq = BitConverter.ToUInt32(BinEndian(data.Skip(4).Take(4)).ToArray(), 0);
- // Command
- var command = (TuyaCommand)BitConverter.ToUInt32(BigEndian(data.Skip(8).Take(4)).ToArray(), 0);
- // Return code
- int returnCode = BitConverter.ToInt32(BigEndian(data.Skip(16).Take(4)).ToArray(), 0);
- // Data
- data = data.Skip(20).Take(length - 12).ToArray();
-
- var realVersion = protocolVersion;
- // Remove version 3.1 header
- if (data.Take(PROTOCOL_VERSION_BYTES_31.Length).SequenceEqual(PROTOCOL_VERSION_BYTES_31))
- {
- data = data.Skip(PROTOCOL_VERSION_BYTES_31.Length).ToArray();
- realVersion = TuyaProtocolVersion.V31;
- }
- // Remove version 3.3 header
- if (data.Take(PROTOCOL_VERSION_BYTES_33.Length).SequenceEqual(PROTOCOL_VERSION_BYTES_33))
- {
- data = data.Skip(PROTOCOL_33_HEADER.Length).ToArray();
- realVersion = TuyaProtocolVersion.V33;
- }
-
- if (realVersion == TuyaProtocolVersion.V33)
- {
- data = Decrypt(data, key);
- }
-
- if (data.Length == 0)
- return new TuyaLocalResponse(command, returnCode, null);
-
- var json = Encoding.UTF8.GetString(data);
- if (!json.StartsWith("{") || !json.EndsWith("}"))
- throw new InvalidDataException($"Response is not JSON: {json}");
- return new TuyaLocalResponse(command, returnCode, json);
- }
- }
-}
diff --git a/src/TuyaNet.Console/Database.cs b/src/TuyaNet.Console/Database.cs
new file mode 100644
index 0000000..9bca72f
--- /dev/null
+++ b/src/TuyaNet.Console/Database.cs
@@ -0,0 +1,17 @@
+using System.Text.Json;
+using com.clusterrr.TuyaNet.Models;
+
+namespace TuyaNet.Console
+{
+ public static class Database
+ {
+ public static TuyaDeviceInfo[] Devices { get; set; }
+
+ public static void LoadFromFile(string path)
+ {
+ var fileBytes = File.ReadAllBytes(path);
+ var readOnlySpan = new ReadOnlySpan(fileBytes);
+ Devices = JsonSerializer.Deserialize(readOnlySpan)!;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/TuyaNet.Console/Database.example.json b/src/TuyaNet.Console/Database.example.json
new file mode 100644
index 0000000..081600f
--- /dev/null
+++ b/src/TuyaNet.Console/Database.example.json
@@ -0,0 +1,16 @@
+[
+ {
+ "Name": "room 1 device v 3.4",
+ "DeviceId": "secret",
+ "LocalIp": "127.0.0.1",
+ "LocalKey": "secret",
+ "ApiVer": "3.4"
+ },
+ {
+ "Name": "room 2 device v 3.3",
+ "DeviceId": "secret",
+ "LocalIp": "127.0.0.1",
+ "LocalKey": "secret",
+ "ApiVer": "3.3"
+ }
+]
\ No newline at end of file
diff --git a/src/TuyaNet.Console/Program.cs b/src/TuyaNet.Console/Program.cs
new file mode 100644
index 0000000..ae4bc85
--- /dev/null
+++ b/src/TuyaNet.Console/Program.cs
@@ -0,0 +1,33 @@
+using com.clusterrr.TuyaNet.Services;
+using TuyaNet.Console;
+
+var devicesDatabaseFile = args.FirstOrDefault() ?? "../../Database.example.json";
+Database.LoadFromFile(devicesDatabaseFile);
+
+var device = Database.Devices.First(x => x.ApiVer == "3.4");
+var service = new TuyaSwitcherService(device);
+await service.Connect();
+
+async Task TurnOn()
+{
+ await service.TurnOn();
+ var isEnabled = await service.GetStatus();
+ Console.WriteLine($"status get response: {isEnabled}");
+}
+
+async Task TurnOff()
+{
+ await service.TurnOff();
+ var isEnabled = await service.GetStatus();
+ Console.WriteLine($"status get response: {isEnabled}");
+}
+
+async Task GetIsEnabled()
+{
+ return await service.GetStatus();
+}
+
+if(await GetIsEnabled())
+ await TurnOff();
+else
+ await TurnOn();
\ No newline at end of file
diff --git a/src/TuyaNet.Console/TuyaNet.Console.csproj b/src/TuyaNet.Console/TuyaNet.Console.csproj
new file mode 100644
index 0000000..bc0d94e
--- /dev/null
+++ b/src/TuyaNet.Console/TuyaNet.Console.csproj
@@ -0,0 +1,16 @@
+
+
+
+ Exe
+ net7.0
+ enable
+ enable
+ Linux
+
+
+
+
+
+
+
+
diff --git a/src/TuyaNet.sln b/src/TuyaNet.sln
new file mode 100644
index 0000000..efa21c0
--- /dev/null
+++ b/src/TuyaNet.sln
@@ -0,0 +1,22 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TuyaNet", "TuyaNet\TuyaNet.csproj", "{40D5F27F-9357-4742-AA98-A15256CE7443}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TuyaNet.Console", "TuyaNet.Console\TuyaNet.Console.csproj", "{6CA562A2-B362-4376-A793-ABDF97562A69}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {40D5F27F-9357-4742-AA98-A15256CE7443}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {40D5F27F-9357-4742-AA98-A15256CE7443}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {40D5F27F-9357-4742-AA98-A15256CE7443}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {40D5F27F-9357-4742-AA98-A15256CE7443}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6CA562A2-B362-4376-A793-ABDF97562A69}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6CA562A2-B362-4376-A793-ABDF97562A69}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6CA562A2-B362-4376-A793-ABDF97562A69}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6CA562A2-B362-4376-A793-ABDF97562A69}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/Crc32.cs b/src/TuyaNet/Crc32.cs
similarity index 100%
rename from Crc32.cs
rename to src/TuyaNet/Crc32.cs
diff --git a/Doxyfile b/src/TuyaNet/Doxyfile
similarity index 100%
rename from Doxyfile
rename to src/TuyaNet/Doxyfile
diff --git a/src/TuyaNet/Extensions/EnumExtensions.cs b/src/TuyaNet/Extensions/EnumExtensions.cs
new file mode 100644
index 0000000..804b59e
--- /dev/null
+++ b/src/TuyaNet/Extensions/EnumExtensions.cs
@@ -0,0 +1,20 @@
+using System;
+using System.Linq;
+
+namespace com.clusterrr.TuyaNet.Extensions
+{
+ public static class EnumExtensions
+ {
+ public static TuyaProtocolVersion[] GetTuyaVersionsValues()
+ {
+ var values = (TuyaProtocolVersion[])Enum.GetValues(typeof(TuyaProtocolVersion));
+ return values;
+ }
+
+ public static string GetNames(this TuyaCommand command)
+ {
+ var values = (TuyaCommand[])Enum.GetValues(typeof(TuyaCommand));
+ return string.Join(", ", values.Where(x => x == command).Select(x=>x.ToString()));
+ }
+ }
+}
\ No newline at end of file
diff --git a/LICENSE b/src/TuyaNet/LICENSE
similarity index 100%
rename from LICENSE
rename to src/TuyaNet/LICENSE
diff --git a/src/TuyaNet/Models/TuyaDeviceInfo.cs b/src/TuyaNet/Models/TuyaDeviceInfo.cs
new file mode 100644
index 0000000..53dc70b
--- /dev/null
+++ b/src/TuyaNet/Models/TuyaDeviceInfo.cs
@@ -0,0 +1,13 @@
+namespace com.clusterrr.TuyaNet.Models
+{
+ public class TuyaDeviceInfo
+ {
+ public string Name { get; set; }
+ public string DeviceId { get; set; }
+ public string LocalIp { get; set; }
+ public string LocalKey { get; set; }
+ public int Priority { get; set; }
+ public string ApiVer { get; set; }
+ }
+
+}
\ No newline at end of file
diff --git a/README.md b/src/TuyaNet/README.md
similarity index 100%
rename from README.md
rename to src/TuyaNet/README.md
diff --git a/SemaphoreLockDisposable.cs b/src/TuyaNet/SemaphoreLockDisposable.cs
similarity index 100%
rename from SemaphoreLockDisposable.cs
rename to src/TuyaNet/SemaphoreLockDisposable.cs
diff --git a/src/TuyaNet/Services/TuyaSwitcherService.cs b/src/TuyaNet/Services/TuyaSwitcherService.cs
new file mode 100644
index 0000000..d512e8c
--- /dev/null
+++ b/src/TuyaNet/Services/TuyaSwitcherService.cs
@@ -0,0 +1,113 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using com.clusterrr.TuyaNet.Extensions;
+using com.clusterrr.TuyaNet.Models;
+using Newtonsoft.Json;
+
+namespace com.clusterrr.TuyaNet.Services
+{
+ public class TuyaSwitcherService
+ {
+ private readonly TuyaDevice device;
+
+ public TuyaSwitcherService(TuyaDeviceInfo deviceInfo)
+ {
+ this.device = GetDevice(deviceInfo);
+ }
+
+ private TuyaDevice GetDevice(TuyaDeviceInfo tuyaDeviceInfo)
+ {
+ var allVersions = EnumExtensions.GetTuyaVersionsValues()
+ .Select(x => new { ver = x.ToString().Replace("V", ""), value = x });
+
+ var deviceVersion = allVersions.SingleOrDefault(x => x.ver == tuyaDeviceInfo.ApiVer.Replace(".", ""))
+ ?.value;
+ if (deviceVersion is null)
+ throw new NotSupportedException($"Not supported version {tuyaDeviceInfo.ApiVer}");
+
+ var dev = new TuyaDevice(
+ ip: tuyaDeviceInfo.LocalIp,
+ localKey: tuyaDeviceInfo.LocalKey,
+ deviceId: tuyaDeviceInfo.DeviceId,
+ protocolVersion: deviceVersion.Value);
+
+ dev.PermanentConnection = true;
+
+ return dev;
+ }
+
+ public async Task GetStatus()
+ {
+ var statusResp = await GetStatus(device);
+ Console.WriteLine($"Check status info. Response JSON: {statusResp.Json}");
+ return statusResp.Json?.Contains("true") == true;
+ }
+
+ public async Task TurnOn()
+ {
+ var response = await SetStatus(device, true);
+ Console.WriteLine($"Set status info. Response JSON: {response.Json}");
+ }
+
+ public async Task Connect()
+ {
+ await device.SecureConnectAsync();
+ Console.WriteLine($"Success connected.");
+ }
+
+ public async Task TurnOff()
+ {
+ var response = await SetStatus(device, false);
+ Console.WriteLine($"Set status info. Response JSON: {response.Json}");
+ }
+
+ private async Task GetStatus(TuyaDevice dev)
+ {
+ var requestQuery =
+ "{\"gwId\":\"DEVICE_ID\",\"devId\":\"DEVICE_ID\",\"uid\":\"DEVICE_ID\",\"t\":\"CURRENT_TIME\"}";
+ var command = TuyaCommand.DP_QUERY;
+ var request = dev.EncodeRequest(command, requestQuery);
+ var encryptedResponse = await dev.SendAsync(command, request);
+ var response = dev.DecodeResponse(encryptedResponse);
+ return response;
+ }
+
+ private async Task SetStatus(TuyaDevice dev, bool switchStatus)
+ {
+ var requestQuery = string.Empty;
+ var command = TuyaCommand.CONTROL;
+ if (dev.ProtocolVersion == TuyaProtocolVersion.V34)
+ {
+ command = TuyaCommand.CONTROL_NEW;
+ var rawJson = new
+ {
+ data = new
+ {
+ ctype = 0,
+ devId = dev.DeviceId,
+ gwId = dev.DeviceId,
+ uid = string.Empty,
+ dps = new Dictionary()
+ {
+ { "1", switchStatus }
+ }
+ },
+ protocol = 5,
+ t = (DateTime.Now - new DateTime(1970, 1, 1)).TotalSeconds.ToString("0")
+ };
+ requestQuery = JsonConvert.SerializeObject(rawJson);
+ }
+ else
+ {
+ requestQuery = dev.FillJson("{\"dps\":{\"1\":" + switchStatus.ToString().ToLower() + "}}");
+ }
+
+ var request = dev.EncodeRequest(command, requestQuery);
+ var encryptedResponse = await dev.SendAsync(command, request);
+ var response = dev.DecodeResponse(encryptedResponse);
+ return response;
+ }
+ }
+}
\ No newline at end of file
diff --git a/TuyaApi.cs b/src/TuyaNet/TuyaApi.cs
similarity index 100%
rename from TuyaApi.cs
rename to src/TuyaNet/TuyaApi.cs
diff --git a/TuyaCommand.cs b/src/TuyaNet/TuyaCommand.cs
similarity index 88%
rename from TuyaCommand.cs
rename to src/TuyaNet/TuyaCommand.cs
index 52a93b4..e6230c7 100644
--- a/TuyaCommand.cs
+++ b/src/TuyaNet/TuyaCommand.cs
@@ -9,8 +9,11 @@ public enum TuyaCommand
AP_CONFIG = 1,
ACTIVE = 2,
BIND = 3,
+ SESS_KEY_NEG_START = 3,
RENAME_GW = 4,
+ SESS_KEY_NEG_RES = 4,
RENAME_DEVICE = 5,
+ SESS_KEY_NEG_FINISH = 5,
UNBIND = 6,
CONTROL = 7,
STATUS = 8,
@@ -30,6 +33,7 @@ public enum TuyaCommand
WEATHER_DATA_CMD = 33,
STATE_UPLOAD_SYN_CMD = 34,
STATE_UPLOAD_SYN_RECV_CMD = 35,
+ BOARDCAST_LPV34 = 35,
HEAT_BEAT_STOP = 37,
STREAM_TRANS_CMD = 38,
GET_WIFI_STATUS_CMD = 43,
diff --git a/TuyaDevice.cs b/src/TuyaNet/TuyaDevice.cs
similarity index 74%
rename from TuyaDevice.cs
rename to src/TuyaNet/TuyaDevice.cs
index 731f0a9..2058f51 100644
--- a/TuyaDevice.cs
+++ b/src/TuyaNet/TuyaDevice.cs
@@ -9,6 +9,7 @@
using System.Text;
using System.Threading;
using System.Threading.Tasks;
+using com.clusterrr.TuyaNet.Extensions;
namespace com.clusterrr.TuyaNet
{
@@ -17,6 +18,8 @@ namespace com.clusterrr.TuyaNet
///
public class TuyaDevice : IDisposable
{
+ private readonly TuyaParser parser;
+
///
/// Creates a new instance of the TuyaDevice class.
///
@@ -26,7 +29,11 @@ public class TuyaDevice : IDisposable
/// Protocol version.
/// TCP port of device.
/// Receive timeout (msec).
- public TuyaDevice(string ip, string localKey, string deviceId, TuyaProtocolVersion protocolVersion = TuyaProtocolVersion.V33, int port = 6668, int receiveTimeout = 250)
+ public TuyaDevice(
+ string ip, string localKey, string deviceId,
+ TuyaProtocolVersion protocolVersion = TuyaProtocolVersion.V33,
+ int port = 6668,
+ int receiveTimeout = 1500)
{
IP = ip;
LocalKey = localKey;
@@ -36,6 +43,7 @@ public TuyaDevice(string ip, string localKey, string deviceId, TuyaProtocolVersi
ProtocolVersion = protocolVersion;
Port = port;
ReceiveTimeout = receiveTimeout;
+ parser = new TuyaParser(localKey, protocolVersion);
}
///
@@ -108,6 +116,7 @@ public TuyaDevice(string ip, TuyaApi.Region region, string accessId, string apiS
private string accessId;
private string apiSecret;
private SemaphoreSlim sem = new SemaphoreSlim(1);
+ private NetworkStream networkClientStream;
///
/// Fills JSON string with base fields required by most commands.
@@ -145,7 +154,7 @@ public string FillJson(string json, bool addGwId = true, bool addDevId = true, b
public byte[] EncodeRequest(TuyaCommand command, string json)
{
if (string.IsNullOrEmpty(LocalKey)) throw new ArgumentException("LocalKey is not specified", "LocalKey");
- return TuyaParser.EncodeRequest(command, json, Encoding.UTF8.GetBytes(LocalKey), ProtocolVersion);
+ return parser.EncodeRequest(command, json, Encoding.UTF8.GetBytes(LocalKey), ProtocolVersion);
}
///
@@ -156,7 +165,7 @@ public byte[] EncodeRequest(TuyaCommand command, string json)
public TuyaLocalResponse DecodeResponse(byte[] data)
{
if (string.IsNullOrEmpty(LocalKey)) throw new ArgumentException("LocalKey is not specified", "LocalKey");
- return TuyaParser.DecodeResponse(data, Encoding.UTF8.GetBytes(LocalKey), ProtocolVersion);
+ return parser.DecodeResponse(data);
}
///
@@ -170,8 +179,59 @@ public TuyaLocalResponse DecodeResponse(byte[] data)
/// Cancellation token.
/// Parsed and decrypred received data as instance of TuyaLocalResponse.
public async Task SendAsync(TuyaCommand command, string json, int retries = 2, int nullRetries = 1, int? overrideRecvTimeout = null, CancellationToken cancellationToken = default)
- => DecodeResponse(await SendAsync(EncodeRequest(command, json), retries, nullRetries, overrideRecvTimeout, cancellationToken));
+ => DecodeResponse(await SendAsync(command, EncodeRequest(command, json), retries, nullRetries, overrideRecvTimeout, cancellationToken));
+ public async Task SecureConnectAsync(CancellationToken cancellationToken = default)
+ {
+ if (client == null)
+ client = new TcpClient();
+ if (!client.ConnectAsync(IP, Port).Wait(ConnectionTimeout))
+ throw new IOException("Connection timeout");
+ networkClientStream = client.GetStream();
+
+ if (ProtocolVersion == TuyaProtocolVersion.V34)
+ {
+ var random = new Random();
+ var tmpLocalKey = new byte[16];
+ random.NextBytes(tmpLocalKey);
+ var key = Encoding.UTF8.GetBytes(LocalKey);
+ var request = parser.EncodeRequest34(
+ TuyaCommand.SESS_KEY_NEG_START,
+ tmpLocalKey,
+ key
+ );
+ await networkClientStream.WriteAsync(request, cancellationToken).ConfigureAwait(false);
+ var receivedBytes = await ReceiveAsync(networkClientStream, 1, null, cancellationToken);
+ var response = parser.DecodeResponse(receivedBytes);
+ if ((int)response.Command == (int)TuyaCommand.SESS_KEY_NEG_RES)
+ {
+ var remoteKeySize = 16;
+ var hashSize = 32;
+ var tmpRemoteKey = response.Payload.Take(remoteKeySize).ToArray();
+ var hash = parser.GetHashSha256(tmpLocalKey);
+ var expectedHash = response.Payload.Skip(remoteKeySize).Take(hashSize).ToArray();
+ if (!expectedHash.SequenceEqual(hash))
+ throw new Exception("HMAC mismatch");
+
+ var requestFinish = parser.EncodeRequest34(
+ TuyaCommand.SESS_KEY_NEG_FINISH,
+ parser.GetHashSha256(tmpRemoteKey),
+ key
+ );
+ await networkClientStream.WriteAsync(requestFinish, cancellationToken).ConfigureAwait(false);
+
+ var sessionKey = new byte[tmpLocalKey.Length];
+ for (var i = 0; i < tmpLocalKey.Length; i++)
+ {
+ var value = (byte)(tmpLocalKey[i] ^ tmpRemoteKey[i]);
+ sessionKey[i] = value;
+ }
+ var encryptedSessionKey = parser.Encrypt34(sessionKey);
+ parser.SetupSessionKey(encryptedSessionKey);
+ }
+ }
+ }
+
///
/// Sends raw data over to device and read response.
///
@@ -181,7 +241,7 @@ public async Task SendAsync(TuyaCommand command, string json,
/// Override receive timeout (default - ReceiveTimeout property).
/// Cancellation token.
/// Received data (raw).
- public async Task SendAsync(byte[] data, int retries = 2, int nullRetries = 1, int? overrideRecvTimeout = null, CancellationToken cancellationToken = default)
+ public async Task SendAsync(TuyaCommand command, byte[] data, int retries = 2, int nullRetries = 1, int? overrideRecvTimeout = null, CancellationToken cancellationToken = default)
{
Exception lastException = null;
while (retries-- > 0)
@@ -196,13 +256,26 @@ public async Task SendAsync(byte[] data, int retries = 2, int nullRetrie
{
using (await sem.WaitDisposableAsync(cancellationToken))
{
- if (client == null)
- client = new TcpClient();
- if (!client.ConnectAsync(IP, Port).Wait(ConnectionTimeout))
- throw new IOException("Connection timeout");
- var stream = client.GetStream();
- await stream.WriteAsync(data, 0, data.Length, cancellationToken).ConfigureAwait(false);
- return await ReceiveAsync(stream, nullRetries, overrideRecvTimeout, cancellationToken);
+ if (networkClientStream == null)
+ {
+ throw new Exception("Need invoke ConnectAsync before sendig.");
+ }
+ await networkClientStream.WriteAsync(data, 0, data.Length, cancellationToken).ConfigureAwait(false);
+ var str = client.GetStream();
+ byte[] response = null;
+ while (response == null)
+ {
+ var responseRaw = await ReceiveAsync(str, nullRetries, overrideRecvTimeout, cancellationToken);
+ var responseParsed = parser.DecodeResponse(responseRaw);
+ Console.WriteLine($"Command: {responseParsed.Command.GetNames()}");
+ Console.WriteLine($"Json: {responseParsed.Json}");
+ Console.WriteLine($"Size payload: {responseParsed.Payload?.Length}");
+ //for protocol v3.4 need wait response by command
+ if (responseParsed.Command == command ||
+ ProtocolVersion != TuyaProtocolVersion.V34)
+ response = responseRaw;
+ }
+ return response;
}
}
catch (Exception ex) when (ex is IOException or TimeoutException or SocketException)
@@ -231,12 +304,13 @@ public async Task SendAsync(byte[] data, int retries = 2, int nullRetrie
private async Task ReceiveAsync(NetworkStream stream, int nullRetries = 1, int? overrideRecvTimeout = null, CancellationToken cancellationToken = default)
{
+ var isEnding = false;
byte[] result;
byte[] buffer = new byte[1024];
using (var ms = new MemoryStream())
{
int length = buffer.Length;
- while ((ms.Length < 16) || ((length = BitConverter.ToInt32(TuyaParser.BigEndian(ms.ToArray().Skip(12).Take(4)).ToArray(), 0) + 16) < ms.Length))
+ while (!isEnding && !stream.DataAvailable)
{
var timeoutCancellationTokenSource = new CancellationTokenSource();
var readTask = stream.ReadAsync(buffer, 0, length, cancellationToken: cancellationToken);
@@ -256,6 +330,16 @@ private async Task ReceiveAsync(NetworkStream stream, int nullRetries =
bytes = await readTask;
}
ms.Write(buffer, 0, bytes);
+ var receivedArray = ms.ToArray();
+ if (receivedArray.Length > 4)
+ {
+ var packetEnding = receivedArray.Skip(receivedArray.Length - 4).Take(4).ToArray();
+ isEnding = TuyaParser.SUFFIX.SequenceEqual(packetEnding);
+ }
+ else
+ {
+ isEnding = true;
+ }
}
result = ms.ToArray();
}
@@ -279,9 +363,9 @@ public async Task> GetDpsAsync(int retries = 5, int null
{
var requestJson = FillJson(null);
var response = await SendAsync(TuyaCommand.DP_QUERY, requestJson, retries, nullRetries, overrideRecvTimeout, cancellationToken);
- if (string.IsNullOrEmpty(response.JSON))
+ if (string.IsNullOrEmpty(response.Json))
throw new InvalidDataException("Response is empty");
- var root = JObject.Parse(response.JSON);
+ var root = JObject.Parse(response.Json);
var dps = JsonConvert.DeserializeObject>(root.GetValue("dps").ToString());
return dps.ToDictionary(kv => int.Parse(kv.Key), kv => kv.Value);
}
@@ -319,14 +403,14 @@ public async Task> SetDpsAsync(Dictionary d
string requestJson = JsonConvert.SerializeObject(cmd);
requestJson = FillJson(requestJson);
var response = await SendAsync(TuyaCommand.CONTROL, requestJson, retries, nullRetries, overrideRecvTimeout, cancellationToken);
- if (string.IsNullOrEmpty(response.JSON))
+ if (string.IsNullOrEmpty(response.Json))
{
if (!allowEmptyResponse)
throw new InvalidDataException("Response is empty");
else
return null;
}
- var root = JObject.Parse(response.JSON);
+ var root = JObject.Parse(response.Json);
var newDps = JsonConvert.DeserializeObject>(root.GetValue("dps").ToString());
return newDps.ToDictionary(kv => int.Parse(kv.Key), kv => kv.Value);
}
@@ -349,9 +433,9 @@ public async Task> UpdateDpsAsync(IEnumerable dpIds
string requestJson = JsonConvert.SerializeObject(cmd);
requestJson = FillJson(requestJson);
var response = await SendAsync(TuyaCommand.UPDATE_DPS, requestJson, retries, nullRetries, overrideRecvTimeout, cancellationToken);
- if (string.IsNullOrEmpty(response.JSON))
+ if (string.IsNullOrEmpty(response.Json))
return new Dictionary();
- var root = JObject.Parse(response.JSON);
+ var root = JObject.Parse(response.Json);
var newDps = JsonConvert.DeserializeObject>(root.GetValue("dps").ToString());
return newDps.ToDictionary(kv => int.Parse(kv.Key), kv => kv.Value);
}
diff --git a/TuyaDeviceApiInfo.cs b/src/TuyaNet/TuyaDeviceApiInfo.cs
similarity index 100%
rename from TuyaDeviceApiInfo.cs
rename to src/TuyaNet/TuyaDeviceApiInfo.cs
diff --git a/TuyaDeviceScanInfo.cs b/src/TuyaNet/TuyaDeviceScanInfo.cs
similarity index 100%
rename from TuyaDeviceScanInfo.cs
rename to src/TuyaNet/TuyaDeviceScanInfo.cs
diff --git a/TuyaDeviceStatus.cs b/src/TuyaNet/TuyaDeviceStatus.cs
similarity index 100%
rename from TuyaDeviceStatus.cs
rename to src/TuyaNet/TuyaDeviceStatus.cs
diff --git a/TuyaIRControl.cs b/src/TuyaNet/TuyaIRControl.cs
similarity index 100%
rename from TuyaIRControl.cs
rename to src/TuyaNet/TuyaIRControl.cs
diff --git a/TuyaLocalResponse.cs b/src/TuyaNet/TuyaLocalResponse.cs
similarity index 62%
rename from TuyaLocalResponse.cs
rename to src/TuyaNet/TuyaLocalResponse.cs
index 56e0b17..901775e 100644
--- a/TuyaLocalResponse.cs
+++ b/src/TuyaNet/TuyaLocalResponse.cs
@@ -9,22 +9,30 @@ public class TuyaLocalResponse
/// Command code.
///
public TuyaCommand Command { get; }
+
///
/// Return code.
///
public int ReturnCode { get; }
+
+ ///
+ /// Response as bytes string.
+ ///
+ public byte[] Payload { get; }
+
///
/// Response as JSON string.
///
- public string JSON { get; }
+ public string Json { get; }
- internal TuyaLocalResponse(TuyaCommand command, int returnCode, string json)
+ internal TuyaLocalResponse(TuyaCommand command, int returnCode, byte[] payload, string json = null)
{
Command = command;
ReturnCode = returnCode;
- JSON = json;
+ Payload = payload;
+ Json = json;
}
- public override string ToString() => $"{Command}: {JSON} (return code = {ReturnCode})";
+ public override string ToString() => $"{Command}: {Json} (return code = {ReturnCode})";
}
}
diff --git a/TuyaNet.csproj b/src/TuyaNet/TuyaNet.csproj
similarity index 93%
rename from TuyaNet.csproj
rename to src/TuyaNet/TuyaNet.csproj
index 3f819ea..a18da00 100644
--- a/TuyaNet.csproj
+++ b/src/TuyaNet/TuyaNet.csproj
@@ -1,7 +1,7 @@
- netstandard2.0
+ netstandard2.1
com.clusterrr.TuyaNet
TuyaNet
TuyaNet
@@ -37,4 +37,7 @@
+
+
+
diff --git a/src/TuyaNet/TuyaNet.xml b/src/TuyaNet/TuyaNet.xml
new file mode 100644
index 0000000..1f51d76
--- /dev/null
+++ b/src/TuyaNet/TuyaNet.xml
@@ -0,0 +1,461 @@
+
+
+
+ TuyaNet
+
+
+
+
+ Performs 32-bit reversed cyclic redundancy checks.
+
+
+
+
+ Generator polynomial (modulo 2) for the reversed CRC32 algorithm.
+
+
+
+
+ Creates a new instance of the Crc32 class.
+
+
+
+
+ Calculates the checksum of the byte stream.
+
+ The byte stream to calculate the checksum for.
+ A 32-bit reversed checksum.
+
+
+
+ Contains a cache of calculated checksum chunks.
+
+
+
+
+ Provides access to Tuya Cloud API.
+
+
+
+
+ Creates a new instance of the TuyaApi class.
+
+ Region of server.
+ Access ID/Client ID from https://iot.tuya.com/ .
+ API secret from https://iot.tuya.com/ .
+
+
+
+ Region of server.
+
+
+
+
+ Request method.
+
+
+
+
+ Request to official API.
+
+ Method URI.
+ Body of request if any.
+ Additional headers.
+ Execute query without token.
+ Refresh access token even it's not expired.
+ Cancellation token.
+ JSON string with response.
+
+
+
+ Request access token if it's expired or not requested yet.
+
+
+
+
+ Requests info about device by it's ID.
+
+ Device ID.
+ Refresh access token even it's not expired.
+ Cancellation token.
+ Device info.
+
+
+
+ Requests info about all registered devices, requires ID of any registered device.
+
+ ID of any registered device.
+ Refresh access token even it's not expired.
+ Cancellation token.
+ Array of devices info.
+
+
+
+ Tuya command type
+
+
+
+
+ Connection with Tuya device.
+
+
+
+
+ Creates a new instance of the TuyaDevice class.
+
+ IP address of device.
+ Local key of device (obtained via API).
+ Device ID.
+ Protocol version.
+ TCP port of device.
+ Receive timeout (msec).
+
+
+
+ Creates a new instance of the TuyaDevice class.
+
+ IP address of device.
+ Region to access Cloud API.
+ Access ID to access Cloud API.
+ API secret to access Cloud API.
+ Device ID.
+ Protocol version.
+ TCP port of device.
+ Receive timeout (msec).
+
+
+
+ IP address of device.
+
+
+
+
+ Local key of device.
+
+
+
+
+ Device ID.
+
+
+
+
+ TCP port of device.
+
+
+
+
+ Protocol version.
+
+
+
+
+ Connection timeout.
+
+
+
+
+ Receive timeout.
+
+
+
+
+ Network error retry interval (msec)
+
+
+
+
+ Empty responce retry interval (msec)
+
+
+
+
+ Permanent connection (connect and stay connected).
+
+
+
+
+ Fills JSON string with base fields required by most commands.
+
+ JSON string
+ Add "gwId" field with device ID.
+ Add "devId" field with device ID.
+ Add "uid" field with device ID.
+ Add "time" field with current timestamp.
+ JSON string with added fields.
+
+
+
+ Creates encoded and encrypted payload data from JSON string.
+
+ Tuya command ID.
+ String with JSON to send.
+ Raw data.
+
+
+
+ Parses and decrypts payload data from received bytes.
+
+ Raw data to parse and decrypt.
+ Instance of TuyaLocalResponse.
+
+
+
+ Sends JSON string to device and reads response.
+
+ Tuya command ID.
+ JSON string.
+ Number of retries in case of network error (default - 2).
+ Number of retries in case of empty answer (default - 1).
+ Override receive timeout (default - ReceiveTimeout property).
+ Cancellation token.
+ Parsed and decrypred received data as instance of TuyaLocalResponse.
+
+
+
+ Sends raw data over to device and read response.
+
+ Raw data to send.
+ Number of retries in case of network error (default - 2).
+ Number of retries in case of empty answer (default - 1).
+ Override receive timeout (default - ReceiveTimeout property).
+ Cancellation token.
+ Received data (raw).
+
+
+
+ Requests current DPs status.
+
+ Number of retries in case of network error (default - 2).
+ Number of retries in case of empty answer (default - 1).
+ Override receive timeout (default - ReceiveTimeout property).
+ Cancellation token.
+ Dictionary of DP numbers and values.
+
+
+
+ Sets single DP to specified value.
+
+ DP number.
+ Value.
+ Number of retries in case of network error (default - 2).
+ Number of retries in case of empty answer (default - 1).
+ Override receive timeout (default - ReceiveTimeout property).
+ Do not throw exception on empty Response
+ Cancellation token.
+ Dictionary of DP numbers and values.
+
+
+
+ Sets DPs to specified value.
+
+ Dictionary of DP numbers and values to set.
+ Number of retries in case of network error (default - 2).
+ Number of retries in case of empty answer (default - 1).
+ Override receive timeout (default - ReceiveTimeout property).
+ Do not throw exception on empty Response
+ Cancellation token.
+ Dictionary of DP numbers and values.
+
+
+
+ Update DP values.
+
+ DP identificators to update (can be empty for some devices).
+ Number of retries in case of network error (default - 2).
+ Number of retries in case of empty answer (default - 1).
+ Override receive timeout (default - ReceiveTimeout property).
+ Cancellation token.
+ Dictionary of DP numbers and values.
+
+
+
+ Get current local key from Tuya Cloud API
+
+ Refresh access token even it's not expired.
+ Cancellation token.
+
+
+
+ Disposes object.
+
+
+
+
+ Device info received from Tuya API.
+
+
+
+
+ Device info received from local network.
+
+
+
+
+ Currect device status.
+
+
+
+
+ DPS number
+
+
+
+
+ DPS value.
+
+
+
+
+ Tuya virtual IR remote control
+
+
+
+
+ Creates a new instance of the TuyaDevice class.
+
+ IP address of device.
+ Local key of device (obtained via API).
+ Device ID.
+ Protocol version.
+ TCP port of device.
+ Receive timeout (msec).
+
+
+
+ Creates a new instance of the TuyaDevice class.
+
+ IP address of device.
+ Region to access Cloud API.
+ Access ID to access Cloud API.
+ API secret to access Cloud API.
+ Device ID.
+ Protocol version.
+ TCP port of device.
+ Receive timeout (msec).
+
+
+
+ Learns button code of remote control.
+
+ Learing timeout, you should press RC button during this interval.
+ Cancellation token.
+ Button code as Base64 string.
+
+
+
+ Sends button code.
+
+ Button code in Base64 encoding.
+ Cancellation token.
+
+
+
+ Converts Base64 encoded button code into pulses duration.
+
+ Base64 encoded button code.
+ Pulses/gaps length in microsecods.
+
+
+
+ Converts pulses duration into Base64 encoded button code.
+
+ Pulses/gaps length in microsecods.
+ Base64 encoded button code.
+
+
+
+ Converts hex encoded button code into pulses duration.
+
+ Hex encoded button code.
+ Pulses/gaps length in microsecods.
+
+
+
+ Converts pulses duration into hex encoded button code.
+
+ Pulses/gaps length in microsecods.
+ Hex encoded button code.
+
+
+
+ Response from local Tuya device.
+
+
+
+
+ Command code.
+
+
+
+
+ Return code.
+
+
+
+
+ Response as bytes string.
+
+
+
+
+ Response as JSON string.
+
+
+
+
+ Class to encode and decode data sent over local network.
+
+
+
+
+ Tuya protocol version.
+
+
+
+
+ Version 3.1.
+
+
+
+
+ Version 3.3.
+
+
+
+
+ Version 3.4.
+
+
+
+
+ Scanner to discover devices over local network.
+
+
+
+
+ Even that will be called on every broadcast message from devices.
+
+
+
+
+ Even that will be called only once for every device.
+
+
+
+
+ Creates a new instance of the TuyaScanner class.
+
+
+
+
+ Starts scanner.
+
+
+
+
+ Stops scanner.
+
+
+
+
diff --git a/src/TuyaNet/TuyaParser.cs b/src/TuyaNet/TuyaParser.cs
new file mode 100644
index 0000000..210ebbe
--- /dev/null
+++ b/src/TuyaNet/TuyaParser.cs
@@ -0,0 +1,398 @@
+using Newtonsoft.Json.Linq;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace com.clusterrr.TuyaNet
+{
+ ///
+ /// Class to encode and decode data sent over local network.
+ ///
+ internal class TuyaParser
+ {
+ private static byte[] PROTOCOL_VERSION_BYTES_31 = Encoding.ASCII.GetBytes("3.1");
+ private static byte[] PROTOCOL_VERSION_BYTES_33 = Encoding.ASCII.GetBytes("3.3");
+ private static byte[] PROTOCOL_VERSION_BYTES_34 = Encoding.ASCII.GetBytes("3.4");
+ private static byte[] PROTOCOL_33_HEADER = Enumerable.Concat(PROTOCOL_VERSION_BYTES_33, new byte[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }).ToArray();
+ private static byte[] PROTOCOL_34_HEADER = Enumerable.Concat(PROTOCOL_VERSION_BYTES_34, new byte[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }).ToArray();
+ private static byte[] PREFIX = new byte[] { 0, 0, 0x55, 0xAA };
+ internal static byte[] SUFFIX = { 0, 0, 0xAA, 0x55 };
+ private uint SeqNo = 0;
+ private byte[] sessionKey;
+ private byte[] localKey;
+ private TuyaProtocolVersion version;
+
+ public TuyaParser(string localKey, TuyaProtocolVersion tuyaProtocolVersion)
+ : this(Encoding.UTF8.GetBytes(localKey), tuyaProtocolVersion)
+ {
+ }
+
+ public TuyaParser(byte[] localKey, TuyaProtocolVersion tuyaProtocolVersion)
+ {
+ this.sessionKey = null;
+ this.localKey = localKey;
+ this.version = tuyaProtocolVersion;
+ }
+
+ internal IEnumerable BigEndian(IEnumerable seq) => BitConverter.IsLittleEndian ? seq.Reverse() : seq;
+
+ internal byte[] Encrypt(byte[] data, byte[] key)
+ {
+ var aes = new AesManaged()
+ {
+ Mode = CipherMode.ECB,
+ Key = key
+ };
+ using (var ms = new MemoryStream())
+ using (var cs = new CryptoStream(ms, aes.CreateEncryptor(), CryptoStreamMode.Write))
+ {
+ cs.Write(data, 0, data.Length);
+ cs.Close();
+ data = ms.ToArray(); // encrypt the data
+ }
+ return data;
+ }
+
+ internal byte[] Encrypt34(byte[] data)
+ {
+ var key = GetKey();
+ var chiper = Aes.Create();
+ chiper.Key = key;
+ chiper.Mode = CipherMode.ECB;
+ chiper.Padding = PaddingMode.None;
+ var encryptor = chiper.CreateEncryptor();
+ var dest = encryptor.TransformFinalBlock(data, 0, data.Length);
+ return dest;
+ }
+
+ internal byte[] Decrypt(byte[] data, byte[] key)
+ {
+ if (data is null || data.Length == 0) return data;
+
+ var aes = new AesManaged()
+ {
+ Mode = CipherMode.ECB,
+ Key = key
+ };
+ using (var ms = new MemoryStream())
+ using (var cs = new CryptoStream(ms, aes.CreateDecryptor(), CryptoStreamMode.Write))
+ {
+ cs.Write(data, 0, data.Length);
+ cs.Close();
+ data = ms.ToArray(); // dencrypt the data
+ }
+ return data;
+ }
+
+ internal byte[] EncodeRequest(TuyaCommand command, string json, byte[] key, TuyaProtocolVersion protocolVersion = TuyaProtocolVersion.V33)
+ {
+ if (protocolVersion == TuyaProtocolVersion.V34)
+ return EncodeRequest34(command, json, key);
+
+ // Remove spaces and newlines
+ var root = JObject.Parse(json);
+ json = root.ToString(Newtonsoft.Json.Formatting.None);
+
+ byte[] payload = Encoding.UTF8.GetBytes(json);
+
+ if (protocolVersion == TuyaProtocolVersion.V33)
+ {
+ // Encrypt
+ payload = Encrypt(payload, key);
+ // Add protocol 3.3 header
+ if ((command != TuyaCommand.DP_QUERY) && (command != TuyaCommand.UPDATE_DPS))
+ payload = Enumerable.Concat(PROTOCOL_33_HEADER, payload).ToArray();
+ }
+ else if (command == TuyaCommand.CONTROL)
+ {
+ // Encrypt
+ payload = Encrypt(payload, key);
+ // Encode to base64
+ string data64 = Convert.ToBase64String(payload);
+ // Make string
+ payload = Encoding.UTF8.GetBytes($"data={data64}||lpv=3.1||");
+ using (var md5 = MD5.Create())
+ using (var ms = new MemoryStream())
+ {
+ // Calculate MD5 of data
+ ms.Write(payload, 0, payload.Length);
+ // ...and encryption key
+ ms.Write(key, 0, key.Length);
+ string md5s =
+ BitConverter.ToString( // Make string from MD5
+ md5.ComputeHash(ms.ToArray()) // Calculate MD5
+ )
+ .Replace("-", string.Empty) // Remove '-'
+ .Substring(8, 16) // Get part of it
+ .ToLower(); // Lowercase
+ // Data with protocol header, MD5 hash and data
+ payload = Encoding.UTF8.GetBytes($"3.1{md5s}{data64}");
+ }
+ }
+
+ using (var ms = new MemoryStream())
+ {
+ byte[] seqNo = BitConverter.GetBytes(++SeqNo);
+ if (BitConverter.IsLittleEndian) Array.Reverse(seqNo); // Make big-endian
+ byte[] dataLength = BitConverter.GetBytes(payload.Length + 8);
+ if (BitConverter.IsLittleEndian) Array.Reverse(dataLength); // Make big-endian
+
+ ms.Write(PREFIX, 0, 4); // Prefix
+ ms.Write(seqNo, 0, 4); // Packet number
+ ms.Write(new byte[] { 0, 0, 0, (byte)command }, 0, 4); // Command number
+ ms.Write(dataLength, 0, 4); // Length of data + length of suffix
+ ms.Write(payload, 0, payload.Length); // Data
+ var crc32 = new Crc32();
+ var crc = crc32.Get(ms.ToArray());
+ byte[] crcBin = BitConverter.GetBytes(crc);
+ if (BitConverter.IsLittleEndian) Array.Reverse(crcBin); // Make big-endian
+ ms.Write(crcBin, 0, 4); // CRC32 checksum
+ ms.Write(SUFFIX, 0, 4); // Suffix
+ payload = ms.ToArray();
+ }
+
+ return payload;
+ }
+
+ internal void SetupSessionKey(byte[] sessionKey)
+ {
+ this.sessionKey = sessionKey;
+ }
+
+ internal byte[] EncodeRequest34(TuyaCommand command, string json, byte[] key)
+ {
+ // Remove spaces and newlines
+ //"{\"data\":{\"ctype\":0,\"devId\":\"bf1d446bf5f3fbfc57fu5u\",\"gwId\":\"bf1d446bf5f3fbfc57fu5u\",\"uid\":\"\",\"dps\":{\"1\":true}},\"protocol\":5,\"t\":1694800339}"
+ var root = JObject.Parse(json);
+ json = root.ToString(Newtonsoft.Json.Formatting.None);
+ byte[] payload = Encoding.UTF8.GetBytes(json);
+ return EncodeRequest34(command, payload, key);
+ }
+
+ internal byte[] EncodeRequest34(TuyaCommand command, byte[] payload, byte[] key)
+ {
+ // Add protocol 3.4 header
+ if (
+ (command != TuyaCommand.DP_QUERY) &&
+ (command != TuyaCommand.HEART_BEAT) &&
+ (command != TuyaCommand.DP_QUERY_NEW) &&
+ (command != TuyaCommand.SESS_KEY_NEG_START) &&
+ (command != TuyaCommand.SESS_KEY_NEG_FINISH) &&
+ (command != TuyaCommand.UPDATE_DPS)
+ )
+ payload = Enumerable.Concat(PROTOCOL_34_HEADER, payload).ToArray();
+
+ var paddingSize = 0x10 - (payload.Length & 0xF);
+ payload = payload.Concat(Enumerable.Range(0, paddingSize).Select(x => (byte)paddingSize)).ToArray();
+
+ // Encrypt
+ payload = Encrypt34(payload);
+
+ using (var ms = new MemoryStream())
+ {
+ byte[] seqNo = BitConverter.GetBytes(++SeqNo);
+ if (BitConverter.IsLittleEndian)
+ Array.Reverse(seqNo); // Make big-endian
+ byte[] dataLength = BitConverter.GetBytes(payload.Length + 36);
+ if (BitConverter.IsLittleEndian)
+ Array.Reverse(dataLength); // Make big-endian
+
+ var commandBytes = BitConverter.GetBytes((uint)command);
+ if (BitConverter.IsLittleEndian)
+ Array.Reverse(commandBytes); // Make big-endian
+
+ ms.Write(PREFIX, 0, PREFIX.Length); // Prefix
+ ms.Write(seqNo, 0, seqNo.Length); // Packet number
+ ms.Write(commandBytes, 0, commandBytes.Length); // Command number
+ ms.Write(dataLength, 0, dataLength.Length); // Length of data + length of suffix
+ ms.Write(payload, 0, payload.Length); // Data
+ var hashHmacSha256 = GetHashSha256(ms.ToArray());
+ ms.Write(hashHmacSha256, 0, hashHmacSha256.Length);// hashHmacSha256 checksum
+ ms.Write(SUFFIX, 0, SUFFIX.Length); // Suffix
+ payload = ms.ToArray();
+ }
+ return payload;
+ }
+
+ internal byte[] GetHashSha256(byte[] byteArray)
+ {
+ var encryptKey = GetKey();
+ using (var hasher = new HMACSHA256(encryptKey))
+ {
+ var hashValue = hasher.ComputeHash(byteArray);
+ return hashValue;
+ }
+ }
+
+ internal byte[] GetKey()
+ {
+ return sessionKey is null ? localKey : sessionKey;
+ }
+
+ private TuyaLocalResponse ParseResponse(byte[] data)
+ {
+ var defaultUintSize = 4;
+ var headerSize = 16;
+ var returnCodeSize = defaultUintSize;
+ var suffixSize = defaultUintSize;
+ var responseHeaderSize = headerSize + returnCodeSize;
+ var hashSize = 32;
+ var crcSize = 4;
+ var endingHashWithSuffix = hashSize + defaultUintSize;
+ var endingCrcWithSuffix = crcSize + defaultUintSize;
+
+ // Check length and prefix
+ if (data.Length < 20 || !data.Take(PREFIX.Length).SequenceEqual(PREFIX))
+ {
+ throw new InvalidDataException("Invalid header/prefix");
+ }
+ // Check length
+ var payloadSize = BitConverter.ToInt32(BigEndian(data.Skip(12).Take(4)).ToArray(), 0);
+ if (data.Length != headerSize + payloadSize)
+ {
+ throw new InvalidDataException("Invalid length");
+ }
+ // Check suffix
+ if (!data.Skip(headerSize + payloadSize - SUFFIX.Length).Take(SUFFIX.Length).SequenceEqual(SUFFIX))
+ {
+ throw new InvalidDataException("Invalid suffix");
+ }
+
+ // Packet number
+ var seq = BitConverter.ToUInt32(BigEndian(data.Skip(4).Take(4)).ToArray(), 0);
+
+ // Command
+ var command = (TuyaCommand)BitConverter.ToUInt32(BigEndian(data.Skip(8).Take(4)).ToArray(), 0);
+ var isDiscoveryPackage =
+ command == TuyaCommand.UDP ||
+ command == TuyaCommand.UDP_NEW ||
+ command == TuyaCommand.BOARDCAST_LPV34;
+
+ // Return code
+ var returnCode = BitConverter.ToUInt32(BigEndian(data.Skip(headerSize).Take(4)).ToArray(), 0);
+
+ // Data parse
+ byte[] payload;
+ if ((returnCode & 0xFFFFFF00) > 0) {
+ if (this.version == TuyaProtocolVersion.V34 && !isDiscoveryPackage) {
+ payload = data.Skip(headerSize).Take(payloadSize - endingHashWithSuffix).ToArray();
+ } else {
+ payload = data.Skip(headerSize).Take(payloadSize - returnCodeSize - endingCrcWithSuffix).ToArray();
+ }
+ } else if (this.version == TuyaProtocolVersion.V34 && !isDiscoveryPackage) {
+ payload = data.Skip(responseHeaderSize).Take(payloadSize - returnCodeSize - endingHashWithSuffix).ToArray();
+ } else {
+ payload = data.Skip(responseHeaderSize).Take(payloadSize - returnCodeSize - endingCrcWithSuffix).ToArray();
+ }
+
+ if (this.version == TuyaProtocolVersion.V34 && !isDiscoveryPackage) {
+ var expected = data.Skip(responseHeaderSize + payload.Length).Take(hashSize).ToArray();
+ var computed = GetHashSha256(data.Take(responseHeaderSize + payload.Length).ToArray());
+ if (!expected.SequenceEqual(computed)) {
+ throw new Exception("HMAC mismatch.");
+ }
+ } else
+ {
+ var expected = data.Skip(responseHeaderSize + payload.Length).Take(crcSize).ToArray();
+ var crcComputed = new Crc32().Get(data.Take(responseHeaderSize + payload.Length).ToArray());
+ byte[] computed = BitConverter.GetBytes(crcComputed);
+ if (BitConverter.IsLittleEndian) Array.Reverse(computed);
+ if (!expected.SequenceEqual(computed))
+ {
+ throw new Exception("CRC mismatch.");
+ }
+ }
+
+ if (payload.Length == 0)
+ return new TuyaLocalResponse(command, (int)returnCode, null);
+
+ return new TuyaLocalResponse(command, (int)returnCode, payload);
+ }
+
+ internal TuyaLocalResponse DecodeResponse34(byte[] data)
+ {
+ var byteResponse = ParseResponse(data);
+ var decodedBytes = Decrypt(byteResponse.Payload, GetKey());
+
+ string json = null;
+ try
+ {
+ json = Encoding.UTF8.GetString(decodedBytes);
+ if (!json.StartsWith("{") || !json.EndsWith("}"))
+ {
+ json = null;
+ throw new InvalidDataException($"Response is not JSON: {json}");
+ }
+ }
+ catch (Exception e)
+ {
+ // ignored
+ }
+
+ return new TuyaLocalResponse(byteResponse.Command, byteResponse.ReturnCode, decodedBytes, json);
+ }
+
+ internal TuyaLocalResponse DecodeResponse(byte[] data)
+ {
+ if (version == TuyaProtocolVersion.V34)
+ return DecodeResponse34(data);
+
+ //todo rm next code and use decode34
+ // Check length and prefix
+ if (data.Length < 20 || !data.Take(PREFIX.Length).SequenceEqual(PREFIX))
+ {
+ throw new InvalidDataException("Invalid header/prefix");
+ }
+ // Check length
+ int length = BitConverter.ToInt32(BigEndian(data.Skip(12).Take(4)).ToArray(), 0);
+ if (data.Length != 16 + length)
+ {
+ throw new InvalidDataException("Invalid length");
+ }
+ // Check suffix
+ if (!data.Skip(16 + length - SUFFIX.Length).Take(SUFFIX.Length).SequenceEqual(SUFFIX))
+ {
+ throw new InvalidDataException("Invalid suffix");
+ }
+
+ // Packet number
+ // uint seq = BitConverter.ToUInt32(BinEndian(data.Skip(4).Take(4)).ToArray(), 0);
+ // Command
+ var command = (TuyaCommand)BitConverter.ToUInt32(BigEndian(data.Skip(8).Take(4)).ToArray(), 0);
+ // Return code
+ int returnCode = BitConverter.ToInt32(BigEndian(data.Skip(16).Take(4)).ToArray(), 0);
+ // Data
+ data = data.Skip(20).Take(length - 12).ToArray();
+
+ // Remove version 3.1 header
+ if (data.Take(PROTOCOL_VERSION_BYTES_31.Length).SequenceEqual(PROTOCOL_VERSION_BYTES_31))
+ {
+ data = data.Skip(PROTOCOL_VERSION_BYTES_31.Length).ToArray();
+ this.version = TuyaProtocolVersion.V31;
+ }
+ // Remove version 3.3 header
+ if (data.Take(PROTOCOL_VERSION_BYTES_33.Length).SequenceEqual(PROTOCOL_VERSION_BYTES_33))
+ {
+ data = data.Skip(PROTOCOL_33_HEADER.Length).ToArray();
+ this.version = TuyaProtocolVersion.V33;
+ }
+
+ if (this.version == TuyaProtocolVersion.V33)
+ {
+ data = Decrypt(data, GetKey());
+ }
+
+ if (data.Length == 0)
+ return new TuyaLocalResponse(command, returnCode, null, null);
+
+ var json = Encoding.UTF8.GetString(data);
+ if (!json.StartsWith("{") || !json.EndsWith("}"))
+ throw new InvalidDataException($"Response is not JSON: {json}");
+
+ return new TuyaLocalResponse(command, returnCode, data, json);
+ }
+ }
+}
diff --git a/TuyaProtocolVersion.cs b/src/TuyaNet/TuyaProtocolVersion.cs
similarity index 72%
rename from TuyaProtocolVersion.cs
rename to src/TuyaNet/TuyaProtocolVersion.cs
index d514965..f63f877 100644
--- a/TuyaProtocolVersion.cs
+++ b/src/TuyaNet/TuyaProtocolVersion.cs
@@ -12,6 +12,10 @@ public enum TuyaProtocolVersion
///
/// Version 3.3.
///
- V33
+ V33,
+ ///
+ /// Version 3.4.
+ ///
+ V34
}
}
diff --git a/TuyaScanner.cs b/src/TuyaNet/TuyaScanner.cs
similarity index 66%
rename from TuyaScanner.cs
rename to src/TuyaNet/TuyaScanner.cs
index 440fe94..9eecee5 100644
--- a/TuyaScanner.cs
+++ b/src/TuyaNet/TuyaScanner.cs
@@ -16,14 +16,20 @@ public class TuyaScanner
{
private const ushort UDP_PORT31 = 6666; // Tuya 3.1 UDP Port
private const ushort UDP_PORTS33 = 6667; // Tuya 3.3 encrypted UDP Port
+ private const ushort UDP_PORTS34 = 6667; // Tuya 3.3 encrypted UDP Port
private const string UDP_KEY = "yGAdlopoPVldABfn";
private bool running = false;
private UdpClient udpServer31 = null;
private UdpClient udpServer33 = null;
+ private UdpClient udpServer34 = null;
private Thread udpListener31 = null;
private Thread udpListener33 = null;
+ private Thread udpListener34 = null;
private List devices = new List();
+ private readonly TuyaParser parser31;
+ private readonly TuyaParser parser33;
+ private readonly TuyaParser parser34;
///
/// Even that will be called on every broadcast message from devices.
@@ -37,7 +43,17 @@ public class TuyaScanner
///
/// Creates a new instance of the TuyaScanner class.
///
- public TuyaScanner() { }
+ public TuyaScanner()
+ {
+ byte[] udpKey;
+ using (var md5 = MD5.Create())
+ {
+ udpKey = md5.ComputeHash(Encoding.ASCII.GetBytes(UDP_KEY));
+ }
+ parser31 = new TuyaParser(udpKey, TuyaProtocolVersion.V31);
+ parser33 = new TuyaParser(udpKey, TuyaProtocolVersion.V33);
+ parser34 = new TuyaParser(udpKey, TuyaProtocolVersion.V34);
+ }
///
/// Starts scanner.
@@ -49,10 +65,13 @@ public void Start()
devices.Clear();
udpServer31 = new UdpClient(UDP_PORT31);
udpServer33 = new UdpClient(UDP_PORTS33);
+ udpServer34 = new UdpClient(UDP_PORTS34);
udpListener31 = new Thread(UdpListener31Thread);
udpListener33 = new Thread(UdpListener33Thread);
+ udpListener34 = new Thread(UdpListener34Thread);
udpListener31.Start(udpServer31);
udpListener33.Start(udpServer33);
+ udpListener34.Start(udpServer34);
}
///
@@ -71,27 +90,27 @@ public void Stop()
udpServer33.Dispose();
udpServer33 = null;
}
+ if (udpServer34 != null)
+ {
+ udpServer34.Dispose();
+ udpServer34 = null;
+ }
udpListener31 = null;
udpListener33 = null;
+ udpListener34 = null;
}
private void UdpListener31Thread(object o)
{
var udpServer = o as UdpClient;
- byte[] udp_key;
- using (var md5 = MD5.Create())
- {
- udp_key = md5.ComputeHash(Encoding.ASCII.GetBytes(UDP_KEY));
- }
-
while (running)
{
try
{
IPEndPoint ep = null;
var data = udpServer.Receive(ref ep);
- var response = TuyaParser.DecodeResponse(data, udp_key, TuyaProtocolVersion.V31);
- Parse(response.JSON);
+ var response = parser31.DecodeResponse(data);
+ Parse(response.Json);
}
catch
{
@@ -104,11 +123,27 @@ private void UdpListener31Thread(object o)
private void UdpListener33Thread(object o)
{
var udpServer = o as UdpClient;
- byte[] udp_key;
- using (var md5 = MD5.Create())
+
+ while (running)
{
- udp_key = md5.ComputeHash(Encoding.ASCII.GetBytes(UDP_KEY));
+ try
+ {
+ IPEndPoint ep = null;
+ var data = udpServer.Receive(ref ep);
+ var response = parser33.DecodeResponse(data);
+ Parse(response.Json);
+ }
+ catch
+ {
+ if (!running) return;
+ throw;
+ }
}
+ }
+
+ private void UdpListener34Thread(object o)
+ {
+ var udpServer = o as UdpClient;
while (running)
{
@@ -116,8 +151,8 @@ private void UdpListener33Thread(object o)
{
IPEndPoint ep = null;
var data = udpServer.Receive(ref ep);
- var response = TuyaParser.DecodeResponse(data, udp_key, TuyaProtocolVersion.V33);
- Parse(response.JSON);
+ var response = parser34.DecodeResponse(data);
+ Parse(response.Json);
}
catch
{