Files
AR-Autopilot/docs/firmware.md
T
alro65 65860948b4 sprint-1: firmware ESP32 base -- STANDBY + Modbus + NMEA 2000 + watchdog
End-to-end implementation of Sprint 1 per docs/sprint-1-plan.md.

Builds: pio run -e esp32-dev SUCCESS, RAM 6.7%, Flash 26.5% (347 KB).
Tests: pytest 110/110 green; pio test -e native deferred (needs host
       C++ compiler -- none on this Windows machine).

Firmware (firmware/ar_autopilot_v1/):

- platformio.ini: 4 envs (esp32-dev release, esp32-debug, native unity
  tests, check static analysis). NMEA2000-library@4.22, NMEA2000_esp32@
  1.0, eModbus@1.7.4 pinned.
- main.cpp: boot in STANDBY, FreeRTOS task spawn, returns to scheduler.
- system/: ar_log.h facade, task_config.h (priorities/stacks/cores
  central table), heartbeat (1 Hz LED + uptime).
- modes/: STANDBY-only state machine; non-STANDBY rejected.
- hal/: di_do.cpp (5 DI + 10 DO with debounce + last-state cache),
  rudder_sensor.cpp (100 Hz ADC + 5-sample median filter, Core 1),
  rudder_actuator.cpp (DO1/DO2/DO3 with three safety interlocks:
  power-off, STANDBY mode, limit switch).
- safety/: TWDT @ 2 s panic-on-expire; 50 Hz safety task on Core 1
  enforcing DI1 physical disengage button, DI4 external alarm,
  both-limit-switch interlock.
- protocols/modbus_slave.cpp: eModbus RTU server on UART2 @ 38400 8N1,
  slave ID 1. 17 inputs + 19 discretes + 5 holdings + 4 coils. Reads
  pull live telemetry; writes validate range and route to handlers.
- protocols/nmea2000_consumer.cpp: stack open with CAN TX=GPIO3
  RX=GPIO1, subscribed to PGN 127250 (Heading) + PGN 127251 (Rate of
  Turn). 5 s staleness flag built in for Sprint 6 alarm wiring.
- filters/median.h: templated MedianFilter<T,N> (host testable).

Cross-cutting:

- modbus_registers.yaml: single source of truth for the Modbus register
  map. 45 entries.
- tools/gen_modbus_registers.py: YAML -> C++ header + Python module
  generator with --check for drift detection.
- arautopilot/shared/modbus_register_map.py: generated Python mirror,
  imported by Studio + tools.
- arautopilot/tests/test_modbus_register_map.py: 30 tests covering
  schema, address uniqueness, range, spot-checks, and drift detection
  (fails if YAML edited without regenerating).
- firmware/ar_autopilot_v1/tools/modbus_client_test.py: manual Modbus
  client for poking the slave from a PC with USB-RS485 dongle.
- firmware/ar_autopilot_v1/test/test_median_filter/test_median.cpp:
  8 Unity tests of the median filter (host-side, no Arduino dependency).
- docs/firmware.md: full operator + integrator guide (toolchain, build,
  flash, expected boot log, troubleshooting, Sprint 1 capability matrix).

Architecture note: opted for Arduino-on-ESP32 only instead of the
proposed dual Arduino-as-ESP-IDF-component setup. Rationale documented
in CHANGELOG and docs/firmware.md -- Arduino-on-ESP32 already provides
the FreeRTOS primitives we need; dual framework adds fragility without
benefit at Sprint 1 scope. Reconsider in Sprint 8 (OTA + secure boot).

NOT in Sprint 1 (intentional per brief sec. 12):
  - PID loops (inner/outer)
  - True Course / Track Keeping
  - Full alarm catalogue beyond DI1/DI4
  - Knob driver
  - Studio GUI / dedicated display

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:45:56 -04:00

9.0 KiB

AR-Autopilot firmware — operator + integrator guide

This document covers everything needed to:

  • Build the firmware locally.
  • Flash a bare AR-NMEA-IO board for the first time.
  • Talk to the running firmware over Modbus RTU and via the NMEA 2000 backbone.
  • Diagnose common boot-time failures.

Project layout: see docs/architecture.md. Sprint 1 plan: see docs/sprint-1-plan.md.


1. Toolchain

Tool Version Install
Python 3.11+ system
PlatformIO Core 6.1+ pip install platformio (already in .venv after bash scripts/dev.sh install)
ESP32 toolchain (xtensa-esp32-elf-gcc) latest auto-downloaded by PlatformIO on first build
Host C++ compiler (optional) g++ 13+ or clang 17+ or MSVC needed only for native Unity tests

