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;