Skip to main content

xplorer_rs/
protocol.rs

1use base64::Engine;
2use thiserror::Error;
3
4/// Errors from decoding DP 15 sweeper messages.
5#[derive(Debug, Error)]
6pub enum ProtocolError {
7    /// Message is shorter than minimum 4 bytes.
8    #[error("message too short: need at least 4 bytes, got {0}")]
9    TooShort(usize),
10    /// Start byte is not 0xAA.
11    #[error("invalid start byte: expected 0xAA, got 0x{0:02X}")]
12    InvalidStartByte(u8),
13    /// Base64 decoding failed.
14    #[error("invalid base64: {0}")]
15    InvalidBase64(#[from] base64::DecodeError),
16    /// Header length doesn't match actual data.
17    #[error("length mismatch: header says {expected} bytes, got {actual}")]
18    LengthMismatch {
19        /// Expected total byte count from header.
20        expected: usize,
21        /// Actual byte count received.
22        actual: usize,
23    },
24    /// Command byte doesn't match expected value.
25    #[error("unexpected command 0x{0:02X} for RoomCleanStatusResponse (expected 0x15)")]
26    UnexpectedCommand(u8),
27    /// Payload is too short for the expected response.
28    #[error("payload too short for RoomCleanStatusResponse")]
29    PayloadTooShort,
30}
31
32/// Command types for DP 15 binary protocol.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34#[repr(u8)]
35pub enum CommandType {
36    /// Set virtual walls (0x12).
37    SetVirtualWall = 0x12,
38    /// Virtual wall status response (0x13).
39    VirtualWallStatus = 0x13,
40    /// Start room cleaning (0x14).
41    SetRoomClean = 0x14,
42    /// Room clean status response (0x15).
43    RoomCleanStatus = 0x15,
44    /// Request area clean (0x17).
45    RequestAreaClean = 0x17,
46    /// Set forbidden zones (0x1A).
47    SetVirtualArea = 0x1A,
48    /// Forbidden zone status response (0x1B).
49    VirtualAreaStatus = 0x1B,
50    /// Start zone cleaning (0x28).
51    SetZoneClean = 0x28,
52    /// Zone clean status response (0x29).
53    ZoneCleanStatus = 0x29,
54    /// Custom data transfer (0x31).
55    CustomizeData = 0x31,
56}
57
58/// Restriction mode for forbidden zones (cmd 0x1a).
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60#[repr(u8)]
61pub enum ForbiddenMode {
62    /// Full ban — robot will not enter this area ("zona vietata" in app).
63    FullBan = 0x00,
64    /// No sweep — robot enters but won't vacuum ("zona non lavabile" in app).
65    NoSweep = 0x01,
66    /// No mop — robot enters but won't mop.
67    NoMop = 0x02,
68}
69
70/// A forbidden zone with its restriction mode.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct ForbiddenZone {
73    /// Restriction type (full ban, no sweep, no mop).
74    pub mode: ForbiddenMode,
75    /// Rectangular zone coordinates.
76    pub zone: Zone,
77}
78
79/// A virtual wall defined by two endpoints (a line segment).
80///
81/// The robot will not cross this line during cleaning.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct Wall {
84    /// Start point (x, y) in map coordinates.
85    pub start: (i16, i16),
86    /// End point (x, y) in map coordinates.
87    pub end: (i16, i16),
88}
89
90/// A room cleaning command to send via DP 15.
91///
92/// # Examples
93///
94/// ```
95/// use xplorer_rs::protocol::RoomCleanCommand;
96///
97/// let cmd = RoomCleanCommand { clean_times: 2, room_ids: vec![0, 3] };
98/// let bytes = cmd.encode();
99/// assert_eq!(bytes[0], 0xAA); // start byte
100/// assert_eq!(bytes[3], 0x14); // room clean command
101///
102/// let b64 = cmd.encode_base64();
103/// assert!(!b64.is_empty());
104/// ```
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct RoomCleanCommand {
107    /// Number of cleaning passes per room.
108    pub clean_times: u8,
109    /// Room IDs to clean.
110    pub room_ids: Vec<u8>,
111}
112
113impl RoomCleanCommand {
114    /// Encode to raw bytes: `aa <len_2byte_BE> 0x14 <clean_times> <num_rooms> <room_ids...> <checksum>`
115    pub fn encode(&self) -> Vec<u8> {
116        let cmd: u8 = 0x14;
117        let num_rooms = self.room_ids.len() as u8;
118        // payload = cmd + clean_times + num_rooms + room_ids
119        let payload_len = 1 + 1 + 1 + self.room_ids.len();
120
121        let mut buf = Vec::with_capacity(3 + payload_len + 1);
122        buf.push(0xAA);
123        buf.push((payload_len >> 8) as u8);
124        buf.push(payload_len as u8);
125        buf.push(cmd);
126        buf.push(self.clean_times);
127        buf.push(num_rooms);
128        buf.extend_from_slice(&self.room_ids);
129
130        // Checksum = sum of (cmd + data bytes) & 0xFF
131        let checksum: u8 = buf[3..].iter().copied().fold(0u16, |acc, b| acc + b as u16) as u8;
132        buf.push(checksum);
133        buf
134    }
135
136    /// Encode and return as base64 string.
137    pub fn encode_base64(&self) -> String {
138        base64::engine::general_purpose::STANDARD.encode(self.encode())
139    }
140}
141
142/// A rectangular zone for zone cleaning, defined by 4 vertices (polygon).
143///
144/// Coordinates are in raw map units (i16, same as route points × 10).
145/// Vertices are ordered: top-left, top-right, bottom-right, bottom-left.
146/// For a simple axis-aligned rectangle, construct from two corners with [`Zone::rect`].
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct Zone {
149    /// Four polygon vertices as (x, y) pairs in map coordinates.
150    pub vertices: [(i16, i16); 4],
151}
152
153impl Zone {
154    /// Create an axis-aligned rectangular zone from two opposite corners.
155    ///
156    /// The corners are (x1, y1) = bottom-left and (x2, y2) = top-right
157    /// (y-axis points up, matching the robot's coordinate system).
158    ///
159    /// # Examples
160    ///
161    /// ```
162    /// use xplorer_rs::protocol::Zone;
163    ///
164    /// let zone = Zone::rect(82, -13, 453, 203);
165    /// // Vertices: TL, TR, BR, BL
166    /// assert_eq!(zone.vertices[0], (82, 203));   // top-left
167    /// assert_eq!(zone.vertices[2], (453, -13));   // bottom-right
168    /// ```
169    pub fn rect(x1: i16, y1: i16, x2: i16, y2: i16) -> Self {
170        Zone {
171            vertices: [
172                (x1, y2), // top-left
173                (x2, y2), // top-right
174                (x2, y1), // bottom-right
175                (x1, y1), // bottom-left
176            ],
177        }
178    }
179
180    /// Create a rotated rectangular zone.
181    ///
182    /// Builds an axis-aligned rectangle from two corners, then rotates all
183    /// vertices around the rectangle's center by `angle_deg` degrees
184    /// (counter-clockwise positive).
185    ///
186    /// Use the map's `theta` value (typically `route_header.theta as f64 / 100.0`)
187    /// to compensate for the coordinate system rotation relative to room walls.
188    ///
189    /// # Examples
190    ///
191    /// ```
192    /// use xplorer_rs::protocol::Zone;
193    ///
194    /// // 0° rotation produces the same result as Zone::rect
195    /// let plain = Zone::rect(100, 200, 300, 400);
196    /// let rotated = Zone::rotated_rect(100, 200, 300, 400, 0.0);
197    /// assert_eq!(plain.vertices, rotated.vertices);
198    ///
199    /// // Compensate for map theta of 2.7°
200    /// let zone = Zone::rotated_rect(82, -13, 453, 203, 2.7);
201    /// assert_ne!(zone.vertices, plain.vertices);
202    /// ```
203    pub fn rotated_rect(x1: i16, y1: i16, x2: i16, y2: i16, angle_deg: f64) -> Self {
204        let cx = (x1 as f64 + x2 as f64) / 2.0;
205        let cy = (y1 as f64 + y2 as f64) / 2.0;
206        let cos = angle_deg.to_radians().cos();
207        let sin = angle_deg.to_radians().sin();
208
209        let rotate = |x: f64, y: f64| -> (i16, i16) {
210            let dx = x - cx;
211            let dy = y - cy;
212            let rx = cx + dx * cos - dy * sin;
213            let ry = cy + dx * sin + dy * cos;
214            (rx.round() as i16, ry.round() as i16)
215        };
216
217        let corners: [(f64, f64); 4] = [
218            (x1 as f64, y2 as f64), // top-left
219            (x2 as f64, y2 as f64), // top-right
220            (x2 as f64, y1 as f64), // bottom-right
221            (x1 as f64, y1 as f64), // bottom-left
222        ];
223
224        Zone {
225            vertices: [
226                rotate(corners[0].0, corners[0].1),
227                rotate(corners[1].0, corners[1].1),
228                rotate(corners[2].0, corners[2].1),
229                rotate(corners[3].0, corners[3].1),
230            ],
231        }
232    }
233}
234
235/// Encode a zone-based sweeper frame (used by [`ZoneCleanCommand`], cmd 0x28).
236///
237/// Format: `aa <len_2byte_BE> <cmd> <first_byte> <num_zones> [<num_vertices> <vertices...>]* <checksum>`
238fn encode_zone_frame(cmd: u8, first_byte: u8, zones: &[Zone]) -> Vec<u8> {
239    // payload = cmd + first_byte + num_zones + per-zone(num_vertices + vertices)
240    let mut payload_len = 1 + 1 + 1;
241    for zone in zones {
242        payload_len += 1 + zone.vertices.len() * 4;
243    }
244
245    let mut buf = Vec::with_capacity(3 + payload_len + 1);
246    buf.push(0xAA);
247    buf.push((payload_len >> 8) as u8);
248    buf.push(payload_len as u8);
249    buf.push(cmd);
250    buf.push(first_byte);
251    buf.push(zones.len() as u8);
252    for zone in zones {
253        buf.push(zone.vertices.len() as u8);
254        for &(x, y) in &zone.vertices {
255            buf.extend_from_slice(&x.to_be_bytes());
256            buf.extend_from_slice(&y.to_be_bytes());
257        }
258    }
259
260    // Checksum = sum of (cmd + data bytes) & 0xFF
261    let checksum: u8 = buf[3..].iter().copied().fold(0u16, |acc, b| acc + b as u16) as u8;
262    buf.push(checksum);
263    buf
264}
265
266/// A zone cleaning command to send via DP 15 (cmd 0x28).
267///
268/// Discovered by brute-force testing: cmd 0x28 is the setter, the robot
269/// reports status back in cmd 0x29. Each zone is a polygon defined by
270/// `num_vertices` points (typically 4 for a rectangle).
271///
272/// # Examples
273///
274/// ```
275/// use xplorer_rs::protocol::{Zone, ZoneCleanCommand};
276///
277/// let cmd = ZoneCleanCommand {
278///     clean_times: 1,
279///     zones: vec![Zone::rect(82, -13, 453, 203)],
280/// };
281/// let bytes = cmd.encode();
282/// assert_eq!(bytes[0], 0xAA);
283/// assert_eq!(bytes[3], 0x28); // zone clean command
284/// ```
285#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct ZoneCleanCommand {
287    /// Number of cleaning passes.
288    pub clean_times: u8,
289    /// Rectangular zones to clean.
290    pub zones: Vec<Zone>,
291}
292
293impl ZoneCleanCommand {
294    /// Encode to raw bytes:
295    /// `aa <len_2byte_BE> 0x28 <clean_times> <num_zones> <num_vertices> <vertices...> <checksum>`
296    ///
297    /// Each vertex is 2 × i16 BE = 4 bytes (x, y).
298    /// For a single zone with 4 vertices: payload = 1 + 1 + 1 + 1 + 4*4 = 20 bytes.
299    pub fn encode(&self) -> Vec<u8> {
300        encode_zone_frame(0x28, self.clean_times, &self.zones)
301    }
302
303    /// Encode and return as base64 string.
304    pub fn encode_base64(&self) -> String {
305        base64::engine::general_purpose::STANDARD.encode(self.encode())
306    }
307}
308
309/// A forbidden zone command to send via DP 15 (cmd 0x1a).
310///
311/// Sets no-go / no-sweep / no-mop zones. The robot reports status back in cmd 0x1B.
312/// Each zone has its own [`ForbiddenMode`].
313///
314/// Format: `aa <len> 0x1a <num_zones> [<mode> <num_pts=4> <coords...>]* <checksum>`
315///
316/// # Examples
317///
318/// ```
319/// use xplorer_rs::protocol::{ForbiddenZoneCommand, ForbiddenZone, ForbiddenMode, Zone};
320///
321/// // Set a no-go zone
322/// let cmd = ForbiddenZoneCommand {
323///     zones: vec![ForbiddenZone {
324///         mode: ForbiddenMode::FullBan,
325///         zone: Zone::rect(100, 100, 200, 200),
326///     }],
327/// };
328/// let bytes = cmd.encode();
329/// assert_eq!(bytes[3], 0x1A);
330///
331/// // Clear all forbidden zones
332/// let clear = ForbiddenZoneCommand::clear();
333/// assert!(clear.zones.is_empty());
334/// ```
335#[derive(Debug, Clone, PartialEq, Eq)]
336pub struct ForbiddenZoneCommand {
337    /// Forbidden zones to set.
338    pub zones: Vec<ForbiddenZone>,
339}
340
341impl ForbiddenZoneCommand {
342    /// Encode to raw bytes (cmd 0x1a).
343    pub fn encode(&self) -> Vec<u8> {
344        // payload = cmd(1) + num_zones(1) + per-zone(mode(1) + num_pts(1) + 4*4 coords)
345        let mut payload_len = 1 + 1;
346        for fz in &self.zones {
347            payload_len += 1 + 1 + fz.zone.vertices.len() * 4;
348        }
349
350        let mut buf = Vec::with_capacity(3 + payload_len + 1);
351        buf.push(0xAA);
352        buf.push((payload_len >> 8) as u8);
353        buf.push(payload_len as u8);
354        buf.push(0x1A);
355        buf.push(self.zones.len() as u8);
356        for fz in &self.zones {
357            buf.push(fz.mode as u8);
358            buf.push(fz.zone.vertices.len() as u8);
359            for &(x, y) in &fz.zone.vertices {
360                buf.extend_from_slice(&x.to_be_bytes());
361                buf.extend_from_slice(&y.to_be_bytes());
362            }
363        }
364
365        let checksum: u8 = buf[3..].iter().copied().fold(0u16, |acc, b| acc + b as u16) as u8;
366        buf.push(checksum);
367        buf
368    }
369
370    /// Encode and return as base64 string.
371    pub fn encode_base64(&self) -> String {
372        base64::engine::general_purpose::STANDARD.encode(self.encode())
373    }
374
375    /// Create a command that clears all forbidden zones.
376    pub fn clear() -> Self {
377        Self { zones: vec![] }
378    }
379}
380
381/// A virtual wall command to send via DP 15 (cmd 0x12).
382///
383/// Sets line barriers that the robot will not cross. The robot reports status
384/// back in cmd 0x13.
385///
386/// Format: `aa <len> 0x12 <num_walls> [<x1> <y1> <x2> <y2>]* <checksum>`
387///
388/// # Examples
389///
390/// ```
391/// use xplorer_rs::protocol::{VirtualWallCommand, Wall};
392///
393/// let cmd = VirtualWallCommand {
394///     walls: vec![Wall { start: (100, 50), end: (300, 50) }],
395/// };
396/// let bytes = cmd.encode();
397/// assert_eq!(bytes[3], 0x12);
398///
399/// // Clear all virtual walls
400/// let clear = VirtualWallCommand::clear();
401/// assert!(clear.walls.is_empty());
402/// ```
403#[derive(Debug, Clone, PartialEq, Eq)]
404pub struct VirtualWallCommand {
405    /// Virtual walls to set.
406    pub walls: Vec<Wall>,
407}
408
409impl VirtualWallCommand {
410    /// Encode to raw bytes (cmd 0x12).
411    pub fn encode(&self) -> Vec<u8> {
412        // payload = cmd(1) + num_walls(1) + per-wall(2 points * 2 coords * 2 bytes)
413        let payload_len = 1 + 1 + self.walls.len() * 8;
414
415        let mut buf = Vec::with_capacity(3 + payload_len + 1);
416        buf.push(0xAA);
417        buf.push((payload_len >> 8) as u8);
418        buf.push(payload_len as u8);
419        buf.push(0x12);
420        buf.push(self.walls.len() as u8);
421        for wall in &self.walls {
422            buf.extend_from_slice(&wall.start.0.to_be_bytes());
423            buf.extend_from_slice(&wall.start.1.to_be_bytes());
424            buf.extend_from_slice(&wall.end.0.to_be_bytes());
425            buf.extend_from_slice(&wall.end.1.to_be_bytes());
426        }
427
428        let checksum: u8 = buf[3..].iter().copied().fold(0u16, |acc, b| acc + b as u16) as u8;
429        buf.push(checksum);
430        buf
431    }
432
433    /// Encode and return as base64 string.
434    pub fn encode_base64(&self) -> String {
435        base64::engine::general_purpose::STANDARD.encode(self.encode())
436    }
437
438    /// Create a command that clears all virtual walls.
439    pub fn clear() -> Self {
440        Self { walls: vec![] }
441    }
442}
443
444/// Build a raw sweeper frame: `0xAA` + length(2 BE) + cmd + data + checksum.
445///
446/// Used by both local and cloud device implementations to encode DP 15 commands.
447pub fn build_sweeper_frame(cmd: u8, data: &[u8]) -> Vec<u8> {
448    let payload_len = 1 + data.len();
449    let mut frame = Vec::with_capacity(3 + payload_len + 1);
450    frame.push(0xAA);
451    frame.push((payload_len >> 8) as u8);
452    frame.push(payload_len as u8);
453    frame.push(cmd);
454    frame.extend_from_slice(data);
455    let checksum: u8 = frame[3..]
456        .iter()
457        .copied()
458        .fold(0u16, |acc, b| acc + b as u16) as u8;
459    frame.push(checksum);
460    frame
461}
462
463/// A decoded DP 15 message from the vacuum cleaner.
464#[derive(Debug, Clone, PartialEq, Eq)]
465pub struct SweeperMessage {
466    /// Command byte.
467    pub cmd: u8,
468    /// Payload data (without header and checksum).
469    pub data: Vec<u8>,
470    /// Whether the checksum validated correctly.
471    pub checksum_ok: bool,
472}
473
474impl SweeperMessage {
475    /// Decode from raw bytes.
476    ///
477    /// # Examples
478    ///
479    /// ```
480    /// use xplorer_rs::protocol::SweeperMessage;
481    ///
482    /// let bytes = [0xAA, 0x00, 0x04, 0x15, 0x01, 0x01, 0x04, 0x1B];
483    /// let msg = SweeperMessage::decode(&bytes).unwrap();
484    /// assert_eq!(msg.cmd, 0x15);
485    /// assert!(msg.checksum_ok);
486    /// ```
487    pub fn decode(bytes: &[u8]) -> Result<Self, ProtocolError> {
488        if bytes.len() < 4 {
489            return Err(ProtocolError::TooShort(bytes.len()));
490        }
491        if bytes[0] != 0xAA {
492            return Err(ProtocolError::InvalidStartByte(bytes[0]));
493        }
494
495        let payload_len = ((bytes[1] as usize) << 8) | (bytes[2] as usize);
496        // Total = 3 (header) + payload_len + 1 (checksum)
497        let expected_total = 3 + payload_len + 1;
498        if bytes.len() < expected_total {
499            return Err(ProtocolError::LengthMismatch {
500                expected: expected_total,
501                actual: bytes.len(),
502            });
503        }
504
505        let cmd = bytes[3];
506        let data = bytes[4..3 + payload_len].to_vec();
507        let received_checksum = bytes[3 + payload_len];
508
509        let computed: u8 = bytes[3..3 + payload_len]
510            .iter()
511            .copied()
512            .fold(0u16, |acc, b| acc + b as u16) as u8;
513
514        Ok(SweeperMessage {
515            cmd,
516            data,
517            checksum_ok: computed == received_checksum,
518        })
519    }
520
521    /// Decode from base64 string.
522    ///
523    /// # Examples
524    ///
525    /// ```
526    /// use xplorer_rs::protocol::SweeperMessage;
527    ///
528    /// let msg = SweeperMessage::decode_base64("qgAEFQEBBBs=").unwrap();
529    /// assert_eq!(msg.cmd, 0x15);
530    /// assert_eq!(msg.data, vec![0x01, 0x01, 0x04]);
531    /// ```
532    pub fn decode_base64(s: &str) -> Result<Self, ProtocolError> {
533        let bytes = base64::engine::general_purpose::STANDARD.decode(s)?;
534        Self::decode(&bytes)
535    }
536}
537
538/// Parsed room clean status from the vacuum cleaner (cmd 0x15).
539#[derive(Debug, Clone, PartialEq, Eq)]
540pub struct RoomCleanStatusResponse {
541    /// Number of cleaning passes.
542    pub clean_times: u8,
543    /// Number of rooms being cleaned.
544    pub num_rooms: u8,
545    /// IDs of rooms being cleaned.
546    pub room_ids: Vec<u8>,
547}
548
549impl TryFrom<&SweeperMessage> for RoomCleanStatusResponse {
550    type Error = ProtocolError;
551
552    fn try_from(msg: &SweeperMessage) -> Result<Self, Self::Error> {
553        if msg.cmd != 0x15 {
554            return Err(ProtocolError::UnexpectedCommand(msg.cmd));
555        }
556        if msg.data.len() < 2 {
557            return Err(ProtocolError::PayloadTooShort);
558        }
559        let clean_times = msg.data[0];
560        let num_rooms = msg.data[1];
561        let room_ids = msg.data[2..].to_vec();
562        Ok(RoomCleanStatusResponse {
563            clean_times,
564            num_rooms,
565            room_ids,
566        })
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    #[test]
575    fn encode_single_room() {
576        let cmd = RoomCleanCommand {
577            clean_times: 1,
578            room_ids: vec![4],
579        };
580        assert_eq!(
581            cmd.encode(),
582            vec![0xAA, 0x00, 0x04, 0x14, 0x01, 0x01, 0x04, 0x1A]
583        );
584    }
585
586    #[test]
587    fn encode_multi_room() {
588        let cmd = RoomCleanCommand {
589            clean_times: 2,
590            room_ids: vec![0, 2, 3],
591        };
592        let encoded = cmd.encode();
593        // header: AA 00 06, cmd: 14, clean_times: 02, num_rooms: 03, rooms: 00 02 03
594        assert_eq!(encoded[0], 0xAA);
595        assert_eq!(encoded[1], 0x00);
596        assert_eq!(encoded[2], 0x06); // payload = 1+1+1+3 = 6
597        assert_eq!(encoded[3], 0x14);
598        assert_eq!(encoded[4], 0x02);
599        assert_eq!(encoded[5], 0x03);
600        assert_eq!(encoded[6], 0x00);
601        assert_eq!(encoded[7], 0x02);
602        assert_eq!(encoded[8], 0x03);
603        // checksum: (0x14+0x02+0x03+0x00+0x02+0x03) = 0x1E
604        assert_eq!(encoded[9], 0x1E);
605    }
606
607    #[test]
608    fn decode_room_clean_status() {
609        let bytes = [0xAA, 0x00, 0x04, 0x15, 0x01, 0x01, 0x04, 0x1B];
610        let msg = SweeperMessage::decode(&bytes).unwrap();
611        assert_eq!(msg.cmd, 0x15);
612        assert_eq!(msg.data, vec![0x01, 0x01, 0x04]);
613        assert!(msg.checksum_ok);
614    }
615
616    #[test]
617    fn decode_base64_room_clean_status() {
618        let msg = SweeperMessage::decode_base64("qgAEFQEBBBs=").unwrap();
619        assert_eq!(msg.cmd, 0x15);
620        assert_eq!(msg.data, vec![0x01, 0x01, 0x04]);
621        assert!(msg.checksum_ok);
622    }
623
624    #[test]
625    fn decode_bad_checksum() {
626        let bytes = [0xAA, 0x00, 0x04, 0x15, 0x01, 0x01, 0x04, 0xFF];
627        let msg = SweeperMessage::decode(&bytes).unwrap();
628        assert!(!msg.checksum_ok);
629    }
630
631    #[test]
632    fn decode_too_short() {
633        assert!(SweeperMessage::decode(&[0xAA, 0x00]).is_err());
634    }
635
636    #[test]
637    fn decode_bad_start_byte() {
638        assert!(SweeperMessage::decode(&[0xBB, 0x00, 0x01, 0x14, 0x14]).is_err());
639    }
640
641    #[test]
642    fn room_clean_status_try_from() {
643        let msg = SweeperMessage {
644            cmd: 0x15,
645            data: vec![0x01, 0x01, 0x04],
646            checksum_ok: true,
647        };
648        let resp = RoomCleanStatusResponse::try_from(&msg).unwrap();
649        assert_eq!(resp.clean_times, 1);
650        assert_eq!(resp.num_rooms, 1);
651        assert_eq!(resp.room_ids, vec![4]);
652    }
653
654    #[test]
655    fn room_clean_status_wrong_cmd() {
656        let msg = SweeperMessage {
657            cmd: 0x14,
658            data: vec![1, 1, 4],
659            checksum_ok: true,
660        };
661        assert!(RoomCleanStatusResponse::try_from(&msg).is_err());
662    }
663
664    #[test]
665    fn multi_room_encode_decode_roundtrip() {
666        let cmd = RoomCleanCommand {
667            clean_times: 2,
668            room_ids: vec![0, 2, 3],
669        };
670        let encoded = cmd.encode();
671        let b64 = cmd.encode_base64();
672
673        let msg = SweeperMessage::decode(&encoded).unwrap();
674        assert!(msg.checksum_ok);
675        assert_eq!(msg.cmd, 0x14);
676
677        let msg2 = SweeperMessage::decode_base64(&b64).unwrap();
678        assert_eq!(msg, msg2);
679    }
680
681    // ── Zone clean ──────────────────────────────────────────────
682
683    #[test]
684    fn zone_encode_sala_zone() {
685        // Zone matching a real sniffed command (coordinates from device traffic):
686        // vertices: (82,203), (453,203), (453,-13), (82,-13)
687        // cmd 0x28 is the setter; robot reports back via 0x29 status.
688        let cmd = ZoneCleanCommand {
689            clean_times: 1,
690            zones: vec![Zone {
691                vertices: [(82, 203), (453, 203), (453, -13), (82, -13)],
692            }],
693        };
694        let encoded = cmd.encode();
695        let expected: Vec<u8> = vec![
696            0xAA, 0x00, 0x14, // header: payload_len = 20
697            0x28, // cmd (setter)
698            0x01, // clean_times = 1
699            0x01, // num_zones = 1
700            0x04, // num_vertices = 4
701            0x00, 0x52, // x=82
702            0x00, 0xCB, // y=203
703            0x01, 0xC5, // x=453
704            0x00, 0xCB, // y=203
705            0x01, 0xC5, // x=453
706            0xFF, 0xF3, // y=-13
707            0x00, 0x52, // x=82
708            0xFF, 0xF3, // y=-13
709            0xD8, // checksum (0xD9 - 1 because cmd 0x28 vs 0x29)
710        ];
711        assert_eq!(encoded, expected);
712    }
713
714    #[test]
715    fn zone_rect_helper() {
716        // Zone::rect(x1=82, y1=-13, x2=453, y2=203) should produce the same
717        // 4 vertices as the app capture (TL, TR, BR, BL)
718        let zone = Zone::rect(82, -13, 453, 203);
719        assert_eq!(
720            zone.vertices,
721            [
722                (82, 203),  // top-left
723                (453, 203), // top-right
724                (453, -13), // bottom-right
725                (82, -13),  // bottom-left
726            ]
727        );
728    }
729
730    #[test]
731    fn zone_rotated_rect_zero_angle() {
732        // 0° rotation should equal Zone::rect
733        let plain = Zone::rect(100, 200, 300, 400);
734        let rotated = Zone::rotated_rect(100, 200, 300, 400, 0.0);
735        assert_eq!(plain.vertices, rotated.vertices);
736    }
737
738    #[test]
739    fn zone_rotated_rect_90_degrees() {
740        // 90° CCW around center (200, 300)
741        let zone = Zone::rotated_rect(100, 200, 300, 400, 90.0);
742        // TL (100,400) → rotated: center + (100, -100) → (300, 200)
743        // TR (300,400) → rotated: center + (100, 100) → (100, 200) ... wait
744        // Let me just verify it's still a valid rectangle (all sides equal)
745        let [v0, v1, v2, v3] = zone.vertices;
746        let dist = |a: (i16, i16), b: (i16, i16)| {
747            let dx = (b.0 - a.0) as f64;
748            let dy = (b.1 - a.1) as f64;
749            (dx * dx + dy * dy).sqrt()
750        };
751        let side_a = dist(v0, v1);
752        let side_b = dist(v1, v2);
753        // Opposite sides should be equal
754        assert!((dist(v0, v1) - dist(v2, v3)).abs() < 1.0);
755        assert!((dist(v1, v2) - dist(v3, v0)).abs() < 1.0);
756        // Diagonals should be equal (rectangle property)
757        assert!((dist(v0, v2) - dist(v1, v3)).abs() < 1.0);
758        // Side ratio should match original (200 wide, 200 tall = square)
759        assert!((side_a - side_b).abs() < 1.0);
760    }
761
762    #[test]
763    fn zone_rotated_rect_small_angle() {
764        // 2.68° — typical map theta
765        let zone = Zone::rotated_rect(82, -13, 453, 203, 2.68);
766        let [v0, v1, v2, v3] = zone.vertices;
767        // Should still be a valid rectangle
768        let dist = |a: (i16, i16), b: (i16, i16)| {
769            let dx = (b.0 - a.0) as f64;
770            let dy = (b.1 - a.1) as f64;
771            (dx * dx + dy * dy).sqrt()
772        };
773        assert!((dist(v0, v1) - dist(v2, v3)).abs() < 2.0);
774        assert!((dist(v1, v2) - dist(v3, v0)).abs() < 2.0);
775        // Vertices should differ from axis-aligned
776        let plain = Zone::rect(82, -13, 453, 203);
777        assert_ne!(zone.vertices, plain.vertices);
778        // Center should be the same
779        let center_r = (
780            zone.vertices.iter().map(|v| v.0 as i32).sum::<i32>(),
781            zone.vertices.iter().map(|v| v.1 as i32).sum::<i32>(),
782        );
783        let center_p = (
784            plain.vertices.iter().map(|v| v.0 as i32).sum::<i32>(),
785            plain.vertices.iter().map(|v| v.1 as i32).sum::<i32>(),
786        );
787        assert!((center_r.0 - center_p.0).abs() <= 2);
788        assert!((center_r.1 - center_p.1).abs() <= 2);
789    }
790
791    #[test]
792    fn zone_encode_single() {
793        let cmd = ZoneCleanCommand {
794            clean_times: 1,
795            zones: vec![Zone::rect(82, -13, 453, 203)],
796        };
797        let encoded = cmd.encode();
798        assert_eq!(encoded[0], 0xAA);
799        // payload_len = 1 + 1 + 1 + 1 + 4*4 = 20 = 0x0014
800        assert_eq!(encoded[1], 0x00);
801        assert_eq!(encoded[2], 0x14);
802        assert_eq!(encoded[3], 0x28); // cmd (setter)
803        assert_eq!(encoded[4], 0x01); // clean_times
804        assert_eq!(encoded[5], 0x01); // num_zones
805        assert_eq!(encoded[6], 0x04); // num_vertices
806        // total = 3 + 20 + 1 = 24 bytes
807        assert_eq!(encoded.len(), 24);
808        // verify checksum
809        let sum: u16 = encoded[3..encoded.len() - 1]
810            .iter()
811            .map(|&b| b as u16)
812            .sum();
813        assert_eq!(encoded.last().copied().unwrap(), (sum & 0xFF) as u8);
814    }
815
816    #[test]
817    fn zone_encode_multi() {
818        let cmd = ZoneCleanCommand {
819            clean_times: 2,
820            zones: vec![
821                Zone::rect(100, 200, 300, 400),
822                Zone::rect(500, 600, 700, 800),
823            ],
824        };
825        let encoded = cmd.encode();
826        // payload_len = 1 + 1 + 1 + 2*(1 + 4*4) = 3 + 2*17 = 37 = 0x0025
827        assert_eq!(encoded[1], 0x00);
828        assert_eq!(encoded[2], 0x25);
829        assert_eq!(encoded[3], 0x28); // cmd (setter)
830        assert_eq!(encoded[4], 0x02); // clean_times
831        assert_eq!(encoded[5], 0x02); // num_zones
832        assert_eq!(encoded[6], 0x04); // num_vertices zone 1
833        // total = 3 + 37 + 1 = 41 bytes
834        assert_eq!(encoded.len(), 41);
835        // verify checksum
836        let sum: u16 = encoded[3..encoded.len() - 1]
837            .iter()
838            .map(|&b| b as u16)
839            .sum();
840        assert_eq!(encoded.last().copied().unwrap(), (sum & 0xFF) as u8);
841    }
842
843    #[test]
844    fn zone_encode_negative_coords() {
845        let cmd = ZoneCleanCommand {
846            clean_times: 1,
847            zones: vec![Zone::rect(-100, -200, 100, 200)],
848        };
849        let encoded = cmd.encode();
850        // First vertex is top-left: (-100, 200) → 0xFF9C, 0x00C8
851        assert_eq!(encoded[7], 0xFF);
852        assert_eq!(encoded[8], 0x9C);
853        assert_eq!(encoded[9], 0x00);
854        assert_eq!(encoded[10], 0xC8);
855    }
856
857    #[test]
858    fn zone_encode_decode_roundtrip() {
859        let cmd = ZoneCleanCommand {
860            clean_times: 1,
861            zones: vec![Zone::rect(82, -13, 453, 203)],
862        };
863        let encoded = cmd.encode();
864        let msg = SweeperMessage::decode(&encoded).unwrap();
865        assert!(msg.checksum_ok);
866        assert_eq!(msg.cmd, 0x28);
867        // data: clean_times + num_zones + num_vertices + 4*4 bytes = 19
868        assert_eq!(msg.data.len(), 19);
869        assert_eq!(msg.data[0], 1); // clean_times
870        assert_eq!(msg.data[1], 1); // num_zones
871        assert_eq!(msg.data[2], 4); // num_vertices
872    }
873
874    // ── Forbidden zone (cmd 0x1a) ─────────────────────────────
875
876    #[test]
877    fn forbidden_zone_encode_no_sweep() {
878        // Verified against real robot: mode=0x01 shows as "zona non lavabile" in app
879        let cmd = ForbiddenZoneCommand {
880            zones: vec![ForbiddenZone {
881                mode: ForbiddenMode::NoSweep,
882                zone: Zone::rect(82, -13, 453, 203),
883            }],
884        };
885        let encoded = cmd.encode();
886        assert_eq!(encoded[3], 0x1A);
887        assert_eq!(encoded[5], 0x01); // mode = NoSweep
888        // Matches verified frame: aa 00 14 1a 01 01 04 00 52 00 cb ...
889        assert_eq!(encoded[7], 0x00);
890        assert_eq!(encoded[8], 0x52); // x=82
891    }
892
893    #[test]
894    fn forbidden_zone_matches_verified_frame() {
895        // Exact frame verified on real robot (mode=0x00, full ban, straight rect)
896        // Sent: aa 00 14 1a 01 00 04 00 52 00 cb 01 c5 00 cb 01 c5 ff f3 00 52 ff f3 c9
897        let cmd = ForbiddenZoneCommand {
898            zones: vec![ForbiddenZone {
899                mode: ForbiddenMode::FullBan,
900                zone: Zone::rect(82, -13, 453, 203),
901            }],
902        };
903        let encoded = cmd.encode();
904        let expected: Vec<u8> = vec![
905            0xAA, 0x00, 0x14, 0x1A, 0x01, 0x00, 0x04, 0x00, 0x52, 0x00, 0xCB, 0x01, 0xC5, 0x00,
906            0xCB, 0x01, 0xC5, 0xFF, 0xF3, 0x00, 0x52, 0xFF, 0xF3, 0xC9,
907        ];
908        assert_eq!(encoded, expected);
909    }
910
911    #[test]
912    fn forbidden_zone_clear() {
913        // Clear = aa 00 02 1a 00 1a (verified on real robot)
914        let cmd = ForbiddenZoneCommand::clear();
915        let encoded = cmd.encode();
916        assert_eq!(encoded, vec![0xAA, 0x00, 0x02, 0x1A, 0x00, 0x1A]);
917    }
918
919    #[test]
920    fn forbidden_zone_encode_decode_roundtrip() {
921        let cmd = ForbiddenZoneCommand {
922            zones: vec![ForbiddenZone {
923                mode: ForbiddenMode::NoSweep,
924                zone: Zone::rect(100, 200, 300, 400),
925            }],
926        };
927        let encoded = cmd.encode();
928        let msg = SweeperMessage::decode(&encoded).unwrap();
929        assert!(msg.checksum_ok);
930        assert_eq!(msg.cmd, 0x1A);
931        assert_eq!(msg.data[0], 0x01); // num_zones
932        assert_eq!(msg.data[1], 0x01); // mode = NoSweep
933        assert_eq!(msg.data[2], 0x04); // num_points
934    }
935
936    #[test]
937    fn forbidden_zone_multi_mode() {
938        // Two zones with different modes
939        let cmd = ForbiddenZoneCommand {
940            zones: vec![
941                ForbiddenZone {
942                    mode: ForbiddenMode::FullBan,
943                    zone: Zone::rect(0, 0, 100, 100),
944                },
945                ForbiddenZone {
946                    mode: ForbiddenMode::NoSweep,
947                    zone: Zone::rect(200, 200, 300, 300),
948                },
949            ],
950        };
951        let encoded = cmd.encode();
952        assert_eq!(encoded[3], 0x1A);
953        assert_eq!(encoded[4], 0x02); // num_zones = 2
954        assert_eq!(encoded[5], 0x00); // zone 1 mode = FullBan
955        assert_eq!(encoded[6], 0x04); // zone 1 num_pts
956        // zone 2 starts at 5 + 1 + 1 + 16 = 23
957        assert_eq!(encoded[23], 0x01); // zone 2 mode = NoSweep
958        assert_eq!(encoded[24], 0x04); // zone 2 num_pts
959    }
960
961    // ── Virtual wall (cmd 0x12) ─────────────────────────────
962
963    #[test]
964    fn virtual_wall_encode_horizontal() {
965        // Verified on real robot: horizontal wall (100,100) -> (400,100)
966        let cmd = VirtualWallCommand {
967            walls: vec![Wall {
968                start: (100, 100),
969                end: (400, 100),
970            }],
971        };
972        let encoded = cmd.encode();
973        let expected: Vec<u8> = vec![
974            0xAA, 0x00, 0x0A, // header: payload = 10
975            0x12, // cmd
976            0x01, // num_walls
977            0x00, 0x64, 0x00, 0x64, // start: (100, 100)
978            0x01, 0x90, 0x00, 0x64, // end: (400, 100)
979            0xD0, // checksum
980        ];
981        assert_eq!(encoded, expected);
982    }
983
984    #[test]
985    fn virtual_wall_encode_diagonal() {
986        // Verified on real robot: diagonal wall (100,-100) -> (400,200)
987        let cmd = VirtualWallCommand {
988            walls: vec![Wall {
989                start: (100, -100),
990                end: (400, 200),
991            }],
992        };
993        let encoded = cmd.encode();
994        assert_eq!(encoded[3], 0x12);
995        assert_eq!(encoded[4], 0x01); // num_walls
996        // start: (100, -100) = 0x0064, 0xFF9C
997        assert_eq!(encoded[5], 0x00);
998        assert_eq!(encoded[6], 0x64);
999        assert_eq!(encoded[7], 0xFF);
1000        assert_eq!(encoded[8], 0x9C);
1001        // end: (400, 200) = 0x0190, 0x00C8
1002        assert_eq!(encoded[9], 0x01);
1003        assert_eq!(encoded[10], 0x90);
1004        assert_eq!(encoded[11], 0x00);
1005        assert_eq!(encoded[12], 0xC8);
1006        let sum: u16 = encoded[3..encoded.len() - 1]
1007            .iter()
1008            .map(|&b| b as u16)
1009            .sum();
1010        assert_eq!(encoded.last().copied().unwrap(), (sum & 0xFF) as u8);
1011    }
1012
1013    #[test]
1014    fn virtual_wall_clear() {
1015        // Clear = aa 00 02 12 00 12 (verified on real robot)
1016        let cmd = VirtualWallCommand::clear();
1017        let encoded = cmd.encode();
1018        assert_eq!(encoded, vec![0xAA, 0x00, 0x02, 0x12, 0x00, 0x12]);
1019    }
1020
1021    #[test]
1022    fn virtual_wall_encode_decode_roundtrip() {
1023        let cmd = VirtualWallCommand {
1024            walls: vec![Wall {
1025                start: (-50, 100),
1026                end: (300, -200),
1027            }],
1028        };
1029        let encoded = cmd.encode();
1030        let msg = SweeperMessage::decode(&encoded).unwrap();
1031        assert!(msg.checksum_ok);
1032        assert_eq!(msg.cmd, 0x12);
1033        assert_eq!(msg.data[0], 0x01); // num_walls
1034        assert_eq!(msg.data.len(), 9); // 1 + 8 bytes
1035    }
1036
1037    #[test]
1038    fn virtual_wall_multi() {
1039        let cmd = VirtualWallCommand {
1040            walls: vec![
1041                Wall {
1042                    start: (0, 0),
1043                    end: (100, 0),
1044                },
1045                Wall {
1046                    start: (0, 0),
1047                    end: (0, 100),
1048                },
1049            ],
1050        };
1051        let encoded = cmd.encode();
1052        // payload = 1 + 1 + 2*8 = 18 = 0x12
1053        assert_eq!(encoded[2], 0x12);
1054        assert_eq!(encoded[4], 0x02); // num_walls
1055        assert_eq!(encoded.len(), 3 + 18 + 1); // 22 bytes
1056        let sum: u16 = encoded[3..encoded.len() - 1]
1057            .iter()
1058            .map(|&b| b as u16)
1059            .sum();
1060        assert_eq!(encoded.last().copied().unwrap(), (sum & 0xFF) as u8);
1061    }
1062}