You do NOT need the Arduino IDE; PlatformIO bundles its own version of the Arduino-ESP32 core (pinned in platformio.ini).


2. Build

From the repo root:

# Production build (release, -Os)
.\.venv\Scripts\pio.exe run -e esp32-dev -d firmware/ar_autopilot_v1

# Debug build (-O0, verbose logs)
.\.venv\Scripts\pio.exe run -e esp32-debug -d firmware/ar_autopilot_v1

# Static analysis
.\.venv\Scripts\pio.exe check -e check -d firmware/ar_autopilot_v1

(Or cd firmware/ar_autopilot_v1 first and drop the -d flag.)

Both builds emit firmware/ar_autopilot_v1/.pio/build/esp32-dev/firmware.bin (~340 KB). Flash usage in Sprint 1: ~27 % of the 4 MB partition; RAM at idle ~22 KB of 320 KB.


3. Flash

.\.venv\Scripts\pio.exe run -e esp32-dev -t upload -d firmware/ar_autopilot_v1 --upload-port COM7

(Replace COM7 with the port your board enumerates as. The first power-up after a bare flash should drop the chip into download mode automatically; if not, hold BOOT while pressing EN.)

After upload, open a serial monitor:

.\.venv\Scripts\pio.exe device monitor -e esp32-dev -d firmware/ar_autopilot_v1 --port COM7

You should see, within ~250 ms of boot:

[AR/MAIN] ================================================
[AR/MAIN]   AR-Autopilot -- firmware boot
[AR/MAIN]   version : 0.1.0-sprint1
[AR/MAIN]   variant : release
[AR/MAIN]   build   : ...
[AR/MAIN]   board   : AR-NMEA-IO v1.0
[AR/MAIN]   mcu     : ESP32-DOWD @ 240 MHz
[AR/MAIN]   cores   : 2  free heap: 270xxx bytes
[AR/MAIN] ================================================
[AR/MODE] mode_init: STANDBY
[AR/HAL]  di_init: 5 digital inputs configured (INPUT_PULLUP)
[AR/HAL]  do_init: 10 digital outputs configured (driven LOW)
[AR/HAL]  rudder_sensor_init: 12-bit ADC, 11dB attn, 5-sample median
[AR/HAL]  rudder_actuator_init: power OFF, both directions LOW
[AR/SAFE] watchdog_init: TWDT timeout 2s, panic on expire
[AR/MAIN] spawning Sprint 1 tasks ...
[AR/SAFE] safety_monitor task started on core 1 (50 Hz)
[AR/HAL]  rudder_sensor task started on core 1, pin 36 (ADC1)
[AR/SYS]  heartbeat task started on core 0, pin 4
[AR/MB]   modbus_slave_init: slave_id=1  inputs=17  discretes=19  holdings=5  coils=4
[AR/MB]   modbus_slave_start: listening on UART2 @ 38400 baud (RX=22 TX=21 DE=23)
[AR/N2K]  nmea2000_consumer_init: stack open, CAN TX=3 RX=1, subscribed to PGN 127250 + 127251
[AR/N2K]  nmea2000_consumer task started on core 0
[AR/MAIN] setup() complete; control loop is FreeRTOS-driven.
[AR/MAIN] current mode: STANDBY (helm is manual)

The DO5 LED ("pilot engaged" lamp on the production wiring; co-opted as heartbeat in Sprint 1) should blink at exactly 1 Hz. If it doesn't, the heartbeat task didn't start — check the serial log.


4. Sprint 1 capability matrix

Capability State
Boot in STANDBY mode
1 Hz heartbeat LED + uptime log
100 Hz rudder sensor read with 5-sample median filter
50 Hz safety monitor (DI poll, debounce, watchdog feed)
Physical disengage button (DI1) forces STANDBY + cuts actuator power
External alarm (DI4) forces STANDBY
Both-limit-switch interlock cuts actuator power
Task Watchdog Timer at 2 s (auto-reset if any monitored task hangs)
Modbus RTU slave on UART2 (38400 8N1, slave ID 1)
17 input registers, 19 discrete inputs, 5 holding registers, 4 coils
NMEA 2000 stack open (TX=GPIO3, RX=GPIO1)
Consume PGN 127250 (Heading)
Consume PGN 127251 (Rate of Turn)
Heading + ROT exposed via Modbus input registers 24-26
PID inner loop Sprint 2
PID outer loop + Heading Hold Sprint 3
True Course / Track Keeping Sprint 5
Alarm catalogue + auto-disengage by reason Sprint 6
Knob driver + commissioning wizard Sprint 7
EKF + adaptive tuning Sprint 8

