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
+110
View File
@@ -0,0 +1,110 @@
"""Rudder angle sensor configuration.
Supports single and dual (redundant) sensor setups. The primary sensor
is mandatory for closed-loop operation. The redundant sensor is optional
but strongly recommended for Phase-1 marine deployments.
Sensor families supported in Phase 1:
- ``AS5048A_SPI`` — contactless magnetic absolute encoder, 14-bit, SPI.
Recommended for new installations (brief session 2026-05-18).
- ``POTENTIOMETER`` — resistive potentiometer 0-10 V or 4-20 mA.
Supported for legacy actuators.
Both sensors use a polycarbonate linkage arm to the rudder stock, giving
a proportional rotation to the rudder angle.
"""
from __future__ import annotations
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field
class RudderSensorType(StrEnum):
AS5048A_SPI = "as5048a_spi"
"""AMS AS5048A contactless magnetic encoder — 14-bit, SPI interface.
No mechanical contact; requires diametrically magnetised 6 mm magnet
on the rotating shaft. Recommended for all new Phase-1 installations."""
POTENTIOMETER = "potentiometer"
"""Resistive potentiometer with 0-10 V or 4-20 mA signal conditioning.
Suitable for retrofit on existing actuators that already carry a pot."""
class RudderSensorConfig(BaseModel):
"""Configuration for one physical rudder angle sensor.
The ``full_scale_deg`` / ``zero_offset_deg`` pair maps the electrical
range of the sensor to real rudder degrees. Calibration during
commissioning (Sprint 7) will update these values in NVS.
"""
model_config = ConfigDict(extra="forbid", validate_assignment=True)
type: RudderSensorType
"""Physical sensor technology."""
label: str = Field(
default="",
max_length=60,
description="Free-form label, e.g. 'Primary rudder stock' or 'Redundant actuator arm'.",
)
# SPI-specific (ignored for POTENTIOMETER)
spi_cs_gpio: int = Field(
default=10,
ge=0,
le=39,
description="ESP32 GPIO pin used as SPI chip-select for AS5048A.",
)
# Calibration
full_scale_deg: float = Field(
default=35.0,
gt=0.0,
le=45.0,
description="Rudder angle (degrees) corresponding to full electrical output.",
)
zero_offset_deg: float = Field(
default=0.0,
ge=-10.0,
le=10.0,
description="Offset applied after scaling to correct mechanical amidships misalignment.",
)
# Cross-validation (used by dual-sensor arbitrator)
divergence_alarm_deg: float = Field(
default=3.0,
gt=0.0,
le=15.0,
description="Alarm threshold: raise SENSOR_DIVERGE if |A - B| exceeds this value.",
)
divergence_failover_deg: float = Field(
default=6.0,
gt=0.0,
le=20.0,
description="Failover threshold: switch to the other sensor if |A - B| exceeds this.",
)
class DualRudderSensorConfig(BaseModel):
"""Primary + optional redundant rudder sensor pair.
When ``redundant`` is ``None`` the system operates in single-sensor
mode (feedback_required still enforced). When ``redundant`` is
present the firmware enables cross-validation and automatic failover.
Divergence thresholds are taken from the *primary* sensor's config so
there is one canonical place to tune them.
"""
model_config = ConfigDict(extra="forbid", validate_assignment=True)
primary: RudderSensorConfig
redundant: RudderSensorConfig | None = None
@property
def has_redundancy(self) -> bool:
return self.redundant is not None