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}