5. Talking to the slave from a PC

The repo ships a manual Modbus client at firmware/ar_autopilot_v1/tools/modbus_client_test.py.

# Install pymodbus once (not in requirements by default to keep core lean)
.\.venv\Scripts\python.exe -m pip install "pymodbus>=3.6,<4"

# Run against a USB-RS485 dongle on COM7
.\.venv\Scripts\python.exe firmware/ar_autopilot_v1/tools/modbus_client_test.py --port COM7

It reads firmware version, mode, rudder angle, NMEA-2000 heading/ROT, writes a setpoint, and pulses the disengage coil. Output looks like:

[connect] COM7 @ 38400 8N1, slave=1
[fw   ] version v0.1.0  schema=0  uptime=42s
[mode ] STANDBY (0)
[rudd ] angle=+0.34 deg  raw_adc=2052  valid=yes
[n2k  ] heading=  0.00 deg  rot=+0.00 deg/s  age=60000 ms
[hold ] wrote heading setpoint 180.00 deg, read back +180.00 deg
[coil ] disengage pulsed; PILOT_ENGAGED = no (correct -- Sprint 1 is always STANDBY)

The age=60000 ms reading is normal when no real NMEA 2000 traffic is present — the firmware clamps the displayed age at 60 s.


6. Modbus register map

Single source of truth: firmware/ar_autopilot_v1/modbus_registers.yaml.

Generates:

  • firmware/ar_autopilot_v1/src/protocols/modbus_registers.h (C++)
  • arautopilot/shared/modbus_register_map.py (Python)

Regenerate after editing the YAML:

.\.venv\Scripts\python.exe tools/gen_modbus_registers.py

The pytest suite contains a drift-detection test (test_generated_files_are_in_sync_with_yaml) that will fail if you forget to regenerate.


7. NMEA 2000 wiring

Default CAN pins on the AR-NMEA-IO v1.0:

  • TX = GPIO3
  • RX = GPIO1

These are overridden by #define ESP32_CAN_TX_PIN / RX_PIN in firmware/ar_autopilot_v1/src/protocols/nmea2000_consumer.cpp before including <NMEA2000_CAN.h>. If a future board revision moves the CAN transceiver, update pinout.h and rebuild — no other change is needed.

PGNs consumed in Sprint 1:

PGN Name Where it's used
127250 Vessel Heading input register HEADING_DEG_X100 (16-bit, scale 0.01 deg)
127251 Rate of Turn input register ROT_DPS_X100 (signed int16, scale 0.01 deg/s)

The same task also keeps two age counters (heading_age_ms, rot_age_ms) that drive the heading_valid / rot_valid flags. They become false after 5 s without an update — used in Sprint 6 to fire ALARM_HEADING_LOST.


8. Tests

Test suite Where How to run Status
Python core + library arautopilot/tests/ bash scripts/dev.sh test or pytest 110 / 110 green
Firmware host-side Unity (median filter, future PID math) firmware/ar_autopilot_v1/test/ pio test -e native (needs host g++/clang/MSVC) requires host C++ compiler
Firmware on-target (when board is wired) same pio test -e esp32-dev requires board

The host-side Unity tests are intentionally written so they can run on any developer machine with a working C++ toolchain — they have no Arduino dependency. On this Windows host PlatformIO complained that gcc was not in PATH; install MinGW-w64, MSVC Build Tools, or run the tests on Linux/WSL and they will execute against the same sources.


9. Troubleshooting

Symptom Likely cause Fix
Boot loop with Guru Meditation Watchdog tripped because a custom task didn't feed it within 2 s Look for [AR/SAFE] watchdog_subscribe_current_task() in the task and call watchdog_feed() once per iteration
[AR/N2K] NMEA2000.Open() failed CAN transceiver not powered or pins wrong Confirm PIN_CAN_TX/PIN_CAN_RX in pinout.h match your board; check that the MCP2562/TJA1051 has 5 V supply
Modbus client times out Wrong baudrate, wrong slave ID, DE pin not toggling Check [AR/MB] modbus_slave_start line in the serial log; confirm RS-485 wiring A/B not swapped; install a 120 Ω termination at each end of the bus
[AR/HAL] do_write: unknown pin N Code is trying to write a pin not declared in the g_do_slots table Add the pin to firmware/ar_autopilot_v1/src/hal/di_do.cpp
min_useful_pwm_pct must be >= deadband_pct from Studio Operator profile has a calibration inconsistency Edit the ProjectConfig to make min_useful_pwm_pct >= deadband_pct