Skip to main content

laser_dac/
session.rs

1//! Shared session lifecycle control and exit types.
2
3use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
4use std::sync::mpsc::Sender;
5use std::sync::{Arc, Mutex};
6use std::time::Duration;
7
8use crate::error::{Error, Result};
9
10/// How a shared presentation session ended.
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub enum SessionExit {
13    /// The session received a stop request.
14    Stopped,
15    /// The content source ended normally.
16    ///
17    /// For callback streams, this means the producer returned
18    /// [`crate::ChunkResult::End`].
19    ProducerEnded,
20    /// The device disconnected and reconnection was unavailable or exhausted.
21    Disconnected,
22    /// A replacement device was rejected because its backend kind, output
23    /// model, or PPS range is incompatible with the running session.
24    IncompatibleDevice,
25}
26
27// =============================================================================
28// Stream Control
29// =============================================================================
30
31/// Scheduler notifications sent by [`SessionControl`].
32///
33/// Desired atomic state is authoritative; arm/disarm messages only interrupt
34/// pacing/backpressure so the shared driver can reconcile that state.
35#[derive(Debug, Clone, Copy)]
36pub(crate) enum ControlMsg {
37    /// Desired arm state may have changed.
38    Arm,
39    /// Desired disarm state may have changed.
40    Disarm,
41    /// Request the stream to stop.
42    Stop,
43}
44
45/// One atomically coherent desired arm state and transition generation.
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub(crate) struct DesiredState(u64);
48
49impl DesiredState {
50    const ARMED: u64 = 1;
51
52    pub(crate) const fn initial() -> Self {
53        Self(0)
54    }
55
56    pub(crate) fn is_armed(self) -> bool {
57        self.0 & Self::ARMED != 0
58    }
59}
60
61#[derive(Clone, Copy)]
62struct RateState {
63    pps: u32,
64    min: u32,
65    max: u32,
66}
67
68/// Thread-safe control handle for safety-critical actions.
69///
70/// This allows out-of-band control of the stream (arm/disarm/stop) from
71/// a different thread, e.g., for E-stop functionality.
72///
73/// Control actions take effect as soon as possible - the stream processes
74/// control messages at every opportunity (during waits, between retries, etc.).
75#[derive(Clone)]
76pub struct SessionControl {
77    inner: Arc<SessionControlInner>,
78}
79
80struct SessionControlInner {
81    /// Desired arm bit plus a generation incremented for every actual edge.
82    desired_state: AtomicU64,
83    /// Whether a stop has been requested.
84    stop_requested: AtomicBool,
85    /// Channel for waking the scheduler after control state changes.
86    control_tx: Mutex<Sender<ControlMsg>>,
87    /// Full-precision color delay, read when fresh output is composed.
88    color_delay: Mutex<Duration>,
89    /// Coherent active PPS and capability bounds.
90    rate: Mutex<RateState>,
91}
92
93impl SessionControl {
94    #[cfg(test)]
95    pub(crate) fn new(control_tx: Sender<ControlMsg>, color_delay: Duration, pps: u32) -> Self {
96        Self::new_with_pps_bounds(control_tx, color_delay, pps, 0, u32::MAX)
97    }
98
99    pub(crate) fn new_with_pps_bounds(
100        control_tx: Sender<ControlMsg>,
101        color_delay: Duration,
102        pps: u32,
103        pps_min: u32,
104        pps_max: u32,
105    ) -> Self {
106        Self {
107            inner: Arc::new(SessionControlInner {
108                desired_state: AtomicU64::new(0),
109                stop_requested: AtomicBool::new(false),
110                control_tx: Mutex::new(control_tx),
111                color_delay: Mutex::new(color_delay),
112                rate: Mutex::new(RateState {
113                    pps,
114                    min: pps_min,
115                    max: pps_max,
116                }),
117            }),
118        }
119    }
120
121    fn set_desired_armed(&self, armed: bool, msg: ControlMsg) -> Result<()> {
122        let armed_bit = u64::from(armed);
123        let mut current = self.inner.desired_state.load(Ordering::SeqCst);
124        loop {
125            if current & DesiredState::ARMED == armed_bit {
126                break;
127            }
128            // The low bit is state; adding two advances the generation, then
129            // replacing the low bit records this edge in the same atomic word.
130            let next = (current.wrapping_add(2) & !DesiredState::ARMED) | armed_bit;
131            match self.inner.desired_state.compare_exchange(
132                current,
133                next,
134                Ordering::SeqCst,
135                Ordering::SeqCst,
136            ) {
137                Ok(_) => break,
138                Err(observed) => current = observed,
139            }
140        }
141
142        // Same-state calls do not advance the generation, but still attempt a
143        // wake so a prior notification failure can never be silently masked.
144        let tx = self
145            .inner
146            .control_tx
147            .lock()
148            .unwrap_or_else(|poisoned| poisoned.into_inner());
149        tx.send(msg).map_err(|_| {
150            Error::disconnected("desired session state recorded, but scheduler notification failed")
151        })
152    }
153
154    /// Record an armed desired state and notify the scheduler.
155    ///
156    /// Success means the desired state was recorded and the notification was
157    /// accepted; it does not mean hardware completion. The shared driver
158    /// discards retained output, primes startup blanking, resets filters/device
159    /// buffering, and only then opens the shutter if that exact desired-state
160    /// generation is still current.
161    pub fn arm(&self) -> Result<()> {
162        self.set_desired_armed(true, ControlMsg::Arm)
163    }
164
165    /// Disarm the output (force laser off). Designed for E-stop use.
166    ///
167    /// Success means the desired state was recorded and the notification was
168    /// accepted; it does not confirm hardware completion. The driver closes the
169    /// shutter first, discards retained output, and resets source/filter state.
170    /// Already queued points can still play out.
171    ///
172    /// **Hardware shutter**: Best-effort. LaserCube and Helios have actual hardware
173    /// control; Ether Dream and IDN are no-ops (safety relies on software blanking).
174    pub fn disarm(&self) -> Result<()> {
175        self.set_desired_armed(false, ControlMsg::Disarm)
176    }
177
178    pub(crate) fn desired_state(&self) -> DesiredState {
179        DesiredState(self.inner.desired_state.load(Ordering::SeqCst))
180    }
181
182    /// Check if the output is armed.
183    pub fn is_armed(&self) -> bool {
184        self.desired_state().is_armed()
185    }
186
187    /// Set the color delay for scanner sync compensation.
188    ///
189    /// Takes effect within one chunk period. The delay is quantized to
190    /// whole points: `ceil(delay * pps)`.
191    pub fn set_color_delay(&self, delay: Duration) {
192        *self
193            .inner
194            .color_delay
195            .lock()
196            .unwrap_or_else(|poisoned| poisoned.into_inner()) = delay;
197    }
198
199    /// Get the current color delay.
200    pub fn color_delay(&self) -> Duration {
201        *self
202            .inner
203            .color_delay
204            .lock()
205            .unwrap_or_else(|poisoned| poisoned.into_inner())
206    }
207
208    /// Set the points per second rate.
209    ///
210    /// Takes effect within one chunk period. No session restart is required.
211    /// The update is rejected when it falls outside the connected backend's
212    /// current capability range.
213    pub fn set_pps(&self, pps: u32) -> Result<()> {
214        let mut rate = self
215            .inner
216            .rate
217            .lock()
218            .unwrap_or_else(|poisoned| poisoned.into_inner());
219        if pps < rate.min || pps > rate.max {
220            return Err(Error::invalid_config(format!(
221                "PPS {pps} outside active backend range [{}, {}]",
222                rate.min, rate.max
223            )));
224        }
225        rate.pps = pps;
226        Ok(())
227    }
228
229    pub(crate) fn update_pps_bounds(&self, min: u32, max: u32) -> Result<()> {
230        let mut rate = self
231            .inner
232            .rate
233            .lock()
234            .unwrap_or_else(|poisoned| poisoned.into_inner());
235        if rate.pps < min || rate.pps > max {
236            return Err(Error::invalid_config(format!(
237                "current PPS {} outside replacement backend range [{min}, {max}]",
238                rate.pps
239            )));
240        }
241        rate.min = min;
242        rate.max = max;
243        Ok(())
244    }
245
246    /// Get the current points per second rate.
247    pub fn pps(&self) -> u32 {
248        self.inner
249            .rate
250            .lock()
251            .unwrap_or_else(|poisoned| poisoned.into_inner())
252            .pps
253    }
254
255    /// Request the session to stop.
256    ///
257    /// Success means the stop flag was recorded and the notification was
258    /// accepted; `run()` later returns [`SessionExit::Stopped`]. Hardware
259    /// closure is asynchronous and best-effort.
260    pub fn stop(&self) -> Result<()> {
261        self.inner.stop_requested.store(true, Ordering::SeqCst);
262        let tx = self
263            .inner
264            .control_tx
265            .lock()
266            .unwrap_or_else(|poisoned| poisoned.into_inner());
267        tx.send(ControlMsg::Stop).map_err(|_| {
268            Error::disconnected("session stop recorded, but scheduler notification failed")
269        })
270    }
271
272    /// Check if a stop has been requested.
273    pub fn is_stop_requested(&self) -> bool {
274        self.inner.stop_requested.load(Ordering::SeqCst)
275    }
276}