Skip to main content

tuya_rs/
api.rs

1use std::collections::HashMap;
2
3use hmac::{Hmac, Mac};
4use sha2::{Digest, Sha256};
5use thiserror::Error;
6
7use crate::signing;
8
9/// API error types.
10#[derive(Debug, Error)]
11pub enum ApiError {
12    /// Session expired, re-login required.
13    #[error("session expired — need to re-login")]
14    SessionInvalid,
15    /// Wrong email or password.
16    #[error("wrong email or password")]
17    PasswordWrong,
18    /// API action not available for this client.
19    #[error("API action not available for this client")]
20    IllegalAccessApi,
21    /// HTTP network error.
22    #[error("network error: {0}")]
23    NetworkError(String),
24    /// Server returned an error with a Tuya error code.
25    #[error("server error {code}: {message}")]
26    ServerError {
27        /// Tuya error code.
28        code: String,
29        /// Error message from server.
30        message: String,
31    },
32    /// Failed to parse API response.
33    #[error("response parsing failed: {0}")]
34    ParseError(String),
35}
36
37/// OEM app credentials extracted from APK + Ghidra.
38#[derive(Debug, Clone)]
39pub struct OemCredentials {
40    /// Tuya app client ID.
41    pub client_id: String,
42    /// App secret key.
43    pub app_secret: String,
44    /// BMP signing key.
45    pub bmp_key: String,
46    /// APK certificate SHA-256 hash.
47    pub cert_hash: String,
48    /// Android package name.
49    pub package_name: String,
50    /// App installation device fingerprint, sent as `deviceId` in API requests.
51    pub app_device_id: String,
52}
53
54impl OemCredentials {
55    /// Build the HMAC key for API signing.
56    pub fn hmac_key(&self) -> String {
57        signing::build_hmac_key(
58            &self.package_name,
59            &self.cert_hash,
60            &self.bmp_key,
61            &self.app_secret,
62        )
63    }
64}
65
66/// Active API session.
67#[derive(Debug, Clone)]
68pub struct Session {
69    /// Session ID.
70    pub sid: String,
71    /// User ID.
72    pub uid: String,
73    /// Account email.
74    pub email: String,
75    /// API endpoint domain.
76    pub domain: String,
77}
78
79/// A discovered Tuya device.
80#[derive(Debug, Clone)]
81pub struct DeviceInfo {
82    /// Tuya device ID.
83    pub dev_id: String,
84    /// AES local encryption key.
85    pub local_key: String,
86    /// Device display name.
87    pub name: String,
88    /// Tuya product ID.
89    pub product_id: String,
90}
91
92/// A home/group containing devices.
93#[derive(Debug, Clone)]
94pub struct Home {
95    /// Group/home ID.
96    pub gid: u64,
97    /// Home display name.
98    pub name: String,
99}
100
101/// Cloud device info returned by the Tuya API.
102#[derive(Debug, Clone)]
103pub struct CloudDeviceInfo {
104    /// Tuya device ID.
105    pub dev_id: String,
106    /// Device display name.
107    pub name: String,
108    /// Whether the device is currently online.
109    pub is_online: bool,
110    /// Current DPS values, if available.
111    pub dps: Option<HashMap<String, serde_json::Value>>,
112}
113
114/// Cloud storage credentials for map download (AWS STS temporary).
115#[derive(Debug, Clone)]
116pub struct StorageCredentials {
117    /// AWS access key ID.
118    pub ak: String,
119    /// AWS secret access key.
120    pub sk: String,
121    /// AWS session token.
122    pub token: String,
123    /// S3 bucket name.
124    pub bucket: String,
125    /// S3 region.
126    pub region: String,
127    /// Credentials expiration timestamp.
128    pub expiration: String,
129    /// S3 object key prefix for map files.
130    pub path_prefix: String,
131}
132
133/// Build request parameters for a Tuya API call.
134///
135/// Returns pairs of (key, value). The `sign` parameter is computed and included.
136pub fn build_request_params(
137    creds: &OemCredentials,
138    action: &str,
139    version: &str,
140    post_data: &str,
141    session: Option<&Session>,
142    timestamp: &str,
143    request_id: &str,
144) -> Vec<(String, String)> {
145    let mut params: Vec<(&str, String)> = vec![
146        ("a", action.to_string()),
147        ("v", version.to_string()),
148        ("clientId", creds.client_id.clone()),
149        ("deviceId", creds.app_device_id.clone()),
150        ("os", "Android".to_string()),
151        ("lang", "en_US".to_string()),
152        ("appVersion", "1.0.10".to_string()),
153        ("ttid", format!("sdk_thing@{}", creds.client_id)),
154        ("time", timestamp.to_string()),
155        ("requestId", request_id.to_string()),
156        ("chKey", "71c35f83".to_string()),
157        ("postData", post_data.to_string()),
158    ];
159    if let Some(sess) = session {
160        params.push(("sid", sess.sid.clone()));
161    }
162
163    let sign_pairs: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_str())).collect();
164    let sign_string = signing::build_sign_string(&sign_pairs);
165    let hmac_key = creds.hmac_key();
166    let sign = signing::compute_sign(&sign_string, &hmac_key);
167
168    let mut result: Vec<(String, String)> = params
169        .into_iter()
170        .map(|(k, v)| (k.to_string(), v))
171        .collect();
172    result.push(("sign".to_string(), sign));
173    result
174}
175
176// ── AWS4 presigned URL signing ─────────────────────────────
177
178/// Derive the AWS4 signing key: HMAC chain of date → region → service → "aws4_request".
179pub fn derive_aws4_signing_key(
180    secret_key: &str,
181    date_stamp: &str,
182    region: &str,
183    service: &str,
184) -> Vec<u8> {
185    let k_date = hmac_sha256(
186        format!("AWS4{secret_key}").as_bytes(),
187        date_stamp.as_bytes(),
188    );
189    let k_region = hmac_sha256(&k_date, region.as_bytes());
190    let k_service = hmac_sha256(&k_region, service.as_bytes());
191    hmac_sha256(&k_service, b"aws4_request")
192}
193
194/// Generate an AWS4-HMAC-SHA256 pre-signed URL.
195///
196/// # Examples
197///
198/// ```
199/// use tuya_rs::api::generate_presigned_url;
200///
201/// let url = generate_presigned_url(
202///     "/maps/lay.bin",
203///     "AKIAIOSFODNN7EXAMPLE",
204///     "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
205///     "session-token",
206///     "my-bucket.s3.amazonaws.com",
207///     "eu-west-1",
208///     "20260101T120000Z",
209///     3600,
210/// );
211/// assert!(url.starts_with("https://"));
212/// assert!(url.contains("X-Amz-Signature="));
213/// ```
214#[allow(clippy::too_many_arguments)]
215pub fn generate_presigned_url(
216    path: &str,
217    ak: &str,
218    sk: &str,
219    token: &str,
220    bucket: &str,
221    region: &str,
222    amz_date: &str,
223    expires: u32,
224) -> String {
225    let date_stamp = &amz_date[..8];
226    let host = format!("{bucket}.{region}");
227    let credential_scope = format!("{date_stamp}/{region}/s3/aws4_request");
228    let credential = format!("{ak}/{credential_scope}");
229
230    // Canonical query string (sorted)
231    let mut query_params = [
232        ("X-Amz-Algorithm", "AWS4-HMAC-SHA256".to_string()),
233        ("X-Amz-Credential", credential),
234        ("X-Amz-Date", amz_date.to_string()),
235        ("X-Amz-Expires", expires.to_string()),
236        ("X-Amz-Security-Token", token.to_string()),
237        ("X-Amz-SignedHeaders", "host".to_string()),
238    ];
239    query_params.sort_by_key(|(k, _)| *k);
240
241    let canonical_querystring: String = query_params
242        .iter()
243        .map(|(k, v)| format!("{}={}", url_encode(k), url_encode(v)))
244        .collect::<Vec<_>>()
245        .join("&");
246
247    // Canonical request
248    let canonical_request =
249        format!("GET\n{path}\n{canonical_querystring}\nhost:{host}\n\nhost\nUNSIGNED-PAYLOAD");
250
251    // String to sign
252    let canonical_hash = sha256_hex(canonical_request.as_bytes());
253    let string_to_sign =
254        format!("AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{canonical_hash}");
255
256    // Signature
257    let signing_key = derive_aws4_signing_key(sk, date_stamp, region, "s3");
258    let signature =
259        crate::crypto::hex_encode(&hmac_sha256(&signing_key, string_to_sign.as_bytes()));
260
261    format!("https://{host}{path}?{canonical_querystring}&X-Amz-Signature={signature}")
262}
263
264fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
265    let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC key");
266    mac.update(data);
267    mac.finalize().into_bytes().to_vec()
268}
269
270fn sha256_hex(data: &[u8]) -> String {
271    let mut hasher = Sha256::new();
272    hasher.update(data);
273    crate::crypto::hex_encode(&hasher.finalize())
274}
275
276fn url_encode(s: &str) -> String {
277    let mut result = String::new();
278    for byte in s.as_bytes() {
279        match *byte {
280            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
281                result.push(*byte as char);
282            }
283            _ => {
284                result.push_str(&format!("%{byte:02X}"));
285            }
286        }
287    }
288    result
289}
290
291// ── HTTP client abstraction ────────────────────────────────
292
293/// HTTP transport abstraction for Tuya API calls.
294///
295/// Implement this trait to provide a custom HTTP backend (e.g. for testing).
296#[allow(async_fn_in_trait)]
297pub trait HttpClient {
298    /// Send a POST request with form parameters, returning the response body.
299    async fn post_form(
300        &self,
301        endpoint: &str,
302        params: &[(String, String)],
303    ) -> Result<String, ApiError>;
304}
305
306/// Default HTTP client using [`reqwest`].
307#[cfg(feature = "cloud")]
308pub struct ReqwestClient {
309    client: reqwest::Client,
310}
311
312#[cfg(feature = "cloud")]
313impl HttpClient for ReqwestClient {
314    async fn post_form(
315        &self,
316        endpoint: &str,
317        params: &[(String, String)],
318    ) -> Result<String, ApiError> {
319        let url = reqwest::Url::parse_with_params(endpoint, params)
320            .map_err(|e| ApiError::NetworkError(e.to_string()))?;
321        let resp = self
322            .client
323            .post(url)
324            .header("Content-Type", "application/x-www-form-urlencoded")
325            .send()
326            .await
327            .map_err(|e| ApiError::NetworkError(e.to_string()))?;
328        resp.text()
329            .await
330            .map_err(|e| ApiError::NetworkError(e.to_string()))
331    }
332}
333
334// ── TuyaApi trait ──────────────────────────────────────────
335
336#[allow(async_fn_in_trait)]
337/// Tuya OEM Mobile API client trait.
338pub trait TuyaApi {
339    /// Authenticate with email and password, returning an active session.
340    async fn login(&mut self, email: &str, password: &str) -> Result<Session, ApiError>;
341    /// Return the current session, if logged in.
342    fn session(&self) -> Option<&Session>;
343    /// List all homes/groups for the logged-in user.
344    async fn list_homes(&self) -> Result<Vec<Home>, ApiError>;
345    /// List all devices in the given home/group.
346    async fn list_devices(&self, gid: u64) -> Result<Vec<DeviceInfo>, ApiError>;
347    /// Get temporary AWS credentials for downloading map files.
348    async fn storage_config(&self, dev_id: &str) -> Result<StorageCredentials, ApiError>;
349    /// Publish DPS values to a device via the cloud.
350    async fn publish_dps(&self, dev_id: &str, dps: &serde_json::Value) -> Result<(), ApiError>;
351    /// Get device info (name, online status, current DPS) from the cloud.
352    async fn device_info(&self, dev_id: &str) -> Result<CloudDeviceInfo, ApiError>;
353}
354
355// ── Concrete API client ────────────────────────────────────
356
357/// Tuya OEM API client, generic over the HTTP transport.
358///
359/// Use [`TuyaOemApi::new`] for the default [`ReqwestClient`] backend,
360/// or [`TuyaOemApi::with_http`] to inject a custom [`HttpClient`] (e.g. for testing).
361#[cfg(feature = "cloud")]
362pub struct TuyaOemApi<H: HttpClient = ReqwestClient> {
363    /// OEM app credentials.
364    pub credentials: OemCredentials,
365    /// Active session after login.
366    pub session: Option<Session>,
367    /// HTTP transport.
368    pub http: H,
369    /// API endpoint URL.
370    pub endpoint: String,
371}
372
373#[cfg(feature = "cloud")]
374impl TuyaOemApi {
375    /// Create a new API client with the given OEM credentials.
376    pub fn new(credentials: OemCredentials) -> Self {
377        Self {
378            credentials,
379            session: None,
380            http: ReqwestClient {
381                client: reqwest::Client::new(),
382            },
383            endpoint: "https://a1.tuyaeu.com/api.json".to_string(),
384        }
385    }
386}
387
388#[cfg(feature = "cloud")]
389impl<H: HttpClient> TuyaOemApi<H> {
390    /// Create a new API client with a custom HTTP transport.
391    pub fn with_http(credentials: OemCredentials, http: H) -> Self {
392        Self {
393            credentials,
394            session: None,
395            http,
396            endpoint: "https://a1.tuyaeu.com/api.json".to_string(),
397        }
398    }
399
400    /// Execute a raw Tuya API call, returning the response body.
401    ///
402    /// Builds signed request parameters, sends via the HTTP transport,
403    /// and checks for Tuya error codes in the response.
404    pub async fn raw_call(
405        &self,
406        action: &str,
407        version: &str,
408        post_data: &str,
409        extra_params: &[(&str, &str)],
410    ) -> Result<String, ApiError> {
411        let timestamp = std::time::SystemTime::now()
412            .duration_since(std::time::UNIX_EPOCH)
413            .unwrap()
414            .as_secs()
415            .to_string();
416        let request_id = uuid::Uuid::new_v4().to_string();
417
418        let mut params = build_request_params(
419            &self.credentials,
420            action,
421            version,
422            post_data,
423            self.session.as_ref(),
424            &timestamp,
425            &request_id,
426        );
427
428        for (k, v) in extra_params {
429            params.push((k.to_string(), v.to_string()));
430        }
431
432        let body = self.http.post_form(&self.endpoint, &params).await?;
433
434        // Check for API errors
435        check_api_error(&body)?;
436
437        Ok(body)
438    }
439}
440
441/// Check a Tuya API response body for error codes.
442///
443/// Returns `Ok(())` if no error is found, or the appropriate [`ApiError`].
444#[cfg(feature = "cloud")]
445fn check_api_error(body: &str) -> Result<(), ApiError> {
446    if let Ok(json) = serde_json::from_str::<serde_json::Value>(body)
447        && let Some(err_code) = json.get("errorCode").and_then(|v| v.as_str())
448    {
449        let msg = json
450            .get("errorMsg")
451            .and_then(|v| v.as_str())
452            .unwrap_or("")
453            .to_string();
454        return match err_code {
455            "USER_SESSION_INVALID" => Err(ApiError::SessionInvalid),
456            "USER_PASSWD_WRONG" => Err(ApiError::PasswordWrong),
457            "ILLEGAL_ACCESS_API" => Err(ApiError::IllegalAccessApi),
458            _ => Err(ApiError::ServerError {
459                code: err_code.to_string(),
460                message: msg,
461            }),
462        };
463    }
464    Ok(())
465}
466
467#[cfg(feature = "cloud")]
468impl<H: HttpClient> TuyaApi for TuyaOemApi<H> {
469    async fn login(&mut self, email: &str, password: &str) -> Result<Session, ApiError> {
470        use crate::crypto;
471        use num_bigint::BigUint;
472
473        // Step 1: token create
474        let post_data = serde_json::json!({
475            "countryCode": "",
476            "email": email
477        })
478        .to_string();
479
480        let resp = self
481            .raw_call("tuya.m.user.email.token.create", "1.0", &post_data, &[])
482            .await?;
483        let resp: serde_json::Value =
484            serde_json::from_str(&resp).map_err(|e| ApiError::ParseError(e.to_string()))?;
485
486        let result = resp
487            .get("result")
488            .ok_or_else(|| ApiError::ParseError("no result in token response".into()))?;
489
490        let token = result["token"]
491            .as_str()
492            .ok_or_else(|| ApiError::ParseError("no token".into()))?;
493        let public_key = result["publicKey"]
494            .as_str()
495            .ok_or_else(|| ApiError::ParseError("no publicKey".into()))?;
496        let exponent = result["exponent"]
497            .as_str()
498            .ok_or_else(|| ApiError::ParseError("no exponent".into()))?;
499
500        // Step 2: encrypt password
501        let modulus = public_key
502            .parse::<BigUint>()
503            .map_err(|e| ApiError::ParseError(format!("invalid publicKey: {e}")))?;
504        let exp = exponent
505            .parse::<BigUint>()
506            .map_err(|e| ApiError::ParseError(format!("invalid exponent: {e}")))?;
507
508        let encrypted_passwd = crypto::encrypt_password(password, &modulus, &exp);
509
510        // Step 3: login
511        let post_data = serde_json::json!({
512            "countryCode": "",
513            "email": email,
514            "ifencrypt": 1,
515            "options": "{\"group\": 1}",
516            "passwd": encrypted_passwd,
517            "token": token,
518        })
519        .to_string();
520
521        let resp = self
522            .raw_call("tuya.m.user.email.password.login", "1.0", &post_data, &[])
523            .await?;
524        let resp: serde_json::Value =
525            serde_json::from_str(&resp).map_err(|e| ApiError::ParseError(e.to_string()))?;
526
527        let result = resp.get("result").ok_or(ApiError::PasswordWrong)?;
528
529        let session = Session {
530            sid: result["sid"]
531                .as_str()
532                .ok_or_else(|| ApiError::ParseError("no sid".into()))?
533                .to_string(),
534            uid: result["uid"].as_str().unwrap_or("").to_string(),
535            email: email.to_string(),
536            domain: result
537                .get("domain")
538                .and_then(|d| d.get("mobileApiUrl"))
539                .and_then(|v| v.as_str())
540                .unwrap_or("https://a1.tuyaeu.com")
541                .to_string(),
542        };
543
544        self.session = Some(session.clone());
545        Ok(session)
546    }
547
548    fn session(&self) -> Option<&Session> {
549        self.session.as_ref()
550    }
551
552    async fn list_homes(&self) -> Result<Vec<Home>, ApiError> {
553        let resp = self
554            .raw_call("tuya.m.location.list", "1.0", "{}", &[])
555            .await?;
556        let resp: serde_json::Value =
557            serde_json::from_str(&resp).map_err(|e| ApiError::ParseError(e.to_string()))?;
558        let results = resp["result"]
559            .as_array()
560            .ok_or_else(|| ApiError::ParseError("no result array".into()))?;
561
562        Ok(results
563            .iter()
564            .filter_map(|h| {
565                let gid = h
566                    .get("groupId")
567                    .or_else(|| h.get("gid"))
568                    .and_then(|v| v.as_u64())?;
569                let name = h["name"].as_str().unwrap_or("").to_string();
570                Some(Home { gid, name })
571            })
572            .collect())
573    }
574
575    async fn list_devices(&self, gid: u64) -> Result<Vec<DeviceInfo>, ApiError> {
576        let resp = self
577            .raw_call(
578                "tuya.m.my.group.device.list",
579                "1.0",
580                "{}",
581                &[("gid", &gid.to_string())],
582            )
583            .await?;
584        let resp: serde_json::Value =
585            serde_json::from_str(&resp).map_err(|e| ApiError::ParseError(e.to_string()))?;
586        let results = resp["result"]
587            .as_array()
588            .ok_or_else(|| ApiError::ParseError("no result array".into()))?;
589
590        Ok(results
591            .iter()
592            .filter_map(|d| {
593                Some(DeviceInfo {
594                    dev_id: d["devId"].as_str()?.to_string(),
595                    local_key: d["localKey"].as_str().unwrap_or("").to_string(),
596                    name: d["name"].as_str().unwrap_or("").to_string(),
597                    product_id: d["productId"].as_str().unwrap_or("").to_string(),
598                })
599            })
600            .collect())
601    }
602
603    async fn storage_config(&self, dev_id: &str) -> Result<StorageCredentials, ApiError> {
604        let post_data = serde_json::json!({
605            "devId": dev_id,
606            "type": "Common"
607        })
608        .to_string();
609
610        let resp = self
611            .raw_call("thing.m.dev.storage.config.get", "1.0", &post_data, &[])
612            .await?;
613        let resp: serde_json::Value =
614            serde_json::from_str(&resp).map_err(|e| ApiError::ParseError(e.to_string()))?;
615        let result = resp
616            .get("result")
617            .ok_or_else(|| ApiError::ParseError("no result".into()))?;
618
619        Ok(StorageCredentials {
620            ak: result["ak"].as_str().unwrap_or("").to_string(),
621            sk: result["sk"].as_str().unwrap_or("").to_string(),
622            token: result["token"].as_str().unwrap_or("").to_string(),
623            bucket: result["bucket"]
624                .as_str()
625                .unwrap_or("ty-eu-storage-permanent")
626                .to_string(),
627            region: result["region"]
628                .as_str()
629                .unwrap_or("tuyaeu.com")
630                .to_string(),
631            expiration: result["expiration"].as_str().unwrap_or("").to_string(),
632            path_prefix: result["pathConfig"]["common"]
633                .as_str()
634                .unwrap_or("")
635                .to_string(),
636        })
637    }
638
639    async fn publish_dps(&self, dev_id: &str, dps: &serde_json::Value) -> Result<(), ApiError> {
640        let post_data = serde_json::json!({
641            "devId": dev_id,
642            "gwId": dev_id,
643            "dps": dps,
644        })
645        .to_string();
646
647        self.raw_call("tuya.m.device.dp.publish", "1.0", &post_data, &[])
648            .await?;
649        Ok(())
650    }
651
652    async fn device_info(&self, dev_id: &str) -> Result<CloudDeviceInfo, ApiError> {
653        let post_data = serde_json::json!({
654            "devId": dev_id,
655        })
656        .to_string();
657
658        let resp = self
659            .raw_call("tuya.m.device.get", "1.0", &post_data, &[])
660            .await?;
661        let resp: serde_json::Value =
662            serde_json::from_str(&resp).map_err(|e| ApiError::ParseError(e.to_string()))?;
663        let result = resp
664            .get("result")
665            .ok_or_else(|| ApiError::ParseError("no result".into()))?;
666
667        let dps = result
668            .get("dps")
669            .and_then(|v| v.as_object())
670            .map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
671
672        Ok(CloudDeviceInfo {
673            dev_id: result["devId"].as_str().unwrap_or(dev_id).to_string(),
674            name: result["name"].as_str().unwrap_or("").to_string(),
675            is_online: result["isOnline"].as_bool().unwrap_or(false),
676            dps,
677        })
678    }
679}
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684
685    fn test_creds() -> OemCredentials {
686        OemCredentials {
687            client_id: "test_client_id_placeholder".into(),
688            app_secret: "test_app_secret_placeholder_here".into(),
689            bmp_key: "test_bmp_key_placeholder_here_xx".into(),
690            cert_hash: "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99".into(),
691            package_name: "com.example.test.app".into(),
692            app_device_id: "test_app_device_id_placeholder".into(),
693        }
694    }
695
696    #[test]
697    fn oem_credentials_hmac_key() {
698        let creds = test_creds();
699        let key = creds.hmac_key();
700        // Format: packageName_certHash_bmpKey_appSecret
701        assert!(key.starts_with("com.example.test.app_AA:BB:"));
702        assert!(key.contains("_test_bmp_key_placeholder_here_xx_"));
703        assert!(key.ends_with("_test_app_secret_placeholder_here"));
704    }
705
706    #[test]
707    fn build_request_params_contains_required() {
708        let creds = test_creds();
709        let params = build_request_params(
710            &creds,
711            "tuya.m.location.list",
712            "1.0",
713            "{}",
714            None,
715            "1770808371",
716            "test-uuid",
717        );
718
719        let find = |key: &str| -> Option<String> {
720            params
721                .iter()
722                .find(|(k, _)| k == key)
723                .map(|(_, v)| v.clone())
724        };
725
726        assert_eq!(find("a").unwrap(), "tuya.m.location.list");
727        assert_eq!(find("v").unwrap(), "1.0");
728        assert_eq!(find("clientId").unwrap(), "test_client_id_placeholder");
729        assert_eq!(find("os").unwrap(), "Android");
730        assert_eq!(find("lang").unwrap(), "en_US");
731        assert!(find("sign").is_some());
732        assert!(find("postData").is_some());
733        assert!(find("time").is_some());
734        assert!(find("requestId").is_some());
735        // No session → no sid
736        assert!(find("sid").is_none());
737    }
738
739    #[test]
740    fn build_request_params_with_session() {
741        let creds = test_creds();
742        let session = Session {
743            sid: "test-sid".into(),
744            uid: "uid".into(),
745            email: "test@test.com".into(),
746            domain: "https://a1.tuyaeu.com".into(),
747        };
748        let params =
749            build_request_params(&creds, "test", "1.0", "{}", Some(&session), "123", "uuid");
750
751        let find = |key: &str| -> Option<String> {
752            params
753                .iter()
754                .find(|(k, _)| k == key)
755                .map(|(_, v)| v.clone())
756        };
757
758        assert_eq!(find("sid").unwrap(), "test-sid");
759    }
760
761    #[test]
762    fn derive_aws4_signing_key_deterministic() {
763        let key1 = derive_aws4_signing_key("mysecret", "20260213", "tuyaeu.com", "s3");
764        let key2 = derive_aws4_signing_key("mysecret", "20260213", "tuyaeu.com", "s3");
765        assert_eq!(key1, key2);
766        assert_eq!(key1.len(), 32);
767
768        // Different date → different key
769        let key3 = derive_aws4_signing_key("mysecret", "20260214", "tuyaeu.com", "s3");
770        assert_ne!(key1, key3);
771    }
772
773    #[test]
774    fn generate_presigned_url_structure() {
775        let url = generate_presigned_url(
776            "/test/path/lay.bin",
777            "TESTAKID",
778            "testsecret",
779            "testtoken",
780            "ty-eu-storage-permanent",
781            "tuyaeu.com",
782            "20260213T120000Z",
783            86400,
784        );
785
786        assert!(url.starts_with("https://ty-eu-storage-permanent.tuyaeu.com/test/path/lay.bin?"));
787        assert!(url.contains("X-Amz-Algorithm=AWS4-HMAC-SHA256"));
788        assert!(url.contains("X-Amz-Credential=TESTAKID"));
789        assert!(url.contains("X-Amz-Date=20260213T120000Z"));
790        assert!(url.contains("X-Amz-Expires=86400"));
791        assert!(url.contains("X-Amz-Security-Token=testtoken"));
792        assert!(url.contains("X-Amz-SignedHeaders=host"));
793        assert!(url.contains("X-Amz-Signature="));
794    }
795
796    #[test]
797    fn url_encode_special_chars() {
798        assert_eq!(url_encode("hello world"), "hello%20world");
799        assert_eq!(url_encode("a/b"), "a%2Fb");
800        assert_eq!(url_encode("safe-chars_here.txt~"), "safe-chars_here.txt~");
801    }
802
803    #[test]
804    fn url_encode_all_unreserved() {
805        // All unreserved chars should pass through
806        let unreserved = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~";
807        assert_eq!(url_encode(unreserved), unreserved);
808    }
809
810    #[test]
811    fn url_encode_symbols() {
812        assert_eq!(url_encode("+"), "%2B");
813        assert_eq!(url_encode("="), "%3D");
814        assert_eq!(url_encode("&"), "%26");
815        assert_eq!(url_encode("@"), "%40");
816        assert_eq!(url_encode(":"), "%3A");
817    }
818
819    #[test]
820    fn sha256_hex_known_value() {
821        // SHA-256 of empty string
822        let hash = sha256_hex(b"");
823        assert_eq!(
824            hash,
825            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
826        );
827    }
828
829    #[cfg(feature = "cloud")]
830    #[test]
831    fn tuya_oem_api_new_defaults() {
832        let creds = test_creds();
833        let api = TuyaOemApi::new(creds.clone());
834        assert!(api.session.is_none());
835        assert_eq!(api.endpoint, "https://a1.tuyaeu.com/api.json");
836        assert_eq!(api.credentials.client_id, creds.client_id);
837    }
838
839    // ── MockHttpClient + async tests (cloud feature) ──────
840
841    #[cfg(feature = "cloud")]
842    mod cloud_tests {
843        use super::*;
844        use std::cell::RefCell;
845        use std::collections::VecDeque;
846
847        struct MockHttpClient {
848            responses: RefCell<VecDeque<String>>,
849        }
850
851        impl MockHttpClient {
852            fn new(responses: Vec<&str>) -> Self {
853                Self {
854                    responses: RefCell::new(responses.into_iter().map(String::from).collect()),
855                }
856            }
857        }
858
859        impl HttpClient for MockHttpClient {
860            async fn post_form(
861                &self,
862                _endpoint: &str,
863                _params: &[(String, String)],
864            ) -> Result<String, ApiError> {
865                self.responses
866                    .borrow_mut()
867                    .pop_front()
868                    .ok_or_else(|| ApiError::NetworkError("no more mock responses".into()))
869            }
870        }
871
872        fn mock_api(responses: Vec<&str>) -> TuyaOemApi<MockHttpClient> {
873            TuyaOemApi::with_http(test_creds(), MockHttpClient::new(responses))
874        }
875
876        // ── check_api_error ───────────────────────────────
877
878        #[test]
879        fn check_api_error_no_error() {
880            assert!(check_api_error(r#"{"result":"ok"}"#).is_ok());
881        }
882
883        #[test]
884        fn check_api_error_session_invalid() {
885            let body = r#"{"errorCode":"USER_SESSION_INVALID","errorMsg":"session expired"}"#;
886            assert!(matches!(
887                check_api_error(body),
888                Err(ApiError::SessionInvalid)
889            ));
890        }
891
892        #[test]
893        fn check_api_error_password_wrong() {
894            let body = r#"{"errorCode":"USER_PASSWD_WRONG","errorMsg":"bad password"}"#;
895            assert!(matches!(
896                check_api_error(body),
897                Err(ApiError::PasswordWrong)
898            ));
899        }
900
901        #[test]
902        fn check_api_error_illegal_access() {
903            let body = r#"{"errorCode":"ILLEGAL_ACCESS_API","errorMsg":"denied"}"#;
904            assert!(matches!(
905                check_api_error(body),
906                Err(ApiError::IllegalAccessApi)
907            ));
908        }
909
910        #[test]
911        fn check_api_error_unknown_code() {
912            let body = r#"{"errorCode":"SOMETHING_ELSE","errorMsg":"oops"}"#;
913            match check_api_error(body) {
914                Err(ApiError::ServerError { code, message }) => {
915                    assert_eq!(code, "SOMETHING_ELSE");
916                    assert_eq!(message, "oops");
917                }
918                other => panic!("expected ServerError, got {other:?}"),
919            }
920        }
921
922        #[test]
923        fn check_api_error_no_error_msg() {
924            let body = r#"{"errorCode":"CUSTOM_ERR"}"#;
925            match check_api_error(body) {
926                Err(ApiError::ServerError { message, .. }) => assert_eq!(message, ""),
927                other => panic!("expected ServerError, got {other:?}"),
928            }
929        }
930
931        #[test]
932        fn check_api_error_non_json() {
933            // Non-JSON body should pass through (no error detected)
934            assert!(check_api_error("not json at all").is_ok());
935        }
936
937        // ── raw_call ──────────────────────────────────────
938
939        #[tokio::test]
940        async fn raw_call_success() {
941            let api = mock_api(vec![r#"{"result":"ok"}"#]);
942            let body = api.raw_call("test.action", "1.0", "{}", &[]).await.unwrap();
943            assert_eq!(body, r#"{"result":"ok"}"#);
944        }
945
946        #[tokio::test]
947        async fn raw_call_with_extra_params() {
948            let api = mock_api(vec![r#"{"result":"ok"}"#]);
949            let body = api
950                .raw_call("test", "1.0", "{}", &[("gid", "123")])
951                .await
952                .unwrap();
953            assert_eq!(body, r#"{"result":"ok"}"#);
954        }
955
956        #[tokio::test]
957        async fn raw_call_error_response() {
958            let api = mock_api(vec![
959                r#"{"errorCode":"USER_SESSION_INVALID","errorMsg":"expired"}"#,
960            ]);
961            let err = api.raw_call("test", "1.0", "{}", &[]).await.unwrap_err();
962            assert!(matches!(err, ApiError::SessionInvalid));
963        }
964
965        // ── list_homes ────────────────────────────────────
966
967        #[tokio::test]
968        async fn list_homes_success() {
969            let api = mock_api(vec![
970                r#"{"result":[{"groupId":1,"name":"Home"},{"gid":2,"name":"Office"}]}"#,
971            ]);
972            let homes = api.list_homes().await.unwrap();
973            assert_eq!(homes.len(), 2);
974            assert_eq!(homes[0].gid, 1);
975            assert_eq!(homes[0].name, "Home");
976            assert_eq!(homes[1].gid, 2);
977            assert_eq!(homes[1].name, "Office");
978        }
979
980        #[tokio::test]
981        async fn list_homes_empty() {
982            let api = mock_api(vec![r#"{"result":[]}"#]);
983            let homes = api.list_homes().await.unwrap();
984            assert!(homes.is_empty());
985        }
986
987        #[tokio::test]
988        async fn list_homes_no_result() {
989            let api = mock_api(vec![r#"{"status":"ok"}"#]);
990            let err = api.list_homes().await.unwrap_err();
991            assert!(matches!(err, ApiError::ParseError(_)));
992        }
993
994        #[tokio::test]
995        async fn list_homes_skips_invalid_entries() {
996            // Entry without groupId/gid is skipped
997            let api = mock_api(vec![
998                r#"{"result":[{"name":"NoId"},{"groupId":5,"name":"Valid"}]}"#,
999            ]);
1000            let homes = api.list_homes().await.unwrap();
1001            assert_eq!(homes.len(), 1);
1002            assert_eq!(homes[0].gid, 5);
1003        }
1004
1005        // ── list_devices ──────────────────────────────────
1006
1007        #[tokio::test]
1008        async fn list_devices_success() {
1009            let api = mock_api(vec![
1010                r#"{"result":[{"devId":"d1","localKey":"k1","name":"Robot","productId":"p1"}]}"#,
1011            ]);
1012            let devs = api.list_devices(1).await.unwrap();
1013            assert_eq!(devs.len(), 1);
1014            assert_eq!(devs[0].dev_id, "d1");
1015            assert_eq!(devs[0].local_key, "k1");
1016            assert_eq!(devs[0].name, "Robot");
1017            assert_eq!(devs[0].product_id, "p1");
1018        }
1019
1020        #[tokio::test]
1021        async fn list_devices_defaults_for_missing_fields() {
1022            let api = mock_api(vec![r#"{"result":[{"devId":"d2"}]}"#]);
1023            let devs = api.list_devices(1).await.unwrap();
1024            assert_eq!(devs[0].local_key, "");
1025            assert_eq!(devs[0].name, "");
1026            assert_eq!(devs[0].product_id, "");
1027        }
1028
1029        #[tokio::test]
1030        async fn list_devices_skips_without_dev_id() {
1031            let api = mock_api(vec![r#"{"result":[{"name":"NoId"},{"devId":"d3"}]}"#]);
1032            let devs = api.list_devices(1).await.unwrap();
1033            assert_eq!(devs.len(), 1);
1034            assert_eq!(devs[0].dev_id, "d3");
1035        }
1036
1037        #[tokio::test]
1038        async fn list_devices_no_result() {
1039            let api = mock_api(vec![r#"{}"#]);
1040            assert!(matches!(
1041                api.list_devices(1).await.unwrap_err(),
1042                ApiError::ParseError(_)
1043            ));
1044        }
1045
1046        // ── storage_config ────────────────────────────────
1047
1048        #[tokio::test]
1049        async fn storage_config_success() {
1050            let api = mock_api(vec![
1051                r#"{"result":{
1052                "ak":"AK1","sk":"SK1","token":"TOK1",
1053                "bucket":"my-bucket","region":"eu-west-1",
1054                "expiration":"2026-01-01",
1055                "pathConfig":{"common":"/maps/dev1/"}
1056            }}"#,
1057            ]);
1058            let creds = api.storage_config("dev1").await.unwrap();
1059            assert_eq!(creds.ak, "AK1");
1060            assert_eq!(creds.sk, "SK1");
1061            assert_eq!(creds.token, "TOK1");
1062            assert_eq!(creds.bucket, "my-bucket");
1063            assert_eq!(creds.region, "eu-west-1");
1064            assert_eq!(creds.expiration, "2026-01-01");
1065            assert_eq!(creds.path_prefix, "/maps/dev1/");
1066        }
1067
1068        #[tokio::test]
1069        async fn storage_config_defaults() {
1070            let api = mock_api(vec![r#"{"result":{}}"#]);
1071            let creds = api.storage_config("dev1").await.unwrap();
1072            assert_eq!(creds.ak, "");
1073            assert_eq!(creds.bucket, "ty-eu-storage-permanent");
1074            assert_eq!(creds.region, "tuyaeu.com");
1075            assert_eq!(creds.path_prefix, "");
1076        }
1077
1078        #[tokio::test]
1079        async fn storage_config_no_result() {
1080            let api = mock_api(vec![r#"{}"#]);
1081            assert!(matches!(
1082                api.storage_config("dev1").await.unwrap_err(),
1083                ApiError::ParseError(_)
1084            ));
1085        }
1086
1087        // ── login ─────────────────────────────────────────
1088
1089        #[tokio::test]
1090        async fn login_success() {
1091            let mut api = mock_api(vec![
1092                // Step 1: token create response
1093                r#"{"result":{"token":"tok123","publicKey":"12345","exponent":"65537"}}"#,
1094                // Step 2: login response
1095                r#"{"result":{"sid":"session1","uid":"user1","domain":{"mobileApiUrl":"https://a2.tuyaeu.com"}}}"#,
1096            ]);
1097            let session = api.login("test@test.com", "password123").await.unwrap();
1098            assert_eq!(session.sid, "session1");
1099            assert_eq!(session.uid, "user1");
1100            assert_eq!(session.email, "test@test.com");
1101            assert_eq!(session.domain, "https://a2.tuyaeu.com");
1102            // Session is stored
1103            assert!(api.session().is_some());
1104            assert_eq!(api.session().unwrap().sid, "session1");
1105        }
1106
1107        #[tokio::test]
1108        async fn login_default_domain() {
1109            let mut api = mock_api(vec![
1110                r#"{"result":{"token":"tok","publicKey":"12345","exponent":"65537"}}"#,
1111                r#"{"result":{"sid":"s1"}}"#,
1112            ]);
1113            let session = api.login("a@b.com", "pw").await.unwrap();
1114            assert_eq!(session.domain, "https://a1.tuyaeu.com");
1115        }
1116
1117        #[tokio::test]
1118        async fn login_token_error_propagates() {
1119            let mut api = mock_api(vec![
1120                r#"{"errorCode":"ILLEGAL_ACCESS_API","errorMsg":"denied"}"#,
1121            ]);
1122            assert!(matches!(
1123                api.login("a@b.com", "pw").await.unwrap_err(),
1124                ApiError::IllegalAccessApi
1125            ));
1126        }
1127
1128        #[tokio::test]
1129        async fn login_no_token_in_response() {
1130            let mut api = mock_api(vec![r#"{"result":{}}"#]);
1131            assert!(matches!(
1132                api.login("a@b.com", "pw").await.unwrap_err(),
1133                ApiError::ParseError(_)
1134            ));
1135        }
1136
1137        #[tokio::test]
1138        async fn login_no_result_in_login_response() {
1139            let mut api = mock_api(vec![
1140                r#"{"result":{"token":"tok","publicKey":"12345","exponent":"65537"}}"#,
1141                r#"{"status":"error"}"#, // no "result" key
1142            ]);
1143            assert!(matches!(
1144                api.login("a@b.com", "pw").await.unwrap_err(),
1145                ApiError::PasswordWrong
1146            ));
1147        }
1148
1149        #[tokio::test]
1150        async fn login_invalid_public_key() {
1151            let mut api = mock_api(vec![
1152                r#"{"result":{"token":"tok","publicKey":"not_a_number","exponent":"65537"}}"#,
1153            ]);
1154            assert!(matches!(
1155                api.login("a@b.com", "pw").await.unwrap_err(),
1156                ApiError::ParseError(_)
1157            ));
1158        }
1159
1160        #[tokio::test]
1161        async fn login_no_sid_in_response() {
1162            let mut api = mock_api(vec![
1163                r#"{"result":{"token":"tok","publicKey":"12345","exponent":"65537"}}"#,
1164                r#"{"result":{"uid":"u1"}}"#, // no sid
1165            ]);
1166            assert!(matches!(
1167                api.login("a@b.com", "pw").await.unwrap_err(),
1168                ApiError::ParseError(_)
1169            ));
1170        }
1171
1172        // ── publish_dps ───────────────────────────────────
1173
1174        #[tokio::test]
1175        async fn publish_dps_success() {
1176            let api = mock_api(vec![r#"{"result":true}"#]);
1177            api.publish_dps("dev1", &serde_json::json!({"1": true}))
1178                .await
1179                .unwrap();
1180        }
1181
1182        #[tokio::test]
1183        async fn publish_dps_error() {
1184            let api = mock_api(vec![
1185                r#"{"errorCode":"USER_SESSION_INVALID","errorMsg":"expired"}"#,
1186            ]);
1187            assert!(matches!(
1188                api.publish_dps("dev1", &serde_json::json!({"1": true}))
1189                    .await
1190                    .unwrap_err(),
1191                ApiError::SessionInvalid
1192            ));
1193        }
1194
1195        // ── device_info ──────────────────────────────────
1196
1197        #[tokio::test]
1198        async fn device_info_success() {
1199            let api = mock_api(vec![
1200                r#"{"result":{"devId":"d1","name":"Robot","isOnline":true,"dps":{"1":true,"8":72}}}"#,
1201            ]);
1202            let info = api.device_info("d1").await.unwrap();
1203            assert_eq!(info.dev_id, "d1");
1204            assert_eq!(info.name, "Robot");
1205            assert!(info.is_online);
1206            let dps = info.dps.unwrap();
1207            assert_eq!(dps["1"], serde_json::json!(true));
1208            assert_eq!(dps["8"], serde_json::json!(72));
1209        }
1210
1211        #[tokio::test]
1212        async fn device_info_defaults() {
1213            let api = mock_api(vec![r#"{"result":{}}"#]);
1214            let info = api.device_info("d1").await.unwrap();
1215            assert_eq!(info.dev_id, "d1");
1216            assert_eq!(info.name, "");
1217            assert!(!info.is_online);
1218            assert!(info.dps.is_none());
1219        }
1220
1221        #[tokio::test]
1222        async fn device_info_no_result() {
1223            let api = mock_api(vec![r#"{}"#]);
1224            assert!(matches!(
1225                api.device_info("d1").await.unwrap_err(),
1226                ApiError::ParseError(_)
1227            ));
1228        }
1229
1230        // ── session / with_http ───────────────────────────
1231
1232        #[test]
1233        fn session_none_by_default() {
1234            let api = mock_api(vec![]);
1235            assert!(api.session().is_none());
1236        }
1237
1238        #[test]
1239        fn with_http_sets_defaults() {
1240            let api = mock_api(vec![]);
1241            assert_eq!(api.endpoint, "https://a1.tuyaeu.com/api.json");
1242            assert!(api.session.is_none());
1243        }
1244    }
1245}