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