e4812e9b44
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>
246 lines
11 KiB
Python
246 lines
11 KiB
Python
"""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")
|