12 KiB
AM32 ESC Flasher Module - Implementation Plan
Context
PX4 has no upstream support for AM32 ESC firmware updates. Matt's branch (jake/matt_firefly_am32_flasher) proves the concept works but has architectural issues: firmware baked into the build as a C array, ~100-state machine with poor error recovery, tight coupling to DShot via a 4-message uORB handshake, no GCS progress reporting, and ~826 lines of flashing logic injected into the DShot driver.
Goal: Clean, upstream-quality esc_flasher module that reads firmware from SD card, is triggered via MAVLink command from QGC, reports per-ESC progress via a custom MAVLink message, and requires a reboot to resume normal DShot operation. Structured to allow future USB passthrough (4-way interface) extension.
Architecture Overview
QGC PX4 │ │ ├─COMMAND_LONG──────────────►│ vehicle_command uORB │ MAV_CMD_ESC_FW_UPDATE │ │ │ │ esc_flasher module │ │ (dormant at boot, wakes on cmd) │ │ │ │ │ 1. Validate (disarmed, files exist) │ │ 2. ACK command │ │ 3. Stop DShot module │ │ 4. Take over motor GPIOs │ │ 5. For each ESC: │ │ - Bootloader handshake │ │ - Read device info → pick FW file │◄─ESC_FW_UPDATE_STATUS──────│ - Flash 256-byte chunks │ (per-ESC progress) │ - Send RUN_APP │ │ 6. Publish REBOOT_REQUIRED │◄─COMMAND_ACK (ACCEPTED)────│ 7. Go idle (reboot needed)
New Files
- src/modules/esc_flasher/EscFlasher.hpp
Module class inheriting ModuleBase + ModuleParams + ScheduledWorkItem.
Key members:
- _vehicle_command_sub — polls for MAV_CMD trigger
- _esc_fw_update_status_pub — publishes per-ESC progress
- _command_ack_pub — ACKs the initial command
- State enum: Idle, StoppingDShot, InitGPIOs, ConnectingESC, ReadingInfo, Flashing, RunApp, Complete, Failed
- Per-ESC state: _current_esc_index, _esc_count, _bytes_written, _firmware_size
- GPIO pin array: uint32_t _gpio_pins[MAX_MOTORS]
- File handle for current firmware
- src/modules/esc_flasher/EscFlasher.cpp
Main module implementation (~600-800 lines, much simpler than Matt's ~1,900).
Module lifecycle:
- Auto-started at boot on supported boards (Kconfig opt-in)
- Runs at 1Hz when idle (vehicle_command poll only — negligible overhead)
- Transitions to 100Hz active polling during flashing
- Returns to idle after completion (reboot required to restore DShot)
DShot shutdown:
- Calls system("dshot stop") via NuttX shell — DShot destructor calls up_dshot_arm(false)
- Waits up to 2s for DShot to exit, then proceeds
- Simple, no coupling — DShot module has no knowledge of the flasher
GPIO takeover:
- After DShot stops, configures motor pins as GPIO outputs via px4_arch_configgpio(io_timer_channel_get_gpio_output(ch))
- Drives all pins HIGH for 600ms to force ESCs into bootloader mode on reboot
- ESC power cycle not required — AM32 bootloader detects signal-high at startup
Flashing loop (per ESC):
- Select ESC (drive target pin, keep others HIGH)
- Send bootloader handshake → get 9-byte device info
- Read firmware tag (CMD_READ_FLASH at tag address) → extract hardware target string
- Open /fs/microsd/am32/.bin (e.g., AM32_G071_48K.bin)
- For each 256-byte chunk:
- CMD_SET_ADDRESS
- CMD_SET_BUFFER + data + CRC
- CMD_PROG_FLASH → wait ACK
- Publish progress (chunk_index / total_chunks * 100)
-
Write firmware tag section
-
CMD_RUN → ESC reboots to application
-
Move to next ESC
-
src/modules/esc_flasher/am32_protocol.hpp / .cpp
Extracted and cleaned AM32 bootloader protocol implementation.
Reused from Matt's code (protocol is correct):
- crc16_ccitt() — CRC-16 with polynomial 0xA001
- bitbang_send_packet() — bit-banged UART TX/RX at 19200 baud
- 52µs bit time, 26µs half-bit for sampling
- px4_enter_critical_section() for timing accuracy
- hrt_absolute_time() busy-wait loops
- Bootloader handshake packet: {0x00 * 8, 0x0D, 'B','L','H','e','l','i', CRC}
- Command encoding: SET_ADDRESS(0xFF), SET_BUFFER(0xFE), PROG_FLASH(0x01), RUN(0x00), READ_FLASH(0x03)
Cleaned up vs Matt's code:
- Function-based API instead of inline state machine code
- Proper error return codes instead of magic numbers
- Configurable timeouts instead of hardcoded values
- Separated from module state machine logic
Public API: namespace am32 { int handshake(uint32_t gpio, uint8_t device_info[9]); int set_address(uint32_t gpio, uint16_t address); int write_chunk(uint32_t gpio, const uint8_t *data, uint16_t len); int read_flash(uint32_t gpio, uint16_t address, uint8_t *buf, uint16_t len); int run_app(uint32_t gpio); uint16_t crc16(const uint8_t *buf, uint16_t len); }
- src/modules/esc_flasher/CMakeLists.txt
px4_add_module( MODULE modules__esc_flasher MAIN esc_flasher SRCS EscFlasher.cpp am32_protocol.cpp DEPENDS px4_work_queue )
- src/modules/esc_flasher/Kconfig
menuconfig MODULES_ESC_FLASHER bool "esc_flasher" default n ---help--- AM32 ESC firmware update module
- msg/EscFirmwareUpdateStatus.msg
uint64 timestamp
uint8 esc_index # Current ESC being flashed (0-based) uint8 esc_count # Total ESCs being flashed uint8 progress_pct # 0-100 for current ESC uint8 overall_progress_pct # 0-100 for entire operation
uint8 status uint8 STATUS_IDLE = 0 uint8 STATUS_CONNECTING = 1 uint8 STATUS_READING_INFO = 2 uint8 STATUS_ERASING = 3 uint8 STATUS_WRITING = 4 uint8 STATUS_COMPLETE = 5 uint8 STATUS_FAILED = 6 uint8 STATUS_REBOOT_REQUIRED = 7
uint8 error uint8 ERROR_NONE = 0 uint8 ERROR_NOT_DISARMED = 1 uint8 ERROR_NO_FIRMWARE_FILE = 2 uint8 ERROR_COMMS_TIMEOUT = 3 uint8 ERROR_CRC_MISMATCH = 4 uint8 ERROR_VERIFICATION_FAILED = 5 uint8 ERROR_UNSUPPORTED_HARDWARE = 6 uint8 ERROR_DSHOT_STOP_FAILED = 7
uint32 current_firmware_version # Currently installed version uint32 new_firmware_version # Version being written
Modified Files
- msg/versioned/VehicleCommand.msg
Add command constant in PX4-internal range (for development; upstream MAVLink later):
uint32 VEHICLE_CMD_ESC_FIRMWARE_UPDATE = 100002
- param1: ESC index (0-based, 255 = all ESCs)
- param2: ESC type (0 = AM32)
- MAVLink message definition (development.xml in mavlink submodule)
Add ESC_FIRMWARE_UPDATE_STATUS message definition. This is in the MAVLink submodule — we'll fork/PR it separately. For initial development, stream the uORB message as a generic STATUSTEXT fallback.
- src/modules/mavlink/streams/ESC_FIRMWARE_UPDATE_STATUS.hpp
New MAVLink stream class that subscribes to esc_firmware_update_status uORB and sends the MAVLink message. Follow the pattern of existing streams like ESC_STATUS.hpp.
- src/modules/mavlink/mavlink_messages.cpp
Register the new stream.
- Board configs
Enable MODULES_ESC_FLASHER on boards that use AM32 ESCs (e.g., ARK boards). Add esc_flasher start to the board's startup script (rc.board_defaults or similar).
Key Design Decisions
Why stop DShot entirely (not just pause)?
- DShot uses DMA + timer hardware that conflicts with GPIO bit-bang
- Clean shutdown via destructor guarantees all DMA released, timers freed
- Reboot to restore is simple and safe — avoids complex reinit edge cases
- ESCs need power cycle anyway after reflash
Why system("dshot stop") instead of uORB handshake?
- Zero coupling — DShot module doesn't know flasher exists
- Works with any output driver (PWM, OneShot, future drivers)
- DShot's existing ModuleBase::stop() + destructor already handles cleanup
- Matt's 4-message uORB handshake added 826 lines to DShot for this
Why auto-start + idle instead of on-demand spawn?
- Module is already loaded and subscribed to vehicle_command — instant response
- 1Hz idle poll is negligible overhead
- Avoids complexity of commander spawning external modules
- Kconfig opt-in means boards that don't need it pay zero cost
Why separate am32_protocol from module?
- Clean separation of protocol logic from module orchestration
- Protocol code can be reused by future 4-way passthrough implementation
- Easier to test protocol functions in isolation
Firmware File Convention
Files in /fs/microsd/am32/:
- Named by AM32 hardware target: AM32_G071_48K.bin, AM32_F051_32K.bin, etc.
- The flasher connects to each ESC bootloader, reads the firmware tag area to determine hardware target
- Maps tag → filename, opens file, flashes
- If no matching file found, reports ERROR_NO_FIRMWARE_FILE for that ESC and moves on
State Machine (simplified)
Idle ──(MAV_CMD)──► StoppingDShot ──(dshot stopped)──► InitGPIOs │ (pins HIGH, 600ms) │ ┌──► ConnectESC │ │ │ (handshake OK) │ │ │ ReadInfo ──► LoadFirmware ──► Flash │ │ │ (all chunks done) │ │ │ RunApp ◄──────────────────────┘ │ │ │ (next ESC?) │ │ └──YES─┘ │ NO ▼ Complete (REBOOT_REQUIRED)
Error from any flashing state → Failed (with error code), skip to next ESC or abort.
Implementation Order
- am32_protocol.cpp/hpp — Extract and clean up Matt's protocol code (CRC, bitbang, commands)
- EscFirmwareUpdateStatus.msg — uORB message definition
- VehicleCommand.msg — Add command constant
- EscFlasher.hpp/cpp — Module skeleton with state machine
- CMakeLists.txt + Kconfig — Build integration
- MAVLink stream — ESC_FIRMWARE_UPDATE_STATUS.hpp + register in mavlink_messages.cpp
- Board config — Enable on a test board
- Test — SITL smoke test (state machine logic), then hardware test
Verification
- Build: make ark_fmu-v6x_default (or target board) — verify clean compile
- SITL smoke test: Module starts, responds to vehicle command, reports error (no GPIOs in SITL)
- Hardware test:
- Place AM32 firmware on SD card
- Send MAV_CMD_ESC_FIRMWARE_UPDATE via commander shell or MAVLink
- Verify DShot stops, ESCs enter bootloader, firmware flashes, progress reported
- Reboot, verify ESCs running new firmware
- QGC integration: Verify COMMAND_ACK and status messages appear in MAVLink inspector
Future Extensions (not in this PR)
- 4-way interface passthrough: Add USB serial passthrough mode using the same am32_protocol infrastructure. The flasher module would proxy between USB serial (4-way protocol) and GPIO bit-bang.
- Runtime DShot restart: Instead of requiring reboot, add dshot start after flashing. Needs careful timer/DMA reinit validation.
- ESC settings read/write: Extend am32_protocol with EEPROM read/write commands.
- Orchestrator module: If hot-switching between DShot and passthrough becomes needed, add a motor_output_mgr similar to CDC ACM.