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}