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>
This commit is contained in:
2026-05-18 10:45:56 -04:00
parent 1d7dd63327
commit 65860948b4
31 changed files with 3310 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
"""AR-Autopilot Modbus RTU register map -- generated mirror of the firmware.
AUTO-GENERATED. Do not edit by hand.
Source: firmware/ar_autopilot_v1/modbus_registers.yaml
Regenerate with: python tools/gen_modbus_registers.py
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Reg:
"""One Modbus register entry as seen from Python tooling."""
addr: int
name: str
desc: str = ""
unit: str = ""
scale: float = 1.0
offset: float = 0.0
MAP_SCHEMA_VERSION = "0.1.0"
SLAVE_ID = 1
BAUDRATE = 38400
PARITY = "N"
DATA_BITS = 8
STOP_BITS = 1
# DISCRETES
DISCRETES: dict[str, Reg] = {
"PILOT_ENGAGED": Reg(addr=0, name="PILOT_ENGAGED", desc='1 if the pilot is currently engaged (any mode other than STANDBY)', unit="", scale=1.0, offset=0.0),
"DI_DISENGAGE_BUTTON": Reg(addr=1, name="DI_DISENGAGE_BUTTON", desc='Live state of the physical Engage/Disengage push-button', unit="", scale=1.0, offset=0.0),
"DI_LIMIT_PORT": Reg(addr=2, name="DI_LIMIT_PORT", desc='Port-side rudder mechanical end-stop reached', unit="", scale=1.0, offset=0.0),
"DI_LIMIT_STBD": Reg(addr=3, name="DI_LIMIT_STBD", desc='Starboard-side rudder mechanical end-stop reached', unit="", scale=1.0, offset=0.0),
"DI_EXTERNAL_ALARM": Reg(addr=4, name="DI_EXTERNAL_ALARM", desc='External critical alarm (VMS / genset) asserted', unit="", scale=1.0, offset=0.0),
"DI_MANUAL_CONFIRM": Reg(addr=5, name="DI_MANUAL_CONFIRM", desc='Manual confirmation switch asserted (emergency override)', unit="", scale=1.0, offset=0.0),
"ACTUATOR_POWER": Reg(addr=8, name="ACTUATOR_POWER", desc='Master actuator power relay (DO3) commanded ON', unit="", scale=1.0, offset=0.0),
"ACTUATOR_DRIVING_PORT": Reg(addr=9, name="ACTUATOR_DRIVING_PORT", desc='Actuator direction output: driving to port', unit="", scale=1.0, offset=0.0),
"ACTUATOR_DRIVING_STBD": Reg(addr=10, name="ACTUATOR_DRIVING_STBD", desc='Actuator direction output: driving to starboard', unit="", scale=1.0, offset=0.0),
"ALARM_OFF_COURSE": Reg(addr=16, name="ALARM_OFF_COURSE", desc='Off-course warning active', unit="", scale=1.0, offset=0.0),
"ALARM_OFF_COURSE_SEVERE": Reg(addr=17, name="ALARM_OFF_COURSE_SEVERE", desc='Severe off-course alarm (auto-disengage)', unit="", scale=1.0, offset=0.0),
"ALARM_RUDDER_NOT_RESP": Reg(addr=18, name="ALARM_RUDDER_NOT_RESP", desc='Rudder command sent but no feedback motion', unit="", scale=1.0, offset=0.0),
"ALARM_HEADING_LOST": Reg(addr=19, name="ALARM_HEADING_LOST", desc='NMEA 2000 heading PGN not received >5 s', unit="", scale=1.0, offset=0.0),
"ALARM_ACTUATOR_OVERCURR": Reg(addr=20, name="ALARM_ACTUATOR_OVERCURR", desc='Actuator current over threshold', unit="", scale=1.0, offset=0.0),
"ALARM_VOLTAGE_LOW": Reg(addr=21, name="ALARM_VOLTAGE_LOW", desc='Supply voltage below safe threshold', unit="", scale=1.0, offset=0.0),
"ALARM_LIMIT_REACHED": Reg(addr=22, name="ALARM_LIMIT_REACHED", desc='Rudder reached mechanical end-stop', unit="", scale=1.0, offset=0.0),
"ALARM_WATCHDOG_TRIPPED": Reg(addr=23, name="ALARM_WATCHDOG_TRIPPED", desc='Firmware watchdog fired -- controller reset', unit="", scale=1.0, offset=0.0),
"ALARM_VMS_CRITICAL": Reg(addr=24, name="ALARM_VMS_CRITICAL", desc='VMS reported blackout or electrical fault', unit="", scale=1.0, offset=0.0),
"ANY_ALARM_ACTIVE": Reg(addr=25, name="ANY_ALARM_ACTIVE", desc='OR of all alarm bits (convenience)', unit="", scale=1.0, offset=0.0),
}
# COILS
COILS: dict[str, Reg] = {
"CMD_ENGAGE_REQUEST": Reg(addr=0, name="CMD_ENGAGE_REQUEST", desc='Rising edge requests pilot engagement (subject to interlocks)', unit="", scale=1.0, offset=0.0),
"CMD_DISENGAGE_REQUEST": Reg(addr=1, name="CMD_DISENGAGE_REQUEST", desc='Rising edge forces pilot to STANDBY', unit="", scale=1.0, offset=0.0),
"CMD_ACK_ALL_ALARMS": Reg(addr=2, name="CMD_ACK_ALL_ALARMS", desc='Rising edge acknowledges every active alarm', unit="", scale=1.0, offset=0.0),
"CMD_KNOB_ARM": Reg(addr=3, name="CMD_KNOB_ARM", desc='Knob arming request (Sprint 7+)', unit="", scale=1.0, offset=0.0),
}
# INPUTS
INPUTS: dict[str, Reg] = {
"FW_VERSION_MAJOR": Reg(addr=0, name="FW_VERSION_MAJOR", desc='Firmware major version', unit="", scale=1.0, offset=0.0),
"FW_VERSION_MINOR": Reg(addr=1, name="FW_VERSION_MINOR", desc='Firmware minor version', unit="", scale=1.0, offset=0.0),
"FW_VERSION_PATCH": Reg(addr=2, name="FW_VERSION_PATCH", desc='Firmware patch version', unit="", scale=1.0, offset=0.0),
"SCHEMA_VERSION": Reg(addr=3, name="SCHEMA_VERSION", desc='Modbus map schema version (0=v0.1.0)', unit="", scale=1.0, offset=0.0),
"UPTIME_SECONDS_LO": Reg(addr=4, name="UPTIME_SECONDS_LO", desc='Uptime seconds, low 16 bits', unit="s", scale=1.0, offset=0.0),
"UPTIME_SECONDS_HI": Reg(addr=5, name="UPTIME_SECONDS_HI", desc='Uptime seconds, high 16 bits', unit="s", scale=1.0, offset=0.0),
"CURRENT_MODE": Reg(addr=6, name="CURRENT_MODE", desc='Current AutopilotMode (0=STANDBY,1=HH,2=TC,3=TK,4=DODGE)', unit="", scale=1.0, offset=0.0),
"FREE_HEAP_KB": Reg(addr=7, name="FREE_HEAP_KB", desc='Current free heap, KiB', unit="KiB", scale=1.0, offset=0.0),
"MIN_FREE_HEAP_KB": Reg(addr=8, name="MIN_FREE_HEAP_KB", desc='Minimum free heap since boot', unit="KiB", scale=1.0, offset=0.0),
"RUDDER_ANGLE_DEG_X100": Reg(addr=16, name="RUDDER_ANGLE_DEG_X100", desc='Filtered rudder angle, deg * 100 (-3500..+3500)', unit="deg", scale=0.01, offset=0.0),
"RUDDER_RAW_ADC": Reg(addr=17, name="RUDDER_RAW_ADC", desc='Raw ADC reading after median filter (0..4095)', unit="counts", scale=1.0, offset=0.0),
"RUDDER_VALID": Reg(addr=18, name="RUDDER_VALID", desc='1 if median filter has filled (>=5 samples)', unit="", scale=1.0, offset=0.0),
"HEADING_DEG_X100": Reg(addr=24, name="HEADING_DEG_X100", desc='Current heading from NMEA 2000 PGN 127250, deg*100 (0..35999)', unit="deg", scale=0.01, offset=0.0),
"ROT_DPS_X100": Reg(addr=25, name="ROT_DPS_X100", desc='Rate of turn from PGN 127251, deg/s*100 (signed int16)', unit="deg/s", scale=0.01, offset=0.0),
"HEADING_AGE_MS": Reg(addr=26, name="HEADING_AGE_MS", desc='Milliseconds since the last heading update (0..60000)', unit="ms", scale=1.0, offset=0.0),
"BATTERY_VOLTAGE_X100": Reg(addr=32, name="BATTERY_VOLTAGE_X100", desc='System battery voltage, V*100', unit="V", scale=0.01, offset=0.0),
"ACTUATOR_CURRENT_X100": Reg(addr=33, name="ACTUATOR_CURRENT_X100", desc='Actuator current, A*100', unit="A", scale=0.01, offset=0.0),
}
# HOLDINGS
HOLDINGS: dict[str, Reg] = {
"MODE_REQUEST": Reg(addr=0, name="MODE_REQUEST", desc='Mode requested by operator (0=STANDBY,1=HH,2=TC,3=TK,4=DODGE)', unit="", scale=1.0, offset=0.0),
"HEADING_SETPOINT_X100": Reg(addr=1, name="HEADING_SETPOINT_X100", desc='Desired heading, deg*100', unit="deg", scale=0.01, offset=0.0),
"BRIGHTNESS_PCT": Reg(addr=2, name="BRIGHTNESS_PCT", desc='Display brightness 0..100', unit="%", scale=1.0, offset=0.0),
"ALARM_VOLUME_PCT": Reg(addr=3, name="ALARM_VOLUME_PCT", desc='Alarm volume 0..100', unit="%", scale=1.0, offset=0.0),
"DODGE_OFFSET_DEG_X100": Reg(addr=8, name="DODGE_OFFSET_DEG_X100", desc='Dodge mode heading offset, deg*100 (signed int16)', unit="deg", scale=0.01, offset=0.0),
}
ALL_GROUPS: dict[str, dict[str, Reg]] = {
"discretes": DISCRETES,
"coils": COILS,
"inputs": INPUTS,
"holdings": HOLDINGS,
}
@@ -0,0 +1,164 @@
"""Tests for the generated ``arautopilot.shared.modbus_register_map``.
This module is regenerated from ``firmware/ar_autopilot_v1/modbus_registers.yaml``
by ``tools/gen_modbus_registers.py``. The tests below are the safety net that
flags drift: if anyone edits the YAML, the generated Python module must be
regenerated, and these tests verify the result is structurally sound.
The tests deliberately do NOT pin the *content* of every register (which would
just duplicate the YAML); they pin invariants that must always hold.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
import pytest
from arautopilot.shared import modbus_register_map as m
REPO_ROOT = Path(__file__).resolve().parents[2]
YAML_PATH = REPO_ROOT / "firmware/ar_autopilot_v1/modbus_registers.yaml"
GEN_SCRIPT = REPO_ROOT / "tools/gen_modbus_registers.py"
HEADER_PATH = (
REPO_ROOT / "firmware/ar_autopilot_v1/src/protocols/modbus_registers.h"
)
PY_PATH = REPO_ROOT / "arautopilot/shared/modbus_register_map.py"
# ----------------------------------------------------------------------------
# Schema / connection settings
# ----------------------------------------------------------------------------
def test_schema_version_is_semver_string() -> None:
parts = m.MAP_SCHEMA_VERSION.split(".")
assert len(parts) == 3, f"schema version must be semver: {m.MAP_SCHEMA_VERSION}"
for p in parts:
assert p.isdigit(), f"non-numeric semver component: {p}"
def test_slave_id_in_valid_range() -> None:
# Modbus RTU spec: slave addresses 1..247.
assert 1 <= m.SLAVE_ID <= 247
def test_baudrate_is_supported_value() -> None:
assert m.BAUDRATE in {9600, 19200, 38400, 57600, 115200}
def test_serial_framing_is_8N1() -> None:
assert m.DATA_BITS == 8
assert m.PARITY == "N"
assert m.STOP_BITS == 1
# ----------------------------------------------------------------------------
# Register groups
# ----------------------------------------------------------------------------
@pytest.mark.parametrize("group", ["DISCRETES", "COILS", "INPUTS", "HOLDINGS"])
def test_groups_are_nonempty(group: str) -> None:
assert len(getattr(m, group)) > 0, f"group {group} is empty"
@pytest.mark.parametrize("group", ["DISCRETES", "COILS", "INPUTS", "HOLDINGS"])
def test_addresses_are_unique_within_group(group: str) -> None:
regs = getattr(m, group)
addrs = [r.addr for r in regs.values()]
assert len(addrs) == len(set(addrs)), f"duplicate addresses in {group}"
@pytest.mark.parametrize("group", ["DISCRETES", "COILS", "INPUTS", "HOLDINGS"])
def test_names_match_dict_keys(group: str) -> None:
"""The dict key and the Reg.name field must match exactly."""
regs = getattr(m, group)
for key, reg in regs.items():
assert key == reg.name, f"{group}[{key!r}].name mismatch: {reg.name!r}"
@pytest.mark.parametrize("group", ["DISCRETES", "COILS", "INPUTS", "HOLDINGS"])
def test_addresses_in_modbus_range(group: str) -> None:
"""Modbus addresses are uint16. Anything past 65535 is invalid."""
for r in getattr(m, group).values():
assert 0 <= r.addr <= 0xFFFF, f"out-of-range addr in {group}: {r}"
# ----------------------------------------------------------------------------
# Spot-check the critical registers
# ----------------------------------------------------------------------------
def test_pilot_engaged_discrete_exists() -> None:
assert "PILOT_ENGAGED" in m.DISCRETES
def test_disengage_button_discrete_exists() -> None:
assert "DI_DISENGAGE_BUTTON" in m.DISCRETES
def test_mode_request_holding_exists_and_has_no_scale() -> None:
r = m.HOLDINGS["MODE_REQUEST"]
assert r.scale == 1.0
assert r.offset == 0.0
def test_heading_setpoint_uses_centideg_scale() -> None:
r = m.HOLDINGS["HEADING_SETPOINT_X100"]
assert r.scale == pytest.approx(0.01)
assert r.unit == "deg"
def test_disengage_command_coil_exists() -> None:
assert "CMD_DISENGAGE_REQUEST" in m.COILS
def test_rudder_angle_input_uses_centideg_scale() -> None:
r = m.INPUTS["RUDDER_ANGLE_DEG_X100"]
assert r.scale == pytest.approx(0.01)
assert r.unit == "deg"
def test_alarms_have_bit_neighbourhood() -> None:
"""Every ALARM_* discrete must be at addr >= 16 (reserved alarm range)."""
for name, reg in m.DISCRETES.items():
if name.startswith("ALARM_") or name == "ANY_ALARM_ACTIVE":
assert reg.addr >= 16, f"{name} should live in the alarm range"
# ----------------------------------------------------------------------------
# Drift detection: regenerated files must be in sync with the YAML
# ----------------------------------------------------------------------------
def test_generated_files_are_in_sync_with_yaml() -> None:
"""If this test fails, run: python tools/gen_modbus_registers.py"""
result = subprocess.run(
[sys.executable, str(GEN_SCRIPT), "--check"],
capture_output=True,
text=True,
cwd=str(REPO_ROOT),
)
assert result.returncode == 0, (
f"generated Modbus map is OUT-OF-DATE. Regenerate with:\n"
f" python tools/gen_modbus_registers.py\n\n"
f"stderr:\n{result.stderr}"
)
def test_header_file_exists() -> None:
assert HEADER_PATH.exists(), (
f"firmware header not generated: {HEADER_PATH}"
)
def test_generated_header_mentions_every_register_name() -> None:
header = HEADER_PATH.read_text(encoding="utf-8")
for group_dict in m.ALL_GROUPS.values():
for name in group_dict:
assert name in header, (
f"register {name} missing from generated header"
)