Files
AR-Autopilot/arautopilot/tests/test_modbus_register_map.py
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

165 lines
5.5 KiB
Python

"""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"
)