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;
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
143    /// Create a new stream configuration with the given PPS.
144    pub fn new(pps: u32) -> Self {
145        Self {
146            pps,
147            ..Default::default()
148        }
149    }
150
151    /// Set the target buffer level to maintain (builder pattern).
152    ///
153    /// Default: 20ms. Higher values provide more safety margin against underruns.
154    /// Lower values reduce perceived latency.
155    pub fn with_target_buffer(mut self, duration: std::time::Duration) -> Self {
156        self.target_buffer = duration;
157        self
158    }
159
160    /// Set the idle policy (builder pattern).
161    ///
162    /// Controls behavior when the stream is idle — either because the producer
163    /// can't keep up (underrun) or the stream is disarmed. See [`IdlePolicy`].
164    pub fn with_idle_policy(mut self, policy: IdlePolicy) -> Self {
165        self.idle_policy = policy;
166        self
167    }
168
169    /// Deprecated — use [`with_idle_policy`](Self::with_idle_policy) instead.
170    #[deprecated(since = "0.8.0", note = "renamed to with_idle_policy")]
171    pub fn with_underrun(self, policy: IdlePolicy) -> Self {
172        self.with_idle_policy(policy)
173    }
174
175    /// Set the drain timeout for graceful shutdown (builder pattern).
176    ///
177    /// Default: 1 second. Set to `Duration::ZERO` to skip drain entirely.
178    pub fn with_drain_timeout(mut self, timeout: std::time::Duration) -> Self {
179        self.drain_timeout = timeout;
180        self
181    }
182
183    /// Set the color delay for scanner sync compensation (builder pattern).
184    ///
185    /// Default: `Duration::ZERO` (disabled). Typical values: 50–200µs.
186    pub fn with_color_delay(mut self, delay: std::time::Duration) -> Self {
187        self.color_delay = delay;
188        self
189    }
190
191    /// Set the startup blanking duration after arming (builder pattern).
192    ///
193    /// Default: 1ms. Set to `Duration::ZERO` to disable.
194    pub fn with_startup_blank(mut self, duration: std::time::Duration) -> Self {
195        self.startup_blank = duration;
196        self
197    }
198
199    /// Enable automatic reconnection (builder pattern).
200    ///
201    /// Requires the device to have been opened via [`open_device`](crate::open_device).
202    pub fn with_reconnect(mut self, config: ReconnectConfig) -> Self {
203        self.reconnect = Some(config);
204        self
205    }
206}
207
208/// Policy for what to output when the stream is idle (disarmed or underrun).
209///
210/// This governs both underrun recovery (producer can't keep up) and disarm
211/// behavior (laser safety off). When disarmed, `RepeatLast` falls back to
212/// `Blank` — repeating lit content on a disarmed stream is never correct.
213#[derive(Clone, Debug, PartialEq)]
214#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
215#[derive(Default)]
216pub enum IdlePolicy {
217    /// Repeat the last chunk of points (underrun only; falls back to `Blank` when disarmed).
218    RepeatLast,
219    /// Output blanked points at the origin (laser off, scanners park at 0,0).
220    #[default]
221    Blank,
222    /// Park the beam at a specific position with laser off.
223    Park { x: f32, y: f32 },
224    /// Stop the stream entirely on underrun.
225    Stop,
226}
227
228/// Deprecated alias — use [`IdlePolicy`] instead.
229#[deprecated(since = "0.8.0", note = "renamed to IdlePolicy")]
230pub type UnderrunPolicy = IdlePolicy;
231
232/// Configuration for automatic reconnection behavior.
233///
234/// Used with [`StreamConfig::with_reconnect`] or
235/// [`FrameSessionConfig::with_reconnect`](crate::FrameSessionConfig::with_reconnect)
236/// to enable transparent reconnection when the device disconnects.
237///
238/// # Example
239///
240/// ```
241/// use laser_dac::ReconnectConfig;
242/// use std::time::Duration;
243///
244/// let rc = ReconnectConfig::new()
245///     .max_retries(5)
246///     .backoff(Duration::from_secs(2))
247///     .on_disconnect(|err| eprintln!("Lost connection: {}", err))
248///     .on_reconnect(|info| println!("Reconnected to {}", info.name));
249/// ```
250type DisconnectCb = Box<dyn FnMut(&crate::Error) + Send + 'static>;
251type ReconnectCb = Box<dyn FnMut(&DacInfo) + Send + 'static>;
252
253pub struct ReconnectConfig {
254    pub(crate) max_retries: Option<u32>,
255    pub(crate) backoff: std::time::Duration,
256    pub(crate) on_disconnect: Option<DisconnectCb>,
257    pub(crate) on_reconnect: Option<ReconnectCb>,
258}
259
260impl ReconnectConfig {
261    /// Create a new reconnect configuration with defaults.
262    ///
263    /// Defaults: infinite retries, 1s backoff, no callbacks.
264    pub fn new() -> Self {
265        Self {
266            max_retries: None,
267            backoff: std::time::Duration::from_secs(1),
268            on_disconnect: None,
269            on_reconnect: None,
270        }
271    }
272
273    /// Set the maximum number of consecutive reconnect attempts.
274    ///
275    /// `None` (default) retries forever. `Some(0)` disables retries.
276    pub fn max_retries(mut self, max_retries: u32) -> Self {
277        self.max_retries = Some(max_retries);
278        self
279    }
280
281    /// Set a fixed backoff duration between reconnect attempts.
282    pub fn backoff(mut self, backoff: std::time::Duration) -> Self {
283        self.backoff = backoff;
284        self
285    }
286
287    /// Register a callback invoked when a disconnect is detected.
288    pub fn on_disconnect<F>(mut self, f: F) -> Self
289    where
290        F: FnMut(&crate::Error) + Send + 'static,
291    {
292        self.on_disconnect = Some(Box::new(f));
293        self
294    }
295
296    /// Register a callback invoked after a successful reconnect.
297    pub fn on_reconnect<F>(mut self, f: F) -> Self
298    where
299        F: FnMut(&DacInfo) + Send + 'static,
300    {
301        self.on_reconnect = Some(Box::new(f));
302        self
303    }
304}
305
306impl Default for ReconnectConfig {
307    fn default() -> Self {
308        Self::new()
309    }
310}
311
312impl fmt::Debug for ReconnectConfig {
313    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
314        f.debug_struct("ReconnectConfig")
315            .field("max_retries", &self.max_retries)
316            .field("backoff", &self.backoff)
317            .field("on_disconnect", &self.on_disconnect.as_ref().map(|_| ".."))
318            .field("on_reconnect", &self.on_reconnect.as_ref().map(|_| ".."))
319            .finish()
320    }
321}
322
323#[cfg(all(test, feature = "serde"))]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn test_stream_config_serde_roundtrip() {
329        use std::time::Duration;
330
331        let config = StreamConfig {
332            pps: 45000,
333            target_buffer: Duration::from_millis(50),
334            idle_policy: IdlePolicy::Park { x: 0.5, y: -0.3 },
335            drain_timeout: Duration::from_secs(2),
336            color_delay: Duration::from_micros(150),
337            startup_blank: Duration::from_micros(800),
338            reconnect: None,
339        };
340
341        // Round-trip through JSON
342        let json = serde_json::to_string(&config).expect("serialize to JSON");
343        let restored: StreamConfig = serde_json::from_str(&json).expect("deserialize from JSON");
344
345        assert_eq!(restored.pps, config.pps);
346        assert_eq!(restored.target_buffer, config.target_buffer);
347        assert_eq!(restored.drain_timeout, config.drain_timeout);
348        assert_eq!(restored.color_delay, config.color_delay);
349        assert_eq!(restored.startup_blank, config.startup_blank);
350
351        // Verify idle policy
352        match restored.idle_policy {
353            IdlePolicy::Park { x, y } => {
354                assert!((x - 0.5).abs() < f32::EPSILON);
355                assert!((y - (-0.3)).abs() < f32::EPSILON);
356            }
357            _ => panic!("Expected Park policy"),
358        }
359    }
360
361    #[test]
362    fn test_duration_millis_roundtrip_consistency() {
363        use std::time::Duration;
364
365        // Test various duration values round-trip correctly
366        let test_durations = [
367            Duration::from_millis(0),
368            Duration::from_millis(1),
369            Duration::from_millis(10),
370            Duration::from_millis(100),
371            Duration::from_millis(1000),
372            Duration::from_millis(u64::MAX / 1000), // Large but valid
373        ];
374
375        for &duration in &test_durations {
376            let config = StreamConfig {
377                target_buffer: duration,
378                ..StreamConfig::default()
379            };
380
381            let json = serde_json::to_string(&config).expect("serialize");
382            let restored: StreamConfig = serde_json::from_str(&json).expect("deserialize");
383
384            assert_eq!(
385                restored.target_buffer, duration,
386                "Duration {:?} did not round-trip correctly",
387                duration
388            );
389        }
390    }
391}