# 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`](architecture.md). Sprint 1 plan: see [`docs/sprint-1-plan.md`](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**: ```powershell # 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 ```powershell .\.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: ```powershell .\.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`](../firmware/ar_autopilot_v1/tools/modbus_client_test.py). ```powershell # 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`](../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: ```powershell .\.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 ``. 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](https://www.mingw-w64.org/), 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` |