1use std::collections::HashMap;
2
3use hmac::{Hmac, Mac};
4use sha2::{Digest, Sha256};
5use thiserror::Error;
6
7use crate::signing;
8
9#[derive(Debug, Error)]
11pub enum ApiError {
12 #[error("session expired — need to re-login")]
14 SessionInvalid,
15 #[error("wrong email or password")]
17 PasswordWrong,
18 #[error("API action not available for this client")]
20 IllegalAccessApi,
21 #[error("network error: {0}")]
23 NetworkError(String),
24 #[error("server error {code}: {message}")]
26 ServerError {
27 code: String,
29 message: String,
31 },
32 #[error("response parsing failed: {0}")]
34 ParseError(String),
35}
36
37#[derive(Debug, Clone)]
39pub struct OemCredentials {
40 pub client_id: String,
42 pub app_secret: String,
44 pub bmp_key: String,
46 pub cert_hash: String,
48 pub package_name: String,
50 pub app_device_id: String,
52}
53
54impl OemCredentials {
55 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#[derive(Debug, Clone)]
68pub struct Session {
69 pub sid: String,
71 pub uid: String,
73 pub email: String,
75 pub domain: String,
77}
78
79#[derive(Debug, Clone)]
81pub struct DeviceInfo {
82 pub dev_id: String,
84 pub local_key: String,
86 pub name: String,
88 pub product_id: String,
90}
91
92#[derive(Debug, Clone)]
94pub struct Home {
95 pub gid: u64,
97 pub name: String,
99}
100
101#[derive(Debug, Clone)]
103pub struct CloudDeviceInfo {
104 pub dev_id: String,
106 pub name: String,
108 pub is_online: bool,
110 pub dps: Option<HashMap<String, serde_json::Value>>,
112}
113
114#[derive(Debug, Clone)]
116pub struct StorageCredentials {
117 pub ak: String,
119 pub sk: String,
121 pub token: String,
123 pub bucket: String,
125 pub region: String,
127 pub expiration: String,
129 pub path_prefix: String,
131}
132
133pub 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
176pub 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#[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 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 let canonical_request =
249 format!("GET\n{path}\n{canonical_querystring}\nhost:{host}\n\nhost\nUNSIGNED-PAYLOAD");
250
251 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 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#[allow(async_fn_in_trait)]
297pub trait HttpClient {
298 async fn post_form(
300 &self,
301 endpoint: &str,
302 params: &[(String, String)],
303 ) -> Result<String, ApiError>;
304}
305
306#[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#[allow(async_fn_in_trait)]
337pub trait TuyaApi {
339 async fn login(&mut self, email: &str, password: &str) -> Result<Session, ApiError>;
341 fn session(&self) -> Option<&Session>;
343 async fn list_homes(&self) -> Result<Vec<Home>, ApiError>;
345 async fn list_devices(&self, gid: u64) -> Result<Vec<DeviceInfo>, ApiError>;
347 async fn storage_config(&self, dev_id: &str) -> Result<StorageCredentials, ApiError>;
349 async fn publish_dps(&self, dev_id: &str, dps: &serde_json::Value) -> Result<(), ApiError>;
351 async fn device_info(&self, dev_id: &str) -> Result<CloudDeviceInfo, ApiError>;
353}
354
355#[cfg(feature = "cloud")]
362pub struct TuyaOemApi<H: HttpClient = ReqwestClient> {
363 pub credentials: OemCredentials,
365 pub session: Option<Session>,
367 pub http: H,
369 pub endpoint: String,
371}
372
373#[cfg(feature = "cloud")]
374impl TuyaOemApi {
375 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 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 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 ×tamp,
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, ¶ms).await?;
433
434 check_api_error(&body)?;
436
437 Ok(body)
438 }
439}
440
441#[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 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 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 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 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 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 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 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 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 #[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 #[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 assert!(check_api_error("not json at all").is_ok());
935 }
936
937 #[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 #[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 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 #[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 #[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 #[tokio::test]
1090 async fn login_success() {
1091 let mut api = mock_api(vec![
1092 r#"{"result":{"token":"tok123","publicKey":"12345","exponent":"65537"}}"#,
1094 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 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"}"#, ]);
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"}}"#, ]);
1166 assert!(matches!(
1167 api.login("a@b.com", "pw").await.unwrap_err(),
1168 ApiError::ParseError(_)
1169 ));
1170 }
1171
1172 #[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 #[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 #[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}