Skip to main content

m0601/
lib.rs

1//! Driver for the DFRobot **M0601** direct-drive hub motor over half-duplex
2//! RS485. Covers both SKUs — **FIT1042** (left) and **FIT1038** (right) are
3//! mirror-image builds of the same motor and speak the identical protocol;
4//! see [`M0601::mirrored`] for making "forward" mean the same thing on both
5//! sides of a chassis.
6//!
7//! The M0601 is **not Modbus**. It speaks a fixed 10-byte frame protocol at
8//! 115200 8N1 with a CRC-8/MAXIM checksum, and it is a *polling* device:
9//! motion is sustained only while the host keeps resending drive frames.
10//! RS485 is multi-drop: several motors share one A/B pair (IDs
11//! `0x01..=0xFE`) — a [`Bus`] owns the port and mints per-motor [`M0601`]
12//! handles.
13//!
14//! # Safety and the polling protocol
15//!
16//! **A single drive command will not keep the wheel spinning.** The motor
17//! moves only while drive frames arrive at
18//! ≥[`DRIVE_HZ_MIN`](protocol::DRIVE_HZ_MIN) (50) Hz, up to
19//! [`CMD_HZ_MAX`](protocol::CMD_HZ_MAX) (500) Hz. If the host stops —
20//! crash, unplugged adapter, power loss — the motor **coasts to a stop**.
21//! That is the protocol's built-in fail-safe; [`M0601::safe_stop`] upgrades
22//! a coast to an active braked stop for orderly shutdowns and should be
23//! called on every exit path of a control loop.
24//!
25//! One consequence deserves stating outright: **a zero setpoint does not
26//! mean "stop"** except in velocity mode. The same zero-valued `0x64` frame
27//! commands a move to 0° in position mode and zero torque in current mode,
28//! which is why [`M0601::safe_stop`] establishes velocity mode before it
29//! sends anything else.
30//!
31//! The motor also protects itself in hardware (each auto-resets after ~5 s):
32//!
33//! | Protection        | Trip                    | Fault bit |
34//! |-------------------|-------------------------|-----------|
35//! | Sensor error      | hall/encoder fault      | `0x01`    |
36//! | Bus overcurrent   | 3 A                     | `0x02`    |
37//! | Phase overcurrent | 4.6 A                   | `0x04`    |
38//! | Stall             | locked > 5 s            | `0x08`    |
39//! | Over-temperature  | 80 °C (releases 75 °C)  | `0x10`    |
40//!
41//! # Wire format
42//!
43//! Host → motor frames (see [`protocol`]):
44//!
45//! | Byte | 0  | 1   | 2      | 3      | 4 | 5 | 6     | 7     | 8 | 9   |
46//! |------|----|-----|--------|--------|---|---|-------|-------|---|-----|
47//! |      | ID | CMD | VAL_HI | VAL_LO | 0 | 0 | ACCEL | BRAKE | 0 | CRC |
48//!
49//! - `CMD` is `0x64` (drive), `0x74` (feedback query) or `0xA0` (mode
50//!   switch). **For `0xA0` the last byte is the mode (`01`/`02`/`03`), not
51//!   a CRC.**
52//! - `ACCEL` sets ramp steepness: larger is gentler, and `0` selects the
53//!   motor default — which measures identical to `1`, the *fastest* ramp.
54//!   No vendor source states that direction; see
55//!   [`protocol::frame_velocity`] for the measurement.
56//! - `BRAKE` = `0xFF` engages the electric brake (velocity mode only).
57//! - Two special unaddressed frames exist: the broadcast ID query
58//!   (`C8 64 00×7 DE`) and set-ID (`AA 55 53 <id> 00×6`, no CRC, must be
59//!   sent 5×, one motor on the bus).
60//!
61//! Motor → host telemetry replies come in **two layouts**, selected by the
62//! command that elicited them ([`ReplyKind`]):
63//!
64//! Reply to a `0x74` feedback query ([`ReplyKind::Query`]):
65//!
66//! | Byte | 0  | 1    | 2–3                | 4–5             | 6       | 7            | 8      | 9   |
67//! |------|----|------|--------------------|-----------------|---------|--------------|--------|-----|
68//! |      | ID | mode | current (i16 BE)   | speed (i16 BE)  | temp °C | position u8  | faults | chk |
69//!
70//! Reply to a `0x64` drive frame or the broadcast ID query
71//! ([`ReplyKind::Drive`]) — no temperature, but a 16-bit position:
72//!
73//! | Byte | 0  | 1    | 2–3                | 4–5             | 6–7                  | 8      | 9   |
74//! |------|----|------|--------------------|-----------------|----------------------|--------|-----|
75//! |      | ID | mode | current (i16 BE)   | speed (i16 BE)  | position (u16 BE)    | faults | chk |
76//!
77//! Current scales ×8/32767 to amps; the 8-bit position ×360/255 and the
78//! 16-bit position ×360/32767 to degrees. Replies carry a CRC-8/MAXIM in
79//! byte 9 (verified on hardware). **By default** telemetry is not rejected
80//! on it — [`Feedback::crc_ok`] is informational — but the opt-in strict
81//! mode ([`Bus::with_strict_crc`] / [`M0601::with_strict_crc`]) turns a bad
82//! checksum into `Ok(None)`. See `PROTOCOL.md`.
83//!
84//! # Multiple motors on one bus
85//!
86//! [`Bus`] enforces a minimum idle gap between frames
87//! ([`Bus::with_min_gap`]) so no two frames — or a frame and the reply an
88//! earlier drive frame elicited — can overlap on the half-duplex pair;
89//! [`Bus::set_mode_all`] and [`Bus::safe_stop_all`] switch or stop every
90//! wheel round-major, so a vehicle stops in the same ~300 ms as one motor.
91//! Budget the wire: each motor needs its drive frame at ≥50 Hz, so N
92//! motors put ≥N×50 frames/s (plus replies, plus gaps) through one bus.
93//! [`bus_period`] computes that occupancy from [`frame_time`] and the gap;
94//! a loop's cycle must exceed it yet stay within [`drive_floor`]. See
95//! [Budgeting the wire] for the worked arithmetic.
96//!
97//! [Budgeting the wire]: https://github.com/dougcalobrisi/m0601-rs/blob/main/docs/content/docs/library/budgeting.md
98//!
99//! Coming from another fieldbus or motor-control ecosystem, the concepts
100//! map directly:
101//!
102//! | Here | Elsewhere |
103//! |------|-----------|
104//! | enforced inter-frame gap ([`Bus::with_min_gap`]) | Modbus RTU's 3.5-character silence; CANopen's PDO inhibit time |
105//! | coast when drive frames stop (the 50 Hz floor) | a command watchdog / failsafe timeout, permanently enabled |
106//! | [`Bus::set_mode_all`] / [`Bus::safe_stop_all`] (reply-less batching) | Dynamixel's broadcast Sync Write |
107//! | automatic low-latency request ([`SerialTransport::low_latency`](transport::SerialTransport::low_latency)) | pyserial's `set_low_latency_mode(True)` |
108//!
109//! # Control modes
110//!
111//! | [`Mode`]              | Wire | Value range        | Meaning        |
112//! |-----------------------|------|--------------------|----------------|
113//! | [`Mode::Current`]     | 0x01 | −32767..=32767     | ≈ −8 A..+8 A   |
114//! | [`Mode::Velocity`]    | 0x02 | −330..=330         | RPM            |
115//! | [`Mode::Position`]    | 0x03 | 0..=32767          | 0°..360°       |
116//!
117//! Setpoints outside these ranges are clamped, never wrapped. Mode switches
118//! must be sent five times ([`M0601::set_mode`] does). Switching to position
119//! mode requires the wheel to be under 10 RPM.
120//!
121//! # Example
122//!
123//! Real hardware:
124//!
125//! ```no_run
126//! use std::time::Duration;
127//! use m0601::M0601;
128//!
129//! # fn main() -> m0601::Result<()> {
130//! let mut motor = M0601::open("/dev/ttyUSB0", 0x01, Duration::from_millis(150))?;
131//! match motor.query()? {
132//!     // `query()` replies always carry the winding temperature.
133//!     Some(fb) if fb.temp_c.is_some_and(|t| t < 70) => {
134//!         println!("{:+} RPM, faults: {}", fb.speed_rpm, fb.faults);
135//!     }
136//!     Some(fb) => println!("running hot: {:?} °C", fb.temp_c),
137//!     None => println!("no reply — check 18 V power, wiring (brown → GND), A/B polarity"),
138//! }
139//! # Ok(())
140//! # }
141//! ```
142//!
143//! No hardware needed — every driver behavior runs against
144//! [`MockTransport`]:
145//!
146//! ```
147//! use std::time::Duration;
148//! use m0601::{M0601, MockTransport};
149//!
150//! # fn main() -> m0601::Result<()> {
151//! let mock = MockTransport::with_replies([
152//!     vec![0x01, 0x02, 0x00, 0x00, 0x00, 0x64, 0x28, 0x00, 0x00, 0x00],
153//! ]);
154//! let mut motor = M0601::with_transport(mock, 0x01, Duration::from_millis(150))?;
155//! let fb = motor.query()?.unwrap();
156//! assert_eq!(fb.speed_rpm, 100);
157//! # Ok(())
158//! # }
159//! ```
160//!
161//! # References
162//!
163//! The repository's [protocol reference] is the full protocol and hardware
164//! reference, with per-claim sourcing and the known contradictions between
165//! sources (every `PROTOCOL.md` mention in these docs points there — the
166//! root `PROTOCOL.md` is now a pointer to that page). Primary materials:
167//!
168//! [protocol reference]: https://github.com/dougcalobrisi/m0601-rs/blob/main/docs/content/docs/protocol.md
169//!
170//! - [DDT M0601C_111 manual (PDF)](https://d2air1d4eqhwg2.cloudfront.net/media/files/a48110eb-432c-4083-a159-9e0f35913b23.pdf)
171//!   — the manufacturer's 16-page datasheet (the M0601 is a rebadged DDT
172//!   M0601C-111)
173//! - [DDTRobot/motor-driver-examples](https://github.com/DDTRobot/motor-driver-examples)
174//!   — the manufacturer's own sample code
175//! - [DFRobot FIT1042 protocol wiki](https://wiki.dfrobot.com/fit1042/docs/23322)
176//! - [DDT_M0601C_111, third-party samples](https://github.com/tech-life-hacking/DDT_M0601C_111)
177//! - [navigation_robot, independent C driver](https://github.com/Il1yasviel/navigation_robot)
178//! - [MotorLink, independent implementation](https://github.com/MukeshSankhla/MotorLink)
179
180// `deny`, not `forbid`: the single place unsafe exists is the pair of Linux
181// TIOCGSERIAL/TIOCSSERIAL ioctls in `low_latency` (scoped allow there, with
182// the safety argument). Everything else remains unsafe-free.
183#![deny(unsafe_code)]
184#![warn(missing_docs)]
185
186pub mod bus;
187pub mod error;
188#[cfg(target_os = "linux")]
189mod low_latency;
190pub mod protocol;
191pub mod slew;
192pub mod transport;
193pub mod types;
194
195pub use bus::{
196    Bus, BusTiming, DEFAULT_DRIVE_ACCEL, DEFAULT_MIN_GAP, M0601, PositionMirror, ScanReport,
197    bus_period,
198};
199pub use error::{Error, Result};
200pub use protocol::{ReplyKind, drive_floor, frame_time};
201pub use slew::SlewLimiter;
202pub use transport::{MockTransport, SerialTransport, Transport};
203pub use types::{Faults, Feedback, Mode, PositionAccumulator, Telemetry};