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