Skip to main content

laser_dac/presentation/
session.rs

1//! FrameSession and FrameSessionConfig — public frame-mode API.
2
3use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
4use std::sync::mpsc;
5use std::sync::{Arc, Mutex};
6use std::thread::JoinHandle;
7use std::time::{Duration, Instant};
8
9use crate::backend::BackendKind;
10use crate::config::StreamConfig;
11use crate::device::{DacInfo, OutputModel};
12use crate::error::{Error, Result};
13use crate::reconnect::ReconnectPolicy;
14use crate::stream::{ControlMsg, RunExit, StreamControl};
15
16use super::driver::{self, DriverInputs, SourceOwned};
17use super::engine::PresentationEngine;
18use super::slice_pipeline::SlicePipeline;
19use super::{default_transition, Frame, OutputResetReason, TransitionFn};
20
21// =============================================================================
22// FrameSessionConfig
23// =============================================================================
24
25/// Configuration for a frame-mode streaming session.
26pub struct FrameSessionConfig {
27    /// Points per second output rate.
28    pub pps: u32,
29    /// Transition function for blanking between frames.
30    pub transition_fn: TransitionFn,
31    /// Duration of forced blanking after arming (default: 1ms).
32    pub startup_blank: std::time::Duration,
33    /// Number of points to shift RGB relative to XY (0 = disabled).
34    ///
35    /// Delays color channels relative to XY coordinates, compensating for
36    /// the difference in galvo mirror and laser modulation response times.
37    /// Applied at composition time. Set to 0 to disable.
38    pub color_delay_points: usize,
39    /// Reconnection configuration (default: disabled).
40    ///
41    /// Set via [`with_reconnect`](Self::with_reconnect) to enable automatic
42    /// reconnection when the device disconnects.
43    pub reconnect: Option<crate::config::ReconnectConfig>,
44    /// Policy for what to output when the stream is idle (disarmed).
45    ///
46    /// Controls scanner behavior when disarmed. Default: [`Blank`](crate::config::IdlePolicy::Blank)
47    /// (park at origin with laser off). Use [`Park`](crate::config::IdlePolicy::Park) to park at a
48    /// specific position.
49    pub idle_policy: crate::config::IdlePolicy,
50    /// Optional hook for processing the final presented output.
51    pub output_filter: Option<Box<dyn super::OutputFilter>>,
52}
53
54impl FrameSessionConfig {
55    const DEFAULT_COLOR_DELAY: std::time::Duration = std::time::Duration::from_micros(150);
56
57    /// Create a new config with the given PPS and default transition.
58    pub fn new(pps: u32) -> Self {
59        let color_delay_points =
60            (Self::DEFAULT_COLOR_DELAY.as_secs_f64() * pps as f64).ceil() as usize;
61        Self {
62            pps,
63            transition_fn: default_transition(pps),
64            startup_blank: std::time::Duration::from_millis(1),
65            color_delay_points,
66            idle_policy: crate::config::IdlePolicy::default(),
67            reconnect: None,
68            output_filter: None,
69        }
70    }
71
72    /// Set the transition function (builder pattern).
73    pub fn with_transition_fn(mut self, f: TransitionFn) -> Self {
74        self.transition_fn = f;
75        self
76    }
77
78    /// Set the startup blank duration (builder pattern).
79    pub fn with_startup_blank(mut self, duration: std::time::Duration) -> Self {
80        self.startup_blank = duration;
81        self
82    }
83
84    /// Set the color delay in points (builder pattern).
85    pub fn with_color_delay_points(mut self, n: usize) -> Self {
86        self.color_delay_points = n;
87        self
88    }
89
90    /// Enable automatic reconnection (builder pattern).
91    ///
92    /// Requires the device to have been opened via [`open_device`](crate::open_device).
93    pub fn with_reconnect(mut self, config: crate::config::ReconnectConfig) -> Self {
94        self.reconnect = Some(config);
95        self
96    }
97
98    /// Set the idle policy (builder pattern).
99    ///
100    /// Controls scanner behavior when disarmed. See [`crate::config::IdlePolicy`].
101    pub fn with_idle_policy(mut self, policy: crate::config::IdlePolicy) -> Self {
102        self.idle_policy = policy;
103        self
104    }
105
106    /// Install a final-output filter (builder pattern).
107    pub fn with_output_filter(mut self, filter: Box<dyn super::OutputFilter>) -> Self {
108        self.output_filter = Some(filter);
109        self
110    }
111}
112
113// =============================================================================
114// FrameSession
115// =============================================================================
116
117/// Read-only liveness and connectivity metrics for a [`FrameSession`].
118#[derive(Clone)]
119pub struct FrameSessionMetrics {
120    inner: Arc<FrameSessionMetricsInner>,
121}
122
123struct FrameSessionMetricsInner {
124    connected: AtomicBool,
125    origin: Instant,
126    last_loop_activity_nanos: AtomicU64,
127    last_write_success_nanos: AtomicU64,
128}
129
130impl FrameSessionMetrics {
131    pub(crate) fn new(connected: bool) -> Self {
132        let metrics = Self {
133            inner: Arc::new(FrameSessionMetricsInner {
134                connected: AtomicBool::new(connected),
135                origin: Instant::now(),
136                last_loop_activity_nanos: AtomicU64::new(0),
137                last_write_success_nanos: AtomicU64::new(0),
138            }),
139        };
140        metrics.mark_loop_activity();
141        metrics
142    }
143
144    /// Returns whether the session currently has a connected backend.
145    pub fn connected(&self) -> bool {
146        self.inner.connected.load(Ordering::SeqCst)
147    }
148
149    /// Returns the last time the scheduler thread showed progress.
150    pub fn last_loop_activity(&self) -> Option<Instant> {
151        self.instant_from_nanos(self.inner.last_loop_activity_nanos.load(Ordering::SeqCst))
152    }
153
154    /// Returns the last time a backend write completed successfully.
155    pub fn last_write_success(&self) -> Option<Instant> {
156        self.instant_from_nanos(self.inner.last_write_success_nanos.load(Ordering::SeqCst))
157    }
158
159    fn instant_from_nanos(&self, nanos: u64) -> Option<Instant> {
160        if nanos == 0 {
161            None
162        } else {
163            self.inner.origin.checked_add(Duration::from_nanos(nanos))
164        }
165    }
166
167    fn now_nanos(&self) -> u64 {
168        (self.inner.origin.elapsed().as_nanos().min(u64::MAX as u128) as u64).max(1)
169    }
170
171    pub(super) fn mark_loop_activity(&self) {
172        self.inner
173            .last_loop_activity_nanos
174            .store(self.now_nanos(), Ordering::SeqCst);
175    }
176
177    pub(super) fn mark_write_success(&self) {
178        let now = self.now_nanos();
179        self.inner
180            .last_loop_activity_nanos
181            .store(now, Ordering::SeqCst);
182        self.inner
183            .last_write_success_nanos
184            .store(now, Ordering::SeqCst);
185    }
186
187    pub(super) fn set_connected(&self, connected: bool) {
188        self.inner.connected.store(connected, Ordering::SeqCst);
189        self.mark_loop_activity();
190    }
191}
192
193struct MetricsDisconnectGuard(FrameSessionMetrics);
194
195impl Drop for MetricsDisconnectGuard {
196    fn drop(&mut self) {
197        self.0.set_connected(false);
198    }
199}
200
201/// A frame-mode streaming session.
202///
203/// Owns a scheduler thread that reads frames from a channel and writes them
204/// to the DAC backend using the appropriate strategy (FIFO or frame-swap).
205///
206/// # Example
207///
208/// ```ignore
209/// use laser_dac::{open_device, FrameSessionConfig, Frame, LaserPoint};
210///
211/// let device = open_device("my-device")?;
212/// let config = FrameSessionConfig::new(30_000);
213/// let (session, _info) = device.start_frame_session(config)?;
214///
215/// session.control().arm()?;
216/// session.send_frame(Frame::new(vec![
217///     LaserPoint::new(0.0, 0.0, 65535, 0, 0, 65535),
218/// ]));
219/// ```
220pub struct FrameSession {
221    control: StreamControl,
222    thread: Option<JoinHandle<Result<RunExit>>>,
223    frame_slot: Arc<Mutex<Option<Frame>>>,
224    metrics: FrameSessionMetrics,
225}
226
227impl FrameSession {
228    /// Start a frame session on the given backend.
229    pub(crate) fn start(
230        mut backend: BackendKind,
231        config: FrameSessionConfig,
232        reconnect_policy: Option<ReconnectPolicy>,
233    ) -> Result<Self> {
234        if !backend.is_connected() {
235            backend.connect()?;
236        }
237
238        let (control_tx, control_rx) = mpsc::channel();
239        let initial_color_delay = if config.color_delay_points > 0 {
240            Duration::from_secs_f64(config.color_delay_points as f64 / config.pps as f64)
241        } else {
242            Duration::ZERO
243        };
244        let control = StreamControl::new(control_tx, initial_color_delay, config.pps);
245        let frame_slot: Arc<Mutex<Option<Frame>>> = Arc::new(Mutex::new(None));
246        let metrics = FrameSessionMetrics::new(backend.is_connected());
247
248        let control_clone = control.clone();
249        let slot_clone = frame_slot.clone();
250        let metrics_clone = metrics.clone();
251
252        // Named for diagnosability in profilers / thread dumps. Elevating this
253        // thread's scheduling priority so pacing sleeps aren't preempted under
254        // load is tracked separately (see issue #35).
255        let thread = std::thread::Builder::new()
256            .name("laser-frame-scheduler".to_string())
257            .spawn(move || {
258                let _disconnect_guard = MetricsDisconnectGuard(metrics_clone.clone());
259                Self::run_loop(
260                    backend,
261                    config,
262                    control_clone,
263                    control_rx,
264                    slot_clone,
265                    metrics_clone,
266                    reconnect_policy,
267                )
268            })
269            .map_err(Error::backend)?;
270
271        Ok(Self {
272            control,
273            thread: Some(thread),
274            frame_slot,
275            metrics,
276        })
277    }
278
279    /// Returns a control handle for arm/disarm/stop.
280    pub fn control(&self) -> StreamControl {
281        self.control.clone()
282    }
283
284    /// Submit a frame for display. Latest-wins: overwrites any unconsumed
285    /// pending frame immediately, with no buffering or memory growth.
286    pub fn send_frame(&self, frame: Frame) {
287        *self.frame_slot.lock().unwrap() = Some(frame);
288    }
289
290    /// Returns true if the scheduler thread has finished.
291    pub fn is_finished(&self) -> bool {
292        self.thread.as_ref().is_some_and(|h| h.is_finished())
293    }
294
295    /// Returns a metrics handle for observing scheduler liveness.
296    pub fn metrics(&self) -> FrameSessionMetrics {
297        self.metrics.clone()
298    }
299
300    /// Wait for the session thread to finish and return the exit reason.
301    pub fn join(mut self) -> Result<RunExit> {
302        if let Some(handle) = self.thread.take() {
303            handle
304                .join()
305                .unwrap_or(Err(Error::disconnected("thread panicked")))
306        } else {
307            Ok(RunExit::Stopped)
308        }
309    }
310
311    // =========================================================================
312    // Driver loop
313    // =========================================================================
314
315    fn run_loop(
316        mut backend: BackendKind,
317        config: FrameSessionConfig,
318        control: StreamControl,
319        control_rx: mpsc::Receiver<ControlMsg>,
320        frame_slot: Arc<Mutex<Option<Frame>>>,
321        metrics: FrameSessionMetrics,
322        reconnect_policy: Option<ReconnectPolicy>,
323    ) -> Result<RunExit> {
324        let FrameSessionConfig {
325            pps: _,
326            transition_fn,
327            startup_blank,
328            color_delay_points,
329            idle_policy,
330            output_filter,
331            reconnect: _,
332        } = config;
333
334        let mut engine = PresentationEngine::new(transition_fn);
335        if backend.is_frame_swap() {
336            engine.set_frame_capacity(backend.frame_capacity());
337        }
338
339        // Per-OutputModel initial buffer sizing: FIFO bounds by max_points_per_chunk;
340        // frame-swap bounds by frame_capacity (max_points_per_chunk is meaningless there).
341        let initial_buf_capacity = match backend.caps().output_model {
342            OutputModel::UsbFrameSwap => backend.frame_capacity().unwrap_or(0),
343            OutputModel::NetworkFifo | OutputModel::UdpTimed | OutputModel::BlockingFifo => {
344                backend.caps().max_points_per_chunk
345            }
346        };
347        let mut pipeline = SlicePipeline::with_startup_blank(
348            engine,
349            color_delay_points,
350            output_filter,
351            idle_policy,
352            initial_buf_capacity,
353            startup_blank,
354        );
355        pipeline.reset_output_filter(OutputResetReason::SessionStart);
356
357        let expected_frame_swap = backend.is_frame_swap();
358        let source: SourceOwned = if expected_frame_swap {
359            SourceOwned::Frame(Box::new(pipeline))
360        } else {
361            SourceOwned::Fifo(Box::new(pipeline))
362        };
363
364        let validator = Self::reconnect_validator(reconnect_policy.as_ref());
365        if !backend.is_connected() {
366            backend.connect()?;
367        }
368
369        let target_buffer = target_buffer_for_backend(&backend);
370
371        driver::run(DriverInputs {
372            backend,
373            source,
374            control,
375            control_rx,
376            metrics,
377            reconnect_policy,
378            validator,
379            error_sink: Box::new(|_e: Error| { /* frame-mode swallows non-fatal errors */ }),
380            target_buffer,
381            drain_timeout: Duration::ZERO,
382            pending_frame: Some(frame_slot),
383            clock: DriverInputs::system_clock(),
384        })
385    }
386
387    fn reconnect_validator(policy: Option<&ReconnectPolicy>) -> driver::ReconnectValidator {
388        let target_id = policy
389            .map(|p| p.target.device_id.clone())
390            .unwrap_or_default();
391        Box::new(move |info: &DacInfo, _backend: &BackendKind, pps: u32| {
392            if pps < info.caps.pps_min || pps > info.caps.pps_max {
393                log::error!(
394                    "'{}' PPS {} outside new device range [{}, {}]",
395                    target_id,
396                    pps,
397                    info.caps.pps_min,
398                    info.caps.pps_max
399                );
400                return Err(RunExit::Disconnected);
401            }
402            Ok(())
403        })
404    }
405}
406
407impl Drop for FrameSession {
408    fn drop(&mut self) {
409        let _ = self.control.stop();
410        if let Some(handle) = self.thread.take() {
411            let _ = handle.join();
412        }
413    }
414}
415
416pub(super) fn target_buffer_for_backend(backend: &BackendKind) -> Duration {
417    // Delegate to the shared policy so the frame path and the stream path's
418    // `apply_backend_buffer_defaults` can never disagree on the cushion.
419    StreamConfig::default_target_buffer_for(&backend.dac_type(), &backend.caps().output_model)
420}