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:
@@ -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()
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Tests for RudderSensorConfig and DualRudderSensorConfig — Sprint 4."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from arautopilot.core.sensor_config import (
|
||||
DualRudderSensorConfig,
|
||||
RudderSensorConfig,
|
||||
RudderSensorType,
|
||||
)
|
||||
|
||||
|
||||
def _primary(
|
||||
sensor_type: RudderSensorType = RudderSensorType.AS5048A_SPI,
|
||||
cs: int = 10,
|
||||
fsd: float = 35.0,
|
||||
) -> RudderSensorConfig:
|
||||
return RudderSensorConfig(type=sensor_type, spi_cs_gpio=cs, full_scale_deg=fsd)
|
||||
|
||||
|
||||
def _redundant(cs: int = 11) -> RudderSensorConfig:
|
||||
return RudderSensorConfig(
|
||||
type=RudderSensorType.AS5048A_SPI,
|
||||
label="Redundant – actuator arm",
|
||||
spi_cs_gpio=cs,
|
||||
full_scale_deg=35.0,
|
||||
)
|
||||
|
||||
|
||||
class TestRudderSensorConfig:
|
||||
def test_as5048a_defaults(self) -> None:
|
||||
s = _primary()
|
||||
assert s.type == RudderSensorType.AS5048A_SPI
|
||||
assert s.spi_cs_gpio == 10
|
||||
assert s.full_scale_deg == 35.0
|
||||
assert s.zero_offset_deg == 0.0
|
||||
assert s.divergence_alarm_deg == 3.0
|
||||
assert s.divergence_failover_deg == 6.0
|
||||
|
||||
def test_potentiometer_type(self) -> None:
|
||||
s = _primary(sensor_type=RudderSensorType.POTENTIOMETER)
|
||||
assert s.type == RudderSensorType.POTENTIOMETER
|
||||
|
||||
def test_gpio_bounds(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
RudderSensorConfig(type=RudderSensorType.AS5048A_SPI, spi_cs_gpio=40)
|
||||
with pytest.raises(ValidationError):
|
||||
RudderSensorConfig(type=RudderSensorType.AS5048A_SPI, spi_cs_gpio=-1)
|
||||
|
||||
def test_full_scale_bounds(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
_primary(fsd=0.0)
|
||||
with pytest.raises(ValidationError):
|
||||
_primary(fsd=46.0)
|
||||
|
||||
def test_label_optional(self) -> None:
|
||||
s = RudderSensorConfig(type=RudderSensorType.AS5048A_SPI)
|
||||
assert s.label == ""
|
||||
|
||||
def test_extra_fields_forbidden(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
RudderSensorConfig(
|
||||
type=RudderSensorType.AS5048A_SPI,
|
||||
unknown_field="boom", # type: ignore[call-arg]
|
||||
)
|
||||
|
||||
def test_roundtrip_json(self) -> None:
|
||||
s = _primary()
|
||||
restored = RudderSensorConfig.model_validate_json(s.model_dump_json())
|
||||
assert restored == s
|
||||
|
||||
|
||||
class TestDualRudderSensorConfig:
|
||||
def test_single_sensor_mode(self) -> None:
|
||||
d = DualRudderSensorConfig(primary=_primary())
|
||||
assert d.redundant is None
|
||||
assert not d.has_redundancy
|
||||
|
||||
def test_dual_sensor_mode(self) -> None:
|
||||
d = DualRudderSensorConfig(primary=_primary(), redundant=_redundant())
|
||||
assert d.redundant is not None
|
||||
assert d.has_redundancy
|
||||
|
||||
def test_different_cs_pins(self) -> None:
|
||||
d = DualRudderSensorConfig(primary=_primary(cs=10), redundant=_redundant(cs=11))
|
||||
assert d.primary.spi_cs_gpio == 10
|
||||
assert d.redundant is not None
|
||||
assert d.redundant.spi_cs_gpio == 11
|
||||
|
||||
def test_roundtrip_yaml(self) -> None:
|
||||
import yaml
|
||||
|
||||
d = DualRudderSensorConfig(primary=_primary(), redundant=_redundant())
|
||||
data = yaml.safe_dump(d.model_dump(mode="json"))
|
||||
restored = DualRudderSensorConfig.model_validate(yaml.safe_load(data))
|
||||
assert restored == d
|
||||
Reference in New Issue
Block a user