Status: Accepted Date: 2026-02-01 Context: DShot driver for flight control test bench with multiple motors
The test bench controls 2 drone motors via DShot protocol. Each motor has an independent ESC and signal wire. The current implementation has unreliable arming behavior - often one motor arms successfully while the other fails (indicated by beeping).
ESCs require continuous DShot commands at high frequency (every 1-2ms) to:
- Complete the arming sequence (typically 300ms of throttle=0 commands)
- Remain armed (timeout if no valid commands received)
The current single-threaded implementation has gaps in command transmission caused by:
- UI updates (display rendering)
- Button polling and debouncing
- Sleep intervals in the main loop (50ms in disarmed state)
- Any other application logic
These gaps cause ESCs to reset their arming counters or disarm entirely.
The test bench will implement closed-loop control where motors receive different thrust levels based on:
- Desired attitude setpoints (pitch, roll)
- Yaw rotation commands
- Sensor feedback (magnetic encoder, IMU)
This means individual motors will have independent, continuously varying throttle values - not synchronized identical commands.
Implement a three-layer architecture using the RP2040/RP2350 dual-core capability:
┌─────────────────────────────────────────────────────────────┐
│ CLIENT CODE (Core 0) │
│ │
│ - User interface (display, buttons) │
│ - Control algorithms (PID, attitude control) │
│ - Sensor reading and fusion │
│ - Throttle calculations per motor │
│ │
│ motorGroup.setThrottle(index, value) │
└──────────────────────────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ MOTOR GROUP FACADE (spans both cores) │
│ │
│ Core 0 side: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ setThrottle(index, value) │ │
│ │ setAllThrottles([v1, v2, ...]) │ │
│ │ arm() / disarm() │ │
│ │ start() / stop() │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ Shared throttle array (lock-free writes) │
│ │ │
│ Core 1 side: ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Dedicated loop running at 1kHz: │ │
│ │ - Read current throttle values │ │
│ │ - Send DShot command to each motor │ │
│ │ - Maintain precise timing │ │
│ └─────────────────────────────────────────────────────┘ │
└──────────────────────────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ DRIVER (DShotPIO) │
│ │
│ - PIO state machine management │
│ - DShot packet encoding (throttle + telemetry + CRC) │
│ - Single responsibility: send one command to one motor │
│ │
│ motor.sendThrottleCommand(throttle_value) │
└─────────────────────────────────────────────────────────────┘
| Layer | Responsibilities | Core |
|---|---|---|
| Client | Application logic, UI, control algorithms, sensor fusion | Core 0 |
| MotorGroup Facade | Thread lifecycle, timing guarantees, command queue, arming orchestration | Both |
| DShotPIO Driver | Packet encoding, PIO control, single-motor command transmission | Core 1 (called from) |
-
DShotPIO remains simple: No threading awareness, no state beyond PIO. Easy to test and understand.
-
Facade absorbs complexity: Threading, synchronization, timing - all hidden from client code.
-
Client stays focused: Control algorithms don't need to know about DShot timing requirements.
-
Independent throttle values: Each motor has its own throttle in the shared array, supporting differential thrust for attitude control.
Decision: Use lock-free writes for throttle values instead of mutex synchronization.
On ARM Cortex-M0+/M33 (RP2040/RP2350), aligned integer writes are atomic at the machine level. MicroPython integers that fit in a machine word (32-bit) are written atomically.
# This is atomic - no lock needed
self._throttles[motor_index] = value| Aspect | With Lock | Lock-Free |
|---|---|---|
| Latency | Lock acquisition overhead | Zero overhead |
| Complexity | Need lock management | Simpler code |
| Consistency | All values read together | Each value independent |
| Worst case | Brief contention | Core 1 sees stale value for 1 cycle (1ms) |
-
Independent motors: Each throttle value is independent. No requirement to update all motors atomically together.
-
High update rate: Core 1 runs at 1kHz. A 1-cycle-stale value means at most 1ms delay in applying new throttle.
-
Control loop tolerance: Flight control loops typically run at 100-500Hz. The 1ms potential staleness is well within tolerance.
-
Simpler debugging: No deadlock risk, no lock ordering concerns.
class MotorThrottleGroup:
def __init__(self, pins, dshot_speed=DSHOT_SPEEDS.DSHOT600):
# pins: list of Pin objects, e.g. [Pin(4), Pin(5)]
# Create DShotPIO instances internally
self._motors = [DShotPIO(i, pin, dshot_speed) for i, pin in enumerate(pins)]
self._throttles = array.array('H', [0] * len(pins)) # Unsigned 16-bit
self._running = False
def setThrottle(self, motor_index, value):
"""Called from Core 0 - atomic write"""
self._throttles[motor_index] = value
def _core1_loop(self):
"""Runs on Core 1"""
while self._running:
for i, motor in enumerate(self._motors):
motor.sendThrottleCommand(self._throttles[i]) # Atomic read
utime.sleep_us(1000)Using array.array('H', ...) ensures:
- Contiguous memory layout
- Guaranteed 16-bit aligned access
- No Python object overhead per element
- Reliable arming: Commands flow continuously regardless of Core 0 activity
- Consistent timing: 1kHz update rate maintained even during heavy UI updates
- Scalable: Pattern works for 2, 4, or more motors
- Clean separation: Each layer has single responsibility
- Testable: DShotPIO can be tested independently without threading
- Core 1 dedicated: Cannot use Core 1 for other tasks while motors are active
- Debugging harder: Core 1 exceptions don't appear in REPL
- Thread lifecycle: Must properly start/stop Core 1 thread to avoid resource leaks
- MicroPython limitation:
_threadmodule has limited features (no join, limited stack size control)
| Risk | Mitigation |
|---|---|
| Core 1 crash goes unnoticed | Add heartbeat flag that Core 0 monitors |
| Thread not stopped on exception | Use try/finally in main code to call stop() |
| Stack overflow on Core 1 | Keep Core 1 loop minimal, no deep call chains |
The facade should provide an arm() method that:
- Sets all throttles to 0
- Waits for arming duration (300-500ms depending on ESC firmware)
- Returns success (future: verify via bidirectional DShot telemetry)
Since Core 1 is continuously sending, arming just requires holding throttle at 0 for the required duration - no special timing code needed.
Set all throttles to 0. ESCs interpret continuous throttle=0 as disarmed state.
def emergencyStop(self):
"""Immediate stop - called from Core 0"""
for i in range(len(self._throttles)):
self._throttles[i] = 0This is safe because each write is atomic and Core 1 will pick up the zeros within 1ms.
Status: Verified on hardware (2026-02-01)
| Component | Model | Specifications |
|---|---|---|
| Controller | Raspberry Pi Pico 2 | RP2350, dual ARM Cortex-M33 |
| Motors | BetaFPV Lava Series 1104 (x2) | 7200KV, 5g weight |
| ESC | JHEMCU Brushless Wing Dual 40A 2-in-1 | 40A×2 channels, 2-6S, BLHeli_S G-H-30 V16.7 |
| Protocol | DShot600 | 4.8MHz PIO clock |
| Test | Result | Notes |
|---|---|---|
test_dshot_single_motor.py |
Pass | Low-level driver, 1ms command interval |
test_motor_throttle_group.py |
Pass | Facade, both motors arm reliably |
Tested with specific hardware (JHEMCU 40A ESC + test bench motors). Not tested with alternative hardware.
| Parameter | Value | Notes |
|---|---|---|
| Minimum throttle | 70 | Hardware-specific; values 50-69 unreliable on test bench |
| Command interval (single-threaded) | 1ms | 2ms caused occasional dropouts |
| Command interval (facade) | 1ms | Core 1 maintains steady 1kHz |
| Arming duration | 500ms | Reliable with BLHeli_S firmware |
The dual-core architecture completely eliminated arming failures. Previously, arming succeeded ~50% of the time with one motor often failing. After implementing MotorThrottleGroup, both motors arm reliably every time.
- RP2040 Datasheet - Dual Core
- MicroPython _thread module
- ARM Cortex-M0+ Memory Ordering
- DShot Protocol Specification:
specification/DSHOT_PROTOCOL.md - BetaFPV Lava 1104 Motors
- JHEMCU Dual 40A ESC