Skip to main content

tuya_rs/
connection.rs

1use std::io::{Read, Write};
2use std::net::TcpStream;
3use std::time::Duration;
4
5use thiserror::Error;
6
7use crate::crypto;
8
9/// Connection parameters for a Tuya device over local network.
10///
11/// # Examples
12///
13/// ```
14/// use tuya_rs::connection::DeviceConfig;
15///
16/// let config = DeviceConfig {
17///     dev_id: "my_device_id".into(),
18///     address: "192.168.1.100".into(),
19///     local_key: "0123456789abcdef".into(),
20///     ..Default::default()
21/// };
22/// assert_eq!(config.version, 3.3);
23/// assert_eq!(config.port, 6668);
24/// ```
25#[derive(Debug, Clone)]
26pub struct DeviceConfig {
27    /// Tuya device ID (`devId`).
28    pub dev_id: String,
29    /// Device IP address on local network.
30    pub address: String,
31    /// AES-128 local key (16 ASCII characters).
32    pub local_key: String,
33    /// Protocol version (default 3.3).
34    pub version: f32,
35    /// TCP port (default 6668).
36    pub port: u16,
37}
38
39impl DeviceConfig {
40    /// Build config from environment variables: DEVICE_IP, DEVICE_ID, LOCAL_KEY.
41    ///
42    /// # Examples
43    ///
44    /// ```no_run
45    /// use tuya_rs::connection::DeviceConfig;
46    ///
47    /// // Requires DEVICE_ID, DEVICE_IP, LOCAL_KEY env vars
48    /// let config = DeviceConfig::from_env().expect("env vars not set");
49    /// ```
50    pub fn from_env() -> Result<Self, String> {
51        Ok(Self {
52            dev_id: std::env::var("DEVICE_ID").map_err(|_| "DEVICE_ID not set")?,
53            address: std::env::var("DEVICE_IP").map_err(|_| "DEVICE_IP not set")?,
54            local_key: std::env::var("LOCAL_KEY").map_err(|_| "LOCAL_KEY not set")?,
55            ..Default::default()
56        })
57    }
58}
59
60impl Default for DeviceConfig {
61    fn default() -> Self {
62        Self {
63            dev_id: String::new(),
64            address: String::new(),
65            local_key: String::new(),
66            version: 3.3,
67            port: 6668,
68        }
69    }
70}
71
72/// Error type for device operations.
73#[derive(Debug, Error)]
74pub enum DeviceError {
75    /// TCP connection could not be established.
76    #[error("TCP connection failed: {0}")]
77    ConnectionFailed(String),
78    /// AES decryption produced invalid data.
79    #[error("AES decryption failed")]
80    DecryptionFailed,
81    /// Socket read timed out.
82    #[error("socket timeout")]
83    Timeout,
84    /// Response packet is malformed.
85    #[error("invalid response: {0}")]
86    InvalidResponse(String),
87    /// TCP connection was dropped.
88    #[error("connection dropped")]
89    Disconnected,
90}
91
92// ── Tuya v3.3 packet format ────────────────────────────────
93
94const MAGIC_PREFIX: u32 = 0x000055AA;
95const MAGIC_SUFFIX: u32 = 0x0000AA55;
96/// Maximum allowed packet payload size (64 KB). Protects against
97/// malformed packets that would otherwise cause unbounded allocation.
98const MAX_PACKET_SIZE: usize = 65_536;
99
100/// Tuya command codes.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102#[repr(u32)]
103pub enum TuyaCommand {
104    /// Set device DPS values (cmd 7).
105    Control = 7,
106    /// Status push from device (cmd 8).
107    Status = 8,
108    /// Keep-alive ping (cmd 9).
109    Heartbeat = 9,
110    /// Query device DPS state (cmd 10).
111    DpQuery = 10,
112    /// Request refresh of specific DPS (cmd 18).
113    UpdateDps = 18,
114}
115
116/// A raw Tuya v3.3 packet.
117#[derive(Debug, Clone, PartialEq)]
118pub struct TuyaPacket {
119    /// Sequence number.
120    pub seq_num: u32,
121    /// Command code.
122    pub command: u32,
123    /// Decrypted payload bytes (typically JSON).
124    pub payload: Vec<u8>,
125}
126
127impl TuyaPacket {
128    /// Encode a packet to bytes with AES-ECB encryption and CRC32.
129    ///
130    /// Format: `prefix(4) + seq(4) + cmd(4) + len(4) + [v3.3_header(15)] + encrypted + crc32(4) + suffix(4)`
131    ///
132    /// The v3.3 protocol header is only included for certain commands (Control, Status).
133    /// DpQuery, UpdateDps, and Heartbeat send encrypted data directly without the header,
134    /// matching tinytuya's `NO_PROTOCOL_HEADER_CMDS` behavior.
135    pub fn to_bytes(&self, key: &[u8; 16]) -> Vec<u8> {
136        let encrypted = crypto::aes_ecb_encrypt(key, &self.payload);
137
138        // Commands that skip the v3.3 protocol header (same as tinytuya)
139        let needs_header = !matches!(
140            self.command,
141            9 | 10 | 16 | 18 // Heartbeat, DpQuery, DpQueryNew, UpdateDps
142        );
143
144        let header_bytes: &[u8] = if needs_header {
145            b"3.3\0\0\0\0\0\0\0\0\0\0\0\0"
146        } else {
147            b""
148        };
149        let data_len = header_bytes.len() + encrypted.len() + 8; // +8 for CRC + suffix
150
151        let mut buf = Vec::with_capacity(16 + data_len);
152        buf.extend_from_slice(&MAGIC_PREFIX.to_be_bytes());
153        buf.extend_from_slice(&self.seq_num.to_be_bytes());
154        buf.extend_from_slice(&self.command.to_be_bytes());
155        buf.extend_from_slice(&(data_len as u32).to_be_bytes());
156        buf.extend_from_slice(header_bytes);
157        buf.extend_from_slice(&encrypted);
158
159        // CRC32 over everything before this point
160        let crc = crc32fast::hash(&buf);
161        buf.extend_from_slice(&crc.to_be_bytes());
162        buf.extend_from_slice(&MAGIC_SUFFIX.to_be_bytes());
163
164        buf
165    }
166
167    /// Decode a packet from bytes, decrypting the payload with AES-ECB.
168    ///
169    /// Tuya v3.3 response formats vary — the data region may include:
170    ///   - v3.3 prefix ("3.3\0..." 15 bytes)
171    ///   - return code (4 bytes: 0x00000000 = OK, 0x00000001 = error)
172    ///   - AES-encrypted payload (multiple of 16 bytes)
173    ///
174    /// Not all responses include all parts. Status pushes (cmd 8) typically
175    /// have only the v3.3 prefix + encrypted data, with no return code.
176    /// ACKs may have prefix + return code only. This method tries multiple
177    /// interpretations and returns the first that succeeds.
178    pub fn from_bytes(data: &[u8], key: &[u8; 16]) -> Result<Self, DeviceError> {
179        if data.len() < 24 {
180            return Err(DeviceError::InvalidResponse("packet too short".into()));
181        }
182
183        // Verify prefix
184        let prefix = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
185        if prefix != MAGIC_PREFIX {
186            return Err(DeviceError::InvalidResponse(format!(
187                "bad prefix: 0x{prefix:08X}"
188            )));
189        }
190
191        let seq_num = u32::from_be_bytes([data[4], data[5], data[6], data[7]]);
192        let command = u32::from_be_bytes([data[8], data[9], data[10], data[11]]);
193        let total_len = u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize;
194
195        // Verify suffix
196        let suffix_start = 16 + total_len - 4;
197        if data.len() < suffix_start + 4 {
198            return Err(DeviceError::InvalidResponse("packet truncated".into()));
199        }
200        let suffix = u32::from_be_bytes([
201            data[suffix_start],
202            data[suffix_start + 1],
203            data[suffix_start + 2],
204            data[suffix_start + 3],
205        ]);
206        if suffix != MAGIC_SUFFIX {
207            return Err(DeviceError::InvalidResponse(format!(
208                "bad suffix: 0x{suffix:08X}"
209            )));
210        }
211
212        // CRC32: last 8 bytes are CRC + suffix
213        let crc_start = suffix_start - 4;
214        let received_crc = u32::from_be_bytes([
215            data[crc_start],
216            data[crc_start + 1],
217            data[crc_start + 2],
218            data[crc_start + 3],
219        ]);
220        let computed_crc = crc32fast::hash(&data[..crc_start]);
221        if received_crc != computed_crc {
222            return Err(DeviceError::InvalidResponse(format!(
223                "CRC mismatch: received 0x{received_crc:08X}, computed 0x{computed_crc:08X}"
224            )));
225        }
226
227        let offset = 16usize;
228        let data_end = crc_start;
229
230        if data_end <= offset {
231            return Ok(TuyaPacket {
232                seq_num,
233                command,
234                payload: Vec::new(),
235            });
236        }
237
238        let raw = &data[offset..data_end];
239
240        // Try all known v3.3 response formats and return the first that works.
241        if let Some(payload) = Self::try_decode(raw, key) {
242            return Ok(TuyaPacket {
243                seq_num,
244                command,
245                payload,
246            });
247        }
248
249        // Nothing worked — include hex dump for debugging
250        let hex: String = raw
251            .iter()
252            .take(64)
253            .map(|b| format!("{b:02x}"))
254            .collect::<Vec<_>>()
255            .join(" ");
256        Err(DeviceError::InvalidResponse(format!(
257            "cannot decode seq={seq_num} cmd={command} data={}b: {hex}{}",
258            raw.len(),
259            if raw.len() > 64 { "..." } else { "" }
260        )))
261    }
262
263    /// Try to decode the data region of a v3.3 packet.
264    ///
265    /// The device can send data in several formats:
266    ///   A. `prefix(15) + encrypted`              — command responses (prefix first)
267    ///   B. `prefix(15) + retcode(4) + encrypted`  — command ACKs with data
268    ///   C. `retcode(4) + prefix(15) + encrypted`  — status pushes (retcode first!)
269    ///   D. `retcode(4) + encrypted`               — responses without prefix
270    ///   E. `retcode(4) + plaintext`               — error messages (retcode=1)
271    ///   F. `retcode(4)` only                      — ACK with no data
272    ///   G. `encrypted` only                       — bare encrypted payload
273    fn try_decode(raw: &[u8], key: &[u8; 16]) -> Option<Vec<u8>> {
274        if raw.is_empty() {
275            return Some(Vec::new());
276        }
277
278        let has_prefix_at = |off: usize| -> bool {
279            raw.len() > off + 2 && raw[off] == b'3' && raw[off + 1] == b'.' && raw[off + 2] == b'3'
280        };
281
282        let try_aes = |slice: &[u8]| -> Option<Vec<u8>> {
283            if !slice.is_empty() && slice.len().is_multiple_of(16) {
284                let decrypted = crypto::aes_ecb_decrypt(key, slice).ok()?;
285                if decrypted.is_empty() {
286                    return Some(decrypted);
287                }
288                // Tuya encrypted payloads are always JSON objects.
289                // Reject false-positive PKCS7 padding matches.
290                if decrypted.first() == Some(&b'{') {
291                    Some(decrypted)
292                } else {
293                    None
294                }
295            } else {
296                None
297            }
298        };
299
300        // Format A: prefix(15) + encrypted
301        if has_prefix_at(0) && raw.len() > 15 {
302            let after_prefix = &raw[15..];
303            if let Some(payload) = try_aes(after_prefix) {
304                return Some(payload);
305            }
306
307            // Format B: prefix(15) + retcode(4) + encrypted/plaintext
308            if after_prefix.len() >= 4 {
309                let retcode = u32::from_be_bytes(after_prefix[..4].try_into().unwrap());
310                let after_rc = &after_prefix[4..];
311                if retcode == 1 {
312                    return Some(after_rc.to_vec()); // plaintext error
313                }
314                if retcode == 0 {
315                    if after_rc.is_empty() {
316                        return Some(Vec::new());
317                    }
318                    if let Some(payload) = try_aes(after_rc) {
319                        return Some(payload);
320                    }
321                }
322            }
323        }
324
325        // Check for retcode at position 0
326        if raw.len() >= 4 {
327            let retcode = u32::from_be_bytes(raw[..4].try_into().unwrap());
328
329            if retcode <= 1 {
330                let after_rc = &raw[4..];
331
332                // Format F: retcode only
333                if after_rc.is_empty() {
334                    return Some(Vec::new());
335                }
336
337                // Format E: retcode(1) + plaintext error
338                if retcode == 1 {
339                    return Some(after_rc.to_vec());
340                }
341
342                // Format C: retcode(0) + prefix(15) + encrypted
343                if has_prefix_at(4) && after_rc.len() > 15 {
344                    let after_both = &after_rc[15..];
345                    if let Some(payload) = try_aes(after_both) {
346                        return Some(payload);
347                    }
348                }
349
350                // Format D: retcode(0) + encrypted
351                if let Some(payload) = try_aes(after_rc) {
352                    return Some(payload);
353                }
354            }
355        }
356
357        // Format G: bare encrypted (no prefix, no retcode)
358        if let Some(payload) = try_aes(raw) {
359            return Some(payload);
360        }
361
362        // Last resort: plaintext
363        if raw.iter().all(|&b| b.is_ascii_graphic() || b == b' ') {
364            return Some(raw.to_vec());
365        }
366
367        None
368    }
369}
370
371// ── Transport trait ──────────────────────────────────────────
372
373/// Abstraction over the Tuya device communication channel.
374///
375/// Implemented by [`TuyaConnection`] for real TCP connections.
376/// Can be mocked for testing without a physical device.
377pub trait Transport {
378    /// Return the device ID.
379    fn dev_id(&self) -> &str;
380    /// Send a command and read one response.
381    fn send(&mut self, command: TuyaCommand, payload: Vec<u8>) -> Result<TuyaPacket, DeviceError>;
382    /// Read one packet from the device.
383    fn recv(&mut self) -> Result<TuyaPacket, DeviceError>;
384}
385
386// ── TCP connection ───────────────────────────────────────────
387
388/// Live TCP connection to a Tuya v3.3 device.
389pub struct TuyaConnection {
390    dev_id: String,
391    key: [u8; 16],
392    stream: TcpStream,
393    seq: u32,
394}
395
396impl TuyaConnection {
397    /// Connect to a device over TCP/6668 (5 s timeout).
398    ///
399    /// # Examples
400    ///
401    /// ```no_run
402    /// use tuya_rs::connection::{DeviceConfig, TuyaConnection, TuyaCommand, Transport};
403    ///
404    /// let config = DeviceConfig {
405    ///     dev_id: "my_device_id".into(),
406    ///     address: "192.168.1.100".into(),
407    ///     local_key: "0123456789abcdef".into(),
408    ///     ..Default::default()
409    /// };
410    /// let mut conn = TuyaConnection::connect(&config).unwrap();
411    /// let response = conn.send(TuyaCommand::DpQuery, b"{}".to_vec()).unwrap();
412    /// println!("payload: {:?}", String::from_utf8_lossy(&response.payload));
413    /// ```
414    pub fn connect(config: &DeviceConfig) -> Result<Self, DeviceError> {
415        let addr = format!("{}:{}", config.address, config.port);
416        let sock_addr: std::net::SocketAddr = addr
417            .parse()
418            .map_err(|e: std::net::AddrParseError| DeviceError::ConnectionFailed(e.to_string()))?;
419
420        let stream = TcpStream::connect_timeout(&sock_addr, Duration::from_secs(5))
421            .map_err(|e| DeviceError::ConnectionFailed(e.to_string()))?;
422        stream
423            .set_read_timeout(Some(Duration::from_secs(5)))
424            .map_err(|e| DeviceError::ConnectionFailed(e.to_string()))?;
425
426        let key_bytes = config.local_key.as_bytes();
427        if key_bytes.len() != 16 {
428            return Err(DeviceError::ConnectionFailed(format!(
429                "local_key must be exactly 16 bytes, got {}",
430                key_bytes.len()
431            )));
432        }
433        let mut key = [0u8; 16];
434        key.copy_from_slice(key_bytes);
435
436        Ok(Self {
437            dev_id: config.dev_id.clone(),
438            key,
439            stream,
440            seq: 0,
441        })
442    }
443
444    /// Read one packet from the device.
445    fn recv_packet(&mut self) -> Result<TuyaPacket, DeviceError> {
446        let mut header = [0u8; 16];
447        self.read_exact(&mut header)?;
448
449        let prefix = u32::from_be_bytes([header[0], header[1], header[2], header[3]]);
450        if prefix != MAGIC_PREFIX {
451            return Err(DeviceError::InvalidResponse(format!(
452                "bad prefix: 0x{prefix:08X}"
453            )));
454        }
455
456        let data_len =
457            u32::from_be_bytes([header[12], header[13], header[14], header[15]]) as usize;
458
459        if data_len > MAX_PACKET_SIZE {
460            return Err(DeviceError::InvalidResponse(format!(
461                "packet too large: {data_len} bytes (max {MAX_PACKET_SIZE})"
462            )));
463        }
464
465        let mut rest = vec![0u8; data_len];
466        self.read_exact(&mut rest)?;
467
468        let mut full = Vec::with_capacity(16 + data_len);
469        full.extend_from_slice(&header);
470        full.extend_from_slice(&rest);
471
472        TuyaPacket::from_bytes(&full, &self.key)
473    }
474
475    fn read_exact(&mut self, buf: &mut [u8]) -> Result<(), DeviceError> {
476        self.stream.read_exact(buf).map_err(|e| match e.kind() {
477            std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => DeviceError::Timeout,
478            _ => DeviceError::Disconnected,
479        })
480    }
481}
482
483impl Transport for TuyaConnection {
484    fn dev_id(&self) -> &str {
485        &self.dev_id
486    }
487
488    fn send(&mut self, command: TuyaCommand, payload: Vec<u8>) -> Result<TuyaPacket, DeviceError> {
489        self.seq += 1;
490        let packet = TuyaPacket {
491            seq_num: self.seq,
492            command: command as u32,
493            payload,
494        };
495        let bytes = packet.to_bytes(&self.key);
496
497        self.stream
498            .write_all(&bytes)
499            .map_err(|_| DeviceError::Disconnected)?;
500
501        self.recv_packet()
502    }
503
504    fn recv(&mut self) -> Result<TuyaPacket, DeviceError> {
505        self.recv_packet()
506    }
507}
508
509// ── Helpers ─────────────────────────────────────────────────
510
511/// Build a JSON payload for setting DPS values.
512///
513/// # Examples
514///
515/// ```
516/// use tuya_rs::connection::build_dps_json;
517/// use serde_json::json;
518///
519/// let payload = build_dps_json("device123", 1700000000, &[("1", json!(true))]);
520/// let parsed: serde_json::Value = serde_json::from_str(&payload).unwrap();
521/// assert_eq!(parsed["devId"], "device123");
522/// assert_eq!(parsed["dps"]["1"], true);
523/// ```
524pub fn build_dps_json(dev_id: &str, timestamp: u64, dps: &[(&str, serde_json::Value)]) -> String {
525    let mut dps_map = serde_json::Map::new();
526    for (k, v) in dps {
527        dps_map.insert(k.to_string(), v.clone());
528    }
529
530    serde_json::json!({
531        "devId": dev_id,
532        "uid": "",
533        "t": timestamp,
534        "dps": dps_map,
535    })
536    .to_string()
537}
538
539/// Return current UNIX timestamp in seconds.
540pub fn now() -> u64 {
541    std::time::SystemTime::now()
542        .duration_since(std::time::UNIX_EPOCH)
543        .unwrap()
544        .as_secs()
545}
546
547/// Possible DP value types for raw access.
548#[derive(Debug, Clone, PartialEq)]
549pub enum DpValue {
550    /// Boolean value.
551    Boolean(bool),
552    /// Integer value.
553    Integer(i64),
554    /// String value.
555    String(String),
556    /// Raw bytes (sent as base64).
557    Raw(Vec<u8>),
558}
559
560/// Raw DPS update from the device.
561#[derive(Debug, Clone, PartialEq)]
562pub struct DpsUpdate {
563    /// List of (DP number, value string) pairs.
564    pub dps: Vec<(u8, String)>,
565    /// Optional update timestamp.
566    pub timestamp: Option<u64>,
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572    use serde_json::json;
573
574    #[test]
575    fn build_dps_json_format() {
576        let json = build_dps_json("devId123", 1770808371, &[("1", json!(true))]);
577        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
578        assert_eq!(parsed["devId"], "devId123");
579        assert_eq!(parsed["uid"], "");
580        assert_eq!(parsed["t"], 1770808371u64);
581        assert_eq!(parsed["dps"]["1"], true);
582    }
583
584    // ── DeviceConfig ────────────────────────────────────────
585
586    #[test]
587    fn device_config_default() {
588        let cfg = DeviceConfig::default();
589        assert!(cfg.dev_id.is_empty());
590        assert!(cfg.address.is_empty());
591        assert!(cfg.local_key.is_empty());
592        assert_eq!(cfg.version, 3.3);
593        assert_eq!(cfg.port, 6668);
594    }
595
596    #[test]
597    fn device_config_from_env_missing_vars() {
598        // All env vars are almost certainly unset in test
599        assert!(DeviceConfig::from_env().is_err());
600    }
601
602    // ── Packet encode (no-header commands) ──────────────────
603
604    #[test]
605    fn packet_encode_heartbeat_no_header() {
606        let key = b"0123456789abcdef";
607        let pkt = TuyaPacket {
608            seq_num: 1,
609            command: TuyaCommand::Heartbeat as u32,
610            payload: b"{}".to_vec(),
611        };
612        let bytes = pkt.to_bytes(key);
613        // Heartbeat should NOT have the "3.3\0..." header after the 16-byte fixed header
614        // The encrypted data starts right at offset 16
615        assert_ne!(&bytes[16..19], b"3.3");
616    }
617
618    #[test]
619    fn packet_encode_control_has_header() {
620        let key = b"0123456789abcdef";
621        let pkt = TuyaPacket {
622            seq_num: 1,
623            command: TuyaCommand::Control as u32,
624            payload: b"{}".to_vec(),
625        };
626        let bytes = pkt.to_bytes(key);
627        assert_eq!(&bytes[16..19], b"3.3");
628    }
629
630    // ── from_bytes error paths ──────────────────────────────
631
632    /// Build a raw packet with valid envelope (prefix, CRC, suffix) around arbitrary data.
633    fn build_raw_packet(seq: u32, cmd: u32, data: &[u8]) -> Vec<u8> {
634        let data_len = data.len() + 8; // +8 for CRC + suffix
635        let mut buf = Vec::with_capacity(16 + data_len);
636        buf.extend_from_slice(&MAGIC_PREFIX.to_be_bytes());
637        buf.extend_from_slice(&seq.to_be_bytes());
638        buf.extend_from_slice(&cmd.to_be_bytes());
639        buf.extend_from_slice(&(data_len as u32).to_be_bytes());
640        buf.extend_from_slice(data);
641        let crc = crc32fast::hash(&buf);
642        buf.extend_from_slice(&crc.to_be_bytes());
643        buf.extend_from_slice(&MAGIC_SUFFIX.to_be_bytes());
644        buf
645    }
646
647    #[test]
648    fn from_bytes_too_short() {
649        let key = b"0123456789abcdef";
650        let err = TuyaPacket::from_bytes(&[0; 20], key).unwrap_err();
651        assert!(matches!(err, DeviceError::InvalidResponse(_)));
652    }
653
654    #[test]
655    fn from_bytes_bad_prefix() {
656        let key = b"0123456789abcdef";
657        let mut pkt = build_raw_packet(1, 7, b"");
658        pkt[0] = 0xFF; // corrupt prefix
659        let err = TuyaPacket::from_bytes(&pkt, key).unwrap_err();
660        let msg = format!("{err}");
661        assert!(msg.contains("bad prefix"));
662    }
663
664    #[test]
665    fn from_bytes_bad_suffix() {
666        let key = b"0123456789abcdef";
667        let mut pkt = build_raw_packet(1, 7, b"some data here!!");
668        let last = pkt.len();
669        pkt[last - 1] = 0xFF; // corrupt suffix
670        let err = TuyaPacket::from_bytes(&pkt, key).unwrap_err();
671        let msg = format!("{err}");
672        assert!(msg.contains("bad suffix"));
673    }
674
675    #[test]
676    fn from_bytes_truncated() {
677        let key = b"0123456789abcdef";
678        // Valid prefix but claimed length exceeds actual data
679        let mut buf = Vec::new();
680        buf.extend_from_slice(&MAGIC_PREFIX.to_be_bytes());
681        buf.extend_from_slice(&0u32.to_be_bytes()); // seq
682        buf.extend_from_slice(&7u32.to_be_bytes()); // cmd
683        buf.extend_from_slice(&255u32.to_be_bytes()); // len = 255 (way too big)
684        buf.extend_from_slice(&[0u8; 8]); // not enough data
685        let err = TuyaPacket::from_bytes(&buf, key).unwrap_err();
686        let msg = format!("{err}");
687        assert!(msg.contains("truncated"));
688    }
689
690    #[test]
691    fn from_bytes_crc_mismatch() {
692        let key = b"0123456789abcdef";
693        let mut pkt = build_raw_packet(1, 7, b"some data here!!");
694        // Corrupt a data byte (between header and CRC)
695        pkt[16] ^= 0xFF;
696        let err = TuyaPacket::from_bytes(&pkt, key).unwrap_err();
697        let msg = format!("{err}");
698        assert!(msg.contains("CRC mismatch"));
699    }
700
701    #[test]
702    fn from_bytes_empty_payload() {
703        let key = b"0123456789abcdef";
704        let pkt = build_raw_packet(42, 9, &[]);
705        let decoded = TuyaPacket::from_bytes(&pkt, key).unwrap();
706        assert_eq!(decoded.seq_num, 42);
707        assert_eq!(decoded.command, 9);
708        assert!(decoded.payload.is_empty());
709    }
710
711    #[test]
712    fn from_bytes_undecryptable_data() {
713        let key = b"0123456789abcdef";
714        // 16 bytes of random non-decodable data (not valid AES, not plaintext, not prefix)
715        let garbage = [0x80u8; 16];
716        let pkt = build_raw_packet(1, 7, &garbage);
717        let err = TuyaPacket::from_bytes(&pkt, key).unwrap_err();
718        let msg = format!("{err}");
719        assert!(msg.contains("cannot decode"));
720    }
721
722    // ── try_decode format coverage (via from_bytes) ─────────
723
724    #[test]
725    fn decode_format_a_prefix_plus_encrypted() {
726        // Already covered by packet_encode_decode_roundtrip (Control command)
727        // but let's verify explicitly for a Status command
728        let key = b"0123456789abcdef";
729        let pkt = TuyaPacket {
730            seq_num: 1,
731            command: TuyaCommand::Status as u32,
732            payload: b"{\"dps\":{\"1\":true}}".to_vec(),
733        };
734        let bytes = pkt.to_bytes(key);
735        let decoded = TuyaPacket::from_bytes(&bytes, key).unwrap();
736        assert_eq!(decoded.payload, pkt.payload);
737    }
738
739    #[test]
740    fn decode_format_b_prefix_retcode0_encrypted() {
741        let key = b"0123456789abcdef";
742        let plaintext = b"{\"result\":\"ok\"}";
743        let encrypted = crypto::aes_ecb_encrypt(key, plaintext);
744
745        let mut data = Vec::new();
746        data.extend_from_slice(b"3.3\0\0\0\0\0\0\0\0\0\0\0\0"); // prefix
747        data.extend_from_slice(&0u32.to_be_bytes()); // retcode = 0
748        data.extend_from_slice(&encrypted);
749
750        let pkt = build_raw_packet(1, 8, &data);
751        let decoded = TuyaPacket::from_bytes(&pkt, key).unwrap();
752        assert_eq!(decoded.payload, plaintext);
753    }
754
755    #[test]
756    fn decode_format_b_prefix_retcode0_empty() {
757        let key = b"0123456789abcdef";
758        let mut data = Vec::new();
759        data.extend_from_slice(b"3.3\0\0\0\0\0\0\0\0\0\0\0\0"); // prefix
760        data.extend_from_slice(&0u32.to_be_bytes()); // retcode = 0
761        // no encrypted data → empty
762
763        let pkt = build_raw_packet(1, 8, &data);
764        let decoded = TuyaPacket::from_bytes(&pkt, key).unwrap();
765        assert!(decoded.payload.is_empty());
766    }
767
768    #[test]
769    fn decode_format_b_prefix_retcode1_plaintext_error() {
770        let key = b"0123456789abcdef";
771        let mut data = Vec::new();
772        data.extend_from_slice(b"3.3\0\0\0\0\0\0\0\0\0\0\0\0"); // prefix
773        data.extend_from_slice(&1u32.to_be_bytes()); // retcode = 1
774        data.extend_from_slice(b"parse data error"); // plaintext error
775
776        let pkt = build_raw_packet(1, 8, &data);
777        let decoded = TuyaPacket::from_bytes(&pkt, key).unwrap();
778        assert_eq!(decoded.payload, b"parse data error");
779    }
780
781    #[test]
782    fn decode_format_c_retcode0_prefix_encrypted() {
783        let key = b"0123456789abcdef";
784        let plaintext = b"{\"status\":\"ok\"}";
785        let encrypted = crypto::aes_ecb_encrypt(key, plaintext);
786
787        let mut data = Vec::new();
788        data.extend_from_slice(&0u32.to_be_bytes()); // retcode = 0
789        data.extend_from_slice(b"3.3\0\0\0\0\0\0\0\0\0\0\0\0"); // prefix
790        data.extend_from_slice(&encrypted);
791
792        let pkt = build_raw_packet(1, 8, &data);
793        let decoded = TuyaPacket::from_bytes(&pkt, key).unwrap();
794        assert_eq!(decoded.payload, plaintext);
795    }
796
797    #[test]
798    fn decode_format_d_retcode0_encrypted() {
799        let key = b"0123456789abcdef";
800        let plaintext = b"{\"dps\":{\"8\":72}}";
801        let encrypted = crypto::aes_ecb_encrypt(key, plaintext);
802
803        let mut data = Vec::new();
804        data.extend_from_slice(&0u32.to_be_bytes()); // retcode = 0
805        data.extend_from_slice(&encrypted);
806
807        let pkt = build_raw_packet(1, 10, &data);
808        let decoded = TuyaPacket::from_bytes(&pkt, key).unwrap();
809        assert_eq!(decoded.payload, plaintext);
810    }
811
812    #[test]
813    fn decode_format_e_retcode1_plaintext_error() {
814        let key = b"0123456789abcdef";
815        let mut data = Vec::new();
816        data.extend_from_slice(&1u32.to_be_bytes()); // retcode = 1
817        data.extend_from_slice(b"json parse error");
818
819        let pkt = build_raw_packet(1, 10, &data);
820        let decoded = TuyaPacket::from_bytes(&pkt, key).unwrap();
821        assert_eq!(decoded.payload, b"json parse error");
822    }
823
824    #[test]
825    fn decode_format_f_retcode_only() {
826        let key = b"0123456789abcdef";
827        let data = 0u32.to_be_bytes(); // retcode = 0, nothing else
828
829        let pkt = build_raw_packet(1, 7, &data);
830        let decoded = TuyaPacket::from_bytes(&pkt, key).unwrap();
831        assert!(decoded.payload.is_empty());
832    }
833
834    #[test]
835    fn decode_format_g_bare_encrypted() {
836        let key = b"0123456789abcdef";
837        let plaintext = b"{\"bare\":true}";
838        let encrypted = crypto::aes_ecb_encrypt(key, plaintext);
839
840        // No prefix, no retcode — just encrypted
841        let pkt = build_raw_packet(1, 10, &encrypted);
842        let decoded = TuyaPacket::from_bytes(&pkt, key).unwrap();
843        assert_eq!(decoded.payload, plaintext);
844    }
845
846    #[test]
847    fn decode_plaintext_fallback() {
848        let key = b"0123456789abcdef";
849        // ASCII-only data that doesn't look like any known format
850        // (not starting with "3.3", not a valid retcode pattern, not valid AES)
851        let data = b"PLAIN TEXT ERROR MSG";
852
853        let pkt = build_raw_packet(1, 7, data);
854        let decoded = TuyaPacket::from_bytes(&pkt, key).unwrap();
855        assert_eq!(decoded.payload, b"PLAIN TEXT ERROR MSG");
856    }
857
858    #[test]
859    fn decode_hex_dump_truncated_at_64() {
860        let key = b"0123456789abcdef";
861        // 80 bytes of non-decodable binary (> 64 bytes for hex dump truncation)
862        let garbage: Vec<u8> = (0..80).map(|i| 0x80 | (i & 0x0F)).collect();
863        let pkt = build_raw_packet(1, 7, &garbage);
864        let err = TuyaPacket::from_bytes(&pkt, key).unwrap_err();
865        let msg = format!("{err}");
866        assert!(msg.contains("..."));
867    }
868}