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 {