DShot protocol implementation for Raspberry Pi Pico/Pico 2 (RP2040/RP2350) using PIO, built for pet project - flight control test bench.
- DShot300/600 protocol support via PIO state machines (restricted to what AM32 documents support for)
- Scheduling-agnostic - your application decides which core runs the command loop
- Lock-free design for low-latency throttle updates across cores
- MicroPython runtime (no external dependencies)
- ESC configuration access - the main Pico reads every ESC's AM32 settings (JSON) or hands the ESCs to the AM32 web
configurator, and changes named motor, start-up and protection settings (ranges from the AM32 source) one ESC at a time, with no flight controller
(
tools/esc_buddy/, ADR-006)
AM32 is the only supported ESC firmware (see CLAUDE.md's "Supported ESC targets") - no other ESC exists on this project's bench. An earlier BLHeli_S ESC was used before the project narrowed to AM32 only; its measurements live on in ADR-001/ADR-004 as the reasoning trail for some of the library's conservative defaults, not as current hardware.
| Component | Model | Specifications |
|---|---|---|
| Controller | Raspberry Pi Pico 2 | RP2350, dual ARM Cortex-M33, 150MHz |
| Motors | BETAFPV LAVA Series 1104 (×4, one per ESC channel) | 7200KV, 5.3g, 12 rotor magnets (6 pole pairs) |
| Load | Gemfan 2218 3-blade props, or printed RPM disks | Props: the current start-quality scope. RPM disks (2-hole, for the optical sensor): paused since 2026-11-02 |
| ESC | Skystar RC KM55A2 (4-in-1) | AM32 2.22 (flashed 2026-11-09/10; shipped with a 2.16 build), target SKYSTARS_KM55_V2_F421 (F421 MCU), bootloader v1; vendor settings as read: specification/KM55A2_DEFAULTS.md; as configured now: "ESC settings as configured" below |
| Optical RPM sensor | Adafruit STEMMA reflective photo interrupt sensor (TCRT1000) | GPIO 14, 3.3V; 2-hole printed disk on motor 1's shaft. Not mounted now (owner, 2026-11-02): the disk and sensor tests were removed, see backlog item W41 |
The 12-magnet count is not on BETAFPV's product page; it was counted by eye on the rotor and then
confirmed by measurement: the optical RPM sensor gave eRPM / shaft RPM = 6.02-6.05 at throttle 60-100
(the test, removed 2026-11-02, is in git before the removal commit). The ESC reports electrical RPM, so shaft RPM = eRPM / 6 here
(about 3,400 RPM at throttle 100, which the ESC reports as about 20,600 eRPM). Each scenario file
carries the motor's name, KV and magnet count in a hardware block, and a session's device record in session.json
records them.
See tests/bench/dshot_protocol/scenarios/*.json for the source-of-truth wiring.
┌───────────────────────┐
│ Pico 2 │
│ (RP2350) │
└──┬─────┬─────┬─────┬──┘
GPIO 6│ 7│ 8│ 9│
▼ ▼ ▼ ▼
┌─────────────────────────────┐
│ Skystar KM55A2 (4-in-1) │
│ AM32 2.22, one MCU/channel │
│ ch1 ch2 ch3 ch4 │
└──┬─────┬─────┬─────┬────────┘
▼ ▼ ▼ ▼
M1 M2 M3 M4 BETAFPV 1104, 7200KV
load: Gemfan 2218 prop or RPM disk
Four motors, one per channel: GPIO 6 = ch1 = motor 1, GPIO 7 = ch2 = motor 2, GPIO 8 = ch3 = motor 3, GPIO 9 = ch4 =
motor 4. Each can carry a Gemfan 2218 3-blade prop (the current start-quality scope) or a printed RPM disk; what the
motors carried in a run is recorded per session (--load in session.json).
A second Pico 2, the wingman, is a permanent part of the bench: wired to the same GPIO 6-9 and to the start line (GPIO 15), it
records every edge of the four signal lines, our frames and the ESCs' replies, for every run with an ESC (tests/bench/wingman/README.md): a
scenario cannot be run without it, and a session without its recording has no verdict. A device script on the Pico alone, with no ESC, is
recorded only when asked.
The ESCs hold the vendor's settings except for the three below (owner's go for each write, via tools/esc_buddy/; the vendor's values, identity and
wire facts: specification/KM55A2_DEFAULTS.md). Last read 2026-11-09/10 (after the 2.22 flash, layout 4): the four ESCs of each board identical to each other, the two boards differ in poles only.
| Setting | Byte | Vendor | Now | Why |
|---|---|---|---|---|
| Motor KV | 26 | 2220 | 7180 | the motors are 7200 KV; KV is stored in steps of 40 from 20, so 7200 cannot be stored and 7180 is the nearest step below |
| Motor poles | 27 | 14 | 12 | the motors have 12 rotor magnets (above); AM32's "motor poles" is the magnet count |
| Beeper volume | 30 | 5 | 1 | the startup and no-signal beeps at 5 were too loud |
- History. 2026-11-05: KV and beeper volume written to all eight ESCs of the two KM55A2 boards that have been on the bench (a spare and the primary).
2026-11-08: poles written on the four ESCs on GPIO 6-9. The
other board's four ESCs hold 14 poles (read 2026-11-09), KV 7180 and volume 1; the Poles row above is the board that is on GPIO 6-9 when it is the primary one.
2026-11-09/10: firmware changed to AM32 2.22 on all eight ESCs (
--flash, ADR-007); the firmware migrated the settings pages to layout 4 and left these three values alone, but rewrote others (new fields, timing advance, temperature limit: ADR-007). The bootloader (version 1) was never changed. Motor-run results taken before 2026-11-05 were taken with the vendor values (KV 2220, volume 5), and all results before 2026-11-09 with firmware 2.16. - What KV and poles do at the start test (AM32 2.16 source; poles checked on the bench on 2.22, BUG-009). They only set a low-speed duty cap with a floor of 400/2000, and
throttle 215 asks for about 194/2000, so the cap never limits; poles have no effect at all (
32/14and32/12are both 2). What shapes the start is startup power (100). Detail and line numbers: BUG-009, "KV and poles do not act at this start". Bench check on 2.22 (poles 14 against 12, eRPM at throttle 215): no effect beyond the run-to-run spread; figures in BUG-009. - Read them again with
tools/esc_buddy/(--read) before relying on this table. Do not change this section without the owner's go.
The driver uses a three-layer architecture. MotorGroup is the library's public
API; DShotPIO below it is the low-level I/O core (PIO and pin), which sends the
values it is given and judges none of them. The library is deliberately
core-agnostic: it exposes update(), and your application decides where that
runs - a dedicated Core 1 thread, a timer IRQ, or its own main loop. See
ADR-004 for why, and
ADR-001 for the timing
requirements that drive it.
┌─────────────────────────────────────┐
│ Application │
│ UI, control algorithms, sensors │
│ OWNS THE COMMAND LOOP │
└──────────────────┬──────────────────┘
│ update() ── every UPDATE_INTERVAL_US, at a steady period
│ set_throttle()
▼
┌─────────────────────────────────────┐
│ MotorGroup (public API) │
│ Throttle values and their valid │
│ range, arming sequence. │
│ Core-agnostic. │
└──────────────────┬──────────────────┘
│ activate() / deactivate()
│ send_frame()
▼
┌─────────────────────────────────────┐
│ DShotPIO (low-level I/O core) │
│ PIO state machines, pin, encoding │
└─────────────────────────────────────┘
Stopping the motors is the application's job. The library has no call that stops a motor: the application sets
throttle 0 and keeps update() running so the ESCs receive it. MotorGroup.deactivate() only ends the signal; an ESC
that was still commanded to spin holds that throttle until its own signal-loss timeout (AM32: 500 ms) and then resets
(ADR-004, amendment of 2026-11-14).
ESCs disarm if commands stop arriving, so whatever context you choose must call
update() at a steady period (MotorGroup.UPDATE_INTERVAL_US), without long or irregular gaps. How fast depends on
the ESC firmware - see "Verified Parameters" below; on this bench, AM32 arms reliably with
Core1Runner's deadline-anchored pacing at 1045us (20/20, then 13/13 clean in later spot checks)
- which is
MotorGroup.UPDATE_INTERVAL_US, the only mode since 2026-10-19 (the earlier max-rate/no-pacing default, 0us, was removed). On this test bench that means a dedicated Core 1 thread, keeping Core 0 free for the display and buttons - seetests/bench/core1_runner.pyfor a ready-made example to copy into your project.
Who reports, who judges. The library reports facts (update_age_ms(), the arming state, the latest reply per motor) and judges
nothing; the application decides on them (ADR-004). The test bench applies the same
rule one level up (ADR-008): the main Pico runs a scenario, reports facts and may
bail out of a run early (it then reports "aborted" and the reason), but never says pass or fail; the PC judges every session from the
wingman's recording of every reply on the wire (scripts/session_summary.py).
A group drives one to four motors; a single motor is a group of one.
from machine import Pin
from dshot_pio import UnidirectionalDShot, DSHOT_SPEEDS
from motor_group import MotorGroup
from core1_runner import Core1Runner # your code - see tests/bench/core1_runner.py
import utime
# One motor object per motor (1-4). You pick the state machine and pin for each;
# use BidirectionalDShot instead where you want eRPM telemetry.
motors = MotorGroup([
UnidirectionalDShot(0, Pin(4), DSHOT_SPEEDS.DSHOT600),
UnidirectionalDShot(1, Pin(5), DSHOT_SPEEDS.DSHOT600),
])
# You choose where the command loop runs. This one dedicates Core 1.
runner = Core1Runner(motors.update, motors.UPDATE_INTERVAL_US)
runner.start()
# Arming is non-blocking - poll while doing something useful
motors.arm()
while not motors.is_armed():
utime.sleep_ms(10)
# Control motors independently
motors.set_throttle(0, 100) # Motor 1
motors.set_throttle(1, 150) # Motor 2
# Or update all at once
motors.set_all_throttles([100, 150])
# Stopping the motors is your job: set throttle 0 and keep the loop running so
# the ESCs receive it.
motors.set_all_throttles([0, 0])
utime.sleep_ms(20)
# Then end the signal. Stop the loop first: while it's running, Core 1 can call
# update() at the same time deactivate() resets the same state machines from
# this core, and nothing serialises the two. deactivate() transmits nothing and
# does not stop a motor: an ESC still commanded to spin holds that throttle
# until its own signal-loss timeout (AM32: 500ms).
if not runner.stop(): # waits for the loop to really exit; False = it did not
print("Core 1 still in update():", runner.error)
motors.deactivate()A bidirectional motor uses two state machines: its own and, one id above it, the receiver for the ESC's reply. The pair takes 31 of the 32 instruction slots of its PIO block (ids 0-3 are PIO0, 4-7 PIO1, 8-11 PIO2): a second bidirectional pair fits in the same block, a unidirectional motor does not and goes on another block.
from machine import Pin
from dshot_pio import BidirectionalDShot, UnidirectionalDShot, DSHOT_SPEEDS
from motor_group import MotorGroup
import utime
motors = MotorGroup([
BidirectionalDShot(0, Pin(6), DSHOT_SPEEDS.DSHOT600, rx_state_machine_id=1), # PIO0: TX on 0, RX on 1
BidirectionalDShot(2, Pin(7), DSHOT_SPEEDS.DSHOT600, rx_state_machine_id=3), # PIO0: a second pair fits
UnidirectionalDShot(8, Pin(8), DSHOT_SPEEDS.DSHOT600), # PIO2: not beside a pair
])
# ...start the loop, arm and set throttles as above. Arming now also waits until
# every bidirectional ESC is replying, so poll is_armed() as before.
last = None
while True:
capture = motors.raw_telemetry(0) # the latest capture of motor 0, or None before ARMED
if capture is not None and capture != last:
last = capture # a new capture: the value differs from the one seen last
reply = motors.decode_telemetry(0, capture) # too slow for the command loop: decode here
if reply["crc_ok"] and not reply["not_running"]:
print("motor 0:", reply["erpm"], "eRPM") # electrical RPM; shaft RPM = eRPM / pole pairs
utime.sleep_ms(10)A capture is not proof that the ESC replied. When an ESC is silent the receiver captures the driver's own next frame, and at some throttle values that capture passes the CRC and decodes to a plausible eRPM. Judging whether an ESC is alive is the application's job (ADR-004): for example, treat a motor that reports "not running", or no real eRPM, while it is commanded to spin as failed.
The same group works when you pump it from your own main loop - useful when Core 1 is busy or unavailable:
motors.arm()
period = motors.UPDATE_INTERVAL_US
next_due = utime.ticks_us()
while True:
motors.update() # must happen at a steady period, without long gaps
...your work here, well under UPDATE_INTERVAL_US...
next_due = utime.ticks_add(next_due, period)
remaining = utime.ticks_diff(next_due, utime.ticks_us())
while remaining <= 0: # late: skip to the next deadline, do not catch up
next_due = utime.ticks_add(next_due, period)
remaining = utime.ticks_diff(next_due, utime.ticks_us())
utime.sleep_us(remaining)When the loop runs late it skips whole periods; it does not make up for lost calls with a burst
(tests/bench/core1_runner.py paces the same way). Should update() still be called too early, it waits until every
transmitter has taken its previous frame and then sends, so no frame is dropped and none is stacked behind another
(ADR-004, amendment of 2026-11-14 (2)).
What this driver guarantees, and what it does not.
| Guarantee | Value |
|---|---|
| Arming success rate | ≥ 99% of arm() attempts reach ARMED |
| Arming duration | ≤ 3.0 s from arm() to ARMED, for every configured motor |
| Throttle range | 0 (stop) or 48-250; MotorGroup rejects 1-47 (DShot commands, not supported yet) and, as a temporary bench ceiling, anything above 250 (THROTTLE_CEILING in driver/dshot_pio.py, mirrored in tests/bench/throttle_profile.py; owner, 2026-10-19: nothing higher is tested yet, raise it when higher throttles are tested) |
| ESC firmware | AM32 only |
| AM32 input match | Measured on the wire with a second Pico recording the signal lines, no ESC (2026-10-28, unidirectional frames, DSHOT600 and DSHOT300, tests/bench/wingman/tools/observer_am32_table.py): every bit reads the same under AM32's own decision rule as in our decoder (0 of 74,752 differ per run), with margins of 0.18 / 0.44 us (DSHOT600) and 0.37 / 0.88 us (DSHOT300) for the 0 and 1 bits; frame length within 2.7% of the window AM32 learns (limit 6.25%); correct CRC polarity and no command values in every frame; largest gap between frames 1.1 ms (limit 500 ms); 4 lines, 3 runs at DSHOT600 and 2 at DSHOT300, identical run to run (properties of the PIO waveform; the observer resolves 0.1 us). With one ESC attached (bidirectional, single motor, throttle 100, DSHOT600 and DSHOT300): the ESC's first reply after 104 frames (latch 101 in AM32's source), every frame answered outside its 301 ms arming silence (which starts 1020 ms after our first frame, as the source's 1.02 s gate predicts), no CRC errors in 9,309 replies per run, the start glitch data12=2 sent by the ESC itself for 6-9 replies after the throttle step (BUG-009). Speed detection on AM32 2.22 (F421, from its source; recordings of 2026-11-14): bidirectional frames at both speeds and unidirectional DSHOT300 classify with margin; unidirectional DSHOT600 at throttle 0 classifies by 0.075 us, under the observer's resolution. A PC model finds DSHOT600 at every starting phase with nominal clocks; the small margin is an accepted limit, and the pulse shape is not changed for it (decision/ADR-007-esc-firmware-flash.md, amendment 2026-11-14). Other motors, throttles and ESCs not yet covered |
| Start quality | Pending - not yet guaranteed. Scope (owner, 2026-11-02): all four motors with Gemfan 2218 3-Blade props (no RPM disks, no heavier or draggier props). Target: ≥ 99% of runs that succeed when jumped from 0 to throttle 215: all four motors spin up and hold, and the slowest spin-up is within 0.5 s (provisional limit); a drone with fewer than four motors turning does not fly. Defined in bug-reports/BUG-009-motors-sometimes-start-rough.md ("Current KPI"), measured from the observer's telemetry in each session's summary.json. Baseline 2026-11-01: all four held in 28 of 28 runs, 26 of 28 fully succeed (93%, exact one-sided 95% lower bound 79%). Second batch 2026-11-02, 32 runs: 30 of 32 fully succeed, both failures a slow GPIO 6 spin-up; the cause is not found (BUG-009 "Result 4" to "Result 6") |
All figures here were measured with AM32 2.16; the bench ESCs run AM32 2.22 since 2026-11-10 and nothing below has been re-measured on it
(decision/ADR-007-esc-firmware-flash.md). The baseline for the arming and start-quality figures restarts on 2.22 (owner, 2026-11-10); runs on 2.16
are not comparable and are no longer kept.
An attempt that has not reached ARMED within 3.0 s counts as a failure, even if it arms later
after an ESC reboot. Not guaranteed: DShot commands, other ESC firmware, motor behaviour after
arming.
The 3.0 s bound is the constant CLEAN_ARM_THRESHOLD_MS (3000) in scripts/arming_bound.py, used by both the batch runner
(scripts/run_scenarios.py) and the session verdict (scripts/session_summary.py); this table quotes it and must agree with it.
Evidence for the arming figures. The 3.0 s bound sits in the empty gap between clean arms (2.08-2.59 s) and arms that
needed an ESC reboot (4.06 s or more) across 698 archived bench sessions (mostly earlier code); that archive justifies the
bound, not the rate. The 99% rate has no supporting evidence on the current code yet: about 300 clean runs are needed
(exact one-sided 95% bound), and the long batch waits to be scheduled (backlog W35). The ESC-side figures of the input-match
row (reply timing, bit time) are on specification/KM55A2_DEFAULTS.md; how the observer measures them: tests/bench/wingman/OBSERVER.md.
Status of the measurement: in progress.
Timing requirements are ESC-firmware-dependent, not just protocol-dependent. The library's
defaults (MotorGroup.UPDATE_INTERVAL_US, DEFAULT_ARM_DURATION_MS) are tuned to AM32's
measured requirements.
| Parameter | AM32 (Skystar KM55A2) |
|---|---|
| Protocol | DShot300 and DShot600. DSHOT600: the arming and start batches. DSHOT300: single-motor runs (ADR-002) and, on 2026-11-14, one four-motor run each of the bidirectional and the unidirectional scenario (both armed, spin confirmed; bidirectional also by about 19,160 real-eRPM replies per motor). One run per scenario is not an arming rate: no DSHOT300 arming rate is established (the command-interval and arming-duration rows below don't depend on which speed) |
| Minimum throttle | 60 confirmed spinning (DShot300, ADR-002; BUG-009 tracks an open rough-start question at low throttle) - 1-47 are special commands, not throttle: MotorGroup.set_throttle() rejects them (see specification/DSHOT.md, BUG-005) |
| Command interval | One mechanism: Core1Runner's deadline-anchored pacing at MotorGroup.UPDATE_INTERVAL_US = 1045us (20/20 clean, then 13/13 in later spot checks, see BUG-002.md). It is the only mode: an unpaced loop (update() back to back, as fast as it returns) was the library default until 2026-10-19 and was removed, and a scenario can no longer set its own period. The 2026-10-13/14 arming evidence (104/104) was gathered on the old unpaced loop and has to be repeated on this one. An earlier (2026-08-23) bring-up failure at "1kHz/500ms" was never a clean test of pacing specifically - its 500ms arm window is well under AM32's own >1.6s gate (specification/AM32.md#16) by itself, so that result doesn't isolate what role pacing played, if any |
| Frame spacing | Bidirectional: the transmitter starts a frame at least 135.4us (DSHOT600) or 270.8us (DSHOT300) after the previous one started, however early it is queued, so that a frame is not sent while the ESC replies. Measured on the Pico without an ESC; that the spacing is long enough for AM32 is derived from its source (reply over about 50us / 80us after our frame, plus its work before it listens again) and not yet tested with an ESC (ADR-002, amendment of 2026-11-14 (2)). Unidirectional: no minimum, the transmitter sends queued frames with no gap. Through MotorGroup.update() at most one frame per motor is ever queued (it waits for the transmitters first), and at the 1045us command interval neither limit is reached |
| Arming duration | The published promise is >=99% within 3.0 s (see "Operational Envelope"). Window: 2000ms (driver/motor_group.py's DEFAULT_ARM_DURATION_MS), grounded in AM32 source rather than bench measurement - see below. With bidirectional motors it is a floor: ARMED also waits until every ESC has replied "not running" for 2s without a reboot-length gap (READY_SPAN_MS, bug-reports/BUG-003) - measured about 2.03s on the 100-run batch of 2026-10-14 (99 of 100 runs between 2026 and 2028ms, one at 2516ms), several seconds if an ESC reboots while arming |
Earlier revisions of this table claimed 500ms (down to 300ms) armed cleanly, "confirmed via
genuine telemetry replies". That reasoning was wrong: per AM32's source (specification/AM32.md#8
and #9), a telemetry reply proves nothing about arm state - AM32 replies whether armed or
disarmed, with the same at-rest sentinel eRPM either way - and a bench re-test at 300/500/1000ms
confirmed the ESC was replying but the motor
never actually spun at any of those durations (eRPM stayed at rest for the whole run; it only
reached a real value in a 3000ms control). AM32's own arming gate needs more than 1s of continuous
zero throttle after it starts listening, plus a 600ms startup tune after a cold boot, hence the
2000ms default with margin. Likewise, the ESC's beep pattern was previously read as decoding
commands "regardless of whether its arm state machine has ever been satisfied" - per source, AM32
only executes DShot commands (including BEEP1) while armed, so a BEEP1 heard on this bench
(sent as throttle value 1 through the low-level sender of that time, send_throttle_command(), not a dedicated command
API - BUG-005's exact path; MotorGroup has refused that value since 2026-10-13) that audibly beeped actually confirms the ESC was armed at that moment,
not that arm state is irrelevant to command
execution. The ESC's own "3 short beeps, then 2 deeper beeps" arm confirmation tone, and current
draw at the power supply (near-zero until genuinely armed and driving), remain the reliable signals
that the motor is actually armed and spinning.
├── driver/ # the library - core-agnostic
│ ├── dshot_pio.py # PIO programs and the UnidirectionalDShot / BidirectionalDShot motors
│ ├── motor_group.py # MotorGroup: multi-motor facade (arm, update, telemetry)
│ ├── capture_mailbox.py # One-slot latest-capture store shared between cores
│ ├── gcr_decode.py # On-device decoder for the ESC's GCR telemetry reply
│ └── dshot_profiles.py # DShot speeds and the tuned receiver profile for each
├── tests/
│ ├── pc/ # PC unit tests (python -m unittest discover -s tests/pc)
│ └── bench/ # tests that run on the Pico with the bench attached
│ ├── core1_runner.py # Core 1 update() loop + fast_stop, shared by the groups below
│ ├── dshot_frame_decode.py # decoder for the DShot frames the bench records on the wire (PC and Pico)
│ ├── group_without_reply_gate.py # a MotorGroup that arms without waiting for replies, for Pico-only scripts with no ESC
│ ├── session_recorder/ # SD-card session recorder
│ ├── dshot_protocol/ # scenario runner + JSON scenarios (the four-channel bidirectional and unidirectional pair, each at DSHOT600 and DSHOT300), the restart-cycles ESC probe
│ ├── pio_and_cores/ # PIO and two-core checks (Pico only, no ESC)
│ ├── wingman/ # the second Pico: records every wire edge (observer), plays the ESC (ESC mock)
├── tools/
│ └── esc_buddy/ # ESC configuration access through the main Pico (--read / --passthrough / --debug)
├── scripts/ # deploy, pull/analyse captures, PC-side reference decoder
├── specification/
│ ├── DSHOT.md # Wire protocol (vendor/host-agnostic)
│ ├── AM32.md # AM32 firmware facts
│ ├── BETAFLIGHT.md # Betaflight reference-host facts
│ ├── KM55A2_DEFAULTS.md # the bench ESC's identity and vendor settings, as read
│ └── REFERENCES.md # registry of external references (videos, articles, repos)
├── decision/
│ └── ADR-00N-*.md # Architecture decision records
└── bug-reports/
└── BUG-00N-*.md # Resolved/tracked bug reports
Nothing under driver/ imports _thread or picks a core. core1_runner.py
lives in tests/bench/ because it is bench infrastructure; an application is free to copy it, but nothing in the DShot library depends on it.
See DSHOT.md for the wire protocol itself, including:
- Packet structure (11-bit throttle + 1-bit telemetry + 4-bit CRC)
- Bit timing for all DShot variants
- Special commands (0-47)
- Bidirectional DShot and eRPM telemetry
See AM32.md for how this project's target ESC firmware implements (and sometimes deviates from) that protocol, and BETAFLIGHT.md for how a reference flight-controller host drives it.
The project's goals and their status.
| Goal | Status | Details |
|---|---|---|
| Improve arming sequence | Done | Evidence-gated arming on a 2000ms floor, not a timer alone: BUG-002, BUG-003; what pacing has been bench-confirmed: "Verified Parameters" above; what is guaranteed: "Operational Envelope" above |
| Invert core assignment to client | Done | ADR-004: library exposes update(), application owns the loop |
| DShot commands | TODO | No driver-side command API yet (BUG-005's prerequisite is done: MotorGroup rejects 1-47, so a command API needs its own entry point, not set_throttle()). AM32 itself implements commands (specification/AM32.md#7), and this bench has already heard a BEEP1 while armed (see above - sent as throttle value 1, not through a real command API) - ADR-003's original "several different ESCs required" blocker no longer applies now that AM32 is the only ESC in scope |
| Bidirectional DShot | Done | ADR-002: frame receiver bench-validated on AM32, the sole production receiver |
| Smooth start | In progress | Definition, scope and baseline: BUG-009 ("Current KPI"); target and current figures: "Operational Envelope" above. The baselines of 2026-11-01/02 were recorded on the paced loop with AM32 2.16; the baseline restarts on 2.22. Limits other than the provisional 0.5 s spin-up are not set yet |
| ESC configuration access | Read and write done | ADR-006, tools/esc_buddy/README.md and the esc-buddy skill: tools/esc_buddy/ reads all four ESCs' settings (JSON), gives the AM32 web configurator read-only passthrough, and writes named motor, start-up and protection settings to one ESC at a time with a backup and read-back (only KV, poles and beeper volume tried on real ESCs so far); firmware flash (--flash, ADR-007) has put AM32 2.22 on all eight ESCs (both boards); the buddy's settings code speaks layout 4 only (no older layouts); bootloader update is not built |
| Extended telemetry (EDT) | Not planned | Out of scope (owner, 2026-11-14) until a concrete need appears: the driver will not enable or decode Extended DShot Telemetry. AM32 sends EDT frames only after DShot command 13 (specification/AM32.md#10), and the driver sends no commands, so every reply is an eRPM frame or the not-running value and the decoder treats it as such. The observer's AM32 check reports any reply shaped like an EDT frame |
See decision/ folder for Architecture Decision Records (ADRs); the bench's division of work between the main Pico and the PC is
ADR-008.
GNU General Public License v3.0
Original DShot PIO implementation from jrddupont/DShotPIO.
- DShot Protocol
- RP2350 Datasheet
- BetaFPV Lava 1104 Motors
- AM32 Firmware - this project's ground truth for ESC behavior
- AM32 Configurator - web app for ESC settings; works with the bench through
tools/esc_buddy/ --passthrough - More external material:
specification/REFERENCES.md