"""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