Skip to main content

laser_dac/
config.rs

1//! Stream and reconnection configuration types.
2//!
3//! - [`StreamConfig`] — buffer-driven timing config for `Dac::start_stream` /
4//!   `start_frame_session`, with optional reconnection.
5//! - [`IdlePolicy`] — what to output when the stream is idle (disarmed or
6//!   underrun). [`UnderrunPolicy`] is a deprecated alias.
7//! - [`ReconnectConfig`] — backoff and callback configuration for transparent
8//!   reconnection after a device disconnect.
9
10#[cfg(feature = "serde")]
11use serde::{Deserialize, Serialize};
12use std::fmt;
13
14use crate::device::{DacInfo, DacType, OutputModel};
15
16/// Configuration for starting a stream.
17///
18/// # Buffer-Driven Timing
19///
20/// The streaming API uses pure buffer-driven timing:
21/// - `target_buffer`: Target buffer level to maintain (default baseline: 20ms)
22///
23/// The callback is invoked when `buffered < target_buffer`. The callback receives
24/// a `ChunkRequest` with `target_points` calculated from this duration and the
25/// current buffer state.
26///
27/// `Dac::start_stream()` may promote an untouched default to a safer network
28/// value for `NetworkFifo` / `UdpTimed` backends.
29///
30/// To reduce perceived latency, reduce `target_buffer`.
31#[derive(Debug)]
32#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
33pub struct StreamConfig {
34    /// Points per second output rate.
35    pub pps: u32,
36
37    /// Target buffer level to maintain (default: 20ms).
38    ///
39    /// The callback's `target_points` is calculated to bring the buffer to this level.
40    /// The callback is invoked when the buffer drops below this level.
41    #[cfg_attr(feature = "serde", serde(with = "duration_millis"))]
42    pub target_buffer: std::time::Duration,
43
44    /// What to do when the stream is idle (underrun or disarmed).
45    pub idle_policy: IdlePolicy,
46
47    /// Maximum time to wait for queued points to drain on graceful shutdown (default: 1s).
48    ///
49    /// When the producer returns `ChunkResult::End`, the stream waits for buffered
50    /// points to play out before returning. This timeout caps that wait to prevent
51    /// blocking forever if the DAC stalls or queue depth is unknown.
52    #[cfg_attr(feature = "serde", serde(with = "duration_millis"))]
53    pub drain_timeout: std::time::Duration,
54
55    /// Initial color delay for scanner sync compensation (default: disabled).
56    ///
57    /// Delays RGB+intensity channels relative to XY coordinates by this duration,
58    /// allowing galvo mirrors time to settle before the laser fires. The delay is
59    /// implemented as a FIFO: output colors lag input colors by `ceil(color_delay * pps)` points.
60    ///
61    /// Can be changed at runtime via [`crate::StreamControl::set_color_delay`].
62    ///
63    /// Typical values: 50–200µs depending on scanner speed.
64    /// `Duration::ZERO` disables the delay (default).
65    #[cfg_attr(feature = "serde", serde(with = "duration_micros"))]
66    pub color_delay: std::time::Duration,
67
68    /// Duration of forced blanking after arming (default: 1ms).
69    ///
70    /// After the stream is armed, the first `ceil(startup_blank * pps)` points
71    /// will have their color channels forced to zero, regardless of what the
72    /// producer writes. This prevents the "flash on start" artifact where
73    /// the laser fires before mirrors reach position.
74    ///
75    /// Note: when `color_delay` is also active, the delay line provides
76    /// `color_delay` worth of natural startup blanking. This `startup_blank`
77    /// setting adds blanking *beyond* that duration.
78    ///
79    /// Set to `Duration::ZERO` to disable explicit startup blanking.
80    #[cfg_attr(feature = "serde", serde(with = "duration_micros"))]
81    pub startup_blank: std::time::Duration,
82
83    /// Reconnection configuration (default: disabled).
84    ///
85    /// Set via [`with_reconnect`](Self::with_reconnect) to enable automatic
86    /// reconnection when the device disconnects.
87    #[cfg_attr(feature = "serde", serde(skip))]
88    pub reconnect: Option<ReconnectConfig>,
89}
90
91#[cfg(feature = "serde")]
92macro_rules! duration_serde_module {
93    ($mod_name:ident, $as_unit:ident, $from_unit:ident) => {
94        mod $mod_name {
95            use serde::{Deserialize, Deserializer, Serialize, Serializer};
96            use std::time::Duration;
97
98            pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
99            where
100                S: Serializer,
101            {
102                let value = duration.$as_unit().min(u64::MAX as u128) as u64;
103                value.serialize(serializer)
104            }
105
106            pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
107            where
108                D: Deserializer<'de>,
109            {
110                let value = u64::deserialize(deserializer)?;
111                Ok(Duration::$from_unit(value))
112            }
113        }
114    };
115}
116
117#[cfg(feature = "serde")]
118duration_serde_module!(duration_millis, as_millis, from_millis);
119#[cfg(feature = "serde")]
120duration_serde_module!(duration_micros, as_micros, from_micros);
121
122impl Default for StreamConfig {
123    fn default() -> Self {
124        Self {
125            pps: 30_000,
126            target_buffer: Self::DEFAULT_TARGET_BUFFER,
127            idle_policy: IdlePolicy::default(),
128            drain_timeout: std::time::Duration::from_secs(1),
129            color_delay: std::time::Duration::ZERO,
130            startup_blank: std::time::Duration::from_millis(1),
131            reconnect: None,
132        }
133    }
134}
135
136impl StreamConfig {
137    /// Baseline default target buffer used by `StreamConfig::new()`.
138    pub const DEFAULT_TARGET_BUFFER: std::time::Duration = std::time::Duration::from_millis(20);
139    /// Safer default target buffer for network DACs when caller leaves defaults untouched.
140    pub const NETWORK_DEFAULT_TARGET_BUFFER: std::time::Duration =
141        std::time::Duration::from_millis(50);
142    /// Default target buffer for LaserCube network devices.
143    ///
144    /// LaserCube Ethernet/client profiles use a device-side cutoff around
145    /// 1800 points, which is already 60ms at 30kpps. This default leaves enough
146    /// host-side cushion for the transport to top up the firmware ringbuffer.
147    pub const LASERCUBE_NETWORK_DEFAULT_TARGET_BUFFER: std::time::Duration =
148        std::time::Duration::from_millis(120);
149
150    /// Default target buffer for a backend, applied when the caller leaves
151    /// `target_buffer` at [`DEFAULT_TARGET_BUFFER`](Self::DEFAULT_TARGET_BUFFER).
152    ///
153    /// Single source of truth shared by the frame path
154    /// (`presentation::session::target_buffer_for_backend`) and the stream path
155    /// (`Dac::apply_backend_buffer_defaults`) so the two never drift.
156    ///
157    /// LaserCube network devices want a deep cushion. Other real network/FIFO
158    /// DACs (`NetworkFifo`/`UdpTimed`/`BlockingFifo` — AVB, oscilloscope,
159    /// Ether Dream, IDN, LaserCube USB, …) get the 50ms network default rather
160    /// than the blanket 20ms; combined with the runtime-authority estimator's
161    /// pps-point conversion this gives audio-clocked backends a genuine
162    /// multi-callback-quantum cushion. `Custom` backends are intentionally
163    /// excluded so they keep the raw 20ms default (test/embedding backends set
164    /// their own policy).
165    pub fn default_target_buffer_for(
166        dac_type: &DacType,
167        output_model: &OutputModel,
168    ) -> std::time::Duration {
169        if matches!(dac_type, DacType::LaserCubeNetwork) {
170            Self::LASERCUBE_NETWORK_DEFAULT_TARGET_BUFFER
171        } else if !matches!(dac_type, DacType::Custom(_))
172            && matches!(
173                output_model,
174                OutputModel::NetworkFifo | OutputModel::UdpTimed | OutputModel::BlockingFifo
175            )
176        {
177            Self::NETWORK_DEFAULT_TARGET_BUFFER
178        } else {
179            Self::DEFAULT_TARGET_BUFFER
180        }
181    }
182
183    /// Create a new stream configuration with the given PPS.
184    pub fn new(pps: u32) -> Self {
185        Self {
186            pps,
187            ..Default::default()
188        }
189    }
190
191    /// Set the target buffer level to maintain (builder pattern).
192    ///
193    /// Default: 20ms. Higher values provide more safety margin against underruns.
194    /// Lower values reduce perceived latency.
195    pub fn with_target_buffer(mut self, duration: std::time::Duration) -> Self {
196        self.target_buffer = duration;
197        self
198    }
199
200    /// Set the idle policy (builder pattern).
201    ///
202    /// Controls behavior when the stream is idle — either because the producer
203    /// can't keep up (underrun) or the stream is disarmed. See [`IdlePolicy`].
204    pub fn with_idle_policy(mut self, policy: IdlePolicy) -> Self {
205        self.idle_policy = policy;
206        self
207    }
208
209    /// Deprecated — use [`with_idle_policy`](Self::with_idle_policy) instead.
210    #[deprecated(since = "0.8.0", note = "renamed to with_idle_policy")]
211    pub fn with_underrun(self, policy: IdlePolicy) -> Self {
212        self.with_idle_policy(policy)
213    }
214
215    /// Set the drain timeout for graceful shutdown (builder pattern).
216    ///
217    /// Default: 1 second. Set to `Duration::ZERO` to skip drain entirely.
218    pub fn with_drain_timeout(mut self, timeout: std::time::Duration) -> Self {
219        self.drain_timeout = timeout;
220        self
221    }
222
223    /// Set the color delay for scanner sync compensation (builder pattern).
224    ///
225    /// Default: `Duration::ZERO` (disabled). Typical values: 50–200µs.
226    pub fn with_color_delay(mut self, delay: std::time::Duration) -> Self {
227        self.color_delay = delay;
228        self
229    }
230
231    /// Set the startup blanking duration after arming (builder pattern).
232    ///
233    /// Default: 1ms. Set to `Duration::ZERO` to disable.
234    pub fn with_startup_blank(mut self, duration: std::time::Duration) -> Self {
235        self.startup_blank = duration;
236        self
237    }
238
239    /// Enable automatic reconnection (builder pattern).
240    ///
241    /// Requires the device to have been opened via [`open_device`](crate::open_device).
242    pub fn with_reconnect(mut self, config: ReconnectConfig) -> Self {
243        self.reconnect = Some(config);
244        self
245    }
246}
247
248/// Policy for what to output when the stream is idle (disarmed or underrun).
249///
250/// This governs both underrun recovery (producer can't keep up) and disarm
251/// behavior (laser safety off). When disarmed, `RepeatLast` falls back to
252/// `Blank` — repeating lit content on a disarmed stream is never correct.
253#[derive(Clone, Debug, PartialEq)]
254#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
255#[derive(Default)]
256pub enum IdlePolicy {
257    /// Repeat the last chunk of points (underrun only; falls back to `Blank` when disarmed).
258    RepeatLast,
259    /// Output blanked points at the origin (laser off, scanners park at 0,0).
260    #[default]
261    Blank,
262    /// Park the beam at a specific position with laser off.
263    Park { x: f32, y: f32 },
264    /// Stop the stream entirely on underrun.
265    Stop,
266}
267
268/// Deprecated alias — use [`IdlePolicy`] instead.
269#[deprecated(since = "0.8.0", note = "renamed to IdlePolicy")]
270pub type UnderrunPolicy = IdlePolicy;
271
272/// Configuration for automatic reconnection behavior.
273///
274/// Used with [`StreamConfig::with_reconnect`] or
275/// [`FrameSessionConfig::with_reconnect`](crate::FrameSessionConfig::with_reconnect)
276/// to enable transparent reconnection when the device disconnects.
277///
278/// # Example
279///
280/// ```
281/// use laser_dac::ReconnectConfig;
282/// use std::time::Duration;
283///
284/// let rc = ReconnectConfig::new()
285///     .max_retries(5)
286///     .backoff(Duration::from_secs(2))
287///     .on_disconnect(|err| eprintln!("Lost connection: {}", err))
288///     .on_reconnect(|info| println!("Reconnected to {}", info.name));
289/// ```
290type DisconnectCb = Box<dyn FnMut(&crate::Error) + Send + 'static>;
291type ReconnectCb = Box<dyn FnMut(&DacInfo) + Send + 'static>;
292
293pub struct ReconnectConfig {
294    pub(crate) max_retries: Option<u32>,
295    pub(crate) backoff: std::time::Duration,
296    pub(crate) on_disconnect: Option<DisconnectCb>,
297    pub(crate) on_reconnect: Option<ReconnectCb>,
298}
299
300impl ReconnectConfig {
301    /// Create a new reconnect configuration with defaults.
302    ///
303    /// Defaults: infinite retries, 1s backoff, no callbacks.
304    pub fn new() -> Self {
305        Self {
306            max_retries: None,
307            backoff: std::time::Duration::from_secs(1),
308            on_disconnect: None,
309            on_reconnect: None,
310        }
311    }
312
313    /// Set the maximum number of consecutive reconnect attempts.
314    ///
315    /// `None` (default) retries forever. `Some(0)` disables retries.
316    pub fn max_retries(mut self, max_retries: u32) -> Self {
317        self.max_retries = Some(max_retries);
318        self
319    }
320
321    /// Set a fixed backoff duration between reconnect attempts.
322    pub fn backoff(mut self, backoff: std::time::Duration) -> Self {
323        self.backoff = backoff;
324        self
325    }
326
327    /// Register a callback invoked when a disconnect is detected.
328    pub fn on_disconnect<F>(mut self, f: F) -> Self
329    where
330        F: FnMut(&crate::Error) + Send + 'static,
331    {
332        self.on_disconnect = Some(Box::new(f));
333        self
334    }
335
336    /// Register a callback invoked after a successful reconnect.
337    pub fn on_reconnect<F>(mut self, f: F) -> Self
338    where
339        F: FnMut(&DacInfo) + Send + 'static,
340    {
341        self.on_reconnect = Some(Box::new(f));
342        self
343    }
344}
345
346impl Default for ReconnectConfig {
347    fn default() -> Self {
348        Self::new()
349    }
350}
351
352impl fmt::Debug for ReconnectConfig {
353    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
354        f.debug_struct("ReconnectConfig")
355            .field("max_retries", &self.max_retries)
356            .field("backoff", &self.backoff)
357            .field("on_disconnect", &self.on_disconnect.as_ref().map(|_| ".."))
358            .field("on_reconnect", &self.on_reconnect.as_ref().map(|_| ".."))
359            .finish()
360    }
361}
362
363#[cfg(all(test, feature = "serde"))]
364mod tests {
365    use super::*;
366
367    #[test]
368    fn test_stream_config_serde_roundtrip() {
369        use std::time::Duration;
370
371        let config = StreamConfig {
372            pps: 45000,
373            target_buffer: Duration::from_millis(50),
374            idle_policy: IdlePolicy::Park { x: 0.5, y: -0.3 },
375            drain_timeout: Duration::from_secs(2),
376            color_delay: Duration::from_micros(150),
377            startup_blank: Duration::from_micros(800),
378            reconnect: None,
379        };
380
381        // Round-trip through JSON
382        let json = serde_json::to_string(&config).expect("serialize to JSON");
383        let restored: StreamConfig = serde_json::from_str(&json).expect("deserialize from JSON");
384
385        assert_eq!(restored.pps, config.pps);
386        assert_eq!(restored.target_buffer, config.target_buffer);
387        assert_eq!(restored.drain_timeout, config.drain_timeout);
388        assert_eq!(restored.color_delay, config.color_delay);
389        assert_eq!(restored.startup_blank, config.startup_blank);
390
391        // Verify idle policy
392        match restored.idle_policy {
393            IdlePolicy::Park { x, y } => {
394                assert!((x - 0.5).abs() < f32::EPSILON);
395                assert!((y - (-0.3)).abs() < f32::EPSILON);
396            }
397            _ => panic!("Expected Park policy"),
398        }
399    }
400
401    #[test]
402    fn test_duration_millis_roundtrip_consistency() {
403        use std::time::Duration;
404
405        // Test various duration values round-trip correctly
406        let test_durations = [
407            Duration::from_millis(0),
408            Duration::from_millis(1),
409            Duration::from_millis(10),
410            Duration::from_millis(100),
411            Duration::from_millis(1000),
412            Duration::from_millis(u64::MAX / 1000), // Large but valid
413        ];
414
415        for &duration in &test_durations {
416            let config = StreamConfig {
417                target_buffer: duration,
418                ..StreamConfig::default()
419            };
420
421            let json = serde_json::to_string(&config).expect("serialize");
422            let restored: StreamConfig = serde_json::from_str(&json).expect("deserialize");
423
424            assert_eq!(
425                restored.target_buffer, duration,
426                "Duration {:?} did not round-trip correctly",
427                duration
428            );
429        }
430    }
431}