Files
AR-Autopilot/arautopilot/studio/editors/project_editor.py
T
alro65 e4812e9b44 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>
2026-05-20 10:29:17 -04:00

620 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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