Skip to main content

laser_dac/protocols/lasercube_wifi/
protocol.rs

1//! Low-level protocol types and constants for LaserCube WiFi DAC communication.
2
3use byteorder::{ByteOrder, LittleEndian, WriteBytesExt};
4use std::io;
5
6use crate::point::LaserPoint;
7
8// Network ports
9/// Command and control port.
10pub const CMD_PORT: u16 = 45457;
11/// Point data transmission port.
12pub const DATA_PORT: u16 = 45458;
13
14// Command bytes
15/// Request device info (used for discovery).
16pub const CMD_GET_FULL_INFO: u8 = 0x77;
17/// Enable buffer size responses on data packets.
18pub const CMD_ENABLE_BUFFER_SIZE_RESPONSE: u8 = 0x78;
19/// Enable or disable laser output.
20pub const CMD_SET_OUTPUT: u8 = 0x80;
21/// Set the playback rate in Hz.
22pub const CMD_SET_RATE: u8 = 0x82;
23/// Query free buffer space (response via data port).
24pub const CMD_GET_RINGBUFFER_EMPTY: u8 = 0x8A;
25/// Clear the internal ring buffer.
26pub const CMD_CLEAR_RINGBUFFER: u8 = 0x8D;
27/// Send point/sample data.
28pub const CMD_SAMPLE_DATA: u8 = 0xA9;
29
30/// Maximum number of points per UDP packet (to fit within MTU).
31pub const MAX_POINTS_PER_PACKET: usize = 140;
32
33/// Size of a single point in bytes.
34pub const POINT_SIZE_BYTES: usize = 10;
35
36/// Size of the data packet header (command + reserved + message_number + frame_number).
37pub const DATA_HEADER_SIZE: usize = 4;
38
39/// Default buffer size for LaserCube devices.
40pub const DEFAULT_BUFFER_CAPACITY: u16 = 6000;
41
42/// A trait for writing protocol types to little-endian bytes.
43pub trait WriteBytes {
44    fn write_bytes<P: WriteToBytes>(&mut self, protocol: P) -> io::Result<()>;
45}
46
47/// Protocol types that may be written to little-endian bytes.
48pub trait WriteToBytes {
49    fn write_to_bytes<W: WriteBytesExt>(&self, writer: W) -> io::Result<()>;
50}
51
52/// A laser point with position and color.
53///
54/// Coordinates and colors are 12-bit values (0-4095).
55/// - X: 0 = right edge, 2047 = center, 4095 = left edge (inverted)
56/// - Y: 0 = top edge, 2047 = center, 4095 = bottom edge (inverted)
57/// - Colors: 12-bit values (0-4095)
58///
59/// Note: When converting from `LaserPoint`, both axes are inverted to match
60/// the LaserCube WiFi hardware orientation.
61#[repr(C)]
62#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
63pub struct Point {
64    /// X coordinate (12-bit, 0-4095, inverted: 0 = right, 4095 = left).
65    pub x: u16,
66    /// Y coordinate (12-bit, 0-4095, inverted: 0 = top, 4095 = bottom).
67    pub y: u16,
68    /// Red intensity (12-bit, 0-4095).
69    pub r: u16,
70    /// Green intensity (12-bit, 0-4095).
71    pub g: u16,
72    /// Blue intensity (12-bit, 0-4095).
73    pub b: u16,
74}
75
76impl Point {
77    /// The center coordinate value (12-bit midpoint).
78    pub const CENTER: u16 = 2047;
79
80    /// Create a new point at the center with no color (blanked).
81    pub fn blank() -> Self {
82        Self {
83            x: Self::CENTER,
84            y: Self::CENTER,
85            r: 0,
86            g: 0,
87            b: 0,
88        }
89    }
90
91    /// Create a new point from signed coordinates (-2048 to 2047) and 12-bit colors.
92    ///
93    /// This converts from a signed coordinate system to the 12-bit unsigned range.
94    pub fn from_signed(x: i16, y: i16, r: u16, g: u16, b: u16) -> Self {
95        Self {
96            x: (x as i32 + 2048).clamp(0, 4095) as u16,
97            y: (y as i32 + 2048).clamp(0, 4095) as u16,
98            r,
99            g,
100            b,
101        }
102    }
103
104    /// Convert this point's coordinates to signed values (-2048 to 2047).
105    pub fn to_signed(&self) -> (i16, i16) {
106        let x = (self.x as i32 - 2048) as i16;
107        let y = (self.y as i32 - 2048) as i16;
108        (x, y)
109    }
110}
111
112impl WriteToBytes for Point {
113    fn write_to_bytes<W: WriteBytesExt>(&self, mut writer: W) -> io::Result<()> {
114        writer.write_u16::<LittleEndian>(self.x)?;
115        writer.write_u16::<LittleEndian>(self.y)?;
116        writer.write_u16::<LittleEndian>(self.r)?;
117        writer.write_u16::<LittleEndian>(self.g)?;
118        writer.write_u16::<LittleEndian>(self.b)?;
119        Ok(())
120    }
121}
122
123impl From<&LaserPoint> for Point {
124    /// Convert a LaserPoint to a LaserCube WiFi Point.
125    ///
126    /// LaserPoint uses f32 coordinates (-1.0 to 1.0) and u16 colors (0-65535).
127    /// LaserCube WiFi uses 12-bit coordinates (0-4095) with inverted Y axis and
128    /// non-inverted X axis (X is mirrored relative to other backends), and 12-bit colors.
129    fn from(p: &LaserPoint) -> Self {
130        Point {
131            x: LaserPoint::coord_to_u12(p.x),
132            y: LaserPoint::coord_to_u12_inverted(p.y),
133            r: LaserPoint::color_to_u12(p.r),
134            g: LaserPoint::color_to_u12(p.g),
135            b: LaserPoint::color_to_u12(p.b),
136        }
137    }
138}
139
140/// Device information received during discovery.
141#[derive(Clone, Debug, PartialEq, Eq)]
142pub struct DeviceInfo {
143    /// Protocol version.
144    pub version: u8,
145    /// Maximum buffer capacity for points.
146    pub max_buffer_space: u16,
147}
148
149impl DeviceInfo {
150    /// Parse device info from a discovery response buffer.
151    ///
152    /// Expected buffer layout:
153    /// - Offset 0: Command echo (0x77)
154    /// - Offset 2: Version byte
155    /// - Offset 21-23: max_buffer_space (u16 LE)
156    /// - Offset 26-32: Serial number (6 bytes, hex encoded)
157    pub fn from_discovery_response(buffer: &[u8]) -> io::Result<Self> {
158        if buffer.len() < 32 {
159            return Err(io::Error::new(
160                io::ErrorKind::InvalidData,
161                format!(
162                    "discovery response too short: {} bytes, expected at least 32",
163                    buffer.len()
164                ),
165            ));
166        }
167
168        // Check command echo
169        if buffer[0] != CMD_GET_FULL_INFO {
170            return Err(io::Error::new(
171                io::ErrorKind::InvalidData,
172                format!(
173                    "unexpected command in discovery response: 0x{:02X}",
174                    buffer[0]
175                ),
176            ));
177        }
178
179        let version = buffer[2];
180        let max_buffer_space = LittleEndian::read_u16(&buffer[21..23]);
181
182        Ok(DeviceInfo {
183            version,
184            max_buffer_space,
185        })
186    }
187}
188
189/// Buffer status response received on the data port.
190#[derive(Copy, Clone, Debug, PartialEq, Eq)]
191pub struct BufferStatus {
192    /// The message number this ACK corresponds to.
193    pub message_number: u8,
194    /// Number of free sample slots in the device buffer.
195    pub free_space: u16,
196}
197
198impl BufferStatus {
199    /// Parse buffer status from a response buffer.
200    ///
201    /// Expected layout:
202    /// - Offset 0: Command (0x8A)
203    /// - Offset 1: Message number (echo of the sent message number)
204    /// - Offset 2-4: free_space (u16 LE)
205    pub fn from_response(buffer: &[u8]) -> io::Result<Self> {
206        if buffer.len() < 4 {
207            return Err(io::Error::new(
208                io::ErrorKind::InvalidData,
209                format!(
210                    "buffer status response too short: {} bytes, expected at least 4",
211                    buffer.len()
212                ),
213            ));
214        }
215
216        if buffer[0] != CMD_GET_RINGBUFFER_EMPTY {
217            return Err(io::Error::new(
218                io::ErrorKind::InvalidData,
219                format!("unexpected command in buffer status: 0x{:02X}", buffer[0]),
220            ));
221        }
222
223        let message_number = buffer[1];
224        let free_space = LittleEndian::read_u16(&buffer[2..4]);
225        Ok(BufferStatus {
226            message_number,
227            free_space,
228        })
229    }
230}
231
232/// Commands that can be sent to the LaserCube.
233pub mod command {
234    use super::*;
235
236    /// Build a GET_FULL_INFO command for discovery.
237    pub fn get_full_info() -> [u8; 1] {
238        [CMD_GET_FULL_INFO]
239    }
240
241    /// Build an ENABLE_BUFFER_SIZE_RESPONSE command.
242    pub fn enable_buffer_size_response(enable: bool) -> [u8; 2] {
243        [CMD_ENABLE_BUFFER_SIZE_RESPONSE, u8::from(enable)]
244    }
245
246    /// Build a SET_OUTPUT command.
247    pub fn set_output(enable: bool) -> [u8; 2] {
248        [CMD_SET_OUTPUT, u8::from(enable)]
249    }
250
251    /// Build a SET_RATE command.
252    pub fn set_rate(rate: u32) -> [u8; 5] {
253        let mut buf = [0u8; 5];
254        buf[0] = CMD_SET_RATE;
255        LittleEndian::write_u32(&mut buf[1..5], rate);
256        buf
257    }
258
259    /// Build a CLEAR_RINGBUFFER command.
260    pub fn clear_ringbuffer() -> [u8; 1] {
261        [CMD_CLEAR_RINGBUFFER]
262    }
263
264    /// Build a sample data packet header.
265    ///
266    /// Returns (header, points_offset) where points should be written starting at points_offset.
267    pub fn sample_data_header(message_number: u8, frame_number: u8) -> [u8; DATA_HEADER_SIZE] {
268        [CMD_SAMPLE_DATA, 0x00, message_number, frame_number]
269    }
270}
271
272impl<P> WriteToBytes for &P
273where
274    P: WriteToBytes,
275{
276    fn write_to_bytes<W: WriteBytesExt>(&self, writer: W) -> io::Result<()> {
277        (*self).write_to_bytes(writer)
278    }
279}
280
281impl<W> WriteBytes for W
282where
283    W: WriteBytesExt,
284{
285    fn write_bytes<P: WriteToBytes>(&mut self, protocol: P) -> io::Result<()> {
286        protocol.write_to_bytes(self)
287    }
288}