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")
@@ -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("<b>Primary sensor</b>"))
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("<b>Redundant sensor (optional)</b>"))
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("<b>Inner loop (rudder position, 50 Hz)</b>"))
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("<b>Outer loop (heading, 10 Hz)</b>"))
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
+2 -5
View File
@@ -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."