Skip to main content

repose_robot_interface/
session.rs

1use bincode::Decode;
2use bincode::Encode;
3use cu29::prelude::*;
4use serde::Deserialize;
5use serde::Serialize;
6
7/// Vendor-neutral session command, the payload of a bridge's `session` Tx channel.
8///
9/// No command moves the robot. [`EnableDrives`](Self::EnableDrives) energises
10/// the drives and releases the brakes while the robot holds position.
11#[derive(
12    Default, Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, Serialize, Deserialize, Reflect,
13)]
14#[serde(rename_all = "snake_case")]
15pub enum CuSessionCommand {
16    /// Ask the controller to accept commands from this client.
17    #[default]
18    AcquireControl,
19    /// Hand control back to the controller or other clients.
20    ReleaseControl,
21    /// Clear resettable controller and drive faults.
22    ClearFaults,
23    /// Energise the drives.
24    EnableDrives,
25    /// De-energise the drives.
26    DisableDrives,
27}
28
29/// Outcome of the most recent session command.
30#[derive(
31    Default, Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, Serialize, Deserialize, Reflect,
32)]
33#[serde(rename_all = "snake_case")]
34pub enum CuSessionResult {
35    /// No command has been requested.
36    #[default]
37    Idle,
38    /// Sent; waiting for the controller's answer.
39    Pending,
40    /// The controller accepted the command.
41    Acknowledged,
42    /// The command was refused; [`CuSessionState::rejection`] says by whom.
43    Rejected,
44}
45
46/// Why the most recent command was rejected.
47#[derive(
48    Default, Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, Serialize, Deserialize, Reflect,
49)]
50#[serde(rename_all = "snake_case")]
51pub enum CuSessionRejection {
52    /// Not rejected.
53    #[default]
54    None,
55    /// Refused locally: the controller does not report this client in control.
56    NotInControl,
57    /// Refused by the controller; [`CuSessionState::detail`] holds its text.
58    Controller,
59}
60
61/// Fixed-capacity ASCII text from a controller, truncated to 32 bytes.
62#[derive(Default, Debug, Clone, PartialEq, Eq, Encode, Decode, Serialize, Deserialize, Reflect)]
63pub struct CuSessionDetail {
64    bytes: [u8; 32],
65    len: u8,
66}
67
68impl CuSessionDetail {
69    /// Store `text`, truncated to 32 bytes on a character boundary.
70    pub fn new(text: &str) -> Self {
71        let mut take = text.len().min(32);
72        while !text.is_char_boundary(take) {
73            take -= 1;
74        }
75        let mut bytes = [0; 32];
76        bytes[..take].copy_from_slice(&text.as_bytes()[..take]);
77        Self {
78            bytes,
79            len: take as u8,
80        }
81    }
82
83    /// The stored text; empty when none was given.
84    pub fn as_str(&self) -> &str {
85        core::str::from_utf8(&self.bytes[..usize::from(self.len.min(32))]).unwrap_or("")
86    }
87}
88
89/// Session state, the payload of a bridge's `session_state` Rx channel.
90#[derive(Default, Debug, Clone, PartialEq, Encode, Decode, Serialize, Deserialize, Reflect)]
91pub struct CuSessionState {
92    /// Most recent command, absent before the first request.
93    pub command: Option<CuSessionCommand>,
94    /// Protocol sequence number the command was sent with.
95    pub sequence: u32,
96    /// Outcome of that command.
97    pub result: CuSessionResult,
98    /// Who rejected it, when `result` is `Rejected`.
99    pub rejection: CuSessionRejection,
100    /// Controller text accompanying a rejection.
101    pub detail: CuSessionDetail,
102    /// The controller reports this client in control.
103    pub control_acquired: bool,
104    /// Drives were enabled by an acknowledged `EnableDrives` and not since disabled.
105    pub drives_enabled: bool,
106    /// Every drive reports ready. Lags `drives_enabled` on controllers that
107    /// bring axes up one at a time.
108    pub drives_ready: bool,
109    /// The controller reports that it would accept motion now.
110    pub motion_allowed: bool,
111    /// Requests refused because another command was still pending.
112    pub refused: u32,
113}
114
115/// What a bridge does with a request.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum CuSessionDecision {
118    /// Queued; fetch it with [`CuSessionTracker::take_outgoing`] and send it.
119    Send,
120    /// Not sent; [`CuSessionState`] records why.
121    Refused,
122}
123
124/// Protocol-independent session rules shared by every bridge.
125///
126/// The bridge feeds it requests from Copper and events decoded from the
127/// controller. It owns no I/O and never allocates.
128#[derive(Default, Debug, Clone)]
129pub struct CuSessionTracker {
130    state: CuSessionState,
131    outgoing: Option<CuSessionCommand>,
132    /// Taken by the bridge but not yet confirmed with `mark_sent`.
133    in_transit: bool,
134    /// Control was lost while a command was in transit.
135    transit_cancelled: bool,
136    /// Whether control was already held when `AcquireControl` went out.
137    held_before_acquire: bool,
138    acquired_here: bool,
139    changed: bool,
140}
141
142impl CuSessionTracker {
143    /// Current state.
144    pub fn state(&self) -> &CuSessionState {
145        &self.state
146    }
147
148    /// True once since the last call if the state changed.
149    pub fn take_changed(&mut self) -> bool {
150        core::mem::take(&mut self.changed)
151    }
152
153    /// True while a command is queued or awaiting its answer.
154    pub fn is_busy(&self) -> bool {
155        self.outgoing.is_some() || self.in_transit || self.state.result == CuSessionResult::Pending
156    }
157
158    /// Handle one request from Copper.
159    pub fn request(&mut self, command: CuSessionCommand) -> CuSessionDecision {
160        self.changed = true;
161        if self.is_busy() {
162            self.state.refused = self.state.refused.saturating_add(1);
163            return CuSessionDecision::Refused;
164        }
165        if command != CuSessionCommand::AcquireControl && !self.state.control_acquired {
166            self.state.command = Some(command);
167            self.state.result = CuSessionResult::Rejected;
168            self.state.rejection = CuSessionRejection::NotInControl;
169            self.state.detail = CuSessionDetail::default();
170            return CuSessionDecision::Refused;
171        }
172        self.outgoing = Some(command);
173        CuSessionDecision::Send
174    }
175
176    /// The queued command, removed from the queue. Pass it to
177    /// [`mark_sent`](Self::mark_sent) once written.
178    pub fn take_outgoing(&mut self) -> Option<CuSessionCommand> {
179        let command = self.outgoing.take();
180        self.in_transit |= command.is_some();
181        command
182    }
183
184    /// Record that `command` went out with protocol sequence `sequence`.
185    pub fn mark_sent(&mut self, command: CuSessionCommand, sequence: u32) {
186        // Only act on the loss if control is still absent; it may have returned.
187        let lost_in_transit =
188            core::mem::take(&mut self.transit_cancelled) && !self.state.control_acquired;
189        self.in_transit = false;
190        if command == CuSessionCommand::AcquireControl {
191            self.held_before_acquire = self.state.control_acquired;
192        }
193        self.state.command = Some(command);
194        self.state.sequence = sequence;
195        self.state.result = CuSessionResult::Pending;
196        self.state.rejection = CuSessionRejection::None;
197        self.state.detail = CuSessionDetail::default();
198        if lost_in_transit && command == CuSessionCommand::ReleaseControl {
199            // Control is already gone: the release has nothing left to wait for.
200            self.state.result = CuSessionResult::Acknowledged;
201        } else if lost_in_transit && needs_control(command) {
202            // Sent after control was lost: no answer may take effect.
203            self.state.result = CuSessionResult::Rejected;
204            self.state.rejection = CuSessionRejection::NotInControl;
205        }
206        self.changed = true;
207    }
208
209    /// The controller accepted command `sequence`. Other sequences are ignored.
210    pub fn on_acknowledged(&mut self, sequence: u32) {
211        if !self.pending(sequence) {
212            return;
213        }
214        self.state.result = CuSessionResult::Acknowledged;
215        match self.state.command {
216            Some(CuSessionCommand::AcquireControl) => {
217                self.state.control_acquired = true;
218                self.acquired_here |= !self.held_before_acquire;
219            }
220            Some(CuSessionCommand::ReleaseControl) => self.lose_control(),
221            Some(CuSessionCommand::EnableDrives) => self.state.drives_enabled = true,
222            Some(CuSessionCommand::DisableDrives) => self.disable(),
223            Some(CuSessionCommand::ClearFaults) | None => {}
224        }
225        self.changed = true;
226    }
227
228    /// The controller refused command `sequence` with `detail`.
229    pub fn on_rejected(&mut self, sequence: u32, detail: &str) {
230        if !self.pending(sequence) {
231            return;
232        }
233        self.state.result = CuSessionResult::Rejected;
234        self.state.rejection = CuSessionRejection::Controller;
235        self.state.detail = CuSessionDetail::new(detail);
236        self.changed = true;
237    }
238
239    /// The controller reported whether this client is in control. On
240    /// controllers that answer control requests this way instead of with an
241    /// acknowledgement, it also completes a pending acquire or release.
242    pub fn on_control_report(&mut self, in_control: bool) {
243        if self.state.result == CuSessionResult::Pending {
244            match (self.state.command, in_control) {
245                (Some(CuSessionCommand::AcquireControl), true) => {
246                    self.state.result = CuSessionResult::Acknowledged;
247                    self.acquired_here |= !self.held_before_acquire;
248                }
249                (Some(CuSessionCommand::ReleaseControl), false) => {
250                    self.state.result = CuSessionResult::Acknowledged;
251                }
252                _ => {}
253            }
254        }
255        if in_control {
256            self.state.control_acquired = true;
257        } else {
258            self.lose_control();
259        }
260        self.changed = true;
261    }
262
263    /// The controller reported drive readiness and whether it would accept motion.
264    pub fn on_drive_report(&mut self, drives_ready: bool, motion_allowed: bool) {
265        if (drives_ready, motion_allowed) != (self.state.drives_ready, self.state.motion_allowed) {
266            self.state.drives_ready = drives_ready;
267            self.state.motion_allowed = motion_allowed;
268            self.changed = true;
269        }
270    }
271
272    /// The transport was lost: nothing is pending and nothing is held.
273    pub fn on_disconnected(&mut self) {
274        self.outgoing = None;
275        self.in_transit = false;
276        self.transit_cancelled = false;
277        if self.state.result == CuSessionResult::Pending {
278            self.state.result = CuSessionResult::Idle;
279        }
280        self.lose_control();
281        self.changed = true;
282    }
283
284    /// Next command to wind the session down, or `None` once this session
285    /// holds nothing: disable drives this session enabled, then release
286    /// control this session acquired. Queue it with [`request`](Self::request)
287    /// when the tracker is not busy.
288    pub fn shutdown_step(&self) -> Option<CuSessionCommand> {
289        if !self.state.control_acquired {
290            None
291        } else if self.state.drives_enabled {
292            Some(CuSessionCommand::DisableDrives)
293        } else if self.acquired_here {
294            Some(CuSessionCommand::ReleaseControl)
295        } else {
296            None
297        }
298    }
299
300    fn pending(&self, sequence: u32) -> bool {
301        self.state.result == CuSessionResult::Pending && self.state.sequence == sequence
302    }
303
304    fn disable(&mut self) {
305        self.state.drives_enabled = false;
306        self.state.drives_ready = false;
307        self.state.motion_allowed = false;
308    }
309
310    /// Control is gone: drop queued commands that need it, and fail a
311    /// pending one so a late answer cannot mark drives enabled.
312    fn lose_control(&mut self) {
313        self.state.control_acquired = false;
314        self.acquired_here = false;
315        self.held_before_acquire = false;
316        self.disable();
317        if self
318            .outgoing
319            .is_some_and(|command| command != CuSessionCommand::AcquireControl)
320        {
321            self.outgoing = None;
322        }
323        self.transit_cancelled |= self.in_transit;
324        if self.state.result == CuSessionResult::Pending
325            && self.state.command.is_some_and(needs_control)
326        {
327            self.state.result = CuSessionResult::Rejected;
328            self.state.rejection = CuSessionRejection::NotInControl;
329        }
330    }
331}
332
333/// Commands that only make sense while this client is in control.
334fn needs_control(command: CuSessionCommand) -> bool {
335    !matches!(
336        command,
337        CuSessionCommand::AcquireControl | CuSessionCommand::ReleaseControl
338    )
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    fn send(tracker: &mut CuSessionTracker, command: CuSessionCommand, sequence: u32) {
346        assert_eq!(tracker.request(command), CuSessionDecision::Send);
347        let out = tracker.take_outgoing().unwrap();
348        assert_eq!(out, command);
349        tracker.mark_sent(out, sequence);
350    }
351
352    #[test]
353    fn test_refuses_without_control_and_while_busy() {
354        let mut tracker = CuSessionTracker::default();
355        assert_eq!(
356            tracker.request(CuSessionCommand::EnableDrives),
357            CuSessionDecision::Refused
358        );
359        assert_eq!(tracker.state().rejection, CuSessionRejection::NotInControl);
360        assert!(tracker.take_outgoing().is_none());
361        send(&mut tracker, CuSessionCommand::AcquireControl, 1);
362        assert_eq!(
363            tracker.request(CuSessionCommand::ClearFaults),
364            CuSessionDecision::Refused
365        );
366        assert_eq!(tracker.state().refused, 1);
367        // A busy refusal leaves the pending command intact.
368        assert_eq!(tracker.state().result, CuSessionResult::Pending);
369        tracker.on_control_report(true);
370        assert_eq!(tracker.state().result, CuSessionResult::Acknowledged);
371    }
372
373    #[test]
374    fn test_answers_match_sequence() {
375        let mut tracker = CuSessionTracker::default();
376        tracker.on_control_report(true);
377        send(&mut tracker, CuSessionCommand::EnableDrives, 5);
378        tracker.on_acknowledged(4);
379        assert_eq!(tracker.state().result, CuSessionResult::Pending);
380        tracker.on_acknowledged(5);
381        assert!(tracker.state().drives_enabled);
382        send(&mut tracker, CuSessionCommand::DisableDrives, 6);
383        tracker.on_rejected(6, "drives busy and this text is longer than thirty-two");
384        assert_eq!(tracker.state().rejection, CuSessionRejection::Controller);
385        assert_eq!(
386            tracker.state().detail.as_str(),
387            "drives busy and this text is lon"
388        );
389        assert!(tracker.state().drives_enabled);
390    }
391
392    #[test]
393    fn test_shutdown_releases_only_what_it_took() {
394        let mut tracker = CuSessionTracker::default();
395        tracker.on_control_report(true);
396        // In control from the start, not acquired here: nothing to release.
397        assert_eq!(tracker.shutdown_step(), None);
398        send(&mut tracker, CuSessionCommand::EnableDrives, 1);
399        tracker.on_acknowledged(1);
400        tracker.on_drive_report(true, true);
401        assert_eq!(
402            tracker.shutdown_step(),
403            Some(CuSessionCommand::DisableDrives)
404        );
405        send(&mut tracker, CuSessionCommand::DisableDrives, 2);
406        tracker.on_acknowledged(2);
407        assert!(!tracker.state().drives_ready && !tracker.state().motion_allowed);
408        assert_eq!(tracker.shutdown_step(), None);
409
410        let mut tracker = CuSessionTracker::default();
411        tracker.on_control_report(false);
412        send(&mut tracker, CuSessionCommand::AcquireControl, 1);
413        tracker.on_control_report(true);
414        assert_eq!(
415            tracker.shutdown_step(),
416            Some(CuSessionCommand::ReleaseControl)
417        );
418        send(&mut tracker, CuSessionCommand::ReleaseControl, 2);
419        tracker.on_control_report(false);
420        assert_eq!(tracker.state().result, CuSessionResult::Acknowledged);
421        assert_eq!(tracker.shutdown_step(), None);
422    }
423
424    #[test]
425    fn test_losing_control_cancels_privileged_commands() {
426        let mut tracker = CuSessionTracker::default();
427        tracker.on_control_report(true);
428        assert_eq!(
429            tracker.request(CuSessionCommand::EnableDrives),
430            CuSessionDecision::Send
431        );
432        tracker.on_control_report(false);
433        assert_eq!(tracker.take_outgoing(), None);
434
435        tracker.on_control_report(true);
436        send(&mut tracker, CuSessionCommand::EnableDrives, 3);
437        tracker.on_control_report(false);
438        assert_eq!(tracker.state().result, CuSessionResult::Rejected);
439        assert_eq!(tracker.state().rejection, CuSessionRejection::NotInControl);
440        tracker.on_acknowledged(3); // late answer is ignored
441        assert!(!tracker.state().drives_enabled);
442    }
443
444    #[test]
445    fn test_control_lost_in_transit() {
446        let mut tracker = CuSessionTracker::default();
447        tracker.on_control_report(true);
448        tracker.request(CuSessionCommand::EnableDrives);
449        let command = tracker.take_outgoing().unwrap();
450        tracker.on_control_report(false);
451        tracker.mark_sent(command, 9);
452        assert_eq!(tracker.state().rejection, CuSessionRejection::NotInControl);
453        tracker.on_acknowledged(9);
454        assert!(!tracker.state().drives_enabled);
455        assert!(!tracker.is_busy());
456    }
457
458    #[test]
459    fn test_release_lost_in_transit_completes() {
460        let mut tracker = CuSessionTracker::default();
461        tracker.on_control_report(true);
462        tracker.request(CuSessionCommand::ReleaseControl);
463        let command = tracker.take_outgoing().unwrap();
464        tracker.on_control_report(false);
465        tracker.mark_sent(command, 4);
466        assert_eq!(tracker.state().result, CuSessionResult::Acknowledged);
467        assert!(!tracker.is_busy());
468    }
469
470    #[test]
471    fn test_control_regained_in_transit_keeps_release_pending() {
472        let mut tracker = CuSessionTracker::default();
473        tracker.on_control_report(true);
474        tracker.request(CuSessionCommand::ReleaseControl);
475        let command = tracker.take_outgoing().unwrap();
476        tracker.on_control_report(false);
477        tracker.on_control_report(true);
478        tracker.mark_sent(command, 5);
479        assert_eq!(tracker.state().result, CuSessionResult::Pending);
480        tracker.on_control_report(false);
481        assert_eq!(tracker.state().result, CuSessionResult::Acknowledged);
482    }
483
484    #[test]
485    fn test_busy_between_take_and_mark_sent() {
486        let mut tracker = CuSessionTracker::default();
487        tracker.on_control_report(true);
488        tracker.request(CuSessionCommand::ClearFaults);
489        let command = tracker.take_outgoing().unwrap();
490        assert!(tracker.is_busy());
491        assert_eq!(
492            tracker.request(CuSessionCommand::EnableDrives),
493            CuSessionDecision::Refused
494        );
495        tracker.mark_sent(command, 1);
496        assert_eq!(tracker.state().command, Some(CuSessionCommand::ClearFaults));
497    }
498
499    #[test]
500    fn test_acquire_while_already_held_is_not_released() {
501        for via_report in [false, true] {
502            let mut tracker = CuSessionTracker::default();
503            tracker.on_control_report(true);
504            send(&mut tracker, CuSessionCommand::AcquireControl, 1);
505            if via_report {
506                tracker.on_control_report(true);
507            } else {
508                tracker.on_acknowledged(1);
509            }
510            assert_eq!(tracker.state().result, CuSessionResult::Acknowledged);
511            assert_eq!(tracker.shutdown_step(), None);
512        }
513    }
514
515    #[test]
516    fn test_reacquire_keeps_ownership() {
517        let mut tracker = CuSessionTracker::default();
518        tracker.on_control_report(false);
519        send(&mut tracker, CuSessionCommand::AcquireControl, 1);
520        tracker.on_control_report(true);
521        send(&mut tracker, CuSessionCommand::AcquireControl, 2);
522        tracker.on_acknowledged(2);
523        assert_eq!(
524            tracker.shutdown_step(),
525            Some(CuSessionCommand::ReleaseControl)
526        );
527    }
528
529    #[test]
530    fn test_disconnect_clears_session() {
531        let mut tracker = CuSessionTracker::default();
532        tracker.on_control_report(true);
533        send(&mut tracker, CuSessionCommand::EnableDrives, 1);
534        tracker.on_disconnected();
535        assert!(!tracker.is_busy());
536        let state = tracker.state();
537        assert_eq!(state.result, CuSessionResult::Idle);
538        assert!(!state.control_acquired && !state.drives_enabled);
539        assert_eq!(tracker.shutdown_step(), None);
540    }
541
542    #[test]
543    fn test_command_ron_spelling() {
544        let command: CuSessionCommand = ron::from_str("enable_drives").unwrap();
545        assert_eq!(command, CuSessionCommand::EnableDrives);
546    }
547}