using com.clusterrr.SemaphoreLock; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; namespace com.clusterrr.TuyaNet { /// /// Connection with Tuya device. /// public class TuyaDevice : IDisposable { /// /// 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). public TuyaDevice(string ip, string localKey, string deviceId, TuyaProtocolVersion protocolVersion = TuyaProtocolVersion.V33, int port = 6668, int receiveTimeout = 250) { IP = ip; LocalKey = localKey; this.accessId = null; this.apiSecret = null; DeviceId = deviceId; ProtocolVersion = protocolVersion; Port = port; ReceiveTimeout = receiveTimeout; } /// /// 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). public TuyaDevice(string ip, TuyaApi.Region region, string accessId, string apiSecret, string deviceId, TuyaProtocolVersion protocolVersion = TuyaProtocolVersion.V33, int port = 6668, int receiveTimeout = 250) { IP = ip; LocalKey = null; this.region = region; this.accessId = accessId; this.apiSecret = apiSecret; DeviceId = deviceId; ProtocolVersion = protocolVersion; Port = port; ReceiveTimeout = receiveTimeout; } /// /// IP address of device. /// public string IP { get; private set; } /// /// Local key of device. /// public string LocalKey { get; set; } /// /// Device ID. /// public string DeviceId { get; private set; } /// /// TCP port of device. /// public int Port { get; private set; } = 6668; /// /// Protocol version. /// public TuyaProtocolVersion ProtocolVersion { get; set; } /// /// Connection timeout. /// public int ConnectionTimeout { get; set; } = 500; /// /// Receive timeout. /// public int ReceiveTimeout { get; set; } /// /// Network error retry interval (msec) /// public int NetworkErrorRetriesInterval { get; set; } = 100; /// /// Empty responce retry interval (msec) /// public int NullRetriesInterval { get; set; } = 0; /// /// Permanent connection (connect and stay connected). /// public bool PermanentConnection { get; set; } = false; private TcpClient client = null; private TuyaApi.Region region; private string accessId; private string apiSecret; private SemaphoreSlim sem = new SemaphoreSlim(1); /// /// 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. public string FillJson(string json, bool addGwId = true, bool addDevId = true, bool addUid = true, bool addTime = true) { if (string.IsNullOrEmpty(json)) json = "{}"; var root = JObject.Parse(json); if ((addGwId || addDevId || addUid) && string.IsNullOrWhiteSpace(DeviceId)) throw new ArgumentNullException("deviceId", "Device ID can't be null."); if (addTime && !root.ContainsKey("t")) root.AddFirst(new JProperty("t", (DateTime.Now - new DateTime(1970, 1, 1)).TotalSeconds.ToString("0"))); if (addUid && !root.ContainsKey("uid")) root.AddFirst(new JProperty("uid", DeviceId)); if (addDevId && !root.ContainsKey("devId")) root.AddFirst(new JProperty("devId", DeviceId)); if (addGwId && !root.ContainsKey("gwId")) root.AddFirst(new JProperty("gwId", DeviceId)); return root.ToString(); } /// /// Creates encoded and encrypted payload data from JSON string. /// /// Tuya command ID. /// String with JSON to send. /// Raw data. 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); } /// /// Parses and decrypts payload data from received bytes. /// /// Raw data to parse and decrypt. /// Instance of TuyaLocalResponse. 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); } /// /// 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. 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)); /// /// 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). public async Task SendAsync(byte[] data, int retries = 2, int nullRetries = 1, int? overrideRecvTimeout = null, CancellationToken cancellationToken = default) { Exception lastException = null; while (retries-- > 0) { if (!PermanentConnection || (client?.Connected == false)) { client?.Close(); client?.Dispose(); client = null; } try { 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); } } catch (Exception ex) when (ex is IOException or TimeoutException or SocketException) { // sockets sometimes drop the connection unexpectedly, so let's // retry at least once lastException = ex; } catch (Exception ex) { throw ex; } finally { if (!PermanentConnection || (client?.Connected == false) || (lastException != null)) { client?.Close(); client?.Dispose(); client = null; } } await Task.Delay(NetworkErrorRetriesInterval, cancellationToken); } throw lastException; } private async Task ReceiveAsync(NetworkStream stream, int nullRetries = 1, int? overrideRecvTimeout = null, CancellationToken cancellationToken = default) { 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)) { var timeoutCancellationTokenSource = new CancellationTokenSource(); var readTask = stream.ReadAsync(buffer, 0, length, cancellationToken: cancellationToken); var timeoutTask = Task.Delay(overrideRecvTimeout ?? ReceiveTimeout, cancellationToken: timeoutCancellationTokenSource.Token); var t = await Task.WhenAny(readTask, timeoutTask).ConfigureAwait(false); timeoutCancellationTokenSource.Cancel(); int bytes = 0; if (t == timeoutTask) { if (stream.DataAvailable) bytes = await stream.ReadAsync(buffer, 0, length, cancellationToken); else throw new TimeoutException(); } else if (t == readTask) { bytes = await readTask; } ms.Write(buffer, 0, bytes); } result = ms.ToArray(); } if ((result.Length <= 28) && (nullRetries > 0)) // empty response { await Task.Delay(NullRetriesInterval, cancellationToken); result = await ReceiveAsync(stream, nullRetries - 1, overrideRecvTimeout: overrideRecvTimeout, cancellationToken); } return result; } /// /// 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. public async Task> GetDpsAsync(int retries = 5, int nullRetries = 1, int? overrideRecvTimeout = null, CancellationToken cancellationToken = default) { var requestJson = FillJson(null); var response = await SendAsync(TuyaCommand.DP_QUERY, requestJson, retries, nullRetries, overrideRecvTimeout, cancellationToken); if (string.IsNullOrEmpty(response.JSON)) throw new InvalidDataException("Response is empty"); 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); } /// /// 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. public async Task> SetDpAsync(int dp, object value, int retries = 2, int nullRetries = 1, int? overrideRecvTimeout = null, bool allowEmptyResponse = false, CancellationToken cancellationToken = default) => await SetDpsAsync(new Dictionary { { dp, value } }, retries, nullRetries, overrideRecvTimeout, allowEmptyResponse, cancellationToken); /// /// 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. public async Task> SetDpsAsync(Dictionary dps, int retries = 2, int nullRetries = 1, int? overrideRecvTimeout = null, bool allowEmptyResponse = false, CancellationToken cancellationToken = default) { var cmd = new Dictionary { { "dps", dps } }; 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 (!allowEmptyResponse) throw new InvalidDataException("Response is empty"); else return null; } 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); } /// /// 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. public async Task> UpdateDpsAsync(IEnumerable dpIds, int retries = 5, int nullRetries = 1, int? overrideRecvTimeout = null, CancellationToken cancellationToken = default) { var cmd = new Dictionary { { "dpId", dpIds.ToArray() } }; 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)) return new Dictionary(); 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); } /// /// Get current local key from Tuya Cloud API /// /// Refresh access token even it's not expired. /// Cancellation token. public async Task RefreshLocalKeyAsync(bool forceTokenRefresh = false, CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(accessId)) throw new ArgumentException("Access ID is not specified", "accessId"); if (string.IsNullOrEmpty(apiSecret)) throw new ArgumentException("API secret is not specified", "apiSecret"); var api = new TuyaApi(region, accessId, apiSecret); var deviceInfo = await api.GetDeviceInfoAsync(DeviceId, forceTokenRefresh: forceTokenRefresh, cancellationToken); LocalKey = deviceInfo.LocalKey; } /// /// Disposes object. /// public void Dispose() { client?.Close(); client?.Dispose(); client = null; } } }