sprint-4: Project Editor + .appack compiler + IRudderActuator HAL + dual sensor config

Cherry-pick of ab28cb7 onto main (was missing from main branch history).

Python:
- arautopilot/core/sensor_config.py: RudderSensorConfig + DualRudderSensorConfig
  (AS5048A SPI / potentiometer, dual with cross-validation thresholds)
- arautopilot/core/vessel_config.py: add sensors: DualRudderSensorConfig field
- arautopilot/studio/compiler/appack.py: .appack compiler (ZIP with
  manifest.json + project.yaml + firmware_config.h + install_notes.txt)
- arautopilot/studio/editors/project_editor.py: full project config editor
  (vessel / actuator / sensors / PID, RBAC-gated fields, save/load .yaml/.json)
- arautopilot/studio/main_window.py: wire ProjectEditorWidget into Project tab
- tests: test_sensor_config.py (11 tests) + test_appack_compiler.py (10 tests)

Firmware:
- hal/rudder_actuator_iface.h: IRudderActuator abstract interface
- hal/rudder_actuator_hydraulic.h: reversible hydraulic pump (LEDC PWM)
- hal/rudder_actuator_electric.h: reversible DC motor + deadband compensation
- hal/rudder_actuator_factory.h: build-time type selection via AR_ACTUATOR_TYPE

Tests: 483 passed (was 462, +21 Sprint 4)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 10:29:17 -04:00
parent a2f3e82f17
commit e4812e9b44
11 changed files with 1661 additions and 5 deletions
+154
View File
@@ -0,0 +1,154 @@
"""Tests for AppackCompiler — Sprint 4."""
from __future__ import annotations
import json
import zipfile
from pathlib import Path
import pytest
from arautopilot.core.actuator_config import ActuatorConfig, ActuatorType
from arautopilot.core.pid_config import PidConfig, PidGains
from arautopilot.core.project_config import ProjectConfig
from arautopilot.core.sensor_config import (
DualRudderSensorConfig,
RudderSensorConfig,
RudderSensorType,
)
from arautopilot.core.vessel_config import VesselConfig, VesselType
from arautopilot.studio.compiler.appack import AppackCompiler
def _make_project(*, dual_sensors: bool = False) -> ProjectConfig:
redundant = (
RudderSensorConfig(
type=RudderSensorType.AS5048A_SPI,
label="Redundant actuator arm",
spi_cs_gpio=11,
)
if dual_sensors
else None
)
return ProjectConfig(
client_name="Test Client",
project_name="Test Project",
vessel=VesselConfig(
name="Test Vessel",
type=VesselType.YACHT_MOTOR_PLANEO,
length_m=30.0,
max_speed_kn=18.0,
actuator=ActuatorConfig(
type=ActuatorType.HYDRAULIC_REVERSIBLE,
max_rudder_angle_deg=35.0,
),
pid=PidConfig(
inner_loop_base=PidGains(kp=1.5, ki=0.1, kd=0.05),
outer_loop_base=PidGains(kp=2.0, ki=0.05, kd=0.3),
),
sensors=DualRudderSensorConfig(
primary=RudderSensorConfig(
type=RudderSensorType.AS5048A_SPI,
spi_cs_gpio=10,
),
redundant=redundant,
),
),
)
class TestAppackCompiler:
def test_compile_creates_file(self, tmp_path: Path) -> None:
out = tmp_path / "test.appack"
compiler = AppackCompiler(_make_project())
result = compiler.compile(out)
assert result == out
assert out.exists()
def test_appack_is_valid_zip(self, tmp_path: Path) -> None:
out = tmp_path / "test.appack"
AppackCompiler(_make_project()).compile(out)
assert zipfile.is_zipfile(out)
def test_appack_contains_required_files(self, tmp_path: Path) -> None:
out = tmp_path / "test.appack"
project = _make_project()
AppackCompiler(project).compile(out)
prefix = str(project.project_id)
with zipfile.ZipFile(out) as zf:
names = set(zf.namelist())
assert f"{prefix}/manifest.json" in names
assert f"{prefix}/project.yaml" in names
assert f"{prefix}/firmware_config.h" in names
assert f"{prefix}/install_notes.txt" in names
def test_manifest_fields(self, tmp_path: Path) -> None:
out = tmp_path / "test.appack"
project = _make_project()
AppackCompiler(project).compile(out)
prefix = str(project.project_id)
with zipfile.ZipFile(out) as zf:
manifest = json.loads(zf.read(f"{prefix}/manifest.json"))
assert manifest["project_id"] == str(project.project_id)
assert manifest["client_name"] == "Test Client"
assert manifest["vessel_name"] == "Test Vessel"
assert "checksums" in manifest
assert "project_yaml_sha256" in manifest["checksums"]
assert "firmware_config_h_sha256" in manifest["checksums"]
def test_firmware_header_contains_defines(self, tmp_path: Path) -> None:
out = tmp_path / "test.appack"
project = _make_project()
AppackCompiler(project).compile(out)
prefix = str(project.project_id)
with zipfile.ZipFile(out) as zf:
header = zf.read(f"{prefix}/firmware_config.h").decode()
assert "#define AR_RUDDER_ANGLE_LIMIT_DEG" in header
assert "#define AR_PID_INNER_KP" in header
assert "#define AR_PID_OUTER_KP" in header
assert "#define AR_SENSOR_PRIMARY_TYPE" in header
assert "ACTUATOR_HYDRAULIC_REVERSIBLE" in header
assert "RUDDER_SENSOR_AS5048A_SPI" in header
assert "35.0f" in header
def test_firmware_header_dual_sensor(self, tmp_path: Path) -> None:
out = tmp_path / "test.appack"
project = _make_project(dual_sensors=True)
AppackCompiler(project).compile(out)
prefix = str(project.project_id)
with zipfile.ZipFile(out) as zf:
header = zf.read(f"{prefix}/firmware_config.h").decode()
notes = zf.read(f"{prefix}/install_notes.txt").decode()
assert "#define AR_SENSOR_REDUNDANT 1" in header
assert "GPIO CS=11" in notes
def test_firmware_header_single_sensor(self, tmp_path: Path) -> None:
out = tmp_path / "test.appack"
project = _make_project(dual_sensors=False)
AppackCompiler(project).compile(out)
prefix = str(project.project_id)
with zipfile.ZipFile(out) as zf:
header = zf.read(f"{prefix}/firmware_config.h").decode()
assert "#define AR_SENSOR_REDUNDANT 0" in header
def test_project_yaml_roundtrips(self, tmp_path: Path) -> None:
out = tmp_path / "test.appack"
project = _make_project()
AppackCompiler(project).compile(out)
prefix = str(project.project_id)
with zipfile.ZipFile(out) as zf:
yaml_text = zf.read(f"{prefix}/project.yaml").decode()
restored = ProjectConfig.from_yaml(yaml_text)
assert restored.client_name == project.client_name
assert restored.vessel.name == project.vessel.name
def test_empty_client_name_raises(self) -> None:
project = _make_project()
object.__setattr__(project, "client_name", " ")
with pytest.raises(ValueError, match="client_name"):
AppackCompiler(project)
def test_output_parent_created(self, tmp_path: Path) -> None:
out = tmp_path / "deep" / "nested" / "output.appack"
AppackCompiler(_make_project()).compile(out)
assert out.exists()