Skip to main content

lighthouse_manager/
protocol.rs

1/// Protocol helpers for building Lighthouse power control commands.
2use crate::lighthouse::{Lighthouse, LighthouseVersion};
3use anyhow::{Result, anyhow};
4
5/// Build the power-on command bytes for a V1 lighthouse.
6/// Format: [0x12, 0x00, 0x00, 0x00] + reversed ID bytes (4) + [0x00; 12] = 20 bytes total.
7///
8/// # Errors
9///
10/// Returns an error if the provided ID is not exactly 8 hex characters.
11pub fn build_v1_power_on(id: &str) -> Result<Vec<u8>> {
12    validate_v1_id(id)?;
13    let id_bytes = parse_v1_id_bytes(id);
14    let mut cmd = vec![0x12, 0x00, 0x00, 0x00];
15    let mut rev_id = id_bytes.clone();
16    rev_id.reverse();
17    cmd.extend_from_slice(&rev_id); // reversed
18    cmd.resize(20, 0x00); // pad to 20 bytes
19    Ok(cmd)
20}
21
22/// Build the sleep command bytes for a V1 lighthouse.
23/// Format: [0x12, 0x02, 0x00, 0x01] + reversed ID bytes (4) + [0x00; 12] = 20 bytes total.
24///
25/// # Errors
26///
27/// Returns an error if the provided ID is not exactly 8 hex characters.
28pub fn build_v1_sleep(id: &str) -> Result<Vec<u8>> {
29    validate_v1_id(id)?;
30    let id_bytes = parse_v1_id_bytes(id);
31    let mut cmd = vec![0x12, 0x02, 0x00, 0x01];
32    let mut rev_id = id_bytes.clone();
33    rev_id.reverse();
34    cmd.extend_from_slice(&rev_id); // reversed
35    cmd.resize(20, 0x00); // pad to 20 bytes
36    Ok(cmd)
37}
38
39/// Build the power-on command for a V2 lighthouse.
40#[must_use]
41pub fn build_v2_power_on() -> Vec<u8> {
42    vec![0x01]
43}
44
45/// Build the sleep command for a V2 lighthouse.
46#[must_use]
47pub fn build_v2_sleep() -> Vec<u8> {
48    vec![0x00]
49}
50
51/// Build the identify command for a V2 lighthouse.
52#[must_use]
53pub fn build_v2_identify() -> Vec<u8> {
54    vec![0x01]
55}
56
57/// Validate that an ID is exactly 8 hex characters.
58fn validate_v1_id(id: &str) -> Result<()> {
59    if id.len() != 8 {
60        return Err(anyhow!("Invalid V1 ID length: {id} (expected 8 chars)"));
61    }
62    if !id.chars().all(|c| c.is_ascii_hexdigit()) {
63        return Err(anyhow!("V1 ID contains non-hex characters: {id}"));
64    }
65    Ok(())
66}
67
68/// Parse an 8-char hex ID string into 4 bytes.
69/// E.g., "AABBCCDD" → [0xAA, 0xBB, 0xCC, 0xDD]
70fn parse_v1_id_bytes(id: &str) -> Vec<u8> {
71    (0..id.len())
72        .step_by(2)
73        .map(|i| u8::from_str_radix(&id[i..i + 2], 16).unwrap())
74        .collect()
75}
76
77/// Build the power control command bytes for a lighthouse.
78///
79/// # Errors
80///
81/// Returns an error if the lighthouse is V1 but has no ID set.
82pub fn build_power_command(lh: &Lighthouse) -> Result<Vec<u8>> {
83    match lh.version() {
84        LighthouseVersion::V1 => {
85            let id = lh
86                .id
87                .as_ref()
88                .ok_or_else(|| anyhow!("V1 lighthouse missing ID for power command"))?;
89            build_v1_power_on(id)
90        }
91        LighthouseVersion::V2 => Ok(build_v2_power_on()),
92    }
93}
94
95/// Build the sleep control command bytes for a lighthouse.
96///
97/// # Errors
98///
99/// Returns an error if the lighthouse is V1 but has no ID set.
100pub fn build_sleep_command(lh: &Lighthouse) -> Result<Vec<u8>> {
101    match lh.version() {
102        LighthouseVersion::V1 => {
103            let id = lh
104                .id
105                .as_ref()
106                .ok_or_else(|| anyhow!("V1 lighthouse missing ID for sleep command"))?;
107            build_v1_sleep(id)
108        }
109        LighthouseVersion::V2 => Ok(build_v2_sleep()),
110    }
111}
112
113/// Build the identify command bytes for a lighthouse (V2 only).
114///
115/// # Errors
116///
117/// Returns an error if the lighthouse is V1, which does not support the identify command.
118pub fn build_identify_command(lh: &Lighthouse) -> Result<Vec<u8>> {
119    match lh.version() {
120        LighthouseVersion::V2 => Ok(build_v2_identify()),
121        LighthouseVersion::V1 => Err(anyhow!("Identify is not supported on V1 lighthouses")),
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn test_v1_power_on_command() {
131        let cmd = build_v1_power_on("AABBCCDD").unwrap();
132        assert_eq!(cmd.len(), 20);
133        assert_eq!(cmd[0], 0x12);
134        assert_eq!(cmd[1], 0x00);
135        assert_eq!(cmd[2], 0x00);
136        assert_eq!(cmd[3], 0x00);
137        // ID bytes reversed: AABBCCDD → DD CC BB AA
138        assert_eq!(&cmd[4..8], &[0xDD, 0xCC, 0xBB, 0xAA]);
139        // Remaining are zeros
140        assert_eq!(&cmd[8..20], &[0u8; 12]);
141    }
142
143    #[test]
144    fn test_v1_sleep_command() {
145        let cmd = build_v1_sleep("AABBCCDD").unwrap();
146        assert_eq!(cmd.len(), 20);
147        assert_eq!(cmd[0], 0x12);
148        assert_eq!(cmd[1], 0x02);
149        assert_eq!(cmd[2], 0x00);
150        assert_eq!(cmd[3], 0x01);
151        // ID bytes reversed
152        assert_eq!(&cmd[4..8], &[0xDD, 0xCC, 0xBB, 0xAA]);
153    }
154
155    #[test]
156    fn test_v2_commands() {
157        assert_eq!(build_v2_power_on(), vec![0x01]);
158        assert_eq!(build_v2_sleep(), vec![0x00]);
159        assert_eq!(build_v2_identify(), vec![0x01]);
160    }
161
162    #[test]
163    fn test_validate_invalid_id() {
164        assert!(build_v1_power_on("12345").is_err()); // too short
165        assert!(build_v1_power_on("GGHHIIJJ").is_err()); // non-hex
166        assert!(build_v1_power_on("AABBCCDD11").is_err()); // too long
167    }
168
169    #[test]
170    fn test_parse_v1_id_bytes() {
171        let bytes = parse_v1_id_bytes("AABBCCDD");
172        assert_eq!(bytes, vec![0xAA, 0xBB, 0xCC, 0xDD]);
173    }
174
175    #[test]
176    fn test_build_power_command_v1() {
177        let lh = Lighthouse {
178            name: "HTC BS-AABBCCDD".into(),
179            address: "AA:BB:CC:DD:EE:FF".into(),
180            id: Some("AABBCCDD".into()),
181            managed: true,
182        };
183        let cmd = build_power_command(&lh).unwrap();
184        assert_eq!(cmd.len(), 20);
185        // Should match v1_power_on
186        assert_eq!(cmd, build_v1_power_on("AABBCCDD").unwrap());
187    }
188
189    #[test]
190    fn test_build_power_command_v2() {
191        let lh = Lighthouse {
192            name: "LHB-0A1B2C3D".into(),
193            address: "11:22:33:44:55:66".into(),
194            id: None,
195            managed: true,
196        };
197        let cmd = build_power_command(&lh).unwrap();
198        assert_eq!(cmd, vec![0x01]);
199    }
200
201    #[test]
202    fn test_build_sleep_command_v2() {
203        let lh = Lighthouse {
204            name: "LHB-0A1B2C3D".into(),
205            address: "11:22:33:44:55:66".into(),
206            id: None,
207            managed: true,
208        };
209        let cmd = build_sleep_command(&lh).unwrap();
210        assert_eq!(cmd, vec![0x00]);
211    }
212
213    #[test]
214    fn test_identify_v1_fails() {
215        let lh = Lighthouse {
216            name: "HTC BS-AABBCCDD".into(),
217            address: "AA:BB:CC:DD:EE:FF".into(),
218            id: Some("AABBCCDD".into()),
219            managed: true,
220        };
221        assert!(build_identify_command(&lh).is_err());
222    }
223
224    #[test]
225    fn test_build_power_command_missing_id() {
226        let lh = Lighthouse {
227            name: "HTC BS-AABBCCDD".into(),
228            address: "AA:BB:CC:DD:EE:FF".into(),
229            id: None,
230            managed: true,
231        };
232        assert!(build_power_command(&lh).is_err());
233    }
234}