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
+245
View File
@@ -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:
<project_id>/
├── 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")