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, ¶ms[..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, ¶ms[..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}