Skip to main content

laser_dac/stream/
mod.rs

1//! Stream and Dac types for point output.
2//!
3//! This module provides the `Stream` type for streaming point chunks to a DAC,
4//! `StreamControl` for out-of-band control (arm/disarm/stop), and `Dac` for
5//! connected devices that can start streaming sessions.
6//!
7//! # Armed/Disarmed Model
8//!
9//! Streams use a binary armed/disarmed safety model:
10//!
11//! - **Armed**: Content passes through to device, shutter opened (best-effort).
12//! - **Disarmed** (default): Shutter closed, intensity/RGB forced to 0.
13//!
14//! All streams start disarmed. Call `arm()` to enable output.
15//! `arm()` and `disarm()` are the only safety controls — there is no separate
16//! shutter API. This keeps the mental model simple: armed = laser may emit.
17//!
18//! # Hardware Shutter Support
19//!
20//! Shutter control is best-effort and varies by backend:
21//! - **LaserCube USB/Network**: Actual hardware control
22//! - **Helios**: Hardware shutter control via USB interrupt
23//! - **Ether Dream, IDN**: No-op (safety relies on software blanking)
24//!
25//! # Disconnect Behavior
26//!
27//! By default, streams do not reconnect — on disconnect, `run()` returns
28//! `RunExit::Disconnected`. Configure reconnection via
29//! [`StreamConfig::with_reconnect`](crate::StreamConfig::with_reconnect) or
30//! [`FrameSessionConfig::with_reconnect`](crate::FrameSessionConfig::with_reconnect).
31//! New streams always start disarmed for safety.
32
33use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
34use std::sync::mpsc::{self, Receiver, Sender};
35use std::sync::{Arc, Mutex};
36use std::time::Duration;
37
38use crate::backend::{BackendKind, Error, Result};
39use crate::config::StreamConfig;
40use crate::device::{DacCapabilities, DacInfo, DacType};
41use crate::discovery::DacDiscovery;
42use crate::point::LaserPoint;
43use crate::reconnect::{ReconnectPolicy, ReconnectTarget};
44
45pub(crate) mod chunk_producer;
46
47// =============================================================================
48// Stream protocol types
49// =============================================================================
50
51#[cfg(feature = "serde")]
52use serde::{Deserialize, Serialize};
53
54/// Represents a point in stream time, anchored to estimated playback position.
55///
56/// `StreamInstant` represents the **estimated playback time** of points, not merely
57/// "points sent so far." When used in `ChunkRequest::start`, it represents:
58///
59/// `start` = playhead + buffered
60///
61/// Where:
62/// - `playhead` = stream_epoch + estimated_consumed_points
63/// - `buffered` = points sent but not yet played
64///
65/// This allows callbacks to generate content for the exact time it will be displayed,
66/// enabling accurate audio synchronization.
67#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
68#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
69pub struct StreamInstant(pub u64);
70
71impl StreamInstant {
72    /// Create a new stream instant from a point count.
73    pub fn new(points: u64) -> Self {
74        Self(points)
75    }
76
77    /// Returns the number of points since stream start.
78    pub fn points(&self) -> u64 {
79        self.0
80    }
81
82    /// Convert this instant to seconds at the given points-per-second rate.
83    pub fn as_seconds(&self, pps: u32) -> f64 {
84        self.0 as f64 / pps as f64
85    }
86
87    /// Convert to seconds at the given PPS.
88    ///
89    /// This is an alias for `as_seconds()` for consistency with standard Rust
90    /// duration naming conventions (e.g., `Duration::as_secs_f64()`).
91    #[inline]
92    pub fn as_secs_f64(&self, pps: u32) -> f64 {
93        self.as_seconds(pps)
94    }
95
96    /// Create a stream instant from a duration in seconds at the given PPS.
97    pub fn from_seconds(seconds: f64, pps: u32) -> Self {
98        Self((seconds * pps as f64) as u64)
99    }
100
101    /// Add a number of points to this instant.
102    pub fn add_points(&self, points: u64) -> Self {
103        Self(self.0.saturating_add(points))
104    }
105
106    /// Subtract a number of points from this instant (saturating at 0).
107    pub fn sub_points(&self, points: u64) -> Self {
108        Self(self.0.saturating_sub(points))
109    }
110}
111
112impl std::ops::Add<u64> for StreamInstant {
113    type Output = Self;
114    fn add(self, rhs: u64) -> Self::Output {
115        self.add_points(rhs)
116    }
117}
118
119impl std::ops::Sub<u64> for StreamInstant {
120    type Output = Self;
121    fn sub(self, rhs: u64) -> Self::Output {
122        self.sub_points(rhs)
123    }
124}
125
126impl std::ops::AddAssign<u64> for StreamInstant {
127    fn add_assign(&mut self, rhs: u64) {
128        self.0 = self.0.saturating_add(rhs);
129    }
130}
131
132impl std::ops::SubAssign<u64> for StreamInstant {
133    fn sub_assign(&mut self, rhs: u64) {
134        self.0 = self.0.saturating_sub(rhs);
135    }
136}
137
138/// A request to fill a buffer with points for streaming.
139///
140/// The callback receives a `ChunkRequest` describing the next chunk's timing
141/// requirements and fills points into a library-owned buffer.
142///
143/// `target_points` is the ideal number of points to reach the target buffer
144/// level, clamped to the provided buffer length. Returning fewer points
145/// triggers the configured [`IdlePolicy`](crate::config::IdlePolicy) for the
146/// remainder.
147#[derive(Clone, Debug)]
148pub struct ChunkRequest {
149    /// Estimated playback time when this chunk starts.
150    ///
151    /// Use this for audio synchronization.
152    pub start: StreamInstant,
153
154    /// Points per second (current value, may change via `StreamControl::set_pps`).
155    pub pps: u32,
156
157    /// Ideal number of points to reach target buffer level.
158    ///
159    /// Calculated as: `ceil((target_buffer - buffered) * pps)`, clamped to buffer length.
160    pub target_points: usize,
161}
162
163/// Result returned by the fill callback indicating how the buffer was filled.
164///
165/// This enum allows the callback to communicate three distinct states:
166/// - Successfully filled some number of points
167/// - Temporarily unable to provide data (underrun policy applies)
168/// - Stream should end gracefully
169///
170/// # `Filled(0)` Semantics
171///
172/// - If `target_points == 0`: Buffer is full, nothing needed. This is fine.
173/// - If `target_points > 0`: We needed points but got none. Treated as `Starved`.
174#[derive(Clone, Copy, Debug, PartialEq, Eq)]
175pub enum ChunkResult {
176    /// Wrote n points to the buffer.
177    ///
178    /// `n` must be <= `buffer.len()`. Partial fills (`n < target_points`) are
179    /// accepted; the remainder is filled by the configured idle policy.
180    Filled(usize),
181
182    /// No data available right now.
183    ///
184    /// Underrun policy is applied (repeat last chunk or blank).
185    /// Stream continues; callback will be called again when buffer needs filling.
186    Starved,
187
188    /// Stream is finished. Shutdown sequence:
189    /// 1. Stop calling callback
190    /// 2. Let queued points drain (play out)
191    /// 3. Blank/park the laser at last position
192    /// 4. Return from stream() with `RunExit::ProducerEnded`
193    End,
194}
195
196/// Current status of a stream.
197#[derive(Clone, Debug)]
198pub struct StreamStatus {
199    /// Whether the device is connected.
200    pub connected: bool,
201    /// Library-owned scheduled amount.
202    pub scheduled_ahead_points: u64,
203    /// Best-effort device/backend estimate.
204    pub device_queued_points: Option<u64>,
205    /// Optional statistics for diagnostics.
206    pub stats: Option<StreamStats>,
207}
208
209/// Stream statistics for diagnostics and debugging.
210#[derive(Clone, Debug, Default)]
211pub struct StreamStats {
212    /// Number of times the stream underran.
213    pub underrun_count: u64,
214    /// Number of chunks that arrived late.
215    pub late_chunk_count: u64,
216    /// Number of times the device reconnected.
217    pub reconnect_count: u64,
218    /// Total chunks written since stream start.
219    pub chunks_written: u64,
220    /// Total points written since stream start.
221    pub points_written: u64,
222}
223
224/// How a callback-mode stream run ended.
225#[derive(Clone, Debug, PartialEq, Eq)]
226pub enum RunExit {
227    /// Stream was stopped via `StreamControl::stop()`.
228    Stopped,
229    /// Producer returned `None` (graceful completion).
230    ProducerEnded,
231    /// Device disconnected. No auto-reconnect; new streams start disarmed.
232    Disconnected,
233}
234
235// =============================================================================
236// Stream Control
237// =============================================================================
238
239/// Control messages sent from StreamControl to Stream.
240///
241/// These messages allow out-of-band control actions to take effect immediately,
242/// even when the stream is waiting (pacing, backpressure, etc.).
243#[derive(Debug, Clone, Copy)]
244pub(crate) enum ControlMsg {
245    /// Arm the output (opens hardware shutter).
246    Arm,
247    /// Disarm the output (closes hardware shutter).
248    Disarm,
249    /// Request the stream to stop.
250    Stop,
251}
252
253/// Thread-safe control handle for safety-critical actions.
254///
255/// This allows out-of-band control of the stream (arm/disarm/stop) from
256/// a different thread, e.g., for E-stop functionality.
257///
258/// Control actions take effect as soon as possible - the stream processes
259/// control messages at every opportunity (during waits, between retries, etc.).
260#[derive(Clone)]
261pub struct StreamControl {
262    inner: Arc<StreamControlInner>,
263}
264
265struct StreamControlInner {
266    /// Whether output is armed (laser can fire).
267    armed: AtomicBool,
268    /// Whether a stop has been requested.
269    stop_requested: AtomicBool,
270    /// Channel for sending control messages to the stream loop.
271    /// Wrapped in Mutex because Sender is Send but not Sync.
272    control_tx: Mutex<Sender<ControlMsg>>,
273    /// Color delay in microseconds (readable per-chunk without locking).
274    color_delay_micros: AtomicU64,
275    /// Points per second (hot-swappable without session restart).
276    pps: AtomicU32,
277}
278
279impl StreamControl {
280    pub(crate) fn new(control_tx: Sender<ControlMsg>, color_delay: Duration, pps: u32) -> Self {
281        Self {
282            inner: Arc::new(StreamControlInner {
283                armed: AtomicBool::new(false),
284                stop_requested: AtomicBool::new(false),
285                control_tx: Mutex::new(control_tx),
286                color_delay_micros: AtomicU64::new(color_delay.as_micros() as u64),
287                pps: AtomicU32::new(pps),
288            }),
289        }
290    }
291
292    /// Arm the output (allow laser to fire).
293    ///
294    /// When armed, content from the producer passes through unmodified
295    /// and the hardware shutter is opened (best-effort).
296    pub fn arm(&self) -> Result<()> {
297        self.inner.armed.store(true, Ordering::SeqCst);
298        // Send message to stream for immediate shutter control
299        if let Ok(tx) = self.inner.control_tx.lock() {
300            let _ = tx.send(ControlMsg::Arm);
301        }
302        Ok(())
303    }
304
305    /// Disarm the output (force laser off). Designed for E-stop use.
306    ///
307    /// Immediately sets an atomic flag (works even if stream loop is blocked),
308    /// then sends a message to close the hardware shutter. All future points
309    /// are blanked in software. The stream stays alive outputting blanks -
310    /// use `stop()` to terminate entirely.
311    ///
312    /// **Latency**: Points already in the device buffer will still play out.
313    /// `target_buffer` bounds this latency.
314    ///
315    /// **Hardware shutter**: Best-effort. LaserCube and Helios have actual hardware
316    /// control; Ether Dream, IDN are no-ops (safety relies on software blanking).
317    pub fn disarm(&self) -> Result<()> {
318        self.inner.armed.store(false, Ordering::SeqCst);
319        // Send message to stream for immediate shutter control
320        if let Ok(tx) = self.inner.control_tx.lock() {
321            let _ = tx.send(ControlMsg::Disarm);
322        }
323        Ok(())
324    }
325
326    /// Check if the output is armed.
327    pub fn is_armed(&self) -> bool {
328        self.inner.armed.load(Ordering::SeqCst)
329    }
330
331    /// Set the color delay for scanner sync compensation.
332    ///
333    /// Takes effect within one chunk period. The delay is quantized to
334    /// whole points: `ceil(delay * pps)`.
335    pub fn set_color_delay(&self, delay: Duration) {
336        self.inner
337            .color_delay_micros
338            .store(delay.as_micros() as u64, Ordering::SeqCst);
339    }
340
341    /// Get the current color delay.
342    pub fn color_delay(&self) -> Duration {
343        Duration::from_micros(self.inner.color_delay_micros.load(Ordering::SeqCst))
344    }
345
346    /// Set the points per second rate.
347    ///
348    /// Takes effect within one chunk period — the stream loop reads this
349    /// atomically each iteration and recalculates timing on the fly.
350    /// No session restart required.
351    pub fn set_pps(&self, pps: u32) {
352        self.inner.pps.store(pps, Ordering::SeqCst);
353    }
354
355    /// Get the current points per second rate.
356    pub fn pps(&self) -> u32 {
357        self.inner.pps.load(Ordering::SeqCst)
358    }
359
360    /// Request the stream to stop.
361    ///
362    /// Signals termination; `run()` returns `RunExit::Stopped`.
363    /// For clean shutdown with shutter close, prefer `Stream::stop()`.
364    pub fn stop(&self) -> Result<()> {
365        self.inner.stop_requested.store(true, Ordering::SeqCst);
366        // Send message to stream for immediate stop
367        if let Ok(tx) = self.inner.control_tx.lock() {
368            let _ = tx.send(ControlMsg::Stop);
369        }
370        Ok(())
371    }
372
373    /// Check if a stop has been requested.
374    pub fn is_stop_requested(&self) -> bool {
375        self.inner.stop_requested.load(Ordering::SeqCst)
376    }
377}
378
379// =============================================================================
380// Stream State
381// =============================================================================
382
383/// Diagnostics carried across a stream's lifetime. The production streaming
384/// path lives entirely in [`crate::presentation::driver::run`] via
385/// [`chunk_producer::ChunkProducer`]; the only state the `Stream` wrapper still
386/// keeps is the stats snapshot surfaced by [`Stream::status`] and
387/// [`Stream::into_dac`].
388#[derive(Default)]
389struct StreamState {
390    /// Statistics.
391    stats: StreamStats,
392}
393
394impl StreamState {
395    fn new() -> Self {
396        Self::default()
397    }
398}
399
400// =============================================================================
401// Stream
402// =============================================================================
403
404/// A streaming session for outputting points to a DAC.
405///
406/// Use [`run()`](Self::run) to stream with buffer-driven timing.
407/// The callback is invoked when the buffer needs filling, providing automatic
408/// backpressure handling and zero allocations in the hot path.
409///
410/// The stream owns pacing, backpressure, and the timebase (`StreamInstant`).
411pub struct Stream {
412    /// Device info for this stream.
413    info: DacInfo,
414    /// The backend.
415    backend: Option<BackendKind>,
416    /// Stream configuration.
417    config: StreamConfig,
418    /// Thread-safe control handle.
419    control: StreamControl,
420    /// Receiver for control messages from StreamControl.
421    control_rx: Receiver<ControlMsg>,
422    /// Stream state.
423    state: StreamState,
424    /// Reconnection policy (None = no reconnection).
425    pub(crate) reconnect_policy: Option<ReconnectPolicy>,
426    /// Reopen identity, preserved for `into_dac()` even without reconnect enabled.
427    pub(crate) reconnect_target: Option<ReconnectTarget>,
428}
429
430impl Stream {
431    /// Create a new stream with a backend.
432    pub(crate) fn with_backend(info: DacInfo, backend: BackendKind, config: StreamConfig) -> Self {
433        let (control_tx, control_rx) = mpsc::channel();
434        let color_delay = config.color_delay;
435        let pps = config.pps;
436        Self {
437            info,
438            backend: Some(backend),
439            config,
440            control: StreamControl::new(control_tx, color_delay, pps),
441            control_rx,
442            state: StreamState::new(),
443            reconnect_policy: None,
444            reconnect_target: None,
445        }
446    }
447
448    /// Returns the device info.
449    pub fn info(&self) -> &DacInfo {
450        &self.info
451    }
452
453    /// Returns the stream configuration.
454    pub fn config(&self) -> &StreamConfig {
455        &self.config
456    }
457
458    /// Returns a thread-safe control handle.
459    pub fn control(&self) -> StreamControl {
460        self.control.clone()
461    }
462
463    /// Returns the current stream status.
464    pub fn status(&self) -> Result<StreamStatus> {
465        let buffered = self.estimate_buffer_points();
466        Ok(StreamStatus {
467            connected: self.backend.as_ref().is_some_and(|b| b.is_connected()),
468            scheduled_ahead_points: buffered,
469            device_queued_points: Some(buffered),
470            stats: Some(self.state.stats.clone()),
471        })
472    }
473
474    /// Disarm, close shutter, and stop the backend (best-effort).
475    ///
476    /// Shared shutdown sequence used by `stop()`, `into_dac()`, and `Drop`.
477    /// All errors are ignored since this is a safety-critical shutdown path.
478    fn shutdown_backend(&mut self) {
479        let _ = self.control.disarm();
480        let _ = self.control.stop();
481        if let Some(b) = &mut self.backend {
482            let _ = b.set_shutter(false);
483            let _ = b.stop();
484        }
485    }
486
487    /// Stop the stream and terminate output.
488    ///
489    /// Disarms the output (software blanking + hardware shutter) before stopping
490    /// the backend to prevent the "freeze on last bright point" hazard.
491    /// Use `disarm()` instead if you want to keep the stream alive but safe.
492    pub fn stop(&mut self) -> Result<()> {
493        self.shutdown_backend();
494
495        // Disconnect is needed for protocols like IDN where stop() only blanks
496        // but the DAC keeps replaying its buffer until the session is closed.
497        if let Some(b) = &mut self.backend {
498            b.disconnect()?;
499        }
500
501        Ok(())
502    }
503
504    /// Consume the stream and recover the device for reuse.
505    ///
506    /// This method disarms and stops the stream (software blanking + hardware shutter),
507    /// then returns the underlying `Dac` along with the final `StreamStats`.
508    /// The device can then be used to start a new stream with different configuration.
509    ///
510    /// # Example
511    ///
512    /// ```ignore
513    /// use laser_dac::StreamConfig;
514    ///
515    /// // device: Dac, config: StreamConfig (from prior setup)
516    /// let (stream, info) = device.start_stream(config)?;
517    /// // ... stream for a while ...
518    /// let (device, stats) = stream.into_dac();
519    /// println!("Streamed {} points", stats.points_written);
520    ///
521    /// // Restart with different config
522    /// let new_config = StreamConfig::new(60_000);
523    /// let (stream2, _) = device.start_stream(new_config)?;
524    /// ```
525    pub fn into_dac(mut self) -> (Dac, StreamStats) {
526        self.shutdown_backend();
527
528        // Take the backend (leaves None, so Drop won't try to stop again)
529        let backend = self.backend.take();
530        let stats = self.state.stats.clone();
531        let reconnect_target = self
532            .reconnect_target
533            .take()
534            .or_else(|| self.reconnect_policy.take().map(|p| p.target));
535
536        let dac = Dac {
537            info: self.info.clone(),
538            backend,
539            reconnect_target,
540        };
541
542        (dac, stats)
543    }
544
545    /// Run the stream with the zero-allocation callback API.
546    ///
547    /// This method uses **pure buffer-driven timing**:
548    /// - Callback is invoked when `buffered <= target_buffer`
549    /// - Points requested varies based on buffer headroom (`target_points`)
550    /// - Callback fills a library-owned buffer (zero allocations in hot path)
551    ///
552    /// # Callback Contract
553    ///
554    /// The callback receives a `ChunkRequest` describing buffer state and requirements,
555    /// and a mutable slice to fill with points. It returns:
556    ///
557    /// - `ChunkResult::Filled(n)`: Wrote `n` points to the buffer
558    /// - `ChunkResult::Starved`: No data available (underrun policy applies)
559    /// - `ChunkResult::End`: Stream should end gracefully
560    ///
561    /// # Exit Conditions
562    ///
563    /// - **`RunExit::Stopped`**: Stop requested via `StreamControl::stop()`.
564    /// - **`RunExit::ProducerEnded`**: Callback returned `ChunkResult::End`.
565    /// - **`RunExit::Disconnected`**: Device disconnected.
566    ///
567    /// # Example
568    ///
569    /// ```ignore
570    /// use laser_dac::{ChunkRequest, ChunkResult, LaserPoint};
571    ///
572    /// stream.run(
573    ///     |req: &ChunkRequest, buffer: &mut [LaserPoint]| {
574    ///         let n = req.target_points;
575    ///         for i in 0..n {
576    ///             let t = req.start.as_secs_f64(req.pps) + (i as f64 / req.pps as f64);
577    ///             let angle = (t * std::f64::consts::TAU) as f32;
578    ///             buffer[i] = LaserPoint::new(angle.cos(), angle.sin(), 65535, 0, 0, 65535);
579    ///         }
580    ///         ChunkResult::Filled(n)
581    ///     },
582    ///     |err| eprintln!("Error: {}", err),
583    /// )?;
584    /// ```
585    pub fn run<F, E>(mut self, producer: F, on_error: E) -> Result<RunExit>
586    where
587        F: FnMut(&ChunkRequest, &mut [LaserPoint]) -> ChunkResult + Send + 'static,
588        E: FnMut(Error) + Send + 'static,
589    {
590        use crate::presentation::driver::{self, DriverInputs, SourceOwned};
591        use crate::presentation::FrameSessionMetrics;
592
593        let backend = self
594            .backend
595            .take()
596            .ok_or_else(|| Error::disconnected("backend already consumed"))?;
597        if backend.is_frame_swap() {
598            return Err(Error::invalid_config(
599                "Stream::run is FIFO-only; use start_frame_session for frame-swap DACs",
600            ));
601        }
602
603        let max_points = self.info.caps.max_points_per_chunk;
604        let chunk_producer = chunk_producer::ChunkProducer::new(
605            producer,
606            self.control.clone(),
607            self.config.idle_policy.clone(),
608            self.config.startup_blank,
609            max_points,
610        );
611
612        let validator = Self::build_reconnect_validator();
613        let metrics = FrameSessionMetrics::new(true);
614        // Move the control receiver out of self; the Receiver isn't Clone so we
615        // swap in a fresh dummy channel that nothing will drive.
616        let (_dummy_tx, dummy_rx) = mpsc::channel();
617        let control_rx = std::mem::replace(&mut self.control_rx, dummy_rx);
618
619        driver::run(DriverInputs {
620            backend,
621            source: SourceOwned::Fifo(Box::new(chunk_producer)),
622            control: self.control.clone(),
623            control_rx,
624            metrics,
625            reconnect_policy: self.reconnect_policy.take(),
626            validator,
627            error_sink: Box::new(on_error),
628            target_buffer: self.config.target_buffer,
629            drain_timeout: self.config.drain_timeout,
630            pending_frame: None,
631            clock: DriverInputs::system_clock(),
632        })
633    }
634
635    fn build_reconnect_validator() -> crate::presentation::driver::ReconnectValidator {
636        Box::new(
637            move |_info: &DacInfo, new_backend: &BackendKind, pps: u32| {
638                if new_backend.is_frame_swap() {
639                    log::error!("reconnected device is frame-swap, incompatible with streaming");
640                    return Err(RunExit::Disconnected);
641                }
642                if Dac::validate_pps(new_backend.caps(), pps).is_err() {
643                    log::error!("reconnected device PPS range incompatible with stream config");
644                    return Err(RunExit::Disconnected);
645                }
646                Ok(())
647            },
648        )
649    }
650
651    /// Estimate the current buffer level in points via the backend's
652    /// [`BufferEstimator`].
653    ///
654    /// Each FIFO backend owns a strategy (status-anchored, dual-track ACK,
655    /// runtime-authority, or pure software) and updates it from inside its
656    /// own protocol code. The scheduler trusts that single source of truth.
657    fn estimate_buffer_points(&self) -> u64 {
658        let pps = self.config.pps;
659        let now = std::time::Instant::now();
660        self.backend
661            .as_ref()
662            .and_then(|b| b.estimator())
663            .map_or(0, |e| e.estimated_fullness(now, pps))
664    }
665}
666
667impl Drop for Stream {
668    fn drop(&mut self) {
669        let _ = self.stop();
670    }
671}
672
673// =============================================================================
674// Device
675// =============================================================================
676
677/// A connected device that can start streaming sessions.
678///
679/// When starting a stream, the device is consumed and the backend ownership
680/// transfers to the stream. The `DacInfo` is returned alongside the stream
681/// so metadata remains accessible.
682///
683/// # Example
684///
685/// ```ignore
686/// use laser_dac::{open_device, StreamConfig};
687///
688/// let device = open_device("my-device")?;
689/// let config = StreamConfig::new(30_000);
690/// let (stream, info) = device.start_stream(config)?;
691/// println!("Streaming to: {}", info.name);
692/// ```
693pub struct Dac {
694    info: DacInfo,
695    backend: Option<BackendKind>,
696    pub(crate) reconnect_target: Option<ReconnectTarget>,
697}
698
699impl Dac {
700    /// Create a new device from a backend.
701    pub fn new(info: DacInfo, backend: BackendKind) -> Self {
702        Self {
703            info,
704            backend: Some(backend),
705            reconnect_target: None,
706        }
707    }
708
709    /// Set a custom discovery factory for reconnection.
710    ///
711    /// When reconnection is enabled (via [`crate::FrameSessionConfig::with_reconnect`] or
712    /// [`StreamConfig::with_reconnect`]), the factory is called to create a
713    /// [`DacDiscovery`] instance for each reconnection attempt. This is required
714    /// for custom backends registered via [`DacDiscovery::register`] — without it,
715    /// reconnection uses the default discovery which only finds built-in DAC types.
716    ///
717    /// For ID-based opens, prefer [`open_device_with`](crate::open_device_with).
718    /// For worker-owned scan results, compose
719    /// [`DacDiscovery::open_discovered`](crate::DacDiscovery::open_discovered)
720    /// with this method. Use this method directly when you construct a `Dac`
721    /// yourself around an application-owned backend.
722    ///
723    /// # Example
724    ///
725    /// ```ignore
726    /// use laser_dac::{Dac, DacDiscovery, EnabledDacTypes, FrameSessionConfig, ReconnectConfig};
727    ///
728    /// // `info` and `backend` come from application-owned construction, not
729    /// // from DacDiscovery::open_discovered*.
730    /// let dac = Dac::new(info, backend).with_discovery_factory(|| {
731    ///     let mut d = DacDiscovery::new(EnabledDacTypes::all());
732    ///     d.register(Box::new(MyCustomDiscoverer::new()));
733    ///     d
734    /// });
735    ///
736    /// let config = FrameSessionConfig::new(30_000)
737    ///     .with_reconnect(ReconnectConfig::new());
738    /// let (session, _info) = dac.start_frame_session(config)?;
739    /// ```
740    pub fn with_discovery_factory<F>(mut self, factory: F) -> Self
741    where
742        F: Fn() -> DacDiscovery + Send + 'static,
743    {
744        match self.reconnect_target {
745            Some(ref mut target) => {
746                target.discovery_factory = Some(Box::new(factory));
747            }
748            None => {
749                self.reconnect_target = Some(ReconnectTarget {
750                    device_id: self.info.id.clone(),
751                    discovery_factory: Some(Box::new(factory)),
752                });
753            }
754        }
755        self
756    }
757
758    /// Returns the device info.
759    pub fn info(&self) -> &DacInfo {
760        &self.info
761    }
762
763    /// Returns the device ID.
764    pub fn id(&self) -> &str {
765        &self.info.id
766    }
767
768    /// Returns the device name.
769    pub fn name(&self) -> &str {
770        &self.info.name
771    }
772
773    /// Returns the DAC type.
774    pub fn kind(&self) -> &DacType {
775        &self.info.kind
776    }
777
778    /// Returns the device capabilities.
779    pub fn caps(&self) -> &DacCapabilities {
780        &self.info.caps
781    }
782
783    /// Returns whether the device has a backend (not yet used for a stream).
784    pub fn has_backend(&self) -> bool {
785        self.backend.is_some()
786    }
787
788    /// Consume the Dac and return the backend, if available.
789    pub(crate) fn into_backend(mut self) -> Option<BackendKind> {
790        self.backend.take()
791    }
792
793    /// Returns whether the device is connected.
794    pub fn is_connected(&self) -> bool {
795        self.backend.as_ref().is_some_and(|b| b.is_connected())
796    }
797
798    /// Starts a streaming session, consuming the device.
799    ///
800    /// # Ownership
801    ///
802    /// This method consumes the `Dac` because:
803    /// - Each device can only have one active stream at a time.
804    /// - The backend is moved into the `Stream` to ensure exclusive access.
805    /// - This prevents accidental reuse of a device that's already streaming.
806    ///
807    /// The method returns both the `Stream` and a copy of `DacInfo`, so you
808    /// retain access to device metadata (id, name, capabilities) after starting.
809    ///
810    /// # Connection
811    ///
812    /// If the device is not already connected, this method will establish the
813    /// connection before creating the stream. Connection failures are returned
814    /// as errors.
815    ///
816    /// # Errors
817    ///
818    /// Returns an error if:
819    /// - The device backend has already been used for a stream.
820    /// - The configuration is invalid (PPS out of range, invalid chunk size, etc.).
821    /// - The backend fails to connect.
822    pub fn start_stream(mut self, mut cfg: StreamConfig) -> Result<(Stream, DacInfo)> {
823        // Extract reconnect config before consuming cfg
824        let reconnect_config = cfg.reconnect.take();
825
826        let mut backend = self.backend.take().ok_or_else(|| {
827            Error::invalid_config("device backend has already been used for a stream")
828        })?;
829
830        if backend.is_frame_swap() {
831            return Err(Error::invalid_config(
832                "streaming is not supported on frame-swap DACs (e.g. Helios); \
833                 use start_frame_session() instead",
834            ));
835        }
836
837        let cfg = Self::apply_backend_buffer_defaults(&self.info, cfg);
838
839        Self::validate_pps(&self.info.caps, cfg.pps)?;
840
841        // Connect the backend if not already connected
842        if !backend.is_connected() {
843            backend.connect()?;
844        }
845
846        let mut stream = Stream::with_backend(self.info.clone(), backend, cfg);
847
848        // Always preserve the reopen target on the stream (for into_dac recovery)
849        stream.reconnect_target = self.reconnect_target.take();
850
851        // Wire reconnect policy if configured
852        if let Some(rc) = reconnect_config {
853            let target = stream.reconnect_target.take().ok_or_else(|| {
854                Error::invalid_config("reconnect requires a reconnect target — use open_device(), open_device_with(), or Dac::with_discovery_factory()")
855            })?;
856            stream.reconnect_policy = Some(ReconnectPolicy::new(rc, target));
857        }
858
859        Ok((stream, self.info))
860    }
861
862    fn apply_backend_buffer_defaults(info: &DacInfo, mut cfg: StreamConfig) -> StreamConfig {
863        if cfg.target_buffer == StreamConfig::DEFAULT_TARGET_BUFFER {
864            cfg.target_buffer =
865                StreamConfig::default_target_buffer_for(&info.kind, &info.caps.output_model);
866        }
867
868        cfg
869    }
870
871    fn validate_pps(caps: &DacCapabilities, pps: u32) -> Result<()> {
872        if pps < caps.pps_min || pps > caps.pps_max {
873            return Err(Error::invalid_config(format!(
874                "PPS {} is outside device range [{}, {}]",
875                pps, caps.pps_min, caps.pps_max
876            )));
877        }
878
879        Ok(())
880    }
881
882    /// Starts a frame-mode session, consuming the device.
883    ///
884    /// Similar to [`start_stream`](Self::start_stream) but uses the frame-first
885    /// API where you submit complete [`crate::presentation::Frame`]s instead of filling
886    /// point buffers via callback.
887    ///
888    /// Returns a [`crate::presentation::FrameSession`] that owns the scheduler thread and a
889    /// [`DacInfo`] with device metadata.
890    pub fn start_frame_session(
891        mut self,
892        mut config: crate::presentation::FrameSessionConfig,
893    ) -> Result<(crate::presentation::FrameSession, DacInfo)> {
894        let reconnect_config = config.reconnect.take();
895
896        let backend = self.backend.take().ok_or_else(|| {
897            Error::invalid_config("device backend has already been used for a session")
898        })?;
899
900        Self::validate_pps(backend.caps(), config.pps)?;
901
902        let reconnect_policy = match reconnect_config {
903            Some(rc) => {
904                let target = self.reconnect_target.take().ok_or_else(|| {
905                    Error::invalid_config("reconnect requires a reconnect target — use open_device(), open_device_with(), or Dac::with_discovery_factory()")
906                })?;
907                Some(ReconnectPolicy::new(rc, target))
908            }
909            None => None,
910        };
911
912        let session = crate::presentation::FrameSession::start(backend, config, reconnect_policy)?;
913        Ok((session, self.info))
914    }
915}
916
917#[cfg(test)]
918mod tests;