65860948b4
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>
165 lines
5.5 KiB
Python
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"
|
|
)
|