Skip to main content

cu_feetech/
lib.rs

1//! Feetech STS/SCS serial bus servo bridge for Copper.
2//!
3//! This crate provides a [`CuBridge`] that communicates with Feetech STS/SCS
4//! serial bus servos (such as the **STS3215** used in SO-100 / SO-101 robot
5//! arms) over a half-duplex serial (UART) bus.
6//!
7//! # Protocol overview
8//!
9//! Feetech servos use a Dynamixel-style packet protocol:
10//!
11//! ```text
12//! TX:  [0xFF 0xFF] [ID] [LENGTH] [INSTRUCTION] [PARAM …] [CHECKSUM]
13//! RX:  [0xFF 0xFF] [ID] [LENGTH] [ERROR]       [DATA …]  [CHECKSUM]
14//! ```
15//!
16//! The bus is half-duplex: after each instruction packet the master reads back
17//! a status packet (except for broadcast sync-write, which has no response).
18//!
19//! # Channels
20//!
21//! | Direction | Channel id         | Payload                       | Description                        |
22//! |-----------|--------------------|-------------------------------|------------------------------------|
23//! | Rx        | `positions`        | [`JointPositions`]     | Present positions read from servos |
24//! | Tx        | `goal_positions`   | [`JointPositions`]     | Goal positions written to servos   |
25//!
26//! # Position values
27//!
28//! The unit of published / consumed positions depends on the `"units"` config
29//! key:
30//!
31//! | Value   | Meaning                                        | Calibration required? |
32//! |---------|------------------------------------------------|-----------------------|
33//! | `"raw"`       | Raw 16-bit register values (0..65535). Default. | No  |
34//! | `"deg"`       | Degrees relative to calibration center.         | Yes |
35//! | `"rad"`       | Radians relative to calibration center.         | Yes |
36//! | `"normalize"` | [-1, 1] over calibrated min..max (same scale for leader/follower). | Yes |
37//!
38//! When using `"deg"`, `"rad"`, or `"normalize"`, set `"calibration_file"` to the path of a
39//! JSON file generated by the `feetech-calibrate` tool.  The center (zero) of
40//! each servo is the midpoint of its calibrated min/max range.  Optionally
41//! set `"ticks_per_rev"` (raw units per 360°); the value is model-dependent
42//! (default 4096, e.g. for STS3215).
43//!
44//! # Torque behaviour
45//!
46//! - When **Tx writers are connected** (commander mode) the bridge enables
47//!   torque on [`start`](CuBridge::start) so the servos track goal positions.
48//! - When **no Tx writers are connected** (follower / teach mode) torque is
49//!   left **disabled** so the arm can be moved freely by hand while positions
50//!   are read back.
51//! - On [`stop`](CuBridge::stop) torque is always disabled for safety.
52
53pub mod calibration;
54pub mod messages;
55
56use crate::calibration::{CalibrationData, Units};
57use crate::messages::{JointPositions, MAX_SERVOS};
58use cu_linux_resources::LinuxSerialPort;
59use cu29::cubridge::{
60    BridgeChannel, BridgeChannelConfig, BridgeChannelInfo, BridgeChannelSet, CuBridge,
61};
62use cu29::prelude::*;
63use cu29::resources;
64use heapless::Vec as HeaplessVec;
65use std::io::{self, Read, Write};
66
67// ===========================================================================
68// Feetech STS/SCS protocol constants
69// ===========================================================================
70
71/// Every Feetech packet starts with two 0xFF bytes.
72const HEADER: [u8; 2] = [0xFF, 0xFF];
73
74/// Maximum size of a Feetech packet payload (params or response data).
75/// Sync-write with MAX_SERVOS (8) = 2 (start addr + len) + 8*3 (ID + 2 bytes data) = 26 bytes.
76/// Adding header (2) + ID (1) + length (1) + instruction (1) + checksum (1) = 32 bytes total.
77const MAX_PACKET_SIZE: usize = 32;
78
79/// Maximum size of a status packet response buffer.
80/// Length byte is u8 (max 255), but realistic responses are much smaller.
81/// Using 64 bytes should cover all practical cases while staying on the stack.
82const MAX_STATUS_PACKET_SIZE: usize = 64;
83
84/// Broadcast address — targets all servos on the bus.
85/// Used by sync-write so a single packet sets every servo's goal position.
86#[allow(dead_code)]
87const BROADCAST_ID: u8 = 0xFE;
88
89/// Instruction bytes recognised by STS/SCS servos.
90#[allow(dead_code)]
91mod instr {
92    /// Ask a servo to respond with its status (no data).
93    pub const PING: u8 = 0x01;
94    /// Read `N` bytes starting at a register address.
95    pub const READ: u8 = 0x02;
96    /// Write data starting at a register address.
97    pub const WRITE: u8 = 0x03;
98    /// Buffer a write; execute later with ACTION.
99    pub const REG_WRITE: u8 = 0x04;
100    /// Trigger all pending REG_WRITE commands.
101    pub const ACTION: u8 = 0x05;
102    /// Write the same register(s) to multiple servos in one packet.
103    pub const SYNC_WRITE: u8 = 0x83;
104    /// Factory-reset a servo.
105    pub const RESET: u8 = 0x06;
106}
107
108/// STS3215 register map (addresses and widths).
109///
110/// Only the registers relevant to position control are included here.
111/// See the Feetech STS3215 datasheet for the full map.
112#[allow(dead_code)]
113mod reg {
114    // ---- EEPROM (persisted across power cycles) ----
115    pub const MODEL_NUMBER: u8 = 3; // 2 bytes — model identifier
116    pub const ID: u8 = 5; // 1 byte  — servo bus ID (1..253)
117    pub const BAUD_RATE: u8 = 6; // 1 byte  — baud rate index
118    pub const MIN_ANGLE_LIMIT: u8 = 9; // 2 bytes — CW angle limit
119    pub const MAX_ANGLE_LIMIT: u8 = 11; // 2 bytes — CCW angle limit
120
121    // ---- RAM (volatile, reset on power cycle) ----
122    pub const TORQUE_ENABLE: u8 = 40; // 1 byte  — 0 = free, 1 = hold
123    pub const GOAL_POSITION: u8 = 42; // 2 bytes — target position (0..65535)
124    pub const GOAL_TIME: u8 = 44; // 2 bytes — time to reach goal (ms)
125    pub const GOAL_SPEED: u8 = 46; // 2 bytes — max speed
126    pub const PRESENT_POSITION: u8 = 56; // 2 bytes — current position (0..65535)
127    pub const PRESENT_SPEED: u8 = 58; // 2 bytes — current speed
128    pub const PRESENT_LOAD: u8 = 60; // 2 bytes — current load
129    pub const PRESENT_VOLTAGE: u8 = 62; // 1 byte  — supply voltage
130    pub const PRESENT_TEMPERATURE: u8 = 63; // 1 byte  — internal temperature
131    pub const MOVING: u8 = 66; // 1 byte  — 1 while in motion
132}
133
134// ===========================================================================
135// Checksum
136// ===========================================================================
137
138/// Compute the Feetech packet checksum.
139///
140/// The checksum covers everything between the header and the checksum byte
141/// itself (ID + Length + Instruction/Error + Params):
142///
143/// ```text
144/// checksum = ~(sum of bytes) & 0xFF
145/// ```
146#[inline]
147fn compute_checksum(data: &[u8]) -> u8 {
148    let mut sum: u8 = 0;
149    for &b in data {
150        sum = sum.wrapping_add(b);
151    }
152    !sum
153}
154
155// ===========================================================================
156// Bridge channel declarations
157// ===========================================================================
158
159// Declare the Rx (bridge → task) channel carrying present positions.
160rx_channels! {
161    positions => JointPositions
162}
163
164// Declare the Tx (task → bridge) channel carrying goal positions.
165tx_channels! {
166    goal_positions => JointPositions
167}
168
169// ===========================================================================
170// Resource binding
171// ===========================================================================
172
173// The bridge takes exclusive ownership of a serial port provided by the
174// resource manager (configured in `copperconfig.ron` under `resources`).
175resources!({
176    serial => Owned<LinuxSerialPort>,
177});
178
179// ===========================================================================
180// FeetechBridge
181// ===========================================================================
182
183/// Bidirectional bridge for Feetech STS/SCS serial bus servos.
184///
185/// Created by the Copper runtime from configuration.  Each cycle it:
186///
187/// 1. **Receives** (`receive`): reads present positions from all servos and
188///    publishes them on the `positions` Rx channel.
189/// 2. **Sends** (`send`): if a `goal_positions` Tx message is available,
190///    writes goal positions to all servos via a single sync-write packet.
191///
192/// Positions are converted to the unit specified by the `"units"` config key
193/// (`"raw"`, `"deg"`, `"rad"`, or `"normalize"`).  When using `"deg"`, `"rad"`, or
194/// `"normalize"`, a calibration file (`"calibration_file"` key) must be provided.
195#[derive(Reflect)]
196#[reflect(from_reflect = false)]
197pub struct FeetechBridge {
198    /// Handle to the half-duplex serial port (UART).
199    #[reflect(ignore)]
200    port: LinuxSerialPort,
201
202    /// Servo IDs on the bus.  Only the first `num_servos` entries are valid.
203    /// Populated from `copperconfig.ron` keys `servo0` .. `servo7`.
204    ids: [u8; MAX_SERVOS],
205
206    /// How many servos are configured (1..=[`MAX_SERVOS`]).
207    num_servos: u8,
208
209    /// `true` when at least one Tx (goal_positions) writer is connected.
210    /// Controls whether torque is enabled at startup:
211    /// - `true`  → commander mode: torque ON, servos track goals.
212    /// - `false` → follower / teach mode: torque OFF, arm moves freely.
213    has_writers: bool,
214
215    /// Cached raw positions from the last `read_all_positions` call.
216    /// One entry per configured servo; remaining slots are unused.
217    cached_positions: [u16; MAX_SERVOS],
218
219    /// Output unit for published positions.
220    #[reflect(ignore)]
221    units: Units,
222
223    /// Per-servo calibration center (raw ticks), indexed by servo slot.
224    /// Only meaningful when `units != Raw`.
225    centers: [f32; MAX_SERVOS],
226
227    /// Ticks per revolution (raw units per 360°) for deg/rad conversion. Model-dependent.
228    #[reflect(ignore)]
229    ticks_per_rev: u32,
230
231    /// Per-servo half-range (max - min) / 2 for normalize unit. Only used when `units == Normalize`.
232    #[reflect(ignore)]
233    half_ranges: [f32; MAX_SERVOS],
234}
235
236impl Freezable for FeetechBridge {
237    // No mutable runtime state beyond what the hardware provides.
238    // Default freeze / thaw (no-op) is fine.
239}
240
241// ===========================================================================
242// Low-level protocol helpers
243// ===========================================================================
244
245impl FeetechBridge {
246    /// Build and send an instruction packet, then flush the serial port.
247    ///
248    /// Packet layout:
249    /// ```text
250    /// [0xFF 0xFF] [id] [length] [instruction] [params…] [checksum]
251    /// ```
252    /// where `length = len(params) + 2` (instruction + checksum).
253    fn send_packet(&mut self, id: u8, instruction: u8, params: &[u8]) -> io::Result<()> {
254        let length = (params.len() + 2) as u8;
255        let packet_size = 2 + 1 + 1 + 1 + params.len() + 1; // header + ID + length + instruction + params + checksum
256        if packet_size > MAX_PACKET_SIZE {
257            return Err(io::Error::other("Feetech: packet too large"));
258        }
259        let mut packet = [0u8; MAX_PACKET_SIZE];
260        packet[0..2].copy_from_slice(&HEADER);
261        packet[2] = id;
262        packet[3] = length;
263        packet[4] = instruction;
264        packet[5..5 + params.len()].copy_from_slice(params);
265        // Checksum covers everything after the header (ID onward).
266        let checksum = compute_checksum(&packet[2..5 + params.len()]);
267        packet[5 + params.len()] = checksum;
268        self.port.write_all(&packet[..packet_size])?;
269        self.port.flush()?;
270        Ok(())
271    }
272
273    /// Read and validate a status packet returned by a servo.
274    ///
275    /// Status packet layout:
276    /// ```text
277    /// [0xFF 0xFF] [id] [length] [error] [data…] [checksum]
278    /// ```
279    ///
280    /// Returns `(id, error_byte, data)` on success.
281    fn read_status_packet(
282        &mut self,
283    ) -> io::Result<(u8, u8, HeaplessVec<u8, MAX_STATUS_PACKET_SIZE>)> {
284        // Read the fixed-size portion: header (2) + id (1) + length (1).
285        let mut header = [0u8; 4];
286        self.port.read_exact(&mut header)?;
287
288        if header[0] != 0xFF || header[1] != 0xFF {
289            return Err(io::Error::other("Feetech: invalid response header"));
290        }
291        let id = header[2];
292        let length = header[3] as usize;
293        if length < 2 {
294            return Err(io::Error::other("Feetech: response length too short"));
295        }
296        if length > MAX_STATUS_PACKET_SIZE {
297            return Err(io::Error::other("Feetech: response packet too large"));
298        }
299
300        // Read the variable-size portion: error + data + checksum.
301        let mut remaining = [0u8; MAX_STATUS_PACKET_SIZE];
302        self.port.read_exact(&mut remaining[..length])?;
303
304        // Verify checksum (covers id, length, and all of `remaining` except
305        // the last byte which is the checksum itself).
306        let received_checksum = remaining[length - 1];
307        // Checksum covers: [id, length, error, data...] (everything except checksum)
308        let mut checksum_sum: u8 = id;
309        checksum_sum = checksum_sum.wrapping_add(length as u8);
310        for &b in &remaining[..length - 1] {
311            checksum_sum = checksum_sum.wrapping_add(b);
312        }
313        let expected_checksum = !checksum_sum;
314        if received_checksum != expected_checksum {
315            return Err(io::Error::other("Feetech: checksum mismatch"));
316        }
317
318        let error_byte = remaining[0];
319        // Data sits between the error byte and the checksum.
320        let data_len = length - 2; // length - error byte - checksum
321        let mut data = HeaplessVec::new();
322        data.extend_from_slice(&remaining[1..1 + data_len])
323            .map_err(|_| io::Error::other("Feetech: failed to create data vector"))?;
324        Ok((id, error_byte, data))
325    }
326
327    // =======================================================================
328    // High-level servo operations
329    // =======================================================================
330
331    /// Ping a servo by ID.  Returns `Ok(())` if it responds without error.
332    #[allow(dead_code)]
333    pub fn ping(&mut self, id: u8) -> CuResult<()> {
334        self.send_packet(id, instr::PING, &[])
335            .map_err(|e| CuError::new_with_cause("Feetech: ping write failed", e))?;
336        let (resp_id, error, _) = self
337            .read_status_packet()
338            .map_err(|e| CuError::new_with_cause("Feetech: ping read failed", e))?;
339        if error != 0 {
340            return Err(
341                format!("Feetech: servo {} returned error 0x{:02X}", resp_id, error).into(),
342            );
343        }
344        if resp_id != id {
345            return Err(format!("Feetech: ping expected ID {} but got {}", id, resp_id).into());
346        }
347        Ok(())
348    }
349
350    /// Read `count` bytes starting at `address` from a single servo.
351    fn read_register(
352        &mut self,
353        id: u8,
354        address: u8,
355        count: u8,
356    ) -> io::Result<HeaplessVec<u8, MAX_STATUS_PACKET_SIZE>> {
357        // READ instruction params: [start_address, byte_count].
358        self.send_packet(id, instr::READ, &[address, count])?;
359        let (_id, _error, data) = self.read_status_packet()?;
360        Ok(data)
361    }
362
363    /// Write `data` starting at `address` to a single servo.
364    #[allow(dead_code)]
365    fn write_register(&mut self, id: u8, address: u8, data: &[u8]) -> io::Result<()> {
366        // WRITE instruction params: [start_address, data…].
367        // Max params size: address (1) + data (typically small, max ~20 bytes for multi-register writes)
368        if 1 + data.len() > MAX_PACKET_SIZE - 5 {
369            return Err(io::Error::other("Feetech: write data too large"));
370        }
371        let mut params = [0u8; MAX_PACKET_SIZE - 5];
372        params[0] = address;
373        params[1..1 + data.len()].copy_from_slice(data);
374        self.send_packet(id, instr::WRITE, &params[..1 + data.len()])?;
375        // Every non-broadcast write returns a status acknowledgment.
376        let _ = self.read_status_packet()?;
377        Ok(())
378    }
379
380    /// Read the raw present position (2 bytes, little-endian) from one servo.
381    ///
382    /// Returns a value in 0..65535 (16-bit register).
383    fn read_present_position(&mut self, id: u8) -> CuResult<u16> {
384        let data = self
385            .read_register(id, reg::PRESENT_POSITION, 2)
386            .map_err(|e| {
387                CuError::new_with_cause(
388                    &format!("Feetech: failed to read position from servo {}", id),
389                    e,
390                )
391            })?;
392        if data.len() < 2 {
393            return Err(format!(
394                "Feetech: short read for position from servo {} (got {} bytes)",
395                id,
396                data.len()
397            )
398            .into());
399        }
400        Ok(u16::from_le_bytes([data[0], data[1]]))
401    }
402
403    /// Poll present positions from every configured servo into `cached_positions`.
404    ///
405    /// On a read failure for any individual servo the previously cached value
406    /// is kept and a debug message is logged — the bus continues with the
407    /// remaining servos.
408    fn read_all_positions(&mut self) -> CuResult<()> {
409        for i in 0..self.num_servos as usize {
410            match self.read_present_position(self.ids[i]) {
411                Ok(raw) => self.cached_positions[i] = raw,
412                Err(e) => {
413                    debug!(
414                        "Feetech: failed to read servo {} (ID {}): {}",
415                        i, self.ids[i], e
416                    );
417                }
418            }
419        }
420        Ok(())
421    }
422
423    /// Write goal positions to all configured servos using **sync-write**.
424    ///
425    /// Sync-write (instruction 0x83) packs every servo's data into a single
426    /// broadcast packet, which is much faster than writing to each servo
427    /// individually and doesn't produce a status response.
428    ///
429    /// Packet params layout:
430    /// ```text
431    /// [start_address] [bytes_per_servo] [ID_0] [lo_0] [hi_0] [ID_1] …
432    /// ```
433    fn sync_write_positions(&mut self, positions: &JointPositions) -> CuResult<()> {
434        let vals = positions.as_slice();
435        // Write at most as many servos as we have configured, even if the
436        // payload carries fewer (or more) entries.
437        let n = (self.num_servos as usize).min(vals.len());
438        if n == 0 {
439            return Ok(());
440        }
441
442        let data_len_per_servo: u8 = 2; // 2 bytes for GOAL_POSITION
443        // Max params: 2 (start addr + len) + MAX_SERVOS*3 (ID + 2 bytes data) = 26 bytes
444        let params_size = 2 + n * 3;
445        if params_size > MAX_PACKET_SIZE - 5 {
446            return Err(CuError::from("Feetech: sync-write params too large"));
447        }
448        let mut params = [0u8; MAX_PACKET_SIZE - 5];
449        params[0] = reg::GOAL_POSITION; // start address
450        params[1] = data_len_per_servo;
451        let mut offset = 2;
452        for (i, val) in vals.iter().enumerate().take(n) {
453            let param = self.param_for_slot(i);
454            let raw = self.units.to_raw(*val, self.centers[i], param);
455            params[offset] = self.ids[i]; // servo ID
456            params[offset + 1] = (raw & 0xFF) as u8; // position low byte
457            params[offset + 2] = (raw >> 8) as u8; // position high byte
458            offset += 3;
459        }
460
461        self.send_packet(BROADCAST_ID, instr::SYNC_WRITE, &params[..params_size])
462            .map_err(|e| CuError::new_with_cause("Feetech: sync-write failed", e))?;
463        Ok(())
464    }
465
466    /// Enable or disable torque on a single servo.
467    ///
468    /// When torque is **enabled** the servo actively holds its position.
469    /// When **disabled** the servo can be moved freely by hand.
470    #[allow(dead_code)]
471    pub fn set_torque(&mut self, id: u8, enable: bool) -> io::Result<()> {
472        self.write_register(id, reg::TORQUE_ENABLE, &[enable as u8])
473    }
474
475    /// Parameter for from_raw/to_raw: ticks_per_rev for Deg/Rad, half_ranges[i] for Normalize.
476    #[inline]
477    fn param_for_slot(&self, i: usize) -> f32 {
478        if self.units == Units::Normalize {
479            let hr = self.half_ranges[i];
480            if hr > 0.0 { hr } else { 1.0 } // avoid div-by-zero
481        } else {
482            self.ticks_per_rev as f32
483        }
484    }
485
486    /// Enable torque on every configured servo.
487    fn enable_all_torque(&mut self) -> CuResult<()> {
488        for i in 0..self.num_servos as usize {
489            self.set_torque(self.ids[i], true).map_err(|e| {
490                CuError::new_with_cause(
491                    &format!("Feetech: failed to enable torque on servo {}", self.ids[i]),
492                    e,
493                )
494            })?;
495        }
496        Ok(())
497    }
498}
499
500// ===========================================================================
501// CuBridge trait implementation
502// ===========================================================================
503
504impl CuBridge for FeetechBridge {
505    type Tx = TxChannels;
506    type Rx = RxChannels;
507    type Resources<'r> = Resources;
508
509    /// Construct the bridge from configuration.
510    ///
511    /// Expected `copperconfig.ron` keys under the bridge's `config` block:
512    ///
513    /// | Key                | Type   | Description                                   |
514    /// |--------------------|--------|-----------------------------------------------|
515    /// | `servo0`           | u8     | Bus ID of the first servo                     |
516    /// | `servo1`           | u8     | Bus ID of the second servo                    |
517    /// | …                  | …      | Up to `servo7`                                |
518    /// | `units`            | string | `"raw"` (default), `"deg"`, `"rad"`, or `"normalize"` |
519    /// | `calibration_file` | string | Path to calibration JSON (required for deg/rad/normalize) |
520    /// | `ticks_per_rev`    | integer | Raw units per 360° (model-dependent; default 4096) |
521    ///
522    /// At least `servo0` must be present.
523    fn new(
524        config: Option<&ComponentConfig>,
525        tx_channels: &[BridgeChannelConfig<<Self::Tx as BridgeChannelSet>::Id>],
526        _rx_channels: &[BridgeChannelConfig<<Self::Rx as BridgeChannelSet>::Id>],
527        resources: Self::Resources<'_>,
528    ) -> CuResult<Self>
529    where
530        Self: Sized,
531    {
532        let cfg = config.ok_or("FeetechBridge requires a config block with servo IDs")?;
533
534        // Collect servo IDs from sequential keys: "servo0", "servo1", …
535        // Stop at the first missing key (after servo0, which is mandatory).
536        let mut ids = [0u8; MAX_SERVOS];
537        let mut num_servos: u8 = 0;
538        for (i, id_slot) in ids.iter_mut().enumerate().take(MAX_SERVOS) {
539            let key = format!("servo{}", i);
540            match cfg.get::<u8>(&key)? {
541                Some(id) => {
542                    *id_slot = id;
543                    num_servos = (i + 1) as u8;
544                }
545                None if i == 0 => {
546                    return Err(
547                        "FeetechBridge: you must configure at least one servo ID (\"servo0\")"
548                            .into(),
549                    );
550                }
551                None => break, // no more servos configured
552            }
553        }
554
555        // ---- Parse output units ----
556        let units = match cfg.get::<String>("units")? {
557            Some(s) => s.parse().map_err(|_| {
558                CuError::from(format!(
559                    "FeetechBridge: unknown units \"{s}\". Use \"raw\", \"deg\", \"rad\", or \"normalize\"."
560                ))
561            })?,
562            None => Units::Raw,
563        };
564
565        // ---- Load calibration (required for deg / rad / normalize) ----
566        let mut centers = [0.0f32; MAX_SERVOS];
567        let mut half_ranges = [0.0f32; MAX_SERVOS];
568        if units != Units::Raw {
569            let cal_path = cfg
570                .get::<String>("calibration_file")?
571                .ok_or("FeetechBridge: \"calibration_file\" is required when units != raw")?;
572            let cal = CalibrationData::load(std::path::Path::new(&cal_path)).map_err(|e| {
573                CuError::new_with_cause(
574                    &format!("FeetechBridge: failed to load calibration from \"{cal_path}\""),
575                    e,
576                )
577            })?;
578            for i in 0..num_servos as usize {
579                centers[i] = cal.center_for(ids[i]).ok_or_else(|| {
580                    CuError::from(format!(
581                        "FeetechBridge: no calibration entry for servo ID {} in \"{cal_path}\"",
582                        ids[i]
583                    ))
584                })?;
585                if units == Units::Normalize {
586                    half_ranges[i] = cal.half_range_for(ids[i]).ok_or_else(|| {
587                        CuError::from(format!(
588                            "FeetechBridge: no calibration entry for servo ID {} in \"{cal_path}\" (normalize)",
589                            ids[i]
590                        ))
591                    })?;
592                }
593            }
594        }
595
596        // ---- Ticks per revolution (model-dependent; used for deg/rad) ----
597        let ticks_per_rev = cfg.get::<u32>("ticks_per_rev")?.unwrap_or(4096);
598
599        let port = resources.serial.0;
600
601        // If no Tx channels are wired up in this mission, nobody will send
602        // goal positions → the arm is in read-only (follower / teach) mode.
603        let has_writers = !tx_channels.is_empty();
604
605        Ok(FeetechBridge {
606            port,
607            ids,
608            num_servos,
609            has_writers,
610            cached_positions: [0u16; MAX_SERVOS],
611            units,
612            centers,
613            ticks_per_rev,
614            half_ranges,
615        })
616    }
617
618    /// Called once before the first processing cycle.
619    ///
620    /// Enables torque only when writers are connected (commander mode).
621    /// In follower mode torque stays off so the arm moves freely.
622    fn start(&mut self, _ctx: &CuContext) -> CuResult<()> {
623        if self.has_writers {
624            self.enable_all_torque()?;
625            debug!(
626                "FeetechBridge: enabled torque on {} servos",
627                self.num_servos
628            );
629        } else {
630            debug!(
631                "FeetechBridge: read-only mode, torque left disabled on {} servos",
632                self.num_servos
633            );
634        }
635        Ok(())
636    }
637
638    /// Handle an outgoing message on a Tx channel.
639    ///
640    /// For `goal_positions`: sync-writes the raw positions to the servo bus.
641    fn send<'a, Payload>(
642        &mut self,
643        _ctx: &CuContext,
644        channel: &'static BridgeChannel<<Self::Tx as BridgeChannelSet>::Id, Payload>,
645        msg: &CuMsg<Payload>,
646    ) -> CuResult<()>
647    where
648        Payload: CuMsgPayload + 'a,
649    {
650        match channel.id() {
651            TxId::GoalPositions => {
652                let goal_msg: &CuMsg<JointPositions> = msg.downcast_ref()?;
653                if let Some(positions) = goal_msg.payload() {
654                    self.sync_write_positions(positions)?;
655                }
656            }
657        }
658        Ok(())
659    }
660
661    /// Produce an incoming message on an Rx channel.
662    ///
663    /// For `positions`: reads every servo's present position and publishes
664    /// them as a [`JointPositions`].
665    fn receive<'a, Payload>(
666        &mut self,
667        ctx: &CuContext,
668        channel: &'static BridgeChannel<<Self::Rx as BridgeChannelSet>::Id, Payload>,
669        msg: &mut CuMsg<Payload>,
670    ) -> CuResult<()>
671    where
672        Payload: CuMsgPayload + 'a,
673    {
674        // Poll all servos and update the cache.
675        self.read_all_positions()?;
676
677        // Stamp the message with the current robot time.
678        msg.tov = Tov::Time(ctx.now());
679
680        match channel.id() {
681            RxId::Positions => {
682                // Build the payload, converting each raw position to the
683                // configured output unit (raw / deg / rad).
684                let mut payload = JointPositions::new();
685                payload.fill_from_iter(
686                    self.cached_positions[..self.num_servos as usize]
687                        .iter()
688                        .enumerate()
689                        .map(|(i, &raw)| {
690                            self.units
691                                .from_raw(raw, self.centers[i], self.param_for_slot(i))
692                        }),
693                );
694                let pos_msg: &mut CuMsg<JointPositions> = msg.downcast_mut()?;
695                pos_msg.set_payload(payload);
696            }
697        }
698        Ok(())
699    }
700
701    /// Called once after the last processing cycle.
702    ///
703    /// Disables torque on every servo for safety (prevents the arm from
704    /// holding position with power applied after the application exits).
705    fn stop(&mut self, _ctx: &CuContext) -> CuResult<()> {
706        for i in 0..self.num_servos as usize {
707            if let Err(e) = self.set_torque(self.ids[i], false) {
708                debug!(
709                    "FeetechBridge: failed to disable torque on servo {}: {}",
710                    self.ids[i],
711                    e.to_string()
712                );
713            }
714        }
715        debug!(
716            "FeetechBridge: disabled torque on {} servos",
717            self.num_servos
718        );
719        Ok(())
720    }
721}
722
723// ===========================================================================
724// Tests
725// ===========================================================================
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730
731    #[test]
732    fn checksum_matches_known_values() {
733        // Hand-calculated example:
734        //   ID=1, Length=4, Instruction=WRITE(3), Addr=40, Data=1
735        //   body = [0x01, 0x04, 0x03, 0x28, 0x01]
736        //   sum  = 1+4+3+40+1 = 49 = 0x31
737        //   ~0x31 = 0xCE
738        let body = [0x01u8, 0x04, 0x03, 0x28, 0x01];
739        assert_eq!(compute_checksum(&body), 0xCE);
740    }
741
742    #[test]
743    fn joint_positions_from_slice() {
744        let mut p = JointPositions::new();
745        p.fill_from_iter([0.0f32, 32768.0, 65535.0]);
746        assert_eq!(p.as_slice(), &[0.0, 32768.0, 65535.0]);
747    }
748
749    #[test]
750    fn units_raw_roundtrip() {
751        use crate::calibration::Units;
752        let u = Units::Raw;
753        let tpr = 4096.0;
754        assert_eq!(u.from_raw(2048, 0.0, tpr), 2048.0);
755        assert_eq!(u.to_raw(2048.0, 0.0, tpr), 2048);
756    }
757
758    #[test]
759    fn units_deg_roundtrip() {
760        use crate::calibration::{DEFAULT_TICKS_PER_REV, Units};
761        let u = Units::Deg;
762        let center = 2048.0;
763        // Center should map to 0°.
764        assert!((u.from_raw(2048, center, DEFAULT_TICKS_PER_REV as f32)).abs() < 1e-6);
765        // Converting 0° back should give the center tick.
766        assert_eq!(u.to_raw(0.0, center, DEFAULT_TICKS_PER_REV as f32), 2048);
767    }
768
769    #[test]
770    fn units_rad_roundtrip() {
771        use crate::calibration::{DEFAULT_TICKS_PER_REV, Units};
772        let u = Units::Rad;
773        let center = 2048.0;
774        let rad = u.from_raw(3072, center, DEFAULT_TICKS_PER_REV as f32);
775        let back = u.to_raw(rad, center, DEFAULT_TICKS_PER_REV as f32);
776        assert_eq!(back, 3072);
777    }
778
779    #[test]
780    fn units_normalize_roundtrip() {
781        use crate::calibration::Units;
782        let u = Units::Normalize;
783        let center = 2048.0;
784        let half_range = 1024.0; // min=1024, max=3072
785        assert!((u.from_raw(2048, center, half_range)).abs() < 1e-6);
786        assert!((u.from_raw(1024, center, half_range) + 1.0).abs() < 1e-6);
787        assert!((u.from_raw(3072, center, half_range) - 1.0).abs() < 1e-6);
788        assert_eq!(u.to_raw(0.0, center, half_range), 2048);
789        assert_eq!(u.to_raw(-1.0, center, half_range), 1024);
790        assert_eq!(u.to_raw(1.0, center, half_range), 3072);
791    }
792}