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}