Skip to main content

laser_dac/
types.rs

1//! DAC types for laser output.
2//!
3//! Provides DAC-agnostic types for laser frames and points,
4//! as well as device enumeration types.
5
6#[cfg(feature = "serde")]
7use serde::{Deserialize, Serialize};
8use std::collections::HashSet;
9use std::fmt;
10
11/// A DAC-agnostic laser point with full-precision f32 coordinates.
12///
13/// Coordinates are normalized:
14/// - x: -1.0 (left) to 1.0 (right)
15/// - y: -1.0 (bottom) to 1.0 (top)
16///
17/// Colors are 16-bit (0-65535) to support high-resolution DACs.
18/// DACs with lower resolution (8-bit) will downscale automatically.
19///
20/// This allows each DAC to convert to its native format:
21/// - Helios: 12-bit unsigned (0-4095), inverted
22/// - EtherDream: 16-bit signed (-32768 to 32767)
23#[derive(Debug, Clone, Copy, PartialEq, Default)]
24#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
25pub struct LaserPoint {
26    /// X coordinate, -1.0 to 1.0
27    pub x: f32,
28    /// Y coordinate, -1.0 to 1.0
29    pub y: f32,
30    /// Red channel (0-65535)
31    pub r: u16,
32    /// Green channel (0-65535)
33    pub g: u16,
34    /// Blue channel (0-65535)
35    pub b: u16,
36    /// Intensity (0-65535)
37    pub intensity: u16,
38}
39
40impl LaserPoint {
41    /// Creates a new laser point.
42    pub fn new(x: f32, y: f32, r: u16, g: u16, b: u16, intensity: u16) -> Self {
43        Self {
44            x,
45            y,
46            r,
47            g,
48            b,
49            intensity,
50        }
51    }
52
53    /// Creates a blanked point (laser off) at the given position.
54    pub fn blanked(x: f32, y: f32) -> Self {
55        Self {
56            x,
57            y,
58            ..Default::default()
59        }
60    }
61
62    // =========================================================================
63    // Coordinate conversion helpers (shared across protocol backends)
64    // =========================================================================
65
66    /// Convert a coordinate from [-1.0, 1.0] to 12-bit unsigned (0-4095) with axis inversion.
67    ///
68    /// Used by Helios and LaserCube WiFi backends.
69    #[inline]
70    pub(crate) fn coord_to_u12_inverted(v: f32) -> u16 {
71        ((1.0 - (v + 1.0) / 2.0).clamp(0.0, 1.0) * 4095.0).round() as u16
72    }
73
74    /// Convert a coordinate from [-1.0, 1.0] to 12-bit unsigned (0-4095).
75    ///
76    /// Used by LaserCube USB backend.
77    #[inline]
78    pub(crate) fn coord_to_u12(v: f32) -> u16 {
79        (((v.clamp(-1.0, 1.0) + 1.0) / 2.0) * 4095.0).round() as u16
80    }
81
82    /// Convert a coordinate from [-1.0, 1.0] to signed 16-bit (-32767 to 32767) with inversion.
83    ///
84    /// Used by Ether Dream and IDN backends.
85    #[inline]
86    pub(crate) fn coord_to_i16_inverted(v: f32) -> i16 {
87        (v.clamp(-1.0, 1.0) * -32767.0).round() as i16
88    }
89
90    /// Downscale a u16 color channel (0-65535) to u8 (0-255).
91    #[inline]
92    pub(crate) fn color_to_u8(v: u16) -> u8 {
93        (v >> 8) as u8
94    }
95
96    /// Downscale a u16 color channel (0-65535) to 12-bit (0-4095).
97    #[inline]
98    pub(crate) fn color_to_u12(v: u16) -> u16 {
99        v >> 4
100    }
101}
102
103/// Types of laser DAC hardware supported.
104#[derive(Debug, Clone, PartialEq, Eq, Hash)]
105#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
106pub enum DacType {
107    /// Helios laser DAC (USB connection).
108    Helios,
109    /// Ether Dream laser DAC (network connection).
110    EtherDream,
111    /// IDN laser DAC (ILDA Digital Network, network connection).
112    Idn,
113    /// LaserCube WiFi laser DAC (network connection).
114    LasercubeWifi,
115    /// LaserCube USB laser DAC (USB connection, also known as LaserDock).
116    LasercubeUsb,
117    /// AVB audio device backend.
118    Avb,
119    /// Custom DAC implementation (for external/third-party backends).
120    Custom(String),
121}
122
123impl DacType {
124    /// Returns all available DAC types.
125    pub fn all() -> &'static [DacType] {
126        &[
127            DacType::Helios,
128            DacType::EtherDream,
129            DacType::Idn,
130            DacType::LasercubeWifi,
131            DacType::LasercubeUsb,
132            DacType::Avb,
133        ]
134    }
135
136    /// Returns the display name for this DAC type.
137    pub fn display_name(&self) -> &str {
138        match self {
139            DacType::Helios => "Helios",
140            DacType::EtherDream => "Ether Dream",
141            DacType::Idn => "IDN",
142            DacType::LasercubeWifi => "LaserCube WiFi",
143            DacType::LasercubeUsb => "LaserCube USB (Laserdock)",
144            DacType::Avb => "AVB Audio Device",
145            DacType::Custom(name) => name,
146        }
147    }
148
149    /// Returns a description of this DAC type.
150    pub fn description(&self) -> &'static str {
151        match self {
152            DacType::Helios => "USB laser DAC",
153            DacType::EtherDream => "Network laser DAC",
154            DacType::Idn => "ILDA Digital Network laser DAC",
155            DacType::LasercubeWifi => "WiFi laser DAC",
156            DacType::LasercubeUsb => "USB laser DAC",
157            DacType::Avb => "AVB audio network output",
158            DacType::Custom(_) => "Custom DAC",
159        }
160    }
161}
162
163impl fmt::Display for DacType {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        write!(f, "{}", self.display_name())
166    }
167}
168
169/// Set of enabled DAC types for discovery.
170#[derive(Debug, Clone, PartialEq, Eq)]
171#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
172pub struct EnabledDacTypes {
173    types: HashSet<DacType>,
174}
175
176impl EnabledDacTypes {
177    /// Creates a new set with all DAC types enabled.
178    pub fn all() -> Self {
179        Self {
180            types: DacType::all().iter().cloned().collect(),
181        }
182    }
183
184    /// Creates an empty set (no DAC types enabled).
185    pub fn none() -> Self {
186        Self {
187            types: HashSet::new(),
188        }
189    }
190
191    /// Returns true if the given DAC type is enabled.
192    pub fn is_enabled(&self, dac_type: DacType) -> bool {
193        self.types.contains(&dac_type)
194    }
195
196    /// Enables a DAC type for discovery.
197    ///
198    /// Returns `&mut Self` to allow method chaining.
199    ///
200    /// # Examples
201    ///
202    /// ```
203    /// use laser_dac::{EnabledDacTypes, DacType};
204    ///
205    /// let mut enabled = EnabledDacTypes::none();
206    /// enabled.enable(DacType::Helios).enable(DacType::EtherDream);
207    ///
208    /// assert!(enabled.is_enabled(DacType::Helios));
209    /// assert!(enabled.is_enabled(DacType::EtherDream));
210    /// ```
211    pub fn enable(&mut self, dac_type: DacType) -> &mut Self {
212        self.types.insert(dac_type);
213        self
214    }
215
216    /// Disables a DAC type for discovery.
217    ///
218    /// Returns `&mut Self` to allow method chaining.
219    ///
220    /// # Examples
221    ///
222    /// ```
223    /// use laser_dac::{EnabledDacTypes, DacType};
224    ///
225    /// let mut enabled = EnabledDacTypes::all();
226    /// enabled.disable(DacType::Helios).disable(DacType::EtherDream);
227    ///
228    /// assert!(!enabled.is_enabled(DacType::Helios));
229    /// assert!(!enabled.is_enabled(DacType::EtherDream));
230    /// ```
231    pub fn disable(&mut self, dac_type: DacType) -> &mut Self {
232        self.types.remove(&dac_type);
233        self
234    }
235
236    /// Returns an iterator over enabled DAC types.
237    pub fn iter(&self) -> impl Iterator<Item = DacType> + '_ {
238        self.types.iter().cloned()
239    }
240
241    /// Returns true if no DAC types are enabled.
242    pub fn is_empty(&self) -> bool {
243        self.types.is_empty()
244    }
245}
246
247impl Default for EnabledDacTypes {
248    fn default() -> Self {
249        Self::all()
250    }
251}
252
253impl std::iter::FromIterator<DacType> for EnabledDacTypes {
254    fn from_iter<I: IntoIterator<Item = DacType>>(iter: I) -> Self {
255        Self {
256            types: iter.into_iter().collect(),
257        }
258    }
259}
260
261impl Extend<DacType> for EnabledDacTypes {
262    fn extend<I: IntoIterator<Item = DacType>>(&mut self, iter: I) {
263        self.types.extend(iter);
264    }
265}
266
267/// Information about a discovered DAC device.
268/// The name is the unique identifier for the device.
269#[derive(Debug, Clone, PartialEq, Eq, Hash)]
270#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
271pub struct DacDevice {
272    pub name: String,
273    pub dac_type: DacType,
274}
275
276impl DacDevice {
277    pub fn new(name: String, dac_type: DacType) -> Self {
278        Self { name, dac_type }
279    }
280}
281
282/// Connection state for a single DAC device.
283#[derive(Debug, Clone, PartialEq, Eq, Hash)]
284#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
285pub enum DacConnectionState {
286    /// Successfully connected and ready to receive frames.
287    Connected { name: String },
288    /// Worker stopped normally (callback returned None or stop() was called).
289    Stopped { name: String },
290    /// Connection was lost due to an error.
291    Lost { name: String, error: Option<String> },
292}
293
294// =============================================================================
295// Streaming Types
296// =============================================================================
297
298/// DAC capabilities that inform the stream scheduler about safe chunk sizes and behaviors.
299#[derive(Clone, Debug)]
300#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
301pub struct DacCapabilities {
302    /// Minimum points-per-second (hardware/protocol limit where known).
303    ///
304    /// A value of 1 means no known protocol constraint. Helios (7) and
305    /// Ether Dream (1) have true hardware minimums. Note that very low PPS
306    /// increases point dwell time and can produce flickery output.
307    pub pps_min: u32,
308    /// Maximum supported points-per-second (hardware limit).
309    pub pps_max: u32,
310    /// Maximum number of points allowed per chunk submission.
311    pub max_points_per_chunk: usize,
312    /// The scheduler-relevant output model.
313    pub output_model: OutputModel,
314}
315
316impl Default for DacCapabilities {
317    fn default() -> Self {
318        Self {
319            pps_min: 1,
320            pps_max: 100_000,
321            max_points_per_chunk: 4096,
322            output_model: OutputModel::NetworkFifo,
323        }
324    }
325}
326
327/// Get default capabilities for a DAC type.
328///
329/// This delegates to each protocol's `default_capabilities()` function.
330/// For optimal performance, backends should query actual device capabilities
331/// at runtime where the protocol supports it (e.g., LaserCube's `max_dac_rate`
332/// and ringbuffer queries).
333pub fn caps_for_dac_type(dac_type: &DacType) -> DacCapabilities {
334    match dac_type {
335        #[cfg(feature = "helios")]
336        DacType::Helios => crate::protocols::helios::default_capabilities(),
337        #[cfg(not(feature = "helios"))]
338        DacType::Helios => DacCapabilities::default(),
339
340        #[cfg(feature = "ether-dream")]
341        DacType::EtherDream => crate::protocols::ether_dream::default_capabilities(),
342        #[cfg(not(feature = "ether-dream"))]
343        DacType::EtherDream => DacCapabilities::default(),
344
345        #[cfg(feature = "idn")]
346        DacType::Idn => crate::protocols::idn::default_capabilities(),
347        #[cfg(not(feature = "idn"))]
348        DacType::Idn => DacCapabilities::default(),
349
350        #[cfg(feature = "lasercube-wifi")]
351        DacType::LasercubeWifi => crate::protocols::lasercube_wifi::default_capabilities(),
352        #[cfg(not(feature = "lasercube-wifi"))]
353        DacType::LasercubeWifi => DacCapabilities::default(),
354
355        #[cfg(feature = "lasercube-usb")]
356        DacType::LasercubeUsb => crate::protocols::lasercube_usb::default_capabilities(),
357        #[cfg(not(feature = "lasercube-usb"))]
358        DacType::LasercubeUsb => DacCapabilities::default(),
359
360        #[cfg(feature = "avb")]
361        DacType::Avb => crate::protocols::avb::default_capabilities(),
362        #[cfg(not(feature = "avb"))]
363        DacType::Avb => DacCapabilities::default(),
364
365        DacType::Custom(_) => DacCapabilities::default(),
366    }
367}
368
369/// The scheduler-relevant output model for a DAC.
370#[derive(Clone, Debug, PartialEq, Eq)]
371#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
372pub enum OutputModel {
373    /// Frame swap / limited queue depth (e.g., Helios-style double-buffering).
374    UsbFrameSwap,
375    /// FIFO-ish buffer where "top up" is natural (e.g., Ether Dream-style).
376    NetworkFifo,
377    /// Timed UDP chunks where OS send may not reflect hardware pacing.
378    UdpTimed,
379}
380
381/// Represents a point in stream time, anchored to estimated playback position.
382///
383/// `StreamInstant` represents the **estimated playback time** of points, not merely
384/// "points sent so far." When used in `ChunkRequest::start`, it represents:
385///
386/// `start` = playhead + buffered
387///
388/// Where:
389/// - `playhead` = stream_epoch + estimated_consumed_points
390/// - `buffered` = points sent but not yet played
391///
392/// This allows callbacks to generate content for the exact time it will be displayed,
393/// enabling accurate audio synchronization.
394#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
395#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
396pub struct StreamInstant(pub u64);
397
398impl StreamInstant {
399    /// Create a new stream instant from a point count.
400    pub fn new(points: u64) -> Self {
401        Self(points)
402    }
403
404    /// Returns the number of points since stream start.
405    pub fn points(&self) -> u64 {
406        self.0
407    }
408
409    /// Convert this instant to seconds at the given points-per-second rate.
410    pub fn as_seconds(&self, pps: u32) -> f64 {
411        self.0 as f64 / pps as f64
412    }
413
414    /// Convert to seconds at the given PPS.
415    ///
416    /// This is an alias for `as_seconds()` for consistency with standard Rust
417    /// duration naming conventions (e.g., `Duration::as_secs_f64()`).
418    #[inline]
419    pub fn as_secs_f64(&self, pps: u32) -> f64 {
420        self.as_seconds(pps)
421    }
422
423    /// Create a stream instant from a duration in seconds at the given PPS.
424    pub fn from_seconds(seconds: f64, pps: u32) -> Self {
425        Self((seconds * pps as f64) as u64)
426    }
427
428    /// Add a number of points to this instant.
429    pub fn add_points(&self, points: u64) -> Self {
430        Self(self.0.saturating_add(points))
431    }
432
433    /// Subtract a number of points from this instant (saturating at 0).
434    pub fn sub_points(&self, points: u64) -> Self {
435        Self(self.0.saturating_sub(points))
436    }
437}
438
439impl std::ops::Add<u64> for StreamInstant {
440    type Output = Self;
441    fn add(self, rhs: u64) -> Self::Output {
442        self.add_points(rhs)
443    }
444}
445
446impl std::ops::Sub<u64> for StreamInstant {
447    type Output = Self;
448    fn sub(self, rhs: u64) -> Self::Output {
449        self.sub_points(rhs)
450    }
451}
452
453impl std::ops::AddAssign<u64> for StreamInstant {
454    fn add_assign(&mut self, rhs: u64) {
455        self.0 = self.0.saturating_add(rhs);
456    }
457}
458
459impl std::ops::SubAssign<u64> for StreamInstant {
460    fn sub_assign(&mut self, rhs: u64) {
461        self.0 = self.0.saturating_sub(rhs);
462    }
463}
464
465/// Configuration for starting a stream.
466///
467/// # Buffer-Driven Timing
468///
469/// The streaming API uses pure buffer-driven timing:
470/// - `target_buffer`: Target buffer level to maintain (default: 20ms)
471/// - `min_buffer`: Minimum buffer before requesting urgent fill (default: 8ms)
472///
473/// The callback is invoked when `buffered < target_buffer`. The callback receives
474/// a `ChunkRequest` with `min_points` and `target_points` calculated from these
475/// durations and the current buffer state.
476///
477/// To reduce perceived latency, reduce `target_buffer`.
478#[derive(Clone, Debug)]
479#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
480pub struct StreamConfig {
481    /// Points per second output rate.
482    pub pps: u32,
483
484    /// Target buffer level to maintain (default: 20ms).
485    ///
486    /// The callback's `target_points` is calculated to bring the buffer to this level.
487    /// The callback is invoked when the buffer drops below this level.
488    #[cfg_attr(feature = "serde", serde(with = "duration_millis"))]
489    pub target_buffer: std::time::Duration,
490
491    /// Minimum buffer before requesting urgent fill (default: 8ms).
492    ///
493    /// When buffer drops below this, `min_points` in `ChunkRequest` will be non-zero.
494    #[cfg_attr(feature = "serde", serde(with = "duration_millis"))]
495    pub min_buffer: std::time::Duration,
496
497    /// What to do when the producer can't keep up.
498    pub underrun: UnderrunPolicy,
499
500    /// Maximum time to wait for queued points to drain on graceful shutdown (default: 1s).
501    ///
502    /// When the producer returns `ChunkResult::End`, the stream waits for buffered
503    /// points to play out before returning. This timeout caps that wait to prevent
504    /// blocking forever if the DAC stalls or queue depth is unknown.
505    #[cfg_attr(feature = "serde", serde(with = "duration_millis"))]
506    pub drain_timeout: std::time::Duration,
507
508    /// Initial color delay for scanner sync compensation (default: disabled).
509    ///
510    /// Delays RGB+intensity channels relative to XY coordinates by this duration,
511    /// allowing galvo mirrors time to settle before the laser fires. The delay is
512    /// implemented as a FIFO: output colors lag input colors by `ceil(color_delay * pps)` points.
513    ///
514    /// Can be changed at runtime via [`crate::StreamControl::set_color_delay`] /
515    /// [`crate::SessionControl::set_color_delay`].
516    ///
517    /// Typical values: 50–200µs depending on scanner speed.
518    /// `Duration::ZERO` disables the delay (default).
519    #[cfg_attr(feature = "serde", serde(with = "duration_micros"))]
520    pub color_delay: std::time::Duration,
521
522    /// Duration of forced blanking after arming (default: 1ms).
523    ///
524    /// After the stream is armed, the first `ceil(startup_blank * pps)` points
525    /// will have their color channels forced to zero, regardless of what the
526    /// producer writes. This prevents the "flash on start" artifact where
527    /// the laser fires before mirrors reach position.
528    ///
529    /// Note: when `color_delay` is also active, the delay line provides
530    /// `color_delay` worth of natural startup blanking. This `startup_blank`
531    /// setting adds blanking *beyond* that duration.
532    ///
533    /// Set to `Duration::ZERO` to disable explicit startup blanking.
534    #[cfg_attr(feature = "serde", serde(with = "duration_micros"))]
535    pub startup_blank: std::time::Duration,
536}
537
538#[cfg(feature = "serde")]
539macro_rules! duration_serde_module {
540    ($mod_name:ident, $as_unit:ident, $from_unit:ident) => {
541        mod $mod_name {
542            use serde::{Deserialize, Deserializer, Serialize, Serializer};
543            use std::time::Duration;
544
545            pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
546            where
547                S: Serializer,
548            {
549                let value = duration.$as_unit().min(u64::MAX as u128) as u64;
550                value.serialize(serializer)
551            }
552
553            pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
554            where
555                D: Deserializer<'de>,
556            {
557                let value = u64::deserialize(deserializer)?;
558                Ok(Duration::$from_unit(value))
559            }
560        }
561    };
562}
563
564#[cfg(feature = "serde")]
565duration_serde_module!(duration_millis, as_millis, from_millis);
566#[cfg(feature = "serde")]
567duration_serde_module!(duration_micros, as_micros, from_micros);
568
569impl Default for StreamConfig {
570    fn default() -> Self {
571        use std::time::Duration;
572        Self {
573            pps: 30_000,
574            target_buffer: Duration::from_millis(20),
575            min_buffer: Duration::from_millis(8),
576            underrun: UnderrunPolicy::default(),
577            drain_timeout: Duration::from_secs(1),
578            color_delay: Duration::ZERO,
579            startup_blank: Duration::from_millis(1),
580        }
581    }
582}
583
584impl StreamConfig {
585    /// Create a new stream configuration with the given PPS.
586    pub fn new(pps: u32) -> Self {
587        Self {
588            pps,
589            ..Default::default()
590        }
591    }
592
593    /// Set the target buffer level to maintain (builder pattern).
594    ///
595    /// Default: 20ms. Higher values provide more safety margin against underruns.
596    /// Lower values reduce perceived latency.
597    pub fn with_target_buffer(mut self, duration: std::time::Duration) -> Self {
598        self.target_buffer = duration;
599        self
600    }
601
602    /// Set the minimum buffer level before urgent fill (builder pattern).
603    ///
604    /// Default: 8ms. When buffer drops below this, `min_points` will be non-zero.
605    pub fn with_min_buffer(mut self, duration: std::time::Duration) -> Self {
606        self.min_buffer = duration;
607        self
608    }
609
610    /// Set the underrun policy (builder pattern).
611    pub fn with_underrun(mut self, policy: UnderrunPolicy) -> Self {
612        self.underrun = policy;
613        self
614    }
615
616    /// Set the drain timeout for graceful shutdown (builder pattern).
617    ///
618    /// Default: 1 second. Set to `Duration::ZERO` to skip drain entirely.
619    pub fn with_drain_timeout(mut self, timeout: std::time::Duration) -> Self {
620        self.drain_timeout = timeout;
621        self
622    }
623
624    /// Set the color delay for scanner sync compensation (builder pattern).
625    ///
626    /// Default: `Duration::ZERO` (disabled). Typical values: 50–200µs.
627    pub fn with_color_delay(mut self, delay: std::time::Duration) -> Self {
628        self.color_delay = delay;
629        self
630    }
631
632    /// Set the startup blanking duration after arming (builder pattern).
633    ///
634    /// Default: 1ms. Set to `Duration::ZERO` to disable.
635    pub fn with_startup_blank(mut self, duration: std::time::Duration) -> Self {
636        self.startup_blank = duration;
637        self
638    }
639}
640
641/// Policy for what to do when the producer can't keep up with the stream.
642#[derive(Clone, Debug, PartialEq)]
643#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
644#[derive(Default)]
645pub enum UnderrunPolicy {
646    /// Repeat the last chunk of points.
647    RepeatLast,
648    /// Output blanked points (laser off).
649    #[default]
650    Blank,
651    /// Park the beam at a specific position with laser off.
652    Park { x: f32, y: f32 },
653    /// Stop the stream entirely on underrun.
654    Stop,
655}
656
657/// A request to fill a buffer with points for streaming.
658///
659/// This is the streaming API with pure buffer-driven timing.
660/// The callback receives a `ChunkRequest` describing buffer state and requirements,
661/// and fills points into a library-owned buffer.
662///
663/// # Point Tiers
664///
665/// - `min_points`: Minimum points needed to avoid imminent underrun (ceiling rounded)
666/// - `target_points`: Ideal number of points to reach target buffer level (clamped to buffer length)
667/// - `buffer.len()` (passed separately): Maximum points the callback may write
668///
669/// # Rounding Rules
670///
671/// - `min_points`: Always **ceiling** (underrun prevention)
672/// - `target_points`: **ceiling**, then clamped to buffer length
673#[derive(Clone, Debug)]
674pub struct ChunkRequest {
675    /// Estimated playback time when this chunk starts.
676    ///
677    /// Calculated as: playhead + buffered_points
678    /// Use this for audio synchronization.
679    pub start: StreamInstant,
680
681    /// Points per second (fixed for stream duration).
682    pub pps: u32,
683
684    /// Minimum points needed to avoid imminent underrun.
685    ///
686    /// Calculated with ceiling to prevent underrun: `ceil((min_buffer - buffered) * pps)`
687    /// If 0, buffer is healthy.
688    pub min_points: usize,
689
690    /// Ideal number of points to reach target buffer level.
691    ///
692    /// Calculated as: `ceil((target_buffer - buffered) * pps)`, clamped to buffer length.
693    pub target_points: usize,
694
695    /// Current buffer level in points (for diagnostics/adaptive content).
696    pub buffered_points: u64,
697
698    /// Current buffer level as duration (for audio sync convenience).
699    pub buffered: std::time::Duration,
700
701    /// Raw device queue if available (best-effort, may differ from buffered_points).
702    pub device_queued_points: Option<u64>,
703}
704
705/// Result returned by the fill callback indicating how the buffer was filled.
706///
707/// This enum allows the callback to communicate three distinct states:
708/// - Successfully filled some number of points
709/// - Temporarily unable to provide data (underrun policy applies)
710/// - Stream should end gracefully
711///
712/// # `Filled(0)` Semantics
713///
714/// - If `target_points == 0`: Buffer is full, nothing needed. This is fine.
715/// - If `target_points > 0`: We needed points but got none. Treated as `Starved`.
716#[derive(Clone, Copy, Debug, PartialEq, Eq)]
717pub enum ChunkResult {
718    /// Wrote n points to the buffer.
719    ///
720    /// `n` must be <= `buffer.len()`.
721    /// Partial fills (`n < min_points`) are accepted without padding - useful when
722    /// content is legitimately ending. Return `End` on the next call to signal completion.
723    Filled(usize),
724
725    /// No data available right now.
726    ///
727    /// Underrun policy is applied (repeat last chunk or blank).
728    /// Stream continues; callback will be called again when buffer needs filling.
729    Starved,
730
731    /// Stream is finished. Shutdown sequence:
732    /// 1. Stop calling callback
733    /// 2. Let queued points drain (play out)
734    /// 3. Blank/park the laser at last position
735    /// 4. Return from stream() with `RunExit::ProducerEnded`
736    End,
737}
738
739/// Current status of a stream.
740#[derive(Clone, Debug)]
741pub struct StreamStatus {
742    /// Whether the device is connected.
743    pub connected: bool,
744    /// Library-owned scheduled amount.
745    pub scheduled_ahead_points: u64,
746    /// Best-effort device/backend estimate.
747    pub device_queued_points: Option<u64>,
748    /// Optional statistics for diagnostics.
749    pub stats: Option<StreamStats>,
750}
751
752/// Stream statistics for diagnostics and debugging.
753#[derive(Clone, Debug, Default)]
754pub struct StreamStats {
755    /// Number of times the stream underran.
756    pub underrun_count: u64,
757    /// Number of chunks that arrived late.
758    pub late_chunk_count: u64,
759    /// Number of times the device reconnected.
760    pub reconnect_count: u64,
761    /// Total chunks written since stream start.
762    pub chunks_written: u64,
763    /// Total points written since stream start.
764    pub points_written: u64,
765}
766
767/// How a callback-mode stream run ended.
768#[derive(Clone, Debug, PartialEq, Eq)]
769pub enum RunExit {
770    /// Stream was stopped via `StreamControl::stop()`.
771    Stopped,
772    /// Producer returned `None` (graceful completion).
773    ProducerEnded,
774    /// Device disconnected. No auto-reconnect; new streams start disarmed.
775    Disconnected,
776}
777
778/// Information about a discovered DAC before connection.
779#[derive(Clone, Debug)]
780#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
781pub struct DacInfo {
782    /// Stable, unique identifier used for (re)selecting DACs.
783    pub id: String,
784    /// Human-readable name for the DAC.
785    pub name: String,
786    /// The type of DAC hardware.
787    pub kind: DacType,
788    /// DAC capabilities.
789    pub caps: DacCapabilities,
790}
791
792impl DacInfo {
793    /// Create a new DAC info.
794    pub fn new(
795        id: impl Into<String>,
796        name: impl Into<String>,
797        kind: DacType,
798        caps: DacCapabilities,
799    ) -> Self {
800        Self {
801            id: id.into(),
802            name: name.into(),
803            kind,
804            caps,
805        }
806    }
807}
808
809#[cfg(test)]
810mod tests {
811    use super::*;
812
813    // ==========================================================================
814    // LaserPoint Tests
815    // ==========================================================================
816
817    #[test]
818    fn test_laser_point_blanked_sets_all_colors_to_zero() {
819        // blanked() should set all color channels to 0 while preserving position
820        let point = LaserPoint::blanked(0.25, 0.75);
821        assert_eq!(point.x, 0.25);
822        assert_eq!(point.y, 0.75);
823        assert_eq!(point.r, 0);
824        assert_eq!(point.g, 0);
825        assert_eq!(point.b, 0);
826        assert_eq!(point.intensity, 0);
827    }
828
829    // ==========================================================================
830    // DacType Tests
831    // ==========================================================================
832
833    #[test]
834    fn test_dac_type_all_returns_all_builtin_types() {
835        let all_types = DacType::all();
836        assert_eq!(all_types.len(), 6);
837        assert!(all_types.contains(&DacType::Helios));
838        assert!(all_types.contains(&DacType::EtherDream));
839        assert!(all_types.contains(&DacType::Idn));
840        assert!(all_types.contains(&DacType::LasercubeWifi));
841        assert!(all_types.contains(&DacType::LasercubeUsb));
842        assert!(all_types.contains(&DacType::Avb));
843    }
844
845    #[test]
846    fn test_dac_type_display_uses_display_name() {
847        // Display trait should delegate to display_name
848        assert_eq!(
849            format!("{}", DacType::Helios),
850            DacType::Helios.display_name()
851        );
852        assert_eq!(
853            format!("{}", DacType::EtherDream),
854            DacType::EtherDream.display_name()
855        );
856    }
857
858    #[test]
859    fn test_dac_type_can_be_used_in_hashset() {
860        use std::collections::HashSet;
861
862        let mut set = HashSet::new();
863        set.insert(DacType::Helios);
864        set.insert(DacType::Helios); // Duplicate should not increase count
865
866        assert_eq!(set.len(), 1);
867    }
868
869    // ==========================================================================
870    // EnabledDacTypes Tests
871    // ==========================================================================
872
873    #[test]
874    fn test_enabled_dac_types_all_enables_everything() {
875        let enabled = EnabledDacTypes::all();
876        for dac_type in DacType::all() {
877            assert!(
878                enabled.is_enabled(dac_type.clone()),
879                "{:?} should be enabled",
880                dac_type
881            );
882        }
883        assert!(!enabled.is_empty());
884    }
885
886    #[test]
887    fn test_enabled_dac_types_none_disables_everything() {
888        let enabled = EnabledDacTypes::none();
889        for dac_type in DacType::all() {
890            assert!(
891                !enabled.is_enabled(dac_type.clone()),
892                "{:?} should be disabled",
893                dac_type
894            );
895        }
896        assert!(enabled.is_empty());
897    }
898
899    #[test]
900    fn test_enabled_dac_types_enable_disable_toggles_correctly() {
901        let mut enabled = EnabledDacTypes::none();
902
903        // Enable one
904        enabled.enable(DacType::Helios);
905        assert!(enabled.is_enabled(DacType::Helios));
906        assert!(!enabled.is_enabled(DacType::EtherDream));
907
908        // Enable another
909        enabled.enable(DacType::EtherDream);
910        assert!(enabled.is_enabled(DacType::Helios));
911        assert!(enabled.is_enabled(DacType::EtherDream));
912
913        // Disable first
914        enabled.disable(DacType::Helios);
915        assert!(!enabled.is_enabled(DacType::Helios));
916        assert!(enabled.is_enabled(DacType::EtherDream));
917    }
918
919    #[test]
920    fn test_enabled_dac_types_iter_only_returns_enabled() {
921        let mut enabled = EnabledDacTypes::none();
922        enabled.enable(DacType::Helios);
923        enabled.enable(DacType::Idn);
924
925        let types: Vec<DacType> = enabled.iter().collect();
926        assert_eq!(types.len(), 2);
927        assert!(types.contains(&DacType::Helios));
928        assert!(types.contains(&DacType::Idn));
929        assert!(!types.contains(&DacType::EtherDream));
930    }
931
932    #[test]
933    fn test_enabled_dac_types_default_enables_all() {
934        let enabled = EnabledDacTypes::default();
935        // Default should be same as all()
936        for dac_type in DacType::all() {
937            assert!(enabled.is_enabled(dac_type.clone()));
938        }
939    }
940
941    #[test]
942    fn test_enabled_dac_types_idempotent_operations() {
943        let mut enabled = EnabledDacTypes::none();
944
945        // Enabling twice should have same effect as once
946        enabled.enable(DacType::Helios);
947        enabled.enable(DacType::Helios);
948        assert!(enabled.is_enabled(DacType::Helios));
949
950        // Disabling twice should have same effect as once
951        enabled.disable(DacType::Helios);
952        enabled.disable(DacType::Helios);
953        assert!(!enabled.is_enabled(DacType::Helios));
954    }
955
956    #[test]
957    fn test_enabled_dac_types_chaining() {
958        let mut enabled = EnabledDacTypes::none();
959        enabled
960            .enable(DacType::Helios)
961            .enable(DacType::EtherDream)
962            .disable(DacType::Helios);
963
964        assert!(!enabled.is_enabled(DacType::Helios));
965        assert!(enabled.is_enabled(DacType::EtherDream));
966    }
967
968    // ==========================================================================
969    // DacConnectionState Tests
970    // ==========================================================================
971
972    #[test]
973    fn test_dac_connection_state_equality() {
974        let s1 = DacConnectionState::Connected {
975            name: "DAC1".to_string(),
976        };
977        let s2 = DacConnectionState::Connected {
978            name: "DAC1".to_string(),
979        };
980        let s3 = DacConnectionState::Connected {
981            name: "DAC2".to_string(),
982        };
983        let s4 = DacConnectionState::Lost {
984            name: "DAC1".to_string(),
985            error: None,
986        };
987
988        assert_eq!(s1, s2);
989        assert_ne!(s1, s3); // Different name
990        assert_ne!(s1, s4); // Different variant
991    }
992
993    // ==========================================================================
994    // StreamConfig Serde Tests
995    // ==========================================================================
996
997    #[cfg(feature = "serde")]
998    #[test]
999    fn test_stream_config_serde_roundtrip() {
1000        use std::time::Duration;
1001
1002        let config = StreamConfig {
1003            pps: 45000,
1004            target_buffer: Duration::from_millis(50),
1005            min_buffer: Duration::from_millis(12),
1006            underrun: UnderrunPolicy::Park { x: 0.5, y: -0.3 },
1007            drain_timeout: Duration::from_secs(2),
1008            color_delay: Duration::from_micros(150),
1009            startup_blank: Duration::from_micros(800),
1010        };
1011
1012        // Round-trip through JSON
1013        let json = serde_json::to_string(&config).expect("serialize to JSON");
1014        let restored: StreamConfig = serde_json::from_str(&json).expect("deserialize from JSON");
1015
1016        assert_eq!(restored.pps, config.pps);
1017        assert_eq!(restored.target_buffer, config.target_buffer);
1018        assert_eq!(restored.min_buffer, config.min_buffer);
1019        assert_eq!(restored.drain_timeout, config.drain_timeout);
1020        assert_eq!(restored.color_delay, config.color_delay);
1021        assert_eq!(restored.startup_blank, config.startup_blank);
1022
1023        // Verify underrun policy
1024        match restored.underrun {
1025            UnderrunPolicy::Park { x, y } => {
1026                assert!((x - 0.5).abs() < f32::EPSILON);
1027                assert!((y - (-0.3)).abs() < f32::EPSILON);
1028            }
1029            _ => panic!("Expected Park policy"),
1030        }
1031    }
1032
1033    #[cfg(feature = "serde")]
1034    #[test]
1035    fn test_duration_millis_roundtrip_consistency() {
1036        use std::time::Duration;
1037
1038        // Test various duration values round-trip correctly
1039        let test_durations = [
1040            Duration::from_millis(0),
1041            Duration::from_millis(1),
1042            Duration::from_millis(10),
1043            Duration::from_millis(100),
1044            Duration::from_millis(1000),
1045            Duration::from_millis(u64::MAX / 1000), // Large but valid
1046        ];
1047
1048        for &duration in &test_durations {
1049            let config = StreamConfig {
1050                target_buffer: duration,
1051                ..StreamConfig::default()
1052            };
1053
1054            let json = serde_json::to_string(&config).expect("serialize");
1055            let restored: StreamConfig = serde_json::from_str(&json).expect("deserialize");
1056
1057            assert_eq!(
1058                restored.target_buffer, duration,
1059                "Duration {:?} did not round-trip correctly",
1060                duration
1061            );
1062        }
1063    }
1064}