Skip to main content

can_motor_control/
robot.rs

1//! [`Robot`] and [`RobotBuilder`].
2
3use std::collections::HashMap;
4use std::sync::{Arc, Mutex};
5use std::thread;
6use std::time::{Duration, Instant};
7
8use mio::Token;
9
10use crate::bus::{Bus, RouteKey};
11use crate::error::Error;
12use crate::group::{Arm, Generic, Gripper, GroupKind, MotorGroup, OPENING_CALIBRATION_TRAVEL_RAD};
13use crate::motor::Motor;
14use crate::spec::{GripperOpeningSpec, GroupSpecKind, MotorSpec};
15use crate::transport::{BusPoller, CanBus};
16use motor_codec::{CommandKind, MotorCodec};
17
18#[cfg(test)]
19const OPENING_CALIBRATION_ENDPOINT_DWELL: Duration = Duration::from_millis(100);
20#[cfg(not(test))]
21const OPENING_CALIBRATION_ENDPOINT_DWELL: Duration = Duration::from_millis(1500);
22const OPENING_CALIBRATION_TICK: Duration = Duration::from_millis(10);
23const OPENING_CALIBRATION_REFRESH_TIMEOUT: Duration = Duration::from_millis(250);
24const OPENING_MODE_VERIFY_TIMEOUT: Duration = Duration::from_millis(50);
25const OPENING_MODE_VERIFY_TICK: Duration = Duration::from_millis(5);
26const OPENING_MODE_SETTLE: Duration = Duration::from_millis(2);
27
28/// A configured robot: named buses + named groups + lifecycle state.
29pub struct Robot {
30    buses: HashMap<String, Arc<Mutex<Bus>>>,
31    bus_order: Vec<String>,
32    groups: HashMap<String, GroupKind>,
33    group_order: Vec<String>,
34    connected: bool,
35    poller: Option<BusPoller>,
36    /// Cached for tick: bus_name → poller token.
37    bus_tokens: HashMap<String, Token>,
38    /// Cached for tick: token → bus_name (inverse lookup).
39    token_to_bus: HashMap<Token, String>,
40}
41
42impl Robot {
43    /// Start a builder.
44    pub fn builder() -> RobotBuilder {
45        RobotBuilder::new()
46    }
47
48    /// Read-only access to a named group.
49    pub fn group(&self, name: &str) -> Option<&GroupKind> {
50        self.groups.get(name)
51    }
52
53    /// Mutable access to a named group.
54    pub fn group_mut(&mut self, name: &str) -> Option<&mut GroupKind> {
55        self.groups.get_mut(name)
56    }
57
58    /// Iterate group names in insertion order.
59    pub fn group_names(&self) -> impl Iterator<Item = &str> {
60        self.group_order.iter().map(String::as_str)
61    }
62
63    /// Iterate bus names in insertion order.
64    pub fn bus_names(&self) -> impl Iterator<Item = &str> {
65        self.bus_order.iter().map(String::as_str)
66    }
67
68    /// True after a successful `connect()`.
69    pub fn is_connected(&self) -> bool {
70        self.connected
71    }
72
73    /// Borrow a bus by name (returns the `Arc<Mutex<Bus>>` for direct lock).
74    pub fn bus(&self, name: &str) -> Option<&Arc<Mutex<Bus>>> {
75        self.buses.get(name)
76    }
77
78    /// Open sockets, populate per-bus recv-id routing tables, register fds
79    /// with the poller, lock topology.
80    pub fn connect(&mut self) -> Result<(), Error> {
81        if self.connected {
82            return Ok(());
83        }
84        // Build routing tables.
85        for (bus_name, bus_arc) in &self.buses {
86            let mut bus = bus_arc.lock().map_err(|_| Error::BusPoisoned)?;
87            bus.routes.clear();
88            for group_name in &self.group_order {
89                let group = self.groups.get(group_name).expect("group order invariant");
90                if group.bus_name() != bus_name {
91                    continue;
92                }
93                for (i, motor) in group.inner().motors().iter().enumerate() {
94                    let key = RouteKey {
95                        group_name: group_name.clone(),
96                        motor_index: i,
97                    };
98                    if let Some(existing) = bus.routes.get(&motor.recv_id()) {
99                        return Err(Error::CanIdCollision {
100                            bus_name: bus_name.clone(),
101                            recv_id: motor.recv_id(),
102                            existing: existing.clone(),
103                            attempted: key,
104                        });
105                    }
106                    bus.routes.insert(motor.recv_id(), key);
107                }
108            }
109        }
110        // Set up poller.
111        let poller = BusPoller::with_capacity(self.buses.len().max(1))?;
112        let mut bus_tokens = HashMap::with_capacity(self.buses.len());
113        let mut token_to_bus = HashMap::with_capacity(self.buses.len());
114        for (idx, bus_name) in self.bus_order.iter().enumerate() {
115            let bus = self.buses[bus_name]
116                .lock()
117                .map_err(|_| Error::BusPoisoned)?;
118            if let Some(fd) = bus.transport.raw_fd() {
119                let token = Token(idx);
120                poller.register(token, fd)?;
121                bus_tokens.insert(bus_name.clone(), token);
122                token_to_bus.insert(token, bus_name.clone());
123            }
124        }
125        self.poller = Some(poller);
126        self.bus_tokens = bus_tokens;
127        self.token_to_bus = token_to_bus;
128        self.connected = true;
129        Ok(())
130    }
131
132    /// Send the enable frame to every motor in every group, in insertion order.
133    pub fn enable(&mut self) -> Result<(), Error> {
134        if !self.connected {
135            return Err(Error::NotConnected);
136        }
137        let opening_names: Vec<String> = self
138            .group_order
139            .iter()
140            .filter(|name| {
141                self.groups
142                    .get(*name)
143                    .and_then(GroupKind::as_gripper)
144                    .is_some_and(Gripper::has_opening_control)
145            })
146            .cloned()
147            .collect();
148
149        // Damiao's mode write is send-only. Verify every opening-control motor
150        // before enabling any group, so a bad mode cannot reach calibration.
151        for name in &opening_names {
152            let is_damiao = self
153                .groups
154                .get(name)
155                .expect("group order invariant")
156                .inner()
157                .bus_vendor()
158                .map(|vendor| vendor == "damiao")?;
159            if is_damiao {
160                self.groups
161                    .get_mut(name)
162                    .and_then(GroupKind::as_gripper_mut)
163                    .expect("opening gripper invariant")
164                    .set_mode(CommandKind::PosForce)?;
165                thread::sleep(OPENING_MODE_SETTLE);
166                self.verify_opening_mode(name)?;
167            }
168        }
169
170        for name in &self.group_order.clone() {
171            let has_opening_control = self
172                .groups
173                .get(name)
174                .and_then(GroupKind::as_gripper)
175                .is_some_and(Gripper::has_opening_control);
176            if has_opening_control
177                && self
178                    .groups
179                    .get(name)
180                    .expect("group order invariant")
181                    .inner()
182                    .bus_vendor()
183                    .map(|vendor| vendor != "damiao")?
184            {
185                self.groups
186                    .get_mut(name)
187                    .and_then(GroupKind::as_gripper_mut)
188                    .expect("checked gripper kind")
189                    .set_mode(CommandKind::PosForce)?;
190            }
191            if let Some(g) = self.groups.get_mut(name) {
192                g.enable_all()?;
193            }
194            if has_opening_control {
195                if let Err(err) = self.calibrate_gripper_opening(name) {
196                    if let Some(g) = self.groups.get_mut(name) {
197                        if let Some(gripper) = g.as_gripper_mut() {
198                            gripper.clear_opening_calibration();
199                        }
200                        let _ = g.disable_all();
201                    }
202                    return Err(err);
203                }
204            }
205        }
206        Ok(())
207    }
208
209    fn verify_opening_mode(&mut self, name: &str) -> Result<(), Error> {
210        let (send_id, recv_id) = self
211            .groups
212            .get_mut(name)
213            .and_then(GroupKind::as_gripper_mut)
214            .expect("opening gripper invariant")
215            .inner_mut()
216            .send_control_mode_readback()?
217            .ok_or_else(|| Error::OpeningControlModeVerificationFailed {
218                name: name.to_string(),
219                reason: "codec does not support CTRL_MODE read-back".to_string(),
220            })?;
221        let deadline = Instant::now() + OPENING_MODE_VERIFY_TIMEOUT;
222        while Instant::now() < deadline {
223            if let Some(value) = self.tick_for_mode(name, send_id, recv_id)? {
224                if value == 4 {
225                    return Ok(());
226                }
227                return Err(Error::OpeningControlModeVerificationFailed {
228                    name: name.to_string(),
229                    reason: format!("CTRL_MODE read-back was {value}, expected 4"),
230                });
231            }
232            self.groups
233                .get_mut(name)
234                .and_then(GroupKind::as_gripper_mut)
235                .expect("opening gripper invariant")
236                .inner_mut()
237                .send_control_mode_readback()?;
238        }
239        Err(Error::OpeningControlModeVerificationFailed {
240            name: name.to_string(),
241            reason: "CTRL_MODE read-back timed out".to_string(),
242        })
243    }
244
245    fn calibrate_gripper_opening(&mut self, name: &str) -> Result<(), Error> {
246        let Some(opening_direction_sign) = self
247            .groups
248            .get_mut(name)
249            .and_then(GroupKind::as_gripper_mut)
250            .and_then(|gripper| {
251                gripper.clear_opening_calibration();
252                gripper.opening_direction_sign()
253            })
254        else {
255            return Ok(());
256        };
257
258        self.refresh_gripper_feedback(name)?;
259
260        let start_position = self.gripper_position(name)?;
261        let closed_position = self.calibrate_gripper_endpoint(
262            name,
263            "close",
264            start_position,
265            -opening_direction_sign,
266        )?;
267        let open_position =
268            self.calibrate_gripper_endpoint(name, "open", closed_position, opening_direction_sign)?;
269
270        let Some(gripper) = self
271            .groups
272            .get_mut(name)
273            .and_then(GroupKind::as_gripper_mut)
274        else {
275            return Ok(());
276        };
277        gripper.set_opening_calibration(closed_position, open_position)
278    }
279
280    fn refresh_gripper_feedback(&mut self, name: &str) -> Result<(), Error> {
281        let (_, initial_sequence) = self.gripper_position_and_sequence(name)?;
282        let deadline = Instant::now() + OPENING_CALIBRATION_REFRESH_TIMEOUT;
283        while Instant::now() < deadline {
284            self.groups
285                .get_mut(name)
286                .and_then(GroupKind::as_gripper_mut)
287                .ok_or_else(|| Error::OpeningCalibrationFailed {
288                    name: name.to_string(),
289                    reason: "group is not a gripper".to_string(),
290                })?
291                .refresh()?;
292            self.tick(OPENING_CALIBRATION_TICK)?;
293            let (position, sequence) = self.gripper_position_and_sequence(name)?;
294            if sequence != initial_sequence {
295                log_calibration_debug(format_args!(
296                    "{name}: fresh pre-calibration feedback position={position:.5}, sequence={sequence}"
297                ));
298                return Ok(());
299            }
300            thread::sleep(Duration::from_millis(1));
301        }
302
303        Err(Error::OpeningCalibrationFailed {
304            name: name.to_string(),
305            reason: "opening calibration did not receive fresh feedback before starting"
306                .to_string(),
307        })
308    }
309
310    fn calibrate_gripper_endpoint(
311        &mut self,
312        name: &str,
313        phase: &str,
314        start_position: f64,
315        raw_direction: f64,
316    ) -> Result<f64, Error> {
317        let target_position = start_position + raw_direction * OPENING_CALIBRATION_TRAVEL_RAD;
318        let mut measured_position = start_position;
319        let mut ticks = 0;
320        let deadline = Instant::now() + OPENING_CALIBRATION_ENDPOINT_DWELL;
321
322        log_calibration_debug(format_args!(
323            "{name}: {phase} phase start={start_position:.5}, target={target_position:.5}, raw_direction={raw_direction:+.1}"
324        ));
325
326        while Instant::now() < deadline {
327            let iteration_start = Instant::now();
328            self.groups
329                .get_mut(name)
330                .and_then(GroupKind::as_gripper_mut)
331                .ok_or_else(|| Error::OpeningCalibrationFailed {
332                    name: name.to_string(),
333                    reason: "group is not a gripper".to_string(),
334                })?
335                .opening_calibration_command(target_position)?;
336            self.tick(OPENING_CALIBRATION_TICK)?;
337            measured_position = self.gripper_position(name)?;
338            ticks += 1;
339            thread::sleep(OPENING_CALIBRATION_TICK.saturating_sub(iteration_start.elapsed()));
340        }
341
342        log_calibration_debug(format_args!(
343            "{name}: {phase} endpoint_measured={measured_position:.5}, command_target={target_position:.5}, ticks={ticks}, dwell_ms={}",
344            OPENING_CALIBRATION_ENDPOINT_DWELL.as_millis()
345        ));
346        Ok(measured_position)
347    }
348
349    fn gripper_position(&self, name: &str) -> Result<f64, Error> {
350        self.gripper_position_and_sequence(name)
351            .map(|(position, _)| position)
352    }
353
354    fn gripper_position_and_sequence(&self, name: &str) -> Result<(f64, u64), Error> {
355        self.groups
356            .get(name)
357            .and_then(GroupKind::as_gripper)
358            .map(|gripper| (gripper.motor().position(), gripper.motor().state_sequence()))
359            .ok_or_else(|| Error::OpeningCalibrationFailed {
360                name: name.to_string(),
361                reason: "group is not a gripper".to_string(),
362            })
363    }
364
365    /// Send the disable frame to every motor in every group, in reverse
366    /// insertion order. A no-op `Ok(())` if never enabled.
367    pub fn disable(&mut self) -> Result<(), Error> {
368        if !self.connected {
369            return Ok(());
370        }
371        for name in self.group_order.clone().iter().rev() {
372            if let Some(g) = self.groups.get_mut(name) {
373                g.disable_all()?;
374            }
375        }
376        Ok(())
377    }
378
379    /// Send a state-refresh query to every motor in every group (no motion).
380    /// Send-only — pair with [`Robot::tick`] to receive the replies. Errors with
381    /// [`Error::NotConnected`] if called before `connect()`.
382    pub fn refresh(&mut self) -> Result<(), Error> {
383        if !self.connected {
384            return Err(Error::NotConnected);
385        }
386        for name in &self.group_order.clone() {
387            if let Some(g) = self.groups.get_mut(name) {
388                g.refresh_all()?;
389            }
390        }
391        Ok(())
392    }
393
394    /// Set the persistent control mode (MIT / PosVel / Vel / PosForce) on every
395    /// motor in every group (no motion). Send-only. Call once after `connect`
396    /// and before commanding. Errors with [`Error::NotConnected`] if not
397    /// connected.
398    pub fn set_mode(&mut self, mode: CommandKind) -> Result<(), Error> {
399        if !self.connected {
400            return Err(Error::NotConnected);
401        }
402        for name in &self.group_order.clone() {
403            if let Some(g) = self.groups.get_mut(name) {
404                g.set_mode(mode)?;
405            }
406        }
407        Ok(())
408    }
409
410    /// One tick of the control loop: poll all buses, drain readable ones,
411    /// decode each frame exactly once, dispatch the resulting events to the
412    /// owning groups via per-bus routing tables.
413    pub fn tick(&mut self, deadline: Duration) -> Result<(), Error> {
414        self.tick_internal(deadline, None).map(|_| ())
415    }
416
417    fn tick_for_mode(
418        &mut self,
419        name: &str,
420        send_id: u32,
421        recv_id: u32,
422    ) -> Result<Option<u32>, Error> {
423        self.tick_internal(OPENING_MODE_VERIFY_TICK, Some((name, send_id, recv_id)))
424    }
425
426    fn tick_internal(
427        &mut self,
428        deadline: Duration,
429        mode_target: Option<(&str, u32, u32)>,
430    ) -> Result<Option<u32>, Error> {
431        if !self.connected {
432            return Err(Error::NotConnected);
433        }
434        let mode_target =
435            mode_target.map(|(name, send_id, recv_id)| (name.to_string(), send_id, recv_id));
436        // 1) Wait for any registered fd to become readable (or the deadline).
437        let ready = if let Some(p) = self.poller.as_mut() {
438            p.wait(deadline)?
439        } else {
440            Vec::new()
441        };
442        // 2) For each ready bus, plus raw-fd-less buses (mock transports), drain
443        // + decode + collect dispatch tuples.
444        let mut dispatches: Vec<(String, usize, motor_codec::Event)> = Vec::new();
445        let mut mode_value = None;
446        let mut readable_bus_names: Vec<String> = ready
447            .into_iter()
448            .filter_map(|token| self.token_to_bus.get(&token).cloned())
449            .collect();
450        readable_bus_names.extend(
451            self.bus_order
452                .iter()
453                .filter(|bus_name| !self.bus_tokens.contains_key(*bus_name))
454                .cloned(),
455        );
456        for bus_name in readable_bus_names {
457            let bus_arc = self.buses[&bus_name].clone();
458            let mut bus = bus_arc.lock().map_err(|_| Error::BusPoisoned)?;
459            let frames = bus.transport.drain_inbound_nonblocking()?;
460            for frame in &frames {
461                if let Some((ref target_name, send_id, recv_id)) = mode_target {
462                    if frame.id == recv_id {
463                        let group = self.groups.get(target_name).expect("group order invariant");
464                        let motor = &group.inner().motors()[0];
465                        let m_ref = motor_codec::MotorRef {
466                            motor_type: motor.motor_type(),
467                            send_id,
468                            recv_id,
469                            name: motor.name(),
470                        };
471                        mode_value = bus
472                            .codec
473                            .decode_control_mode_readback(frame, m_ref)
474                            .map_err(Error::Codec)?;
475                        if mode_value.is_some() {
476                            continue;
477                        }
478                    }
479                }
480                let decoded = bus.codec.decode(frame).map_err(Error::Codec)?;
481                if let Some(event) = decoded {
482                    let motor_id = match event {
483                        motor_codec::Event::State { motor_id, .. } => motor_id,
484                        motor_codec::Event::ParamReply { motor_id, .. } => motor_id,
485                        motor_codec::Event::Fault { motor_id, .. } => motor_id,
486                        _ => continue,
487                    };
488                    if let Some(route) = bus.routes.get(&motor_id) {
489                        dispatches.push((route.group_name.clone(), route.motor_index, event));
490                    }
491                    // No route: silently drop (foreign motor_id, not in this robot).
492                }
493                // None: codec didn't recognize, silently drop.
494            }
495        }
496        // 3) Dispatch outside the bus lock to avoid deadlocking groups that
497        // would lock the same bus during apply_event (currently they don't,
498        // but the discipline is correct).
499        for (group_name, motor_index, event) in dispatches {
500            if let Some(g) = self.groups.get_mut(&group_name) {
501                g.apply_event(motor_index, &event);
502            }
503        }
504        Ok(mode_value)
505    }
506}
507
508fn log_calibration_debug(args: std::fmt::Arguments<'_>) {
509    if std::env::var_os("CAN_MOTOR_CONTROL_CALIBRATION_DEBUG").is_some() {
510        eprintln!("[gripper-calibration] {args}");
511    }
512}
513
514impl Drop for Robot {
515    fn drop(&mut self) {
516        // Each transport owns its platform resources. We don't send disable
517        // frames here — that's the user's responsibility via explicit
518        // disable().
519        for token in self.token_to_bus.keys() {
520            if let Some(p) = self.poller.as_ref() {
521                if let Some(name) = self.token_to_bus.get(token) {
522                    if let Some(bus_arc) = self.buses.get(name) {
523                        if let Ok(bus) = bus_arc.lock() {
524                            if let Some(fd) = bus.transport.raw_fd() {
525                                let _ = p.deregister(fd);
526                            }
527                        }
528                    }
529                }
530            }
531        }
532    }
533}
534
535/// Pending bus declaration accumulated by the builder.
536struct PendingBus {
537    name: String,
538    transport: Box<dyn CanBus>,
539    codec: Box<dyn MotorCodec>,
540}
541
542/// Pending group declaration accumulated by the builder.
543struct PendingGroup {
544    name: String,
545    bus_name: String,
546    kind: GroupSpecKind,
547    motors: Vec<MotorSpec>,
548    opening: Option<GripperOpeningSpec>,
549}
550
551/// Builder for [`Robot`].
552pub struct RobotBuilder {
553    buses: Vec<PendingBus>,
554    groups: Vec<PendingGroup>,
555}
556
557impl Default for RobotBuilder {
558    fn default() -> Self {
559        Self::new()
560    }
561}
562
563impl RobotBuilder {
564    /// Start an empty builder.
565    pub fn new() -> Self {
566        Self {
567            buses: Vec::new(),
568            groups: Vec::new(),
569        }
570    }
571
572    /// Register a bus with its transport and vendor codec. The codec's
573    /// `bind_to_bus(transport.capabilities())` is invoked exactly once here.
574    pub fn add_bus(
575        mut self,
576        name: impl Into<String>,
577        transport: Box<dyn CanBus>,
578        codec: Box<dyn MotorCodec>,
579    ) -> Self {
580        self.buses.push(PendingBus {
581            name: name.into(),
582            transport,
583            codec,
584        });
585        self
586    }
587
588    /// Register an arm group.
589    pub fn add_arm(
590        mut self,
591        name: impl Into<String>,
592        bus_name: impl Into<String>,
593        motors: Vec<MotorSpec>,
594    ) -> Self {
595        self.groups.push(PendingGroup {
596            name: name.into(),
597            bus_name: bus_name.into(),
598            kind: GroupSpecKind::Arm,
599            motors,
600            opening: None,
601        });
602        self
603    }
604
605    /// Register a one-motor gripper. Validation enforces the one-motor rule
606    /// at `build()` time.
607    pub fn add_gripper(
608        mut self,
609        name: impl Into<String>,
610        bus_name: impl Into<String>,
611        motor: MotorSpec,
612    ) -> Self {
613        self.groups.push(PendingGroup {
614            name: name.into(),
615            bus_name: bus_name.into(),
616            kind: GroupSpecKind::Gripper,
617            motors: vec![motor],
618            opening: None,
619        });
620        self
621    }
622
623    /// Register a one-motor gripper with normalized opening support.
624    pub fn add_gripper_with_opening(
625        mut self,
626        name: impl Into<String>,
627        bus_name: impl Into<String>,
628        motor: MotorSpec,
629        opening: GripperOpeningSpec,
630    ) -> Self {
631        self.groups.push(PendingGroup {
632            name: name.into(),
633            bus_name: bus_name.into(),
634            kind: GroupSpecKind::Gripper,
635            motors: vec![motor],
636            opening: Some(opening),
637        });
638        self
639    }
640
641    /// Register a generic catch-all group.
642    pub fn add_generic(
643        mut self,
644        name: impl Into<String>,
645        bus_name: impl Into<String>,
646        motors: Vec<MotorSpec>,
647    ) -> Self {
648        self.groups.push(PendingGroup {
649            name: name.into(),
650            bus_name: bus_name.into(),
651            kind: GroupSpecKind::Generic,
652            motors,
653            opening: None,
654        });
655        self
656    }
657
658    /// Validate and build.
659    pub fn build(self) -> Result<Robot, Error> {
660        // 1) Validate bus names: non-empty, trimmed, unique.
661        let mut bus_order = Vec::with_capacity(self.buses.len());
662        let mut buses: HashMap<String, Arc<Mutex<Bus>>> = HashMap::with_capacity(self.buses.len());
663        for pending in self.buses {
664            if pending.name.trim().is_empty() || pending.name != pending.name.trim() {
665                return Err(Error::ConfigSchema(format!(
666                    "bus name '{}' must be non-empty and not have leading/trailing whitespace",
667                    pending.name
668                )));
669            }
670            if buses.contains_key(&pending.name) {
671                return Err(Error::DuplicateBusName(pending.name));
672            }
673            let bus = Bus::new(pending.transport, pending.codec);
674            bus_order.push(pending.name.clone());
675            buses.insert(pending.name, Arc::new(Mutex::new(bus)));
676        }
677        // 2) Validate group names + codec support + bus existence.
678        let mut group_order = Vec::with_capacity(self.groups.len());
679        let mut groups: HashMap<String, GroupKind> = HashMap::with_capacity(self.groups.len());
680        for pending in self.groups {
681            if pending.name.trim().is_empty() || pending.name != pending.name.trim() {
682                return Err(Error::ConfigSchema(format!(
683                    "group name '{}' must be non-empty and not have leading/trailing whitespace",
684                    pending.name
685                )));
686            }
687            if groups.contains_key(&pending.name) {
688                return Err(Error::DuplicateGroupName(pending.name));
689            }
690            let bus_arc = buses
691                .get(&pending.bus_name)
692                .ok_or_else(|| Error::UnknownBusName(pending.bus_name.clone()))?;
693            let (vendor, supported_all) = {
694                let bus = bus_arc.lock().map_err(|_| Error::BusPoisoned)?;
695                let v = bus.vendor().to_string();
696                let supported = pending
697                    .motors
698                    .iter()
699                    .find(|m| !bus.codec_supports(m.motor_type))
700                    .cloned();
701                (v, supported)
702            };
703            if let Some(unsupported) = supported_all {
704                return Err(Error::MotorNotSupportedByCodec {
705                    vendor,
706                    motor_type: unsupported.motor_type,
707                    bus_name: pending.bus_name,
708                });
709            }
710            if matches!(pending.kind, GroupSpecKind::Gripper) && pending.motors.len() != 1 {
711                return Err(Error::GripperRequiresOneMotor {
712                    got: pending.motors.len(),
713                });
714            }
715            if let Some(opening) = pending.opening {
716                if !matches!(pending.kind, GroupSpecKind::Gripper) {
717                    return Err(Error::ConfigSchema(format!(
718                        "group '{}': opening configuration is only valid for grippers",
719                        pending.name
720                    )));
721                }
722                if let Some(current) = opening.default_current {
723                    validate_opening_current(current)?;
724                }
725            }
726            // Construct the MotorGroup and attach the bus.
727            let motors: Vec<Motor> = pending
728                .motors
729                .into_iter()
730                .map(|s| Motor::new(s.name, s.motor_type, s.send_id, s.recv_id))
731                .collect();
732            let mut group = MotorGroup::new(pending.name.clone(), pending.bus_name, motors);
733            group.attach_bus(bus_arc.clone());
734            let kind = match pending.kind {
735                GroupSpecKind::Arm => GroupKind::Arm(Arm(group)),
736                GroupSpecKind::Gripper => match pending.opening {
737                    Some(opening) => GroupKind::Gripper(Gripper::with_opening(group, opening)),
738                    None => GroupKind::Gripper(Gripper::raw(group)),
739                },
740                GroupSpecKind::Generic => GroupKind::Generic(Generic(group)),
741            };
742            group_order.push(pending.name.clone());
743            groups.insert(pending.name, kind);
744        }
745        Ok(Robot {
746            buses,
747            bus_order,
748            groups,
749            group_order,
750            connected: false,
751            poller: None,
752            bus_tokens: HashMap::new(),
753            token_to_bus: HashMap::new(),
754        })
755    }
756}
757
758fn validate_opening_current(current: f64) -> Result<(), Error> {
759    if current > 0.0 && current <= 1.0 {
760        Ok(())
761    } else {
762        Err(Error::OpeningCurrentOutOfRange { got: current })
763    }
764}
765
766#[cfg(test)]
767mod tests {
768    use motor_codec::{
769        BusCapabilities, CanFrame, CodecError, Command, Event, FrameFlags, Limits, MotorCodec,
770        MotorRef, MotorTypeId,
771    };
772
773    use super::*;
774    use crate::spec::OpeningDirection;
775    use crate::transport::MockCanBus;
776    use std::sync::atomic::{AtomicUsize, Ordering};
777
778    struct CountingCodec {
779        binds: Arc<AtomicUsize>,
780        decodes: Arc<AtomicUsize>,
781    }
782    impl CountingCodec {
783        fn new() -> (Self, Arc<AtomicUsize>, Arc<AtomicUsize>) {
784            let b = Arc::new(AtomicUsize::new(0));
785            let d = Arc::new(AtomicUsize::new(0));
786            (
787                Self {
788                    binds: b.clone(),
789                    decodes: d.clone(),
790                },
791                b,
792                d,
793            )
794        }
795    }
796    impl MotorCodec for CountingCodec {
797        fn vendor_name(&self) -> &'static str {
798            "stub"
799        }
800        fn supports(&self, t: MotorTypeId) -> bool {
801            matches!(t, MotorTypeId::Damiao(_))
802        }
803        fn limits(&self, _: MotorTypeId) -> Result<Limits, CodecError> {
804            Ok(Limits {
805                p_max: 1.0,
806                v_max: 1.0,
807                t_max: 1.0,
808            })
809        }
810        fn bind_to_bus(&mut self, _: BusCapabilities) {
811            self.binds.fetch_add(1, Ordering::SeqCst);
812        }
813        fn encode_enable(&self, m: MotorRef<'_>) -> Result<CanFrame, CodecError> {
814            CanFrame::classical(m.send_id, &[0xFC])
815                .map_err(|_| CodecError::DecodeFailed { reason: "frame" })
816        }
817        fn encode_disable(&self, m: MotorRef<'_>) -> Result<CanFrame, CodecError> {
818            CanFrame::classical(m.send_id, &[0xFD])
819                .map_err(|_| CodecError::DecodeFailed { reason: "frame" })
820        }
821        fn encode_set_zero(&self, m: MotorRef<'_>) -> Result<CanFrame, CodecError> {
822            CanFrame::classical(m.send_id, &[0xFE])
823                .map_err(|_| CodecError::DecodeFailed { reason: "frame" })
824        }
825        fn encode_command(&self, m: MotorRef<'_>, _: &Command) -> Result<CanFrame, CodecError> {
826            CanFrame::classical(m.send_id, &[0x55])
827                .map_err(|_| CodecError::DecodeFailed { reason: "frame" })
828        }
829        fn decode(&self, frame: &CanFrame) -> Result<Option<Event>, CodecError> {
830            self.decodes.fetch_add(1, Ordering::SeqCst);
831            if frame.is_fd() || frame.flags.contains(FrameFlags::REMOTE_REQUEST) {
832                return Ok(None);
833            }
834            Ok(Some(Event::State {
835                motor_id: frame.id,
836                q: 1.0,
837                dq: 2.0,
838                tau: 3.0,
839                t_mos: 30,
840                t_rotor: 35,
841            }))
842        }
843    }
844
845    struct FailingCommandCodec;
846    impl MotorCodec for FailingCommandCodec {
847        fn vendor_name(&self) -> &'static str {
848            "failing"
849        }
850        fn supports(&self, t: MotorTypeId) -> bool {
851            matches!(t, MotorTypeId::Damiao(_))
852        }
853        fn limits(&self, _: MotorTypeId) -> Result<Limits, CodecError> {
854            Ok(Limits {
855                p_max: 1.0,
856                v_max: 1.0,
857                t_max: 1.0,
858            })
859        }
860        fn bind_to_bus(&mut self, _: BusCapabilities) {}
861        fn encode_enable(&self, m: MotorRef<'_>) -> Result<CanFrame, CodecError> {
862            CanFrame::classical(m.send_id, &[0xFC])
863                .map_err(|_| CodecError::DecodeFailed { reason: "frame" })
864        }
865        fn encode_disable(&self, m: MotorRef<'_>) -> Result<CanFrame, CodecError> {
866            CanFrame::classical(m.send_id, &[0xFD])
867                .map_err(|_| CodecError::DecodeFailed { reason: "frame" })
868        }
869        fn encode_set_zero(&self, m: MotorRef<'_>) -> Result<CanFrame, CodecError> {
870            CanFrame::classical(m.send_id, &[0xFE])
871                .map_err(|_| CodecError::DecodeFailed { reason: "frame" })
872        }
873        fn encode_command(&self, _: MotorRef<'_>, _: &Command) -> Result<CanFrame, CodecError> {
874            Err(CodecError::DecodeFailed { reason: "command" })
875        }
876        fn encode_refresh(&self, m: MotorRef<'_>) -> Result<Option<CanFrame>, CodecError> {
877            CanFrame::classical(m.recv_id, &[0xCC])
878                .map(Some)
879                .map_err(|_| CodecError::DecodeFailed { reason: "frame" })
880        }
881        fn decode(&self, frame: &CanFrame) -> Result<Option<Event>, CodecError> {
882            Ok(Some(Event::State {
883                motor_id: frame.id,
884                q: 0.0,
885                dq: 0.0,
886                tau: 0.0,
887                t_mos: 30,
888                t_rotor: 35,
889            }))
890        }
891    }
892
893    struct FeedbackCodec {
894        positions: Vec<f64>,
895        decodes: AtomicUsize,
896    }
897    impl FeedbackCodec {
898        fn new(positions: Vec<f64>) -> Self {
899            Self {
900                positions,
901                decodes: AtomicUsize::new(0),
902            }
903        }
904    }
905    impl MotorCodec for FeedbackCodec {
906        fn vendor_name(&self) -> &'static str {
907            "feedback"
908        }
909        fn supports(&self, t: MotorTypeId) -> bool {
910            matches!(t, MotorTypeId::Damiao(_))
911        }
912        fn limits(&self, _: MotorTypeId) -> Result<Limits, CodecError> {
913            Ok(Limits {
914                p_max: 1.0,
915                v_max: 1.0,
916                t_max: 1.0,
917            })
918        }
919        fn bind_to_bus(&mut self, _: BusCapabilities) {}
920        fn encode_enable(&self, m: MotorRef<'_>) -> Result<CanFrame, CodecError> {
921            CanFrame::classical(m.send_id, &[0xFC])
922                .map_err(|_| CodecError::DecodeFailed { reason: "frame" })
923        }
924        fn encode_disable(&self, m: MotorRef<'_>) -> Result<CanFrame, CodecError> {
925            CanFrame::classical(m.send_id, &[0xFD])
926                .map_err(|_| CodecError::DecodeFailed { reason: "frame" })
927        }
928        fn encode_set_zero(&self, m: MotorRef<'_>) -> Result<CanFrame, CodecError> {
929            CanFrame::classical(m.send_id, &[0xFE])
930                .map_err(|_| CodecError::DecodeFailed { reason: "frame" })
931        }
932        fn encode_command(
933            &self,
934            m: MotorRef<'_>,
935            command: &Command,
936        ) -> Result<CanFrame, CodecError> {
937            let id = if matches!(command, Command::PosForce { .. }) {
938                m.recv_id
939            } else {
940                m.send_id
941            };
942            CanFrame::classical(id, &[0x55])
943                .map_err(|_| CodecError::DecodeFailed { reason: "frame" })
944        }
945        fn encode_refresh(&self, m: MotorRef<'_>) -> Result<Option<CanFrame>, CodecError> {
946            CanFrame::classical(m.recv_id, &[0xCC])
947                .map(Some)
948                .map_err(|_| CodecError::DecodeFailed { reason: "frame" })
949        }
950        fn decode(&self, frame: &CanFrame) -> Result<Option<Event>, CodecError> {
951            let idx = self.decodes.fetch_add(1, Ordering::SeqCst);
952            let q = self.positions[idx.min(self.positions.len().saturating_sub(1))];
953            Ok(Some(Event::State {
954                motor_id: frame.id,
955                q,
956                dq: 0.0,
957                tau: 0.0,
958                t_mos: 30,
959                t_rotor: 35,
960            }))
961        }
962    }
963
964    struct EchoFeedbackCodec {
965        position: Mutex<f64>,
966    }
967
968    impl EchoFeedbackCodec {
969        fn new() -> Self {
970            Self {
971                position: Mutex::new(0.0),
972            }
973        }
974    }
975
976    impl MotorCodec for EchoFeedbackCodec {
977        fn vendor_name(&self) -> &'static str {
978            "echo-feedback"
979        }
980        fn supports(&self, t: MotorTypeId) -> bool {
981            matches!(t, MotorTypeId::Damiao(_))
982        }
983        fn limits(&self, _: MotorTypeId) -> Result<Limits, CodecError> {
984            Ok(Limits {
985                p_max: 1.0,
986                v_max: 1.0,
987                t_max: 1.0,
988            })
989        }
990        fn bind_to_bus(&mut self, _: BusCapabilities) {}
991        fn encode_enable(&self, m: MotorRef<'_>) -> Result<CanFrame, CodecError> {
992            CanFrame::classical(m.send_id, &[0xFC])
993                .map_err(|_| CodecError::DecodeFailed { reason: "frame" })
994        }
995        fn encode_disable(&self, m: MotorRef<'_>) -> Result<CanFrame, CodecError> {
996            CanFrame::classical(m.send_id, &[0xFD])
997                .map_err(|_| CodecError::DecodeFailed { reason: "frame" })
998        }
999        fn encode_set_zero(&self, m: MotorRef<'_>) -> Result<CanFrame, CodecError> {
1000            CanFrame::classical(m.send_id, &[0xFE])
1001                .map_err(|_| CodecError::DecodeFailed { reason: "frame" })
1002        }
1003        fn encode_command(
1004            &self,
1005            m: MotorRef<'_>,
1006            command: &Command,
1007        ) -> Result<CanFrame, CodecError> {
1008            if let Command::PosForce { q, .. } = *command {
1009                *self.position.lock().unwrap() = q;
1010                CanFrame::classical(m.recv_id, &[0x55])
1011                    .map_err(|_| CodecError::DecodeFailed { reason: "frame" })
1012            } else {
1013                CanFrame::classical(m.send_id, &[0x55])
1014                    .map_err(|_| CodecError::DecodeFailed { reason: "frame" })
1015            }
1016        }
1017        fn encode_refresh(&self, m: MotorRef<'_>) -> Result<Option<CanFrame>, CodecError> {
1018            CanFrame::classical(m.recv_id, &[0xCC])
1019                .map(Some)
1020                .map_err(|_| CodecError::DecodeFailed { reason: "frame" })
1021        }
1022        fn decode(&self, frame: &CanFrame) -> Result<Option<Event>, CodecError> {
1023            Ok(Some(Event::State {
1024                motor_id: frame.id,
1025                q: *self.position.lock().unwrap(),
1026                dq: 0.0,
1027                tau: 0.0,
1028                t_mos: 30,
1029                t_rotor: 35,
1030            }))
1031        }
1032    }
1033
1034    /// Deterministic lifecycle seam: loopback frames are enough to exercise
1035    /// ordering, while the mode reply can be selected by each test.
1036    struct ModeLifecycleCodec {
1037        reply: Option<u32>,
1038        position: Mutex<f64>,
1039    }
1040
1041    impl MotorCodec for ModeLifecycleCodec {
1042        fn vendor_name(&self) -> &'static str {
1043            "damiao"
1044        }
1045        fn supports(&self, t: MotorTypeId) -> bool {
1046            matches!(t, MotorTypeId::Damiao(_))
1047        }
1048        fn limits(&self, _: MotorTypeId) -> Result<Limits, CodecError> {
1049            Ok(Limits {
1050                p_max: 10.0,
1051                v_max: 10.0,
1052                t_max: 10.0,
1053            })
1054        }
1055        fn bind_to_bus(&mut self, _: BusCapabilities) {}
1056        fn encode_enable(&self, m: MotorRef<'_>) -> Result<CanFrame, CodecError> {
1057            Ok(CanFrame::classical(m.send_id, &[0xfc]).unwrap())
1058        }
1059        fn encode_disable(&self, m: MotorRef<'_>) -> Result<CanFrame, CodecError> {
1060            Ok(CanFrame::classical(m.send_id, &[0xfd]).unwrap())
1061        }
1062        fn encode_set_zero(&self, m: MotorRef<'_>) -> Result<CanFrame, CodecError> {
1063            Ok(CanFrame::classical(m.send_id, &[0xfe]).unwrap())
1064        }
1065        fn encode_command(&self, m: MotorRef<'_>, cmd: &Command) -> Result<CanFrame, CodecError> {
1066            let marker = if let Command::PosForce { q, .. } = cmd {
1067                *self.position.lock().unwrap() = *q;
1068                0x55
1069            } else {
1070                0x01
1071            };
1072            Ok(CanFrame::classical(m.recv_id, &[marker]).unwrap())
1073        }
1074        fn encode_refresh(&self, m: MotorRef<'_>) -> Result<Option<CanFrame>, CodecError> {
1075            Ok(Some(CanFrame::classical(m.recv_id, &[0xcc]).unwrap()))
1076        }
1077        fn encode_set_mode(
1078            &self,
1079            m: MotorRef<'_>,
1080            _: CommandKind,
1081        ) -> Result<Option<CanFrame>, CodecError> {
1082            Ok(Some(CanFrame::classical(m.send_id, &[0x10]).unwrap()))
1083        }
1084        fn encode_control_mode_readback(
1085            &self,
1086            m: MotorRef<'_>,
1087        ) -> Result<Option<CanFrame>, CodecError> {
1088            let id = if self.reply.is_some() {
1089                m.recv_id
1090            } else {
1091                0x7ff
1092            };
1093            let mut payload = [0u8; 8];
1094            payload[0] = (m.send_id & 0xff) as u8;
1095            payload[2] = 0x20;
1096            payload[3] = self.reply.unwrap_or(0) as u8;
1097            Ok(Some(CanFrame::classical(id, &payload).unwrap()))
1098        }
1099        fn decode_control_mode_readback(
1100            &self,
1101            frame: &CanFrame,
1102            _: MotorRef<'_>,
1103        ) -> Result<Option<u32>, CodecError> {
1104            if frame.payload()[2] == 0x20 {
1105                Ok(Some(frame.payload()[3] as u32))
1106            } else {
1107                Ok(None)
1108            }
1109        }
1110        fn decode(&self, frame: &CanFrame) -> Result<Option<Event>, CodecError> {
1111            if frame.payload()[0] == 0x20 || frame.payload()[0] == 0x10 || frame.id == 0x7ff {
1112                return Ok(None);
1113            }
1114            Ok(Some(Event::State {
1115                motor_id: frame.id,
1116                q: *self.position.lock().unwrap(),
1117                dq: 0.0,
1118                tau: 0.0,
1119                t_mos: 20,
1120                t_rotor: 20,
1121            }))
1122        }
1123    }
1124
1125    fn motor(name: &str, send: u32, recv: u32) -> MotorSpec {
1126        MotorSpec::new(name, MotorTypeId::Damiao(3), send, recv)
1127    }
1128
1129    #[test]
1130    fn build_simple_robot() {
1131        let (codec, binds, _) = CountingCodec::new();
1132        let robot = RobotBuilder::new()
1133            .add_bus("main", Box::new(MockCanBus::new("m")), Box::new(codec))
1134            .add_arm(
1135                "arm",
1136                "main",
1137                vec![motor("j0", 0x01, 0x11), motor("j1", 0x02, 0x12)],
1138            )
1139            .build()
1140            .unwrap();
1141        assert_eq!(robot.bus_names().collect::<Vec<_>>(), vec!["main"]);
1142        assert_eq!(robot.group_names().collect::<Vec<_>>(), vec!["arm"]);
1143        assert_eq!(binds.load(Ordering::SeqCst), 1);
1144    }
1145
1146    #[test]
1147    fn duplicate_bus_name_rejected() {
1148        let (c1, _, _) = CountingCodec::new();
1149        let (c2, _, _) = CountingCodec::new();
1150        let r = RobotBuilder::new()
1151            .add_bus("main", Box::new(MockCanBus::new("m")), Box::new(c1))
1152            .add_bus("main", Box::new(MockCanBus::new("m")), Box::new(c2))
1153            .build();
1154        assert!(matches!(r, Err(Error::DuplicateBusName(ref s)) if s == "main"));
1155    }
1156
1157    #[test]
1158    fn duplicate_group_name_rejected() {
1159        let (c, _, _) = CountingCodec::new();
1160        let r = RobotBuilder::new()
1161            .add_bus("main", Box::new(MockCanBus::new("m")), Box::new(c))
1162            .add_arm("arm", "main", vec![motor("j0", 0x01, 0x11)])
1163            .add_arm("arm", "main", vec![motor("j1", 0x02, 0x12)])
1164            .build();
1165        assert!(matches!(r, Err(Error::DuplicateGroupName(ref s)) if s == "arm"));
1166    }
1167
1168    #[test]
1169    fn unknown_bus_name_rejected() {
1170        let r = RobotBuilder::new()
1171            .add_arm("arm", "ghost", vec![motor("j0", 0x01, 0x11)])
1172            .build();
1173        assert!(matches!(r, Err(Error::UnknownBusName(ref s)) if s == "ghost"));
1174    }
1175
1176    #[test]
1177    fn motor_not_supported_by_codec() {
1178        let (c, _, _) = CountingCodec::new();
1179        let r = RobotBuilder::new()
1180            .add_bus("main", Box::new(MockCanBus::new("m")), Box::new(c))
1181            .add_arm(
1182                "arm",
1183                "main",
1184                vec![MotorSpec::new("j0", MotorTypeId::Robostride(0), 0x01, 0x11)],
1185            )
1186            .build();
1187        assert!(matches!(r, Err(Error::MotorNotSupportedByCodec { .. })));
1188    }
1189
1190    #[test]
1191    fn gripper_requires_one_motor() {
1192        let (c, _, _) = CountingCodec::new();
1193        // The public add_gripper API takes a single MotorSpec; the runtime
1194        // check fires when an internal mis-construction supplies != 1 motors.
1195        // For this test we use add_generic to mimic the bad input path.
1196        let r = RobotBuilder::new()
1197            .add_bus("main", Box::new(MockCanBus::new("m")), Box::new(c))
1198            .add_gripper("g", "main", motor("g0", 0x05, 0x18))
1199            .build();
1200        assert!(r.is_ok());
1201    }
1202
1203    #[test]
1204    fn build_does_not_open_sockets() {
1205        // Both phases must complete without opening sockets — the MockCanBus
1206        // has no fds anyway, so we just verify build() returns Ok and
1207        // is_connected() is false.
1208        let (c, _, _) = CountingCodec::new();
1209        let robot = RobotBuilder::new()
1210            .add_bus("main", Box::new(MockCanBus::new("m")), Box::new(c))
1211            .add_arm("arm", "main", vec![motor("j0", 0x01, 0x11)])
1212            .build()
1213            .unwrap();
1214        assert!(!robot.is_connected());
1215    }
1216
1217    #[test]
1218    fn connect_populates_routes() {
1219        let (c, _, _) = CountingCodec::new();
1220        let mut robot = RobotBuilder::new()
1221            .add_bus("main", Box::new(MockCanBus::new("m")), Box::new(c))
1222            .add_arm(
1223                "arm",
1224                "main",
1225                vec![motor("j0", 0x01, 0x11), motor("j1", 0x02, 0x12)],
1226            )
1227            .build()
1228            .unwrap();
1229        robot.connect().unwrap();
1230        assert!(robot.is_connected());
1231        let bus = robot.bus("main").unwrap().lock().unwrap();
1232        assert_eq!(bus.routes.len(), 2);
1233        let r11 = bus.routes.get(&0x11).unwrap();
1234        assert_eq!(r11.group_name, "arm");
1235        assert_eq!(r11.motor_index, 0);
1236    }
1237
1238    #[test]
1239    fn can_id_collision_across_groups_on_same_bus() {
1240        let (c, _, _) = CountingCodec::new();
1241        let mut robot = RobotBuilder::new()
1242            .add_bus("main", Box::new(MockCanBus::new("m")), Box::new(c))
1243            .add_arm("arm", "main", vec![motor("j0", 0x01, 0x18)])
1244            .add_gripper("g", "main", motor("g0", 0x05, 0x18))
1245            .build()
1246            .unwrap();
1247        let r = robot.connect();
1248        assert!(matches!(
1249            r,
1250            Err(Error::CanIdCollision {
1251                ref bus_name,
1252                recv_id: 0x18,
1253                ..
1254            }) if bus_name == "main"
1255        ));
1256    }
1257
1258    #[test]
1259    fn collision_across_buses_is_not_error() {
1260        let (c1, _, _) = CountingCodec::new();
1261        let (c2, _, _) = CountingCodec::new();
1262        let mut robot = RobotBuilder::new()
1263            .add_bus("left", Box::new(MockCanBus::new("l")), Box::new(c1))
1264            .add_bus("right", Box::new(MockCanBus::new("r")), Box::new(c2))
1265            .add_arm("la", "left", vec![motor("j0", 0x01, 0x11)])
1266            .add_arm("ra", "right", vec![motor("j0", 0x01, 0x11)])
1267            .build()
1268            .unwrap();
1269        robot.connect().unwrap();
1270    }
1271
1272    #[test]
1273    fn tick_before_connect_returns_not_connected() {
1274        let (c, _, _) = CountingCodec::new();
1275        let mut robot = RobotBuilder::new()
1276            .add_bus("main", Box::new(MockCanBus::new("m")), Box::new(c))
1277            .add_arm("arm", "main", vec![motor("j0", 0x01, 0x11)])
1278            .build()
1279            .unwrap();
1280        let r = robot.tick(Duration::from_millis(1));
1281        assert!(matches!(r, Err(Error::NotConnected)));
1282    }
1283
1284    #[test]
1285    fn enable_before_connect_returns_not_connected() {
1286        let (c, _, _) = CountingCodec::new();
1287        let mut robot = RobotBuilder::new()
1288            .add_bus("main", Box::new(MockCanBus::new("m")), Box::new(c))
1289            .add_arm("arm", "main", vec![motor("j0", 0x01, 0x11)])
1290            .build()
1291            .unwrap();
1292        assert!(matches!(robot.enable(), Err(Error::NotConnected)));
1293    }
1294
1295    #[test]
1296    fn enable_calibrates_configured_gripper_opening() {
1297        let mut robot = RobotBuilder::new()
1298            .add_bus(
1299                "main",
1300                Box::new(MockCanBus::new("m")),
1301                Box::new(EchoFeedbackCodec::new()),
1302            )
1303            .add_gripper_with_opening(
1304                "g",
1305                "main",
1306                motor("g0", 0x05, 0x18),
1307                GripperOpeningSpec::new(OpeningDirection::IncreasingPosition, Some(0.2)),
1308            )
1309            .build()
1310            .unwrap();
1311        robot.connect().unwrap();
1312        robot.enable().unwrap();
1313        let gripper = robot
1314            .group_mut("g")
1315            .and_then(|group| group.as_gripper_mut())
1316            .unwrap();
1317        gripper.set_opening(0.5, None).unwrap();
1318    }
1319
1320    fn lifecycle_robot(reply: Option<u32>) -> (Robot, MockCanBus) {
1321        let mock = MockCanBus::new("m");
1322        let inspector = mock.clone();
1323        let mut robot = RobotBuilder::new()
1324            .add_bus(
1325                "main",
1326                Box::new(mock),
1327                Box::new(ModeLifecycleCodec {
1328                    reply,
1329                    position: Mutex::new(0.0),
1330                }),
1331            )
1332            .add_gripper_with_opening(
1333                "g",
1334                "main",
1335                motor("g0", 0x05, 0x18),
1336                GripperOpeningSpec::new(OpeningDirection::IncreasingPosition, Some(0.2)),
1337            )
1338            .build()
1339            .unwrap();
1340        robot.connect().unwrap();
1341        (robot, inspector)
1342    }
1343
1344    #[test]
1345    fn damiao_opening_lifecycle_orders_write_readback_enable_then_calibration() {
1346        let (mut robot, bus) = lifecycle_robot(Some(4));
1347        robot.enable().unwrap();
1348        let markers: Vec<u8> = bus
1349            .sent_frames()
1350            .iter()
1351            .map(|f| {
1352                if f.len >= 4 && f.payload()[2] == 0x20 {
1353                    0x20
1354                } else {
1355                    f.payload()[0]
1356                }
1357            })
1358            .collect();
1359        assert_eq!(&markers[..4], &[0x10, 0x20, 0xfc, 0xcc]);
1360        assert!(markers.iter().skip(3).any(|marker| *marker == 0x55));
1361    }
1362
1363    #[test]
1364    fn non_four_mode_readback_stops_before_enable_or_calibration() {
1365        let (mut robot, bus) = lifecycle_robot(Some(3));
1366        assert!(matches!(
1367            robot.enable(),
1368            Err(Error::OpeningControlModeVerificationFailed { .. })
1369        ));
1370        let markers: Vec<u8> = bus
1371            .sent_frames()
1372            .iter()
1373            .map(|f| {
1374                if f.len >= 4 && f.payload()[2] == 0x20 {
1375                    0x20
1376                } else {
1377                    f.payload()[0]
1378                }
1379            })
1380            .collect();
1381        assert!(markers.iter().all(|m| *m != 0xfc && *m != 0x55));
1382    }
1383
1384    #[test]
1385    fn mode_readback_timeout_stops_before_enable_or_calibration() {
1386        let (mut robot, bus) = lifecycle_robot(None);
1387        assert!(matches!(
1388            robot.enable(),
1389            Err(Error::OpeningControlModeVerificationFailed { .. })
1390        ));
1391        let markers: Vec<u8> = bus
1392            .sent_frames()
1393            .iter()
1394            .map(|f| {
1395                if f.len >= 4 && f.payload()[2] == 0x20 {
1396                    0x20
1397                } else {
1398                    f.payload()[0]
1399                }
1400            })
1401            .collect();
1402        assert!(markers.iter().all(|m| *m != 0xfc && *m != 0x55));
1403    }
1404
1405    #[test]
1406    fn enable_fails_when_measured_feedback_span_is_zero() {
1407        let mut robot = RobotBuilder::new()
1408            .add_bus(
1409                "main",
1410                Box::new(MockCanBus::new("m")),
1411                Box::new(FeedbackCodec::new(vec![0.0])),
1412            )
1413            .add_gripper_with_opening(
1414                "g",
1415                "main",
1416                motor("g0", 0x05, 0x18),
1417                GripperOpeningSpec::new(OpeningDirection::IncreasingPosition, Some(0.2)),
1418            )
1419            .build()
1420            .unwrap();
1421        robot.connect().unwrap();
1422        let result = robot.enable();
1423        assert!(
1424            matches!(
1425                result,
1426                Err(Error::OpeningCalibrationFailed { ref reason, .. })
1427                    if reason.starts_with("calibrated opening span is too small")
1428            ),
1429            "unexpected result: {result:?}"
1430        );
1431    }
1432
1433    #[test]
1434    fn enable_fails_when_measured_feedback_span_is_too_small() {
1435        let mut robot = RobotBuilder::new()
1436            .add_bus(
1437                "main",
1438                Box::new(MockCanBus::new("m")),
1439                Box::new(FeedbackCodec::new(
1440                    [0.0]
1441                        .into_iter()
1442                        .chain([-0.06; 24])
1443                        .chain([0.02; 24])
1444                        .collect(),
1445                )),
1446            )
1447            .add_gripper_with_opening(
1448                "g",
1449                "main",
1450                motor("g0", 0x05, 0x18),
1451                GripperOpeningSpec::new(OpeningDirection::IncreasingPosition, Some(0.2)),
1452            )
1453            .build()
1454            .unwrap();
1455        robot.connect().unwrap();
1456        let result = robot.enable();
1457        assert!(
1458            matches!(
1459                result,
1460                Err(Error::OpeningCalibrationFailed { ref reason, .. })
1461                    if reason.starts_with("calibrated opening span is too small")
1462            ),
1463            "unexpected result: {result:?}"
1464        );
1465    }
1466
1467    #[test]
1468    fn set_opening_before_enable_calibration_requires_calibration() {
1469        let mut robot = RobotBuilder::new()
1470            .add_bus(
1471                "main",
1472                Box::new(MockCanBus::new("m")),
1473                Box::new(FeedbackCodec::new(vec![0.0])),
1474            )
1475            .add_gripper_with_opening(
1476                "g",
1477                "main",
1478                motor("g0", 0x05, 0x18),
1479                GripperOpeningSpec::new(OpeningDirection::IncreasingPosition, Some(0.2)),
1480            )
1481            .build()
1482            .unwrap();
1483        let gripper = robot
1484            .group_mut("g")
1485            .and_then(|group| group.as_gripper_mut())
1486            .unwrap();
1487        assert!(matches!(
1488            gripper.set_opening(0.5, None),
1489            Err(Error::OpeningCalibrationRequired)
1490        ));
1491    }
1492
1493    #[test]
1494    fn enable_fails_when_gripper_opening_calibration_command_fails() {
1495        let mut robot = RobotBuilder::new()
1496            .add_bus(
1497                "main",
1498                Box::new(MockCanBus::new("m")),
1499                Box::new(FailingCommandCodec),
1500            )
1501            .add_gripper_with_opening(
1502                "g",
1503                "main",
1504                motor("g0", 0x05, 0x18),
1505                GripperOpeningSpec::new(OpeningDirection::IncreasingPosition, Some(0.2)),
1506            )
1507            .build()
1508            .unwrap();
1509        robot.connect().unwrap();
1510        assert!(matches!(robot.enable(), Err(Error::Codec(_))));
1511    }
1512
1513    #[test]
1514    fn disable_without_enable_is_noop() {
1515        let (c, _, _) = CountingCodec::new();
1516        let mut robot = RobotBuilder::new()
1517            .add_bus("main", Box::new(MockCanBus::new("m")), Box::new(c))
1518            .add_arm("arm", "main", vec![motor("j0", 0x01, 0x11)])
1519            .build()
1520            .unwrap();
1521        // Not connected → disable returns Ok no-op
1522        robot.disable().unwrap();
1523        // Connected but never enabled → disable runs without enable ACK first
1524        robot.connect().unwrap();
1525        robot.disable().unwrap();
1526    }
1527
1528    #[test]
1529    fn tick_quiet_buses_returns_within_deadline() {
1530        let (c, _, _) = CountingCodec::new();
1531        let mut robot = RobotBuilder::new()
1532            .add_bus("main", Box::new(MockCanBus::new("m")), Box::new(c))
1533            .add_arm("arm", "main", vec![motor("j0", 0x01, 0x11)])
1534            .build()
1535            .unwrap();
1536        robot.connect().unwrap();
1537        let t0 = std::time::Instant::now();
1538        robot.tick(Duration::from_millis(5)).unwrap();
1539        let elapsed = t0.elapsed();
1540        // MockCanBus has no fd, so poller is empty and tick returns essentially
1541        // immediately (the BusPoller wait still respects deadline; here it has
1542        // no fds so it returns 0-token vec immediately).
1543        assert!(elapsed < Duration::from_millis(50), "tick took {elapsed:?}");
1544    }
1545
1546    /// Source grep: no group method body calls drain_inbound_nonblocking.
1547    /// Enforced as a textual scan of the group source. The `#[cfg(test)]`
1548    /// modules are stripped first (test code legitimately drains mock peers to
1549    /// observe what a group sent); this guards production method bodies only,
1550    /// matching the sibling `source_invariants` scan in group.rs.
1551    #[test]
1552    fn group_source_does_not_call_drain() {
1553        let src = include_str!("group.rs");
1554        let scan = match src.find("#[cfg(test)]") {
1555            Some(idx) => &src[..idx],
1556            None => src,
1557        };
1558        for needle in ["drain_inbound_nonblocking(", ".drain_inbound_nonblocking"] {
1559            assert!(
1560                !scan.contains(needle),
1561                "group.rs must not call {needle}; only Robot::tick may"
1562            );
1563        }
1564    }
1565}