using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; namespace com.clusterrr.TuyaNet { /// /// Provides access to Tuya Cloud API. /// public class TuyaApi { private readonly Region region; private readonly string accessId; private readonly string apiSecret; private readonly HttpClient httpClient; private TuyaToken token = null; private DateTime tokenTime = new DateTime(); public string TokenUid { get => token?.Uid;} private class TuyaToken { [JsonProperty("access_token")] public string AccessToken { get; set; } [JsonProperty("expire_time")] public int ExpireTime { get; set; } [JsonProperty("refresh_token")] public string RefreshToken { get; set; } [JsonProperty("uid")] public string Uid { get; set; } } /// /// 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/ . public TuyaApi(Region region, string accessId, string apiSecret) { this.region = region; this.accessId = accessId; this.apiSecret = apiSecret; httpClient = new HttpClient(); } /// /// Region of server. /// public enum Region { China, WesternAmerica, EasternAmerica, CentralEurope, WesternEurope, India } /// /// Request method. /// public enum Method { GET, POST, PUT, DELETE } private static string RegionToHost(Region region) { string urlHost = null; switch (region) { case Region.China: urlHost = "openapi.tuyacn.com"; break; case Region.WesternAmerica: urlHost = "openapi.tuyaus.com"; break; case Region.EasternAmerica: urlHost = "openapi-ueaz.tuyaus.com"; break; case Region.CentralEurope: urlHost = "openapi.tuyaeu.com"; break; case Region.WesternEurope: urlHost = "openapi-weaz.tuyaeu.com"; break; case Region.India: urlHost = "openapi.tuyain.com"; break; } return urlHost; } /// /// 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. public async Task RequestAsync(Method method, string uri, string body = null, Dictionary headers = null, bool noToken = false, bool forceTokenRefresh = false, CancellationToken cancellationToken = default) { while (uri.StartsWith("/")) uri = uri.Substring(1); var urlHost = RegionToHost(region); var url = new Uri($"https://{urlHost}/{uri}"); var now = (DateTime.Now.ToUniversalTime() - new DateTime(1970, 1, 1)).TotalMilliseconds.ToString("0"); string headersStr = ""; if (headers == null) { headers = new Dictionary(); } else { headersStr = string.Concat(headers.Select(kv => $"{kv.Key}:{kv.Value}\n")); headers.Add("Signature-Headers", string.Join(":", headers.Keys)); } string payload = accessId; if (noToken) { payload += now; headers["secret"] = apiSecret; } else { await RefreshAccessTokenAsync(forceTokenRefresh, cancellationToken); payload += token.AccessToken + now; } using (var sha256 = SHA256.Create()) { payload += $"{method}\n" + string.Concat(sha256.ComputeHash(Encoding.UTF8.GetBytes(body ?? "")).Select(b => $"{b:x2}")) + '\n' + headersStr + '\n' + url.PathAndQuery; } string signature; using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(apiSecret))) { signature = string.Concat(hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)).Select(b => $"{b:X2}")); } headers["client_id"] = accessId; headers["sign"] = signature; headers["t"] = now; headers["sign_method"] = "HMAC-SHA256"; if (!noToken) headers["access_token"] = token.AccessToken; var httpRequestMessage = new HttpRequestMessage { Method = method switch { Method.GET => HttpMethod.Get, Method.POST => HttpMethod.Post, Method.PUT => HttpMethod.Put, Method.DELETE => HttpMethod.Delete, _ => throw new NotSupportedException($"Unknow method - {method}") }, RequestUri = url, }; foreach (var h in headers) httpRequestMessage.Headers.Add(h.Key, h.Value); if (body != null) httpRequestMessage.Content = new StringContent(body, Encoding.UTF8, "application/json"); using (var response = await httpClient.SendAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false)) { var responseString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); var root = JObject.Parse(responseString); var success = root.GetValue("success").Value(); if (!success) throw new InvalidDataException(root.ContainsKey("msg") ? root.GetValue("msg").Value() : null); var result = root.GetValue("result").ToString(); return result; } } /// /// Request access token if it's expired or not requested yet. /// private async Task RefreshAccessTokenAsync(bool force = false, CancellationToken cancellationToken = default) { if (force || (token == null) || (tokenTime.AddSeconds(token.ExpireTime) >= DateTime.Now) // For some weird reason token expires sooner than it should || (tokenTime.AddMinutes(30) >= DateTime.Now)) { var uri = "v1.0/token?grant_type=1"; var response = await RequestAsync(Method.GET, uri, noToken: true, cancellationToken: cancellationToken); token = JsonConvert.DeserializeObject(response); tokenTime = DateTime.Now; } } /// /// Requests info about device by it's ID. /// /// Device ID. /// Refresh access token even it's not expired. /// Cancellation token. /// Device info. public async Task GetDeviceInfoAsync(string deviceId, bool forceTokenRefresh = false, CancellationToken cancellationToken = default) { var uri = $"v1.0/devices/{deviceId}"; var response = await RequestAsync(Method.GET, uri, forceTokenRefresh: forceTokenRefresh, cancellationToken: cancellationToken); var device = JsonConvert.DeserializeObject(response); return device; } /// /// 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. public async Task GetAllDevicesInfoAsync(string anyDeviceId, bool forceTokenRefresh = false, CancellationToken cancellationToken = default) { var userId = (await GetDeviceInfoAsync(anyDeviceId, forceTokenRefresh: forceTokenRefresh, cancellationToken: cancellationToken)).UserId; var uri = $"v1.0/users/{userId}/devices"; var response = await RequestAsync(Method.GET, uri, forceTokenRefresh: false, cancellationToken: cancellationToken); // Token already refreshed var devices = JsonConvert.DeserializeObject(response); return devices; } } }