lasercube_core/
lib.rs

1//! Core types and constants for the LaserCube network protocol.
2//!
3//! This crate provides the fundamental data structures and protocol definitions
4//! for communicating with LaserCube devices, without any actual network implementation.
5
6pub mod buffer;
7pub mod cmds;
8pub mod point;
9
10// Re-export commonly used types
11pub use buffer::BufferState;
12pub use cmds::{Command, CommandType, SampleData};
13pub use point::Point;
14use std::{convert::TryFrom, ffi::CStr, net::Ipv4Addr};
15use thiserror::Error;
16
17/// Ports that the device listens on.
18pub mod port {
19    /// Port for "alive" messages (simple pings).
20    pub const ALIVE: u16 = 45456;
21    /// Port for commands (get info, enable/disable output, etc.).
22    pub const CMD: u16 = 45457;
23    /// Port for point data transmission.
24    pub const DATA: u16 = 45458;
25}
26
27/// Maximum points per data message to stay under typical network MTU.
28pub const MAX_POINTS_PER_MESSAGE: usize = 140;
29
30/// Default broadcast address
31pub const DEFAULT_BROADCAST_ADDR: &str = "255.255.255.255";
32
33/// Connection type for the LaserCube.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35#[repr(u8)]
36pub enum ConnectionType {
37    /// Unknown connection type.
38    Unknown = 0,
39    /// Connected via USB.
40    Usb = 1,
41    /// Connected via Ethernet.
42    Ethernet = 2,
43    /// Connected via Wifi.
44    Wifi = 3,
45}
46
47/// Error types that can occur when parsing a LaserInfo response
48#[derive(Debug, Error)]
49pub enum LaserInfoParseError {
50    #[error("Response too short: expected at least {expected} bytes, got {actual}")]
51    ResponseTooShort { expected: usize, actual: usize },
52    #[error("Missing null terminator in model name: {0}")]
53    MissingNullTerminator(#[from] std::ffi::FromBytesUntilNulError),
54}
55
56/// Fixed-size header portion of the LaserInfo response
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct LaserInfoHeader {
59    /// Firmware major version
60    pub fw_major: u8,
61    /// Firmware minor version
62    pub fw_minor: u8,
63    /// Whether output is enabled
64    pub output_enabled: bool,
65    /// Current DAC rate
66    pub dac_rate: u32,
67    /// Maximum DAC rate
68    pub max_dac_rate: u32,
69    /// Current free space in the RX buffer
70    pub rx_buffer_free: u16,
71    /// Total size of the RX buffer
72    pub rx_buffer_size: u16,
73    /// Battery percentage
74    pub battery_percent: u8,
75    /// Device temperature
76    pub temperature: u8,
77    /// Model number
78    pub model_number: u8,
79    /// Serial number
80    pub serial_number: [u8; 6],
81    /// IP address
82    pub ip_addr: Ipv4Addr,
83}
84
85/// The fixed-size header along with the variable length model name.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct LaserInfo {
88    /// Fixed-size header fields
89    pub header: LaserInfoHeader,
90    /// Model name (variable length, no greater than 26 bytes).
91    pub model_name: String,
92}
93
94impl LaserInfoHeader {
95    /// The size of the header encoded as bytes.
96    pub const SIZE: usize = 38;
97
98    /// Get the connection type based on the first byte of the serial number
99    pub fn connection_type(&self) -> ConnectionType {
100        ConnectionType::from(self.serial_number[0])
101    }
102}
103
104impl LaserInfo {
105    /// The minimum size of the `LaserInfo` in bytes.
106    pub const MIN_SIZE: usize = LaserInfoHeader::SIZE;
107    /// The maximum size of the `LaserInfo` in bytes.
108    pub const MAX_SIZE: usize = 64;
109    /// The maximum size of the `LaserInfo`'s model name field in bytes.
110    pub const MAX_MODEL_NAME_SIZE: usize = Self::MAX_SIZE - Self::MIN_SIZE;
111
112    /// Get the firmware version as a string (e.g., "1.2")
113    pub fn firmware_version(&self) -> String {
114        format!("{}.{}", self.header.fw_major, self.header.fw_minor)
115    }
116
117    /// Get the serial number as a formatted string (XX:XX:XX:XX:XX:XX)
118    pub fn serial_number_string(&self) -> String {
119        let mut result = String::with_capacity(17);
120        for (i, byte) in self.header.serial_number.iter().enumerate() {
121            if i > 0 {
122                result.push(':');
123            }
124            use std::fmt::Write;
125            write!(result, "{:02x}", byte).unwrap();
126        }
127        result
128    }
129
130    /// Get the connection type based on the first byte of the serial number
131    pub fn connection_type(&self) -> ConnectionType {
132        self.header.connection_type()
133    }
134}
135
136impl From<u8> for ConnectionType {
137    fn from(value: u8) -> Self {
138        match value {
139            1 => ConnectionType::Usb,
140            2 => ConnectionType::Ethernet,
141            3 => ConnectionType::Wifi,
142            _ => ConnectionType::Unknown,
143        }
144    }
145}
146
147impl From<[u8; 38]> for LaserInfoHeader {
148    fn from(bytes: [u8; 38]) -> Self {
149        // Extract values at specific offsets based on protocol
150        #[rustfmt::skip]
151        let [
152            _,
153            _,
154            _,
155            fw_major,
156            fw_minor,
157            output_enabled,
158            _,
159            _,
160            _,
161            _,
162            _,
163            dr0, dr1, dr2, dr3,
164            mdr0, mdr1, mdr2, mdr3,
165            _,
166            rxbf0, rxbf1,
167            rxbs0, rxbs1,
168            battery_percent,
169            temperature,
170            sn0, sn1, sn2, sn3, sn4, sn5,
171            ip0, ip1, ip2, ip3,
172            _,
173            model_number,
174        ] = bytes;
175        Self {
176            fw_major,
177            fw_minor,
178            output_enabled: output_enabled != 0,
179            dac_rate: u32::from_le_bytes([dr0, dr1, dr2, dr3]),
180            max_dac_rate: u32::from_le_bytes([mdr0, mdr1, mdr2, mdr3]),
181            rx_buffer_free: u16::from_le_bytes([rxbf0, rxbf1]),
182            rx_buffer_size: u16::from_le_bytes([rxbs0, rxbs1]),
183            battery_percent,
184            temperature,
185            serial_number: [sn0, sn1, sn2, sn3, sn4, sn5],
186            ip_addr: [ip0, ip1, ip2, ip3].into(),
187            model_number,
188        }
189    }
190}
191
192impl TryFrom<&[u8]> for LaserInfo {
193    type Error = LaserInfoParseError;
194
195    fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
196        // Need at least 38 bytes for the header
197        let header_bytes: &[u8; LaserInfoHeader::SIZE] = bytes
198            .get(0..LaserInfoHeader::SIZE)
199            .and_then(|slice| slice.try_into().ok())
200            .ok_or_else(|| LaserInfoParseError::ResponseTooShort {
201                expected: LaserInfoHeader::SIZE,
202                actual: bytes.len(),
203            })?;
204        // Parse the fixed header portion
205        let header = LaserInfoHeader::from(*header_bytes);
206        // Model name is a null-terminated string starting after the fixed region.
207        let model_name_start = LaserInfoHeader::SIZE;
208        let model_name_cstr = CStr::from_bytes_until_nul(&bytes[model_name_start..])?;
209        let model_name = String::from_utf8_lossy(model_name_cstr.to_bytes()).to_string();
210        Ok(LaserInfo { header, model_name })
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn test_parse_laser_info_header() {
220        // Create a test header array
221        let mut header = [0u8; LaserInfoHeader::SIZE];
222
223        // Set specific fields
224        header[3] = 1; // fw_major
225        header[4] = 2; // fw_minor
226        header[5] = 1; // output_enabled
227
228        // DAC rate (6000)
229        header[11] = 0x70;
230        header[12] = 0x17;
231        header[13] = 0;
232        header[14] = 0;
233
234        // Max DAC rate (6000)
235        header[15] = 0x70;
236        header[16] = 0x17;
237        header[17] = 0;
238        header[18] = 0;
239
240        // Buffer info
241        header[20] = 0x88; // 5000 (low byte)
242        header[21] = 0x13; // 5000 (high byte)
243        header[22] = 0x70; // 6000 (low byte)
244        header[23] = 0x17; // 6000 (high byte)
245
246        // Status
247        header[24] = 100; // battery 100%
248        header[25] = 31; // temperature 31°C
249
250        // Serial number (offset 26-31)
251        // First byte is also used as connection type (2 = Ethernet)
252        header[26] = 2; // This sets both the connection type and first byte of serial
253        header[27] = 2;
254        header[28] = 3;
255        header[29] = 4;
256        header[30] = 5;
257        header[31] = 6;
258
259        // IP address (offset 32-35)
260        header[32] = 192;
261        header[33] = 168;
262        header[34] = 1;
263        header[35] = 100;
264
265        // Model number
266        header[37] = 1;
267
268        let info_header = LaserInfoHeader::from(header);
269
270        assert_eq!(info_header.fw_major, 1);
271        assert_eq!(info_header.fw_minor, 2);
272        assert_eq!(info_header.output_enabled, true);
273        assert_eq!(info_header.dac_rate, 6000);
274        assert_eq!(info_header.max_dac_rate, 6000);
275        assert_eq!(info_header.rx_buffer_free, 5000);
276        assert_eq!(info_header.rx_buffer_size, 6000);
277        assert_eq!(info_header.battery_percent, 100);
278        assert_eq!(info_header.temperature, 31);
279        assert_eq!(info_header.connection_type(), ConnectionType::Ethernet);
280        assert_eq!(info_header.model_number, 1);
281        assert_eq!(info_header.serial_number, [2, 2, 3, 4, 5, 6]); // First byte is 2 for Ethernet
282        assert_eq!(info_header.ip_addr, Ipv4Addr::from([192, 168, 1, 100]));
283    }
284
285    #[test]
286    fn test_parse_laser_info_with_header() {
287        // Create a test header array
288        let mut message = [0u8; 80]; // 64 byte header plus model name and null terminator
289
290        // Fill header with test values
291        message[0] = 0x77; // Command byte
292        message[3] = 1; // fw_major
293        message[4] = 2; // fw_minor
294        message[5] = 1; // output_enabled
295
296        // DAC rate (6000)
297        message[11] = 0x70;
298        message[12] = 0x17;
299        message[13] = 0;
300        message[14] = 0;
301
302        // Max DAC rate (6000)
303        message[15] = 0x70;
304        message[16] = 0x17;
305        message[17] = 0;
306        message[18] = 0;
307
308        // Buffer info
309        message[20] = 0x88; // 5000 (low byte)
310        message[21] = 0x13; // 5000 (high byte)
311        message[22] = 0x70; // 6000 (low byte)
312        message[23] = 0x17; // 6000 (high byte)
313
314        // Status
315        message[24] = 100; // battery 100%
316        message[25] = 31; // temperature 31°C
317
318        // Serial number (offset 26-31)
319        message[26] = 2; // This sets both the connection type and first byte of serial
320        message[27] = 2;
321        message[28] = 3;
322        message[29] = 4;
323        message[30] = 5;
324        message[31] = 6;
325
326        // IP address (offset 32-35)
327        message[32] = 192;
328        message[33] = 168;
329        message[34] = 1;
330        message[35] = 100;
331
332        // Model number
333        message[37] = 1;
334
335        // Model name starting at offset 38
336        let model_name = b"LaserCube Pro";
337        let name_offset = 38;
338        for (i, &byte) in model_name.iter().enumerate() {
339            message[name_offset + i] = byte;
340        }
341        message[name_offset + model_name.len()] = 0; // Null terminator
342
343        let laser_info = LaserInfo::try_from(&message[..]).unwrap();
344
345        assert_eq!(laser_info.header.fw_major, 1);
346        assert_eq!(laser_info.header.fw_minor, 2);
347        assert_eq!(laser_info.header.output_enabled, true);
348        assert_eq!(laser_info.header.dac_rate, 6000);
349        assert_eq!(laser_info.header.max_dac_rate, 6000);
350        assert_eq!(laser_info.header.rx_buffer_free, 5000);
351        assert_eq!(laser_info.header.rx_buffer_size, 6000);
352        assert_eq!(laser_info.header.battery_percent, 100);
353        assert_eq!(laser_info.header.temperature, 31);
354        assert_eq!(
355            laser_info.header.connection_type(),
356            ConnectionType::Ethernet
357        );
358        assert_eq!(laser_info.header.model_number, 1);
359        assert_eq!(laser_info.header.serial_number, [2, 2, 3, 4, 5, 6]);
360        assert_eq!(laser_info.header.ip_addr, Ipv4Addr::from([192, 168, 1, 100]));
361        assert_eq!(laser_info.model_name, "LaserCube Pro");
362    }
363}