From e4812e9b44fd4298b876004f5ee3f877ff351f34 Mon Sep 17 00:00:00 2001 From: alro1965 Date: Wed, 20 May 2026 10:29:17 -0400 Subject: [PATCH] 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 --- arautopilot/core/sensor_config.py | 110 ++++ arautopilot/core/vessel_config.py | 11 + arautopilot/studio/compiler/appack.py | 245 +++++++ arautopilot/studio/editors/project_editor.py | 619 ++++++++++++++++++ arautopilot/studio/main_window.py | 7 +- arautopilot/tests/test_appack_compiler.py | 154 +++++ arautopilot/tests/test_sensor_config.py | 98 +++ .../src/hal/rudder_actuator_electric.h | 138 ++++ .../src/hal/rudder_actuator_factory.h | 52 ++ .../src/hal/rudder_actuator_hydraulic.h | 148 +++++ .../src/hal/rudder_actuator_iface.h | 84 +++ 11 files changed, 1661 insertions(+), 5 deletions(-) create mode 100644 arautopilot/core/sensor_config.py create mode 100644 arautopilot/studio/compiler/appack.py create mode 100644 arautopilot/studio/editors/project_editor.py create mode 100644 arautopilot/tests/test_appack_compiler.py create mode 100644 arautopilot/tests/test_sensor_config.py create mode 100644 firmware/ar_autopilot_v1/src/hal/rudder_actuator_electric.h create mode 100644 firmware/ar_autopilot_v1/src/hal/rudder_actuator_factory.h create mode 100644 firmware/ar_autopilot_v1/src/hal/rudder_actuator_hydraulic.h create mode 100644 firmware/ar_autopilot_v1/src/hal/rudder_actuator_iface.h diff --git a/arautopilot/core/sensor_config.py b/arautopilot/core/sensor_config.py new file mode 100644 index 0000000..9919bbf --- /dev/null +++ b/arautopilot/core/sensor_config.py @@ -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 diff --git a/arautopilot/core/vessel_config.py b/arautopilot/core/vessel_config.py index 6b3a38d..8e435de 100644 --- a/arautopilot/core/vessel_config.py +++ b/arautopilot/core/vessel_config.py @@ -13,6 +13,7 @@ from pydantic import BaseModel, ConfigDict, Field from arautopilot.core.actuator_config import ActuatorConfig from arautopilot.core.ids import VesselId, new_vessel_id from arautopilot.core.pid_config import PidConfig +from arautopilot.core.sensor_config import DualRudderSensorConfig, RudderSensorConfig, RudderSensorType class VesselType(StrEnum): @@ -60,3 +61,13 @@ class VesselConfig(BaseModel): actuator: ActuatorConfig pid: PidConfig + sensors: DualRudderSensorConfig = Field( + default_factory=lambda: DualRudderSensorConfig( + primary=RudderSensorConfig( + type=RudderSensorType.AS5048A_SPI, + label="Primary – rudder stock", + spi_cs_gpio=10, + ) + ), + description="Rudder angle sensor(s). Dual config enables cross-validation and failover.", + ) diff --git a/arautopilot/studio/compiler/appack.py b/arautopilot/studio/compiler/appack.py new file mode 100644 index 0000000..578e430 --- /dev/null +++ b/arautopilot/studio/compiler/appack.py @@ -0,0 +1,245 @@ +"""AR-Autopilot deployment package compiler — Sprint 4. + +An ``.appack`` is a ZIP archive that contains everything needed to deploy +a configured autopilot to a vessel: + + / + ├── manifest.json — metadata, checksums, schema version + ├── project.yaml — full ProjectConfig (human-readable) + ├── firmware_config.h — generated C header consumed by the firmware build + └── install_notes.txt — human summary for the field engineer + +The format is intentionally transparent (plain ZIP, readable YAML/JSON/C) +so that a field engineer can inspect the package contents without special tools. + +Typical workflow +---------------- +1. Integrator configures a ``ProjectConfig`` in the Studio (Project tab). +2. Clicks "Compile .appack…" → this compiler runs. +3. Studio writes the .appack to disk. +4. Field engineer transfers the .appack to the vessel. +5. Future Sprint: Studio "Deploy" tab extracts the package, calls PlatformIO + with the generated header, and flashes the board. +""" + +from __future__ import annotations + +import hashlib +import json +import zipfile +from datetime import UTC, datetime +from pathlib import Path +from textwrap import dedent + +from arautopilot.core.actuator_config import ActuatorType +from arautopilot.core.project_config import ProjectConfig +from arautopilot.core.sensor_config import DualRudderSensorConfig, RudderSensorType +from arautopilot.version import __version__ + +_SCHEMA_VERSION = "0.1.0" + + +class AppackCompiler: + """Compiles a ``ProjectConfig`` into an ``.appack`` deployment archive.""" + + def __init__(self, project: ProjectConfig) -> None: + if not project.client_name.strip(): + raise ValueError("project.client_name must not be empty") + if not project.project_name.strip(): + raise ValueError("project.project_name must not be empty") + self._project = project + + def compile(self, output_path: Path | str) -> Path: + """Build the ``.appack`` archive and write it to *output_path*. + + Returns the resolved output path. + """ + out = Path(output_path) + out.parent.mkdir(parents=True, exist_ok=True) + + project_yaml = self._project.to_yaml() + firmware_header = self._generate_firmware_header() + install_notes = self._generate_install_notes() + manifest = self._build_manifest(project_yaml, firmware_header) + + prefix = str(self._project.project_id) + with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zf: + zf.writestr(f"{prefix}/manifest.json", json.dumps(manifest, indent=2)) + zf.writestr(f"{prefix}/project.yaml", project_yaml) + zf.writestr(f"{prefix}/firmware_config.h", firmware_header) + zf.writestr(f"{prefix}/install_notes.txt", install_notes) + + return out + + # ------------------------------------------------------------------ + # Manifest + # ------------------------------------------------------------------ + + def _build_manifest(self, project_yaml: str, firmware_header: str) -> dict: + return { + "appack_schema": _SCHEMA_VERSION, + "arautopilot_version": __version__, + "compiled_at": datetime.now(UTC).isoformat(), + "project_id": str(self._project.project_id), + "client_name": self._project.client_name, + "project_name": self._project.project_name, + "vessel_name": self._project.vessel.name, + "vessel_type": self._project.vessel.type.value, + "actuator_type": self._project.vessel.actuator.type.value, + "checksums": { + "project_yaml_sha256": _sha256(project_yaml), + "firmware_config_h_sha256": _sha256(firmware_header), + }, + } + + # ------------------------------------------------------------------ + # Firmware header generator + # ------------------------------------------------------------------ + + def _generate_firmware_header(self) -> str: + p = self._project + v = p.vessel + a = v.actuator + pid = v.pid + sensors = v.sensors + + actuator_enum = _actuator_type_to_enum(a.type) + primary_sensor_enum = _sensor_type_to_enum(sensors.primary.type) + + redundant_enabled = "1" if sensors.has_redundancy else "0" + redundant_cs = sensors.redundant.spi_cs_gpio if sensors.redundant else 0 + redundant_sensor_enum = ( + _sensor_type_to_enum(sensors.redundant.type) + if sensors.redundant + else "RUDDER_SENSOR_NONE" + ) + diverge_alarm = sensors.primary.divergence_alarm_deg + diverge_failover = sensors.primary.divergence_failover_deg + + lines = [ + "// AUTO-GENERATED by AR-Autopilot Studio — DO NOT EDIT BY HAND.", + f"// Project : {p.project_name}", + f"// Client : {p.client_name}", + f"// Vessel : {v.name} ({v.type.value})", + f"// Generated: {datetime.now(UTC).isoformat()}", + "//", + "#pragma once", + "", + "// --- Project identity -------------------------------------------------", + f'#define AR_PROJECT_ID "{p.project_id}"', + f'#define AR_CLIENT_NAME "{p.client_name}"', + f'#define AR_PROJECT_NAME "{p.project_name}"', + f'#define AR_VESSEL_NAME "{v.name}"', + "", + "// --- Vessel kinematics ------------------------------------------------", + f"#define AR_VESSEL_LENGTH_M {v.length_m:.1f}f", + f"#define AR_VESSEL_MAX_SPEED_KN {v.max_speed_kn:.1f}f", + "", + "// --- Actuator ---------------------------------------------------------", + f"#define AR_ACTUATOR_TYPE {actuator_enum}", + f"#define AR_RUDDER_ANGLE_LIMIT_DEG {a.max_rudder_angle_deg:.1f}f", + f"#define AR_ACTUATOR_DEADBAND_PCT {a.deadband_pct:.1f}f", + f"#define AR_ACTUATOR_MAX_RATE_DPS {a.max_rate_dps:.1f}f", + f"#define AR_ACTUATOR_MAX_CURRENT_A {a.max_current_a:.1f}f", + f"#define AR_ACTUATOR_ASYM_STBD {a.asymmetry_stbd_over_port:.3f}f", + "", + "// --- Rudder sensors ---------------------------------------------------", + f"#define AR_SENSOR_PRIMARY_TYPE {primary_sensor_enum}", + f"#define AR_SENSOR_PRIMARY_CS_GPIO {sensors.primary.spi_cs_gpio}", + f"#define AR_SENSOR_PRIMARY_FSD_DEG {sensors.primary.full_scale_deg:.1f}f", + f"#define AR_SENSOR_REDUNDANT {redundant_enabled}", + f"#define AR_SENSOR_REDUNDANT_TYPE {redundant_sensor_enum}", + f"#define AR_SENSOR_REDUNDANT_CS_GPIO {redundant_cs}", + f"#define AR_SENSOR_DIVERGE_ALARM_DEG {diverge_alarm:.1f}f", + f"#define AR_SENSOR_DIVERGE_FAILOVER_DEG {diverge_failover:.1f}f", + "", + "// --- PID base gains (integrator IP — not exposed to operator) ---------", + f"#define AR_PID_INNER_KP {pid.inner_loop_base.kp:.4f}f", + f"#define AR_PID_INNER_KI {pid.inner_loop_base.ki:.4f}f", + f"#define AR_PID_INNER_KD {pid.inner_loop_base.kd:.4f}f", + f"#define AR_PID_OUTER_KP {pid.outer_loop_base.kp:.4f}f", + f"#define AR_PID_OUTER_KI {pid.outer_loop_base.ki:.4f}f", + f"#define AR_PID_OUTER_KD {pid.outer_loop_base.kd:.4f}f", + f"#define AR_PID_ROT_FF {pid.rot_feedforward_gain:.4f}f", + f"#define AR_PID_DEADBAND_DEG {pid.setpoint_deadband_deg:.2f}f", + f"#define AR_PID_RATE_LIMIT_DPS {pid.setpoint_rate_limit_dps:.2f}f", + f"#define AR_PID_ANTI_WINDUP {pid.anti_windup_limit:.2f}f", + "", + "// End of generated header.", + ] + return "\n".join(lines) + "\n" + + # ------------------------------------------------------------------ + # Install notes + # ------------------------------------------------------------------ + + def _generate_install_notes(self) -> str: + p = self._project + v = p.vessel + a = v.actuator + s = v.sensors + return dedent(f"""\ + AR-Autopilot — Field Installation Notes + ======================================== + + Project : {p.project_name} + Client : {p.client_name} + Vessel : {v.name} ({v.type.value.replace("_", " ").title()}) + Generated: {datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC")} + + ACTUATOR + -------- + Type : {a.type.value.replace("_", " ").title()} + Model / label : {a.name or "(not specified)"} + Rudder limit : ±{a.max_rudder_angle_deg:.1f}° (software limit — verify ≤ mechanical stop) + Deadband : {a.deadband_pct:.1f} % + Max slew rate : {a.max_rate_dps:.1f} °/s + Overcurrent : {a.max_current_a:.1f} A trip + + SENSORS + ------- + Primary sensor : {s.primary.type.value.upper()} GPIO CS={s.primary.spi_cs_gpio} + Full-scale : ±{s.primary.full_scale_deg:.1f}° + Redundant sensor: {"ENABLED" if s.has_redundancy else "DISABLED"} + {"Redundant type : " + s.redundant.type.value.upper() + " GPIO CS=" + str(s.redundant.spi_cs_gpio) if s.redundant else ""} + Divergence alarm : {s.primary.divergence_alarm_deg:.1f}° + Divergence failover : {s.primary.divergence_failover_deg:.1f}° + + COMMISSIONING STEPS (Sprint 7 wizard will automate these) + ----------------------------------------------------------- + 1. Flash firmware with this .appack (Flash Console or pio upload). + 2. Connect primary rudder sensor to SPI CS GPIO {s.primary.spi_cs_gpio}. + {"3. Connect redundant sensor to SPI CS GPIO " + str(s.redundant.spi_cs_gpio) + "." if s.redundant else "3. (Redundant sensor not configured.)"} + 4. Verify rudder moves to ±{a.max_rudder_angle_deg:.1f}° and stops. + 5. Run Modbus client test: python tools/modbus_client_test.py + 6. Engage HEADING_HOLD and confirm heading capture. + 7. Tune PID gains via Modbus if required. + + SUPPORT + ------- + Contact: AR Suite — alro65@gmail.com + """) + + +# ------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------ + +def _sha256(text: str) -> str: + return hashlib.sha256(text.encode()).hexdigest() + + +def _actuator_type_to_enum(t: ActuatorType) -> str: + return { + ActuatorType.HYDRAULIC_REVERSIBLE: "ACTUATOR_HYDRAULIC_REVERSIBLE", + ActuatorType.ELECTRIC_DC_REVERSIBLE: "ACTUATOR_ELECTRIC_DC", + ActuatorType.SERVOMOTOR_FEEDBACK: "ACTUATOR_SERVOMOTOR", + ActuatorType.STERNDRIVE_ANALOG: "ACTUATOR_STERNDRIVE_ANALOG", + }.get(t, "ACTUATOR_UNKNOWN") + + +def _sensor_type_to_enum(t: RudderSensorType) -> str: + return { + RudderSensorType.AS5048A_SPI: "RUDDER_SENSOR_AS5048A_SPI", + RudderSensorType.POTENTIOMETER: "RUDDER_SENSOR_POTENTIOMETER", + }.get(t, "RUDDER_SENSOR_UNKNOWN") diff --git a/arautopilot/studio/editors/project_editor.py b/arautopilot/studio/editors/project_editor.py new file mode 100644 index 0000000..5309ac5 --- /dev/null +++ b/arautopilot/studio/editors/project_editor.py @@ -0,0 +1,619 @@ +"""Project configurator widget — Sprint 4. + +A scrollable form that lets the integrator (Engineer / Super Admin) create +or edit a ``ProjectConfig`` and compile it into an ``.appack`` deployment +package. + +Layout +------ +┌─────────────────────────────────────────────────────┐ +│ PROJECT INFO client / project name / notes │ +│ VESSEL name / type / dimensions │ +│ ACTUATOR type / angle limit / deadband / … │ +│ SENSORS primary + optional redundant │ +│ PID GAINS inner + outer base (SA-gated) │ +│ ─────────────────────────────────────────────────── │ +│ [New] [Open…] [Save] [Compile .appack…] │ +└─────────────────────────────────────────────────────┘ + +RBAC gates +---------- +- ``EDIT_COMMISSIONING`` (Engineer+) : actuator + sensor + rudder limit. +- ``EDIT_BASE_GAINS`` (Super Admin) : PID base gains fields. +- ``EDIT_COMMISSIONING`` (Engineer+) : Compile .appack button. +- Any role can load and read a project; only the above roles may edit. +""" + +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDoubleSpinBox, + QFileDialog, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QScrollArea, + QSizePolicy, + QSpinBox, + QTextEdit, + QVBoxLayout, + QWidget, +) + +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.rbac import Capability +from arautopilot.core.sensor_config import ( + DualRudderSensorConfig, + RudderSensorConfig, + RudderSensorType, +) +from arautopilot.core.vessel_config import VesselConfig, VesselType +from arautopilot.studio.session import Session + + +class ProjectEditorWidget(QWidget): + """Full project config editor — fills the 'Project' tab in the Studio.""" + + def __init__(self, session: Session, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._session = session + self._project: ProjectConfig | None = None + + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + + # Scrollable form area + scroll = QScrollArea() + scroll.setWidgetResizable(True) + content = QWidget() + form_layout = QVBoxLayout(content) + form_layout.setContentsMargins(12, 12, 12, 12) + form_layout.setSpacing(12) + + self._build_project_info_group(form_layout) + self._build_vessel_group(form_layout) + self._build_actuator_group(form_layout) + self._build_sensor_group(form_layout) + self._build_pid_group(form_layout) + form_layout.addStretch(1) + + scroll.setWidget(content) + root.addWidget(scroll, stretch=1) + root.addWidget(self._build_toolbar()) + + self._apply_rbac() + self._load_defaults() + + # ------------------------------------------------------------------ + # Group builders + # ------------------------------------------------------------------ + + def _build_project_info_group(self, parent: QVBoxLayout) -> None: + box = QGroupBox("Project Info") + fl = QFormLayout(box) + + self._client_name = QLineEdit() + self._client_name.setPlaceholderText("e.g. Astilleros del Sur S.A.") + fl.addRow("Client name *", self._client_name) + + self._project_name = QLineEdit() + self._project_name.setPlaceholderText("e.g. M/Y Pacifica – Autopilot install") + fl.addRow("Project name *", self._project_name) + + self._notes = QTextEdit() + self._notes.setPlaceholderText("Optional notes…") + self._notes.setFixedHeight(72) + fl.addRow("Notes", self._notes) + + parent.addWidget(box) + + def _build_vessel_group(self, parent: QVBoxLayout) -> None: + box = QGroupBox("Vessel") + fl = QFormLayout(box) + + self._vessel_name = QLineEdit() + self._vessel_name.setPlaceholderText("e.g. Pacifica") + fl.addRow("Vessel name *", self._vessel_name) + + self._vessel_type = QComboBox() + for vt in VesselType: + self._vessel_type.addItem(vt.value.replace("_", " ").title(), vt) + fl.addRow("Type", self._vessel_type) + + self._length_m = QDoubleSpinBox() + self._length_m.setRange(5.0, 200.0) + self._length_m.setSuffix(" m") + self._length_m.setDecimals(1) + self._length_m.setValue(30.0) + fl.addRow("Length overall", self._length_m) + + self._displacement_t = QDoubleSpinBox() + self._displacement_t.setRange(0.0, 10_000.0) + self._displacement_t.setSuffix(" t") + self._displacement_t.setDecimals(1) + fl.addRow("Displacement (0 = unknown)", self._displacement_t) + + self._max_speed_kn = QDoubleSpinBox() + self._max_speed_kn.setRange(1.0, 80.0) + self._max_speed_kn.setSuffix(" kn") + self._max_speed_kn.setDecimals(1) + self._max_speed_kn.setValue(18.0) + fl.addRow("Max speed", self._max_speed_kn) + + parent.addWidget(box) + + def _build_actuator_group(self, parent: QVBoxLayout) -> None: + box = QGroupBox("Rudder Actuator") + fl = QFormLayout(box) + + self._act_type = QComboBox() + phase1 = [ + ActuatorType.HYDRAULIC_REVERSIBLE, + ActuatorType.ELECTRIC_DC_REVERSIBLE, + ActuatorType.SERVOMOTOR_FEEDBACK, + ActuatorType.STERNDRIVE_ANALOG, + ] + for at in phase1: + self._act_type.addItem(at.value.replace("_", " ").title(), at) + fl.addRow("Type", self._act_type) + + self._act_name = QLineEdit() + self._act_name.setPlaceholderText("e.g. Hynautic K-21 + Capilano cylinder") + fl.addRow("Model / label", self._act_name) + + self._max_rudder_deg = QDoubleSpinBox() + self._max_rudder_deg.setRange(15.0, 45.0) + self._max_rudder_deg.setSuffix(" °") + self._max_rudder_deg.setDecimals(1) + self._max_rudder_deg.setValue(35.0) + self._max_rudder_deg.setToolTip( + "Software rudder angle limit. Must not exceed the mechanical stop.\n" + "Requires Engineer or Super Admin to change." + ) + fl.addRow("Rudder angle limit ⚠", self._max_rudder_deg) + + self._deadband_pct = QDoubleSpinBox() + self._deadband_pct.setRange(0.0, 30.0) + self._deadband_pct.setSuffix(" %") + self._deadband_pct.setDecimals(1) + self._deadband_pct.setValue(5.0) + fl.addRow("Deadband", self._deadband_pct) + + self._max_rate_dps = QDoubleSpinBox() + self._max_rate_dps.setRange(0.5, 15.0) + self._max_rate_dps.setSuffix(" °/s") + self._max_rate_dps.setDecimals(1) + self._max_rate_dps.setValue(4.5) + fl.addRow("Max slew rate", self._max_rate_dps) + + self._max_current_a = QDoubleSpinBox() + self._max_current_a.setRange(1.0, 200.0) + self._max_current_a.setSuffix(" A") + self._max_current_a.setDecimals(1) + self._max_current_a.setValue(15.0) + fl.addRow("Overcurrent limit", self._max_current_a) + + parent.addWidget(box) + self._actuator_box = box + + def _build_sensor_group(self, parent: QVBoxLayout) -> None: + box = QGroupBox("Rudder Angle Sensors") + fl = QFormLayout(box) + + fl.addRow(QLabel("Primary sensor")) + + self._sens_primary_type = QComboBox() + for st in RudderSensorType: + self._sens_primary_type.addItem(st.value.replace("_", " ").upper(), st) + fl.addRow("Type", self._sens_primary_type) + + self._sens_primary_cs = QSpinBox() + self._sens_primary_cs.setRange(0, 39) + self._sens_primary_cs.setValue(10) + self._sens_primary_cs.setToolTip("ESP32 GPIO used as SPI CS for AS5048A") + fl.addRow("SPI CS GPIO", self._sens_primary_cs) + + self._sens_primary_fsd = QDoubleSpinBox() + self._sens_primary_fsd.setRange(1.0, 45.0) + self._sens_primary_fsd.setSuffix(" °") + self._sens_primary_fsd.setDecimals(1) + self._sens_primary_fsd.setValue(35.0) + fl.addRow("Full-scale angle", self._sens_primary_fsd) + + fl.addRow(QLabel("")) + fl.addRow(QLabel("Redundant sensor (optional)")) + + self._sens_redundant_enabled = QCheckBox("Enable redundant sensor") + fl.addRow("", self._sens_redundant_enabled) + + self._sens_redundant_type = QComboBox() + for st in RudderSensorType: + self._sens_redundant_type.addItem(st.value.replace("_", " ").upper(), st) + fl.addRow("Type", self._sens_redundant_type) + + self._sens_redundant_cs = QSpinBox() + self._sens_redundant_cs.setRange(0, 39) + self._sens_redundant_cs.setValue(11) + fl.addRow("SPI CS GPIO", self._sens_redundant_cs) + + self._sens_redundant_fsd = QDoubleSpinBox() + self._sens_redundant_fsd.setRange(1.0, 45.0) + self._sens_redundant_fsd.setSuffix(" °") + self._sens_redundant_fsd.setDecimals(1) + self._sens_redundant_fsd.setValue(35.0) + fl.addRow("Full-scale angle", self._sens_redundant_fsd) + + self._sens_diverge_alarm = QDoubleSpinBox() + self._sens_diverge_alarm.setRange(0.5, 15.0) + self._sens_diverge_alarm.setSuffix(" °") + self._sens_diverge_alarm.setDecimals(1) + self._sens_diverge_alarm.setValue(3.0) + fl.addRow("Divergence alarm threshold", self._sens_diverge_alarm) + + self._sens_diverge_failover = QDoubleSpinBox() + self._sens_diverge_failover.setRange(1.0, 20.0) + self._sens_diverge_failover.setSuffix(" °") + self._sens_diverge_failover.setDecimals(1) + self._sens_diverge_failover.setValue(6.0) + fl.addRow("Divergence failover threshold", self._sens_diverge_failover) + + self._sens_redundant_enabled.toggled.connect(self._on_redundant_toggled) + self._on_redundant_toggled(False) + + parent.addWidget(box) + self._sensor_box = box + + def _build_pid_group(self, parent: QVBoxLayout) -> None: + box = QGroupBox("PID Base Gains [Super Admin only]") + fl = QFormLayout(box) + + fl.addRow(QLabel("Inner loop (rudder position, 50 Hz)")) + self._inner_kp = self._gain_spin("Inner Kp", fl) + self._inner_ki = self._gain_spin("Inner Ki", fl) + self._inner_kd = self._gain_spin("Inner Kd", fl) + + fl.addRow(QLabel("")) + fl.addRow(QLabel("Outer loop (heading, 10 Hz)")) + self._outer_kp = self._gain_spin("Outer Kp", fl) + self._outer_ki = self._gain_spin("Outer Ki", fl) + self._outer_kd = self._gain_spin("Outer Kd", fl) + + fl.addRow(QLabel("")) + self._rot_ff = self._gain_spin("ROT feed-forward", fl) + + parent.addWidget(box) + self._pid_box = box + + # ------------------------------------------------------------------ + # Toolbar + # ------------------------------------------------------------------ + + def _build_toolbar(self) -> QWidget: + bar = QWidget() + bar.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + h = QHBoxLayout(bar) + h.setContentsMargins(8, 4, 8, 4) + + btn_new = QPushButton("New") + btn_new.clicked.connect(self._on_new) + h.addWidget(btn_new) + + btn_open = QPushButton("Open…") + btn_open.clicked.connect(self._on_open) + h.addWidget(btn_open) + + btn_save = QPushButton("Save") + btn_save.clicked.connect(self._on_save) + h.addWidget(btn_save) + + h.addStretch(1) + + self._btn_compile = QPushButton("Compile .appack…") + self._btn_compile.setToolTip("Generate deployment package (Engineer+)") + self._btn_compile.clicked.connect(self._on_compile) + h.addWidget(self._btn_compile) + + return bar + + # ------------------------------------------------------------------ + # RBAC + # ------------------------------------------------------------------ + + def _apply_rbac(self) -> None: + can_commission = self._session.has(Capability.EDIT_COMMISSIONING) + can_gains = self._session.has(Capability.EDIT_BASE_GAINS) + can_compile = self._session.has(Capability.EDIT_COMMISSIONING) + + for w in [ + self._max_rudder_deg, + self._deadband_pct, + self._max_rate_dps, + self._max_current_a, + self._act_type, + self._act_name, + self._sens_primary_type, + self._sens_primary_cs, + self._sens_primary_fsd, + self._sens_redundant_enabled, + self._sens_redundant_type, + self._sens_redundant_cs, + self._sens_redundant_fsd, + self._sens_diverge_alarm, + self._sens_diverge_failover, + ]: + w.setEnabled(can_commission) + + for w in [ + self._inner_kp, + self._inner_ki, + self._inner_kd, + self._outer_kp, + self._outer_ki, + self._outer_kd, + self._rot_ff, + ]: + w.setEnabled(can_gains) + + self._btn_compile.setEnabled(can_compile) + + if not can_gains: + self._pid_box.setTitle("PID Base Gains [Super Admin only — read-only]") + + # ------------------------------------------------------------------ + # Slots + # ------------------------------------------------------------------ + + def _on_redundant_toggled(self, enabled: bool) -> None: + for w in [ + self._sens_redundant_type, + self._sens_redundant_cs, + self._sens_redundant_fsd, + self._sens_diverge_alarm, + self._sens_diverge_failover, + ]: + w.setEnabled(enabled and self._session.has(Capability.EDIT_COMMISSIONING)) + + def _on_new(self) -> None: + self._project = None + self._load_defaults() + + def _on_open(self) -> None: + path, _ = QFileDialog.getOpenFileName( + self, + "Open project", + str(Path.home()), + "AR-Autopilot Project (*.yaml *.yml *.json);;All files (*)", + ) + if not path: + return + try: + self._project = ProjectConfig.load(path) + self._populate_from_project(self._project) + except Exception as exc: # noqa: BLE001 + QMessageBox.critical(self, "Load error", str(exc)) + + def _on_save(self) -> None: + try: + project = self._build_project() + except Exception as exc: # noqa: BLE001 + QMessageBox.critical(self, "Validation error", str(exc)) + return + + path, _ = QFileDialog.getSaveFileName( + self, + "Save project", + str(Path.home() / f"{project.project_name}.yaml"), + "AR-Autopilot Project (*.yaml);;JSON (*.json)", + ) + if not path: + return + try: + project.save_yaml(path) if path.endswith((".yaml", ".yml")) else project.save_json(path) + self._project = project + QMessageBox.information(self, "Saved", f"Project saved to:\n{path}") + except Exception as exc: # noqa: BLE001 + QMessageBox.critical(self, "Save error", str(exc)) + + def _on_compile(self) -> None: + if not self._session.has(Capability.EDIT_COMMISSIONING): + QMessageBox.warning(self, "Access denied", "Engineer or Super Admin required.") + return + + try: + project = self._build_project() + except Exception as exc: # noqa: BLE001 + QMessageBox.critical(self, "Validation error", str(exc)) + return + + path, _ = QFileDialog.getSaveFileName( + self, + "Save .appack", + str(Path.home() / f"{project.project_name}.appack"), + "AR-Autopilot Package (*.appack)", + ) + if not path: + return + + try: + from arautopilot.studio.compiler.appack import AppackCompiler + compiler = AppackCompiler(project) + out = compiler.compile(Path(path)) + self._session.audit.record( + actor=self._session.user.username, + action="compile_appack", + detail=f"project={project.project_id} output={out}", + ) + QMessageBox.information( + self, "Compiled", f".appack written to:\n{out}" + ) + except Exception as exc: # noqa: BLE001 + QMessageBox.critical(self, "Compile error", str(exc)) + + # ------------------------------------------------------------------ + # Build / populate helpers + # ------------------------------------------------------------------ + + def _build_project(self) -> ProjectConfig: + redundant: RudderSensorConfig | None = None + if self._sens_redundant_enabled.isChecked(): + redundant = RudderSensorConfig( + type=self._sens_redundant_type.currentData(), + label="Redundant – actuator arm", + spi_cs_gpio=self._sens_redundant_cs.value(), + full_scale_deg=self._sens_redundant_fsd.value(), + divergence_alarm_deg=self._sens_diverge_alarm.value(), + divergence_failover_deg=self._sens_diverge_failover.value(), + ) + + vessel = VesselConfig( + name=self._vessel_name.text().strip() or "Unnamed vessel", + type=self._vessel_type.currentData(), + length_m=self._length_m.value(), + displacement_t=self._displacement_t.value(), + max_speed_kn=self._max_speed_kn.value(), + actuator=ActuatorConfig( + type=self._act_type.currentData(), + name=self._act_name.text().strip(), + max_rudder_angle_deg=self._max_rudder_deg.value(), + deadband_pct=self._deadband_pct.value(), + max_rate_dps=self._max_rate_dps.value(), + max_current_a=self._max_current_a.value(), + ), + pid=PidConfig( + inner_loop_base=PidGains( + kp=self._inner_kp.value(), + ki=self._inner_ki.value(), + kd=self._inner_kd.value(), + ), + outer_loop_base=PidGains( + kp=self._outer_kp.value(), + ki=self._outer_ki.value(), + kd=self._outer_kd.value(), + ), + rot_feedforward_gain=self._rot_ff.value(), + ), + sensors=DualRudderSensorConfig( + primary=RudderSensorConfig( + type=self._sens_primary_type.currentData(), + label="Primary – rudder stock", + spi_cs_gpio=self._sens_primary_cs.value(), + full_scale_deg=self._sens_primary_fsd.value(), + ), + redundant=redundant, + ), + ) + + existing_id = self._project.project_id if self._project else None + project = ProjectConfig( + client_name=self._client_name.text().strip() or "Unknown client", + project_name=self._project_name.text().strip() or "Unnamed project", + notes=self._notes.toPlainText().strip(), + vessel=vessel, + ) + if existing_id is not None: + object.__setattr__(project, "project_id", existing_id) + return project + + def _populate_from_project(self, p: ProjectConfig) -> None: + self._client_name.setText(p.client_name) + self._project_name.setText(p.project_name) + self._notes.setPlainText(p.notes) + + v = p.vessel + self._vessel_name.setText(v.name) + _set_combo(self._vessel_type, v.type) + self._length_m.setValue(v.length_m) + self._displacement_t.setValue(v.displacement_t) + self._max_speed_kn.setValue(v.max_speed_kn) + + a = v.actuator + _set_combo(self._act_type, a.type) + self._act_name.setText(a.name) + self._max_rudder_deg.setValue(a.max_rudder_angle_deg) + self._deadband_pct.setValue(a.deadband_pct) + self._max_rate_dps.setValue(a.max_rate_dps) + self._max_current_a.setValue(a.max_current_a) + + sp = v.sensors.primary + _set_combo(self._sens_primary_type, sp.type) + self._sens_primary_cs.setValue(sp.spi_cs_gpio) + self._sens_primary_fsd.setValue(sp.full_scale_deg) + + sr = v.sensors.redundant + self._sens_redundant_enabled.setChecked(sr is not None) + if sr is not None: + _set_combo(self._sens_redundant_type, sr.type) + self._sens_redundant_cs.setValue(sr.spi_cs_gpio) + self._sens_redundant_fsd.setValue(sr.full_scale_deg) + self._sens_diverge_alarm.setValue(sr.divergence_alarm_deg) + self._sens_diverge_failover.setValue(sr.divergence_failover_deg) + + pid = v.pid + self._inner_kp.setValue(pid.inner_loop_base.kp) + self._inner_ki.setValue(pid.inner_loop_base.ki) + self._inner_kd.setValue(pid.inner_loop_base.kd) + self._outer_kp.setValue(pid.outer_loop_base.kp) + self._outer_ki.setValue(pid.outer_loop_base.ki) + self._outer_kd.setValue(pid.outer_loop_base.kd) + self._rot_ff.setValue(pid.rot_feedforward_gain) + + def _load_defaults(self) -> None: + self._client_name.clear() + self._project_name.clear() + self._notes.clear() + self._vessel_name.clear() + self._vessel_type.setCurrentIndex(0) + self._length_m.setValue(30.0) + self._displacement_t.setValue(0.0) + self._max_speed_kn.setValue(18.0) + self._act_type.setCurrentIndex(0) + self._act_name.clear() + self._max_rudder_deg.setValue(35.0) + self._deadband_pct.setValue(5.0) + self._max_rate_dps.setValue(4.5) + self._max_current_a.setValue(15.0) + self._sens_primary_type.setCurrentIndex(0) + self._sens_primary_cs.setValue(10) + self._sens_primary_fsd.setValue(35.0) + self._sens_redundant_enabled.setChecked(False) + self._sens_redundant_cs.setValue(11) + self._sens_redundant_fsd.setValue(35.0) + self._sens_diverge_alarm.setValue(3.0) + self._sens_diverge_failover.setValue(6.0) + self._inner_kp.setValue(1.5) + self._inner_ki.setValue(0.1) + self._inner_kd.setValue(0.05) + self._outer_kp.setValue(2.0) + self._outer_ki.setValue(0.05) + self._outer_kd.setValue(0.3) + self._rot_ff.setValue(0.4) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _gain_spin(label: str, fl: QFormLayout) -> QDoubleSpinBox: + spin = QDoubleSpinBox() + spin.setRange(0.0, 100.0) + spin.setDecimals(4) + spin.setSingleStep(0.01) + fl.addRow(label, spin) + return spin + + +def _set_combo(combo: QComboBox, value: object) -> None: + for i in range(combo.count()): + if combo.itemData(i) == value: + combo.setCurrentIndex(i) + return diff --git a/arautopilot/studio/main_window.py b/arautopilot/studio/main_window.py index de10802..f4cab41 100644 --- a/arautopilot/studio/main_window.py +++ b/arautopilot/studio/main_window.py @@ -24,6 +24,7 @@ from PySide6.QtWidgets import ( ) from arautopilot.core.rbac import capabilities_of +from arautopilot.studio.editors.project_editor import ProjectEditorWidget from arautopilot.studio.flash_console import FlashConsoleWidget from arautopilot.studio.session import Session from arautopilot.version import __version__ @@ -74,11 +75,7 @@ class StudioMainWindow(QMainWindow): tabs.addTab(self._build_overview_tab(), "Overview") tabs.addTab(FlashConsoleWidget(self._session), "Flash Console") - tabs.addTab(self._placeholder_tab( - "Project configurator -- Sprint 4.\n\n" - "Will let you create / edit a per-vessel ProjectConfig and " - "compile it into an .appack for deployment." - ), "Project") + tabs.addTab(ProjectEditorWidget(self._session), "Project") tabs.addTab(self._placeholder_tab( "Telemetry -- Sprint 4.\n\n" "Live Modbus telemetry from the connected AR-NMEA-IO board." diff --git a/arautopilot/tests/test_appack_compiler.py b/arautopilot/tests/test_appack_compiler.py new file mode 100644 index 0000000..d3e24d8 --- /dev/null +++ b/arautopilot/tests/test_appack_compiler.py @@ -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() diff --git a/arautopilot/tests/test_sensor_config.py b/arautopilot/tests/test_sensor_config.py new file mode 100644 index 0000000..438aec4 --- /dev/null +++ b/arautopilot/tests/test_sensor_config.py @@ -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 diff --git a/firmware/ar_autopilot_v1/src/hal/rudder_actuator_electric.h b/firmware/ar_autopilot_v1/src/hal/rudder_actuator_electric.h new file mode 100644 index 0000000..ba10acb --- /dev/null +++ b/firmware/ar_autopilot_v1/src/hal/rudder_actuator_electric.h @@ -0,0 +1,138 @@ +// ============================================================================= +// rudder_actuator_electric.h -- reversible DC motor actuator (Sprint 4) +// ============================================================================= +// +// Drives a reversible DC motor with mechanical end-stops (Lewmar, Simpson +// Lawrence) via H-bridge (DRV8833 or equivalent): +// DO1 (GPIO) -- IN1 (port direction) +// DO2 (GPIO) -- IN2 (starboard direction) +// DO3 (GPIO) -- master power relay (ENA) +// PWM channel -- motor speed (shared LEDC channel 1) +// +// Architecture identical to HydraulicRudderActuator; only the PWM mapping +// differs (electric motors typically need a minimum useful duty to overcome +// static friction — see AR_ACTUATOR_DEADBAND_PCT in firmware_config.h). +// ============================================================================= + +#pragma once + +#include +#include +#include + +#include +#include + +#include "hal/di_do.h" +#include "hal/pinout.h" +#include "hal/rudder_actuator_iface.h" +#include "hal/rudder_sensor.h" +#include "system/ar_log.h" + +#ifndef AR_ACTUATOR_DEADBAND_PCT +# define AR_ACTUATOR_DEADBAND_PCT 5.0f +#endif + +namespace arautopilot::hal { + +class ElectricDcRudderActuator final : public IRudderActuator { +public: + static constexpr const char* NVS_NS = "ar_act"; + static constexpr const char* NVS_KEY = "angle_limit"; + static constexpr float DEFAULT_LIMIT_DEG = 35.0f; + static constexpr float MIN_LIMIT_DEG = 15.0f; + static constexpr float MAX_LIMIT_DEG = 45.0f; + + void init() override { + _prefs.begin(NVS_NS, false); + _limit_deg = _prefs.getFloat(NVS_KEY, DEFAULT_LIMIT_DEG); + _limit_deg = std::clamp(_limit_deg, MIN_LIMIT_DEG, MAX_LIMIT_DEG); + _prefs.end(); + + pinMode(PIN_DO1, OUTPUT); digitalWrite(PIN_DO1, LOW); + pinMode(PIN_DO2, OUTPUT); digitalWrite(PIN_DO2, LOW); + pinMode(PIN_DO3, OUTPUT); digitalWrite(PIN_DO3, LOW); + + ledc_timer_config_t timer{}; + timer.speed_mode = LEDC_LOW_SPEED_MODE; + timer.duty_resolution = LEDC_TIMER_8_BIT; + timer.timer_num = LEDC_TIMER_1; + timer.freq_hz = 20000; // 20 kHz — inaudible for DC motor + timer.clk_cfg = LEDC_AUTO_CLK; + ledc_timer_config(&timer); + + ledc_channel_config_t ch{}; + ch.speed_mode = LEDC_LOW_SPEED_MODE; + ch.channel = LEDC_CHANNEL_1; + ch.timer_sel = LEDC_TIMER_1; + ch.gpio_num = PIN_ACTUATOR_PWM; + ch.duty = 0; + ledc_channel_config(&ch); + + AR_LOGI("ElectricActuator", "init OK, limit=%.1f deg", _limit_deg); + } + + void power_on() override { digitalWrite(PIN_DO3, HIGH); _powered = true; } + void power_off() override { _stop_outputs(); digitalWrite(PIN_DO3, LOW); _powered = false; } + + bool command(float angle_deg) override { + if (!_powered || _at_standby()) return false; + + const float clamped = std::clamp(angle_deg, -_limit_deg, _limit_deg); + if (clamped > 0.0f && _limit_stbd()) { _stop_outputs(); return false; } + if (clamped < 0.0f && _limit_port()) { _stop_outputs(); return false; } + + const float raw_pct = std::fabsf(clamped) / _limit_deg * 100.0f; + // Apply deadband: below deadband -> 0, otherwise scale to [deadband, 100] + const float db = AR_ACTUATOR_DEADBAND_PCT; + const float duty_pct = (raw_pct < 0.5f) ? 0.0f + : db + raw_pct * (100.0f - db) / 100.0f; + + _set_pwm(static_cast(std::min(duty_pct, 100.0f) * 255.0f / 100.0f)); + + if (clamped > 0.01f) { + digitalWrite(PIN_DO1, LOW); digitalWrite(PIN_DO2, HIGH); + } else if (clamped < -0.01f) { + digitalWrite(PIN_DO2, LOW); digitalWrite(PIN_DO1, HIGH); + } else { + _stop_outputs(); + } + return true; + } + + float actual_angle_deg() const override { return rudder_sensor_get_angle_deg(); } + bool is_powered() const override { return _powered; } + bool at_limit() const override { return _limit_port() || _limit_stbd(); } + float angle_limit_deg() const override { return _limit_deg; } + ActuatorType type() const override { return ActuatorType::ELECTRIC_DC; } + + void set_angle_limit_deg(float limit_deg) override { + _limit_deg = std::clamp(limit_deg, MIN_LIMIT_DEG, MAX_LIMIT_DEG); + _prefs.begin(NVS_NS, false); + _prefs.putFloat(NVS_KEY, _limit_deg); + _prefs.end(); + AR_LOGI("ElectricActuator", "angle_limit updated -> %.1f deg", _limit_deg); + } + +private: + Preferences _prefs; + float _limit_deg{DEFAULT_LIMIT_DEG}; + bool _powered{false}; + + static bool _limit_port() { return digitalRead(PIN_DI2) == HIGH; } + static bool _limit_stbd() { return digitalRead(PIN_DI3) == HIGH; } + static bool _at_standby(); + + void _stop_outputs() { + _set_pwm(0); + digitalWrite(PIN_DO1, LOW); + digitalWrite(PIN_DO2, LOW); + } + + static void _set_pwm(uint8_t duty) { + ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_1, duty); + ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_1); + } +}; + +} // namespace arautopilot::hal diff --git a/firmware/ar_autopilot_v1/src/hal/rudder_actuator_factory.h b/firmware/ar_autopilot_v1/src/hal/rudder_actuator_factory.h new file mode 100644 index 0000000..6936504 --- /dev/null +++ b/firmware/ar_autopilot_v1/src/hal/rudder_actuator_factory.h @@ -0,0 +1,52 @@ +// ============================================================================= +// rudder_actuator_factory.h -- selects concrete actuator at build time +// ============================================================================= +// +// The AR_ACTUATOR_TYPE macro is defined in firmware_config.h (generated by +// the .appack compiler). If firmware_config.h is not present (standalone +// build), falls back to ACTUATOR_HYDRAULIC_REVERSIBLE. +// +// Usage in main.cpp: +// +// #include "hal/rudder_actuator_factory.h" +// // ... +// IRudderActuator& actuator = arautopilot::hal::g_rudder_actuator; +// actuator.init(); +// ============================================================================= + +#pragma once + +// Pull in generated config if available +#if __has_include("firmware_config.h") +# include "firmware_config.h" +#endif + +// Actuator type constants (must match _actuator_type_to_enum in appack.py) +#define ACTUATOR_HYDRAULIC_REVERSIBLE 0 +#define ACTUATOR_ELECTRIC_DC 1 +#define ACTUATOR_SERVOMOTOR 2 +#define ACTUATOR_STERNDRIVE_ANALOG 3 +#define ACTUATOR_TEST_RIG_MOTOR 4 + +#ifndef AR_ACTUATOR_TYPE +# define AR_ACTUATOR_TYPE ACTUATOR_HYDRAULIC_REVERSIBLE +#endif + +#if AR_ACTUATOR_TYPE == ACTUATOR_HYDRAULIC_REVERSIBLE +# include "hal/rudder_actuator_hydraulic.h" + namespace arautopilot::hal { + static HydraulicRudderActuator g_rudder_actuator; + } +#elif AR_ACTUATOR_TYPE == ACTUATOR_ELECTRIC_DC +# include "hal/rudder_actuator_electric.h" + namespace arautopilot::hal { + static ElectricDcRudderActuator g_rudder_actuator; + } +#else + // Fallback: hydraulic (compile warning so the integrator notices) +# pragma message("AR_ACTUATOR_TYPE not recognised — defaulting to HydraulicRudderActuator") +# include "hal/rudder_actuator_hydraulic.h" + namespace arautopilot::hal { + static HydraulicRudderActuator g_rudder_actuator; + } +#endif diff --git a/firmware/ar_autopilot_v1/src/hal/rudder_actuator_hydraulic.h b/firmware/ar_autopilot_v1/src/hal/rudder_actuator_hydraulic.h new file mode 100644 index 0000000..bd52730 --- /dev/null +++ b/firmware/ar_autopilot_v1/src/hal/rudder_actuator_hydraulic.h @@ -0,0 +1,148 @@ +// ============================================================================= +// rudder_actuator_hydraulic.h -- reversible hydraulic pump actuator (Sprint 4) +// ============================================================================= +// +// Drives a reversible hydraulic pump (Hynautic, Hypro, Octopus, Vetus, +// Lecomble & Schmitt) via: +// DO1 (GPIO) -- port direction solenoid +// DO2 (GPIO) -- starboard direction solenoid +// DO3 (GPIO) -- master power relay +// PWM channel -- pump speed proportional to |angle_error| +// +// The software angle limit is loaded from NVS at init() and clamped to +// [15, 45] degrees. It can be updated live via set_angle_limit_deg() +// which also persists the new value to NVS. +// ============================================================================= + +#pragma once + +#include +#include +#include + +#include +#include + +#include "hal/di_do.h" +#include "hal/pinout.h" +#include "hal/rudder_actuator_iface.h" +#include "hal/rudder_sensor.h" +#include "system/ar_log.h" + +namespace arautopilot::hal { + +class HydraulicRudderActuator final : public IRudderActuator { +public: + // NVS namespace + key for the angle limit + static constexpr const char* NVS_NS = "ar_act"; + static constexpr const char* NVS_KEY = "angle_limit"; + static constexpr float DEFAULT_LIMIT_DEG = 35.0f; + static constexpr float MIN_LIMIT_DEG = 15.0f; + static constexpr float MAX_LIMIT_DEG = 45.0f; + + void init() override { + // Load angle limit from NVS (falls back to firmware_config.h default) + _prefs.begin(NVS_NS, false); + _limit_deg = _prefs.getFloat(NVS_KEY, DEFAULT_LIMIT_DEG); + _limit_deg = std::clamp(_limit_deg, MIN_LIMIT_DEG, MAX_LIMIT_DEG); + _prefs.end(); + + // Configure direction + power GPIOs + pinMode(PIN_DO1, OUTPUT); digitalWrite(PIN_DO1, LOW); // port + pinMode(PIN_DO2, OUTPUT); digitalWrite(PIN_DO2, LOW); // starboard + pinMode(PIN_DO3, OUTPUT); digitalWrite(PIN_DO3, LOW); // master power + + // Configure LEDC PWM for pump speed + ledc_timer_config_t timer{}; + timer.speed_mode = LEDC_LOW_SPEED_MODE; + timer.duty_resolution = LEDC_TIMER_8_BIT; + timer.timer_num = LEDC_TIMER_0; + timer.freq_hz = 5000; + timer.clk_cfg = LEDC_AUTO_CLK; + ledc_timer_config(&timer); + + ledc_channel_config_t ch{}; + ch.speed_mode = LEDC_LOW_SPEED_MODE; + ch.channel = LEDC_CHANNEL_0; + ch.timer_sel = LEDC_TIMER_0; + ch.gpio_num = PIN_ACTUATOR_PWM; + ch.duty = 0; + ledc_channel_config(&ch); + + AR_LOGI("HydraulicActuator", "init OK, limit=%.1f deg", _limit_deg); + } + + void power_on() override { + digitalWrite(PIN_DO3, HIGH); + _powered = true; + } + + void power_off() override { + _stop_outputs(); + digitalWrite(PIN_DO3, LOW); + _powered = false; + } + + bool command(float angle_deg) override { + if (!_powered) return false; + if (_at_standby()) return false; + + // Software angle limit + const float clamped = std::clamp(angle_deg, -_limit_deg, _limit_deg); + + // Hard-stop if limit switch asserted in the commanded direction + if (clamped > 0.0f && _limit_stbd()) { _stop_outputs(); return false; } + if (clamped < 0.0f && _limit_port()) { _stop_outputs(); return false; } + + const float duty_pct = std::fabsf(clamped) / _limit_deg * 100.0f; + _set_pwm(static_cast(duty_pct * 255.0f / 100.0f)); + + if (clamped > 0.01f) { + digitalWrite(PIN_DO1, LOW); digitalWrite(PIN_DO2, HIGH); + } else if (clamped < -0.01f) { + digitalWrite(PIN_DO2, LOW); digitalWrite(PIN_DO1, HIGH); + } else { + _stop_outputs(); + } + return true; + } + + float actual_angle_deg() const override { + return rudder_sensor_get_angle_deg(); + } + + bool is_powered() const override { return _powered; } + bool at_limit() const override { return _limit_port() || _limit_stbd(); } + float angle_limit_deg() const override { return _limit_deg; } + ActuatorType type() const override { return ActuatorType::HYDRAULIC_REVERSIBLE; } + + void set_angle_limit_deg(float limit_deg) override { + _limit_deg = std::clamp(limit_deg, MIN_LIMIT_DEG, MAX_LIMIT_DEG); + _prefs.begin(NVS_NS, false); + _prefs.putFloat(NVS_KEY, _limit_deg); + _prefs.end(); + AR_LOGI("HydraulicActuator", "angle_limit updated -> %.1f deg", _limit_deg); + } + +private: + Preferences _prefs; + float _limit_deg{DEFAULT_LIMIT_DEG}; + bool _powered{false}; + + static bool _limit_port() { return digitalRead(PIN_DI2) == HIGH; } + static bool _limit_stbd() { return digitalRead(PIN_DI3) == HIGH; } + static bool _at_standby(); // defined in modes/standby.cpp + + void _stop_outputs() { + _set_pwm(0); + digitalWrite(PIN_DO1, LOW); + digitalWrite(PIN_DO2, LOW); + } + + static void _set_pwm(uint8_t duty) { + ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, duty); + ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0); + } +}; + +} // namespace arautopilot::hal diff --git a/firmware/ar_autopilot_v1/src/hal/rudder_actuator_iface.h b/firmware/ar_autopilot_v1/src/hal/rudder_actuator_iface.h new file mode 100644 index 0000000..35599b1 --- /dev/null +++ b/firmware/ar_autopilot_v1/src/hal/rudder_actuator_iface.h @@ -0,0 +1,84 @@ +// ============================================================================= +// rudder_actuator_iface.h -- abstract rudder actuator interface (Sprint 4) +// ============================================================================= +// +// The PID inner loop knows only about IRudderActuator. The concrete +// implementation (hydraulic, electric-DC, servo, or test-rig motor) is +// selected at build time via the AR_ACTUATOR_TYPE macro produced by the +// .appack firmware_config.h. +// +// Safety invariants (all implementations must honour): +// 1. angle_limit_deg -- software limit loaded from NVS (or firmware_config.h +// default). Commanded angles are clamped before +// reaching the physical output. +// 2. Limit switches -- DI2 (port) / DI3 (starboard) block motion in +// the corresponding direction regardless of command. +// 3. Master power -- DO3 must be HIGH before any motion. +// 4. STANDBY guard -- implementations check current_mode() == STANDBY +// and refuse motion. +// ============================================================================= + +#pragma once + +#include + +namespace arautopilot::hal { + +// --------------------------------------------------------------------------- +// Actuator type enumeration (mirrors .appack firmware_config.h values) +// --------------------------------------------------------------------------- + +enum class ActuatorType : uint8_t { + HYDRAULIC_REVERSIBLE = 0, ///< Reversible hydraulic pump (Hynautic, Hypro …) + ELECTRIC_DC = 1, ///< Reversible DC motor with mechanical end-stops + SERVOMOTOR = 2, ///< Servomotor with built-in position feedback + STERNDRIVE_ANALOG = 3, ///< Analog directional sterndrive + TEST_RIG_MOTOR = 4, ///< HIL test rig: DC motor rotates compass platform +}; + +// --------------------------------------------------------------------------- +// Abstract interface +// --------------------------------------------------------------------------- + +class IRudderActuator { +public: + virtual ~IRudderActuator() = default; + + /// One-time hardware initialisation (GPIOs, PWM channels …). + virtual void init() = 0; + + /// Engage master power relay. Required before command(). + virtual void power_on() = 0; + + /// Drop master power relay and stop all outputs immediately. + virtual void power_off() = 0; + + /// Command the actuator toward *angle_deg* (positive = starboard). + /// The implementation MUST clamp to [-angle_limit_deg, +angle_limit_deg] + /// before driving any output. + /// Returns true if the command was applied; false if an interlock blocked it. + virtual bool command(float angle_deg) = 0; + + /// Estimated actual rudder angle (degrees) from the sensor/feedback. + /// Returns 0.0 if no feedback is available. + virtual float actual_angle_deg() const = 0; + + /// True if master power relay is currently energised. + virtual bool is_powered() const = 0; + + /// True if either limit switch is asserted. + virtual bool at_limit() const = 0; + + /// Current software angle limit (degrees, positive value). + /// Can be updated at runtime via set_angle_limit_deg(). + virtual float angle_limit_deg() const = 0; + + /// Update the software angle limit. Clamped to [15, 45] degrees. + /// Persisted to NVS by the concrete implementation. + virtual void set_angle_limit_deg(float limit_deg) = 0; + + /// Actuator type tag for diagnostics. + virtual ActuatorType type() const = 0; +}; + +} // namespace arautopilot::hal