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}