From 5238bd31f0adfdf3f86a387c59135614ed166f45 Mon Sep 17 00:00:00 2001 From: alro1965 Date: Sun, 24 May 2026 11:22:38 -0400 Subject: [PATCH] =?UTF-8?q?feat(studio):=20AR=20Electronics=20branding=20+?= =?UTF-8?q?=20Telemetr=C3=ADa=20+=20Instalar=20J6412=20tabs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ar_style.py — global QSS dark theme + QPalette matching the Flutter brand palette (navy #0D1B2A, electric blue #2563EB, glow #60B8FF). Single call: apply_ar_style(app). app.py — applies AR style and window icon on startup. main_window.py — complete rewrite of the layout: - Sidebar: AR logo (PNG), user/role display, capabilities list, version stamp - 5 tabs: Overview · ⚡ Flash ESP32 · 📋 Proyecto · 📡 Telemetría · 💾 Instalar J6412 - Overview tab: rich-text guide with icons for each tab's purpose telemetry_widget.py — live $PARP STATUS chart tab: - QSerialPort RX-only connection to AR-Concentrador (port selector + Refresh) - Python $PARP XOR-checksum parser (mirrors Dart ParpCodec) - _RollingChart: pure QPainter scrolling time-series, 60 s window, no external charting library - Heading + Setpoint on one chart; Rudder on a second chart - Live value strip shows Rumbo / Setpoint / Timón + mode label installer_widget.py — J6412 USB image builder tab: - Vessel name + serial number (auto-generate or paste) - Optional CSV log path for CRM - App checkboxes (AR-ECDIS / AR-Autopilot / skip Flutter build) - Worker thread runs installer/build_usb.py with streamed log output - "Abrir dist/" button when build succeeds - RBAC gated: Engineer or Super Admin only pyproject.toml — adds [installer] and [license-server] optional dep groups AR_electronics — AR-Autopilot Project --- arautopilot/studio/app.py | 15 + arautopilot/studio/ar_style.py | 270 ++++++++++++++++ arautopilot/studio/installer_widget.py | 334 +++++++++++++++++++ arautopilot/studio/main_window.py | 187 ++++++++--- arautopilot/studio/telemetry_widget.py | 428 +++++++++++++++++++++++++ pyproject.toml | 13 + 6 files changed, 1203 insertions(+), 44 deletions(-) create mode 100644 arautopilot/studio/ar_style.py create mode 100644 arautopilot/studio/installer_widget.py create mode 100644 arautopilot/studio/telemetry_widget.py diff --git a/arautopilot/studio/app.py b/arautopilot/studio/app.py index d6bcfe1..17f637f 100644 --- a/arautopilot/studio/app.py +++ b/arautopilot/studio/app.py @@ -22,6 +22,8 @@ import argparse import sys from pathlib import Path +REPO_ROOT = Path(__file__).resolve().parents[2] + from arautopilot.core.audit import AuditLog from arautopilot.core.user_store import UserStore, seed_demo_users from arautopilot.studio.session import studio_data_dir @@ -81,6 +83,19 @@ def run(argv: list[str] | None = None) -> int: app = QApplication(sys.argv) app.setApplicationName("AR-Autopilot Studio") + # Apply AR Electronics brand theme + from arautopilot.studio.ar_style import apply_ar_style # noqa: PLC0415 + apply_ar_style(app) + + # Window icon (logo) + from PySide6.QtGui import QIcon # noqa: PLC0415 + from pathlib import Path as _Path # noqa: PLC0415 + _logo = REPO_ROOT / "display" / "assets" / "images" / "ar_logo_full.png" + if not _logo.exists(): + _logo = _Path(__file__).resolve().parents[2] / "display" / "assets" / "images" / "ar_logo_full.png" + if _logo.exists(): + app.setWindowIcon(QIcon(str(_logo))) + if len(user_store) == 0: QMessageBox.information( None, diff --git a/arautopilot/studio/ar_style.py b/arautopilot/studio/ar_style.py new file mode 100644 index 0000000..aa4752d --- /dev/null +++ b/arautopilot/studio/ar_style.py @@ -0,0 +1,270 @@ +"""AR Electronics global Qt stylesheet and palette constants. + +Apply once with:: + + from arautopilot.studio.ar_style import apply_ar_style + apply_ar_style(app) # QApplication instance + +All Studio widgets inherit the style automatically. +""" + +from __future__ import annotations + +from PySide6.QtGui import QColor, QPalette +from PySide6.QtWidgets import QApplication + +# --------------------------------------------------------------------------- +# Brand colours (matches Flutter AutopilotTheme and web CSS vars) +# --------------------------------------------------------------------------- +NAVY = "#0D1B2A" +PANEL = "#1A2B3C" +PANEL_LIGHT = "#243447" +BORDER = "#2B3F5C" +ACCENT = "#2563EB" +ACCENT_MID = "#4A9FE8" +GLOW = "#60B8FF" +TEXT_MAIN = "#E2E8F0" +TEXT_MUTED = "#8899AA" +TEXT_DIM = "#445566" +OK = "#22C55E" +WARN = "#F59E0B" +ERROR = "#EF4444" + +AR_QSS = f""" +/* ── Root ──────────────────────────────────────────────────────────────── */ +QMainWindow, QDialog, QWidget {{ + background-color: {NAVY}; + color: {TEXT_MAIN}; + font-family: "Segoe UI", "Inter", sans-serif; + font-size: 12px; +}} + +/* ── Group boxes ─────────────────────────────────────────────────────── */ +QGroupBox {{ + border: 1px solid {BORDER}; + border-radius: 5px; + margin-top: 10px; + padding-top: 10px; + color: {ACCENT_MID}; + font-weight: bold; + font-size: 11px; + letter-spacing: 0.5px; +}} +QGroupBox::title {{ + subcontrol-origin: margin; + left: 10px; + padding: 0 5px; +}} + +/* ── Tabs ──────────────────────────────────────────────────────────────── */ +QTabWidget::pane {{ + border: 1px solid {BORDER}; + background-color: {NAVY}; + top: -1px; +}} +QTabBar::tab {{ + background: {PANEL}; + color: {TEXT_MUTED}; + padding: 7px 18px; + border: 1px solid {BORDER}; + border-bottom: none; + margin-right: 2px; + border-top-left-radius: 4px; + border-top-right-radius: 4px; +}} +QTabBar::tab:selected {{ + background: {NAVY}; + color: {ACCENT_MID}; + border-bottom: 2px solid {ACCENT}; +}} +QTabBar::tab:hover:!selected {{ + color: {TEXT_MAIN}; +}} + +/* ── Buttons ───────────────────────────────────────────────────────────── */ +QPushButton {{ + background-color: {PANEL}; + color: {TEXT_MAIN}; + border: 1px solid {ACCENT}; + border-radius: 4px; + padding: 5px 16px; + min-width: 60px; +}} +QPushButton:hover {{ + background-color: {ACCENT}; + color: white; +}} +QPushButton:pressed {{ + background-color: #1a4db5; +}} +QPushButton:disabled {{ + color: {TEXT_DIM}; + border-color: {BORDER}; + background-color: {PANEL}; +}} +QPushButton#primary {{ + background-color: {ACCENT}; + color: white; + font-weight: bold; +}} +QPushButton#primary:hover {{ + background-color: {GLOW}; + color: {NAVY}; +}} + +/* ── Inputs ────────────────────────────────────────────────────────────── */ +QLineEdit, QTextEdit, QPlainTextEdit {{ + background-color: {PANEL}; + color: {TEXT_MAIN}; + border: 1px solid {BORDER}; + border-radius: 3px; + padding: 4px 7px; + selection-background-color: {ACCENT}; +}} +QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus {{ + border-color: {ACCENT_MID}; +}} +QComboBox {{ + background-color: {PANEL}; + color: {TEXT_MAIN}; + border: 1px solid {BORDER}; + border-radius: 3px; + padding: 4px 7px; + selection-background-color: {ACCENT}; +}} +QComboBox:focus {{ + border-color: {ACCENT_MID}; +}} +QComboBox::drop-down {{ + border-left: 1px solid {BORDER}; + width: 20px; +}} +QComboBox QAbstractItemView {{ + background-color: {PANEL}; + color: {TEXT_MAIN}; + selection-background-color: {ACCENT}; + outline: none; +}} +QSpinBox, QDoubleSpinBox {{ + background-color: {PANEL}; + color: {TEXT_MAIN}; + border: 1px solid {BORDER}; + border-radius: 3px; + padding: 3px 6px; + selection-background-color: {ACCENT}; +}} +QSpinBox:focus, QDoubleSpinBox:focus {{ + border-color: {ACCENT_MID}; +}} + +/* ── Lists ─────────────────────────────────────────────────────────────── */ +QListWidget {{ + background-color: {PANEL}; + color: {TEXT_MAIN}; + border: 1px solid {BORDER}; + border-radius: 3px; + outline: none; +}} +QListWidget::item:selected {{ + background-color: {ACCENT}; +}} + +/* ── Scrollbars ────────────────────────────────────────────────────────── */ +QScrollBar:vertical {{ + background: {NAVY}; + width: 8px; + border-radius: 4px; +}} +QScrollBar::handle:vertical {{ + background: {BORDER}; + border-radius: 4px; + min-height: 20px; +}} +QScrollBar::handle:vertical:hover {{ + background: {ACCENT_MID}; +}} +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ + height: 0; +}} +QScrollBar:horizontal {{ + background: {NAVY}; + height: 8px; +}} +QScrollBar::handle:horizontal {{ + background: {BORDER}; + border-radius: 4px; +}} + +/* ── Splitter ──────────────────────────────────────────────────────────── */ +QSplitter::handle {{ + background: {BORDER}; +}} + +/* ── Status bar ────────────────────────────────────────────────────────── */ +QStatusBar {{ + background-color: #0A1520; + color: {ACCENT_MID}; + font-size: 11px; +}} + +/* ── Checkboxes ────────────────────────────────────────────────────────── */ +QCheckBox {{ + color: {TEXT_MAIN}; + spacing: 6px; +}} +QCheckBox::indicator {{ + width: 14px; + height: 14px; + border: 1px solid {ACCENT}; + border-radius: 3px; + background: {PANEL}; +}} +QCheckBox::indicator:checked {{ + background: {ACCENT}; + image: none; +}} + +/* ── Labels ────────────────────────────────────────────────────────────── */ +QLabel {{ + color: {TEXT_MAIN}; +}} +QLabel[role="muted"] {{ + color: {TEXT_MUTED}; + font-size: 11px; +}} +QLabel[role="heading"] {{ + color: {ACCENT_MID}; + font-weight: bold; + font-size: 14px; + letter-spacing: 1px; +}} + +/* ── Message boxes ─────────────────────────────────────────────────────── */ +QMessageBox {{ + background-color: {PANEL}; +}} +QMessageBox QLabel {{ + color: {TEXT_MAIN}; +}} +""" + + +def apply_ar_style(app: QApplication) -> None: + """Apply the AR Electronics brand stylesheet + dark palette to *app*.""" + app.setStyleSheet(AR_QSS) + + pal = QPalette() + pal.setColor(QPalette.ColorRole.Window, QColor(NAVY)) + pal.setColor(QPalette.ColorRole.WindowText, QColor(TEXT_MAIN)) + pal.setColor(QPalette.ColorRole.Base, QColor(PANEL)) + pal.setColor(QPalette.ColorRole.AlternateBase, QColor(PANEL_LIGHT)) + pal.setColor(QPalette.ColorRole.Text, QColor(TEXT_MAIN)) + pal.setColor(QPalette.ColorRole.BrightText, QColor(GLOW)) + pal.setColor(QPalette.ColorRole.Button, QColor(PANEL)) + pal.setColor(QPalette.ColorRole.ButtonText, QColor(TEXT_MAIN)) + pal.setColor(QPalette.ColorRole.Highlight, QColor(ACCENT)) + pal.setColor(QPalette.ColorRole.HighlightedText, QColor("#FFFFFF")) + pal.setColor(QPalette.ColorRole.Link, QColor(ACCENT_MID)) + pal.setColor(QPalette.ColorRole.Midlight, QColor(BORDER)) + pal.setColor(QPalette.ColorRole.Dark, QColor("#0A1520")) + app.setPalette(pal) diff --git a/arautopilot/studio/installer_widget.py b/arautopilot/studio/installer_widget.py new file mode 100644 index 0000000..a75e2cc --- /dev/null +++ b/arautopilot/studio/installer_widget.py @@ -0,0 +1,334 @@ +"""Installer widget — "Instalar J6412" tab in AR-Autopilot Studio. + +Lets the integrator build a USB pendrive installer image without leaving +the Studio: + + 1. Enter vessel name + generate (or paste) a serial number. + 2. Choose which apps to bundle (AR-ECDIS, AR-Autopilot Display). + 3. Click "Build USB Image" → runs installer/build_usb.py in a worker thread. + 4. Watch the live log. When done, open the dist/ folder. + +RBAC: only Engineer and Super Admin can build installers. +""" + +from __future__ import annotations + +import os +import secrets +import shlex +import subprocess +import sys +from pathlib import Path + +from PySide6.QtCore import QObject, QThread, Signal +from PySide6.QtWidgets import ( + QCheckBox, + QFileDialog, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPlainTextEdit, + QPushButton, + QVBoxLayout, + QWidget, +) + +from arautopilot.core.rbac import Capability +from arautopilot.studio.session import Session + +REPO_ROOT = Path(__file__).resolve().parents[2] +INSTALLER_DIR = REPO_ROOT / "installer" +DIST_DIR = INSTALLER_DIR / "dist" +BUILD_SCRIPT = INSTALLER_DIR / "build_usb.py" + + +# --------------------------------------------------------------------------- +# Serial generator (inline — no dependency on installer/serial_generator.py) +# --------------------------------------------------------------------------- + +def _generate_serial() -> str: + raw = secrets.token_hex(6).upper() + return f"AR-{raw[0:4]}-{raw[4:8]}-{raw[8:12]}" + + +# --------------------------------------------------------------------------- +# Worker thread +# --------------------------------------------------------------------------- + +class _BuildWorker(QObject): + line = Signal(str) + finished = Signal(int) # exit code + error = Signal(str) + + def __init__(self, argv: list[str]) -> None: + super().__init__() + self._argv = argv + self._proc: subprocess.Popen | None = None + self._cancelled = False + + def run(self) -> None: + try: + env = os.environ.copy() + env["PYTHONIOENCODING"] = "utf-8" + self._proc = subprocess.Popen( + self._argv, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + env=env, + ) + assert self._proc.stdout + for raw in self._proc.stdout: + if self._cancelled: + break + self.line.emit(raw.rstrip("\n")) + code = self._proc.wait() + self.finished.emit(code) + except FileNotFoundError as exc: + self.error.emit(f"No se encontró el ejecutable: {exc}") + except Exception as exc: # noqa: BLE001 + self.error.emit(str(exc)) + + def cancel(self) -> None: + self._cancelled = True + if self._proc: + try: + self._proc.terminate() + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Widget +# --------------------------------------------------------------------------- + +class InstallerWidget(QWidget): + """'Instalar J6412' tab content.""" + + def __init__(self, session: Session, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._session = session + self._thread: QThread | None = None + self._worker: _BuildWorker | None = None + self._build_ui() + + # ── UI ─────────────────────────────────────────────────────────────────── + + def _build_ui(self) -> None: + root = QVBoxLayout(self) + root.setContentsMargins(12, 12, 12, 12) + root.setSpacing(12) + + # Header + hdr = QLabel( + "" + "INSTALAR EN J6412
" + "" + "Genera un pendrive USB con las apps AR Electronics y la licencia del buque." + ) + hdr.setTextFormat(1) # RichText + hdr.setWordWrap(True) + root.addWidget(hdr) + + # ── Config group ───────────────────────────────────────────────────── + cfg = QGroupBox("Configuración del paquete") + form = QVBoxLayout(cfg) + + # Vessel name + vessel_row = QHBoxLayout() + vessel_row.addWidget(QLabel("Nombre del buque:")) + self._vessel_edit = QLineEdit() + self._vessel_edit.setPlaceholderText("e.g. M/Y PACIFICO") + vessel_row.addWidget(self._vessel_edit, 1) + form.addLayout(vessel_row) + + # Serial number + serial_row = QHBoxLayout() + serial_row.addWidget(QLabel("Número de serie:")) + self._serial_edit = QLineEdit() + self._serial_edit.setPlaceholderText("AR-XXXX-XXXX-XXXX") + self._serial_edit.setMaximumWidth(220) + gen_btn = QPushButton("Generar") + gen_btn.setToolTip("Genera un nuevo número de serie aleatorio") + gen_btn.clicked.connect(self._on_generate_serial) + serial_row.addWidget(self._serial_edit) + serial_row.addWidget(gen_btn) + serial_row.addStretch(1) + form.addLayout(serial_row) + + # CSV log + csv_row = QHBoxLayout() + csv_row.addWidget(QLabel("Registro CSV:")) + self._csv_edit = QLineEdit() + self._csv_edit.setPlaceholderText("Opcional — ruta al archivo serials.csv") + browse_btn = QPushButton("…") + browse_btn.setFixedWidth(30) + browse_btn.clicked.connect(self._on_browse_csv) + csv_row.addWidget(self._csv_edit, 1) + csv_row.addWidget(browse_btn) + form.addLayout(csv_row) + + # App checkboxes + app_row = QHBoxLayout() + self._chk_autopilot = QCheckBox("AR-Autopilot Display (Flutter)") + self._chk_autopilot.setChecked(True) + self._chk_ecdis = QCheckBox("AR-ECDIS") + self._chk_ecdis.setChecked(True) + self._chk_no_flutter = QCheckBox("Omitir compilación Flutter (usar build existente)") + app_row.addWidget(self._chk_autopilot) + app_row.addWidget(self._chk_ecdis) + app_row.addStretch(1) + form.addLayout(app_row) + form.addWidget(self._chk_no_flutter) + + root.addWidget(cfg) + + # ── Action row ─────────────────────────────────────────────────────── + act = QHBoxLayout() + self._build_btn = QPushButton("▶ Build USB Image") + self._build_btn.setObjectName("primary") + self._build_btn.setToolTip("Compila e empaqueta el instalador USB") + self._build_btn.clicked.connect(self._on_build) + act.addWidget(self._build_btn) + + self._cancel_btn = QPushButton("Cancelar") + self._cancel_btn.setEnabled(False) + self._cancel_btn.clicked.connect(self._on_cancel) + act.addWidget(self._cancel_btn) + + self._open_btn = QPushButton("Abrir dist/") + self._open_btn.setEnabled(False) + self._open_btn.setToolTip(f"Abre la carpeta: {DIST_DIR}") + self._open_btn.clicked.connect(self._on_open_dist) + act.addWidget(self._open_btn) + + act.addStretch(1) + self._status_lbl = QLabel("") + act.addWidget(self._status_lbl) + root.addLayout(act) + + # ── Log ────────────────────────────────────────────────────────────── + self._log = QPlainTextEdit() + self._log.setReadOnly(True) + self._log.setStyleSheet( + "background:#0A1520; color:#C9D1D9; font-family:Consolas,'Cascadia Mono',monospace; font-size:10px;" + ) + self._log.setPlaceholderText("El log del proceso de build aparecerá aquí…") + root.addWidget(self._log, 1) + + # RBAC gate + can_build = self._session.has(Capability.EDIT_COMMISSIONING) + if not can_build: + self._build_btn.setEnabled(False) + self._build_btn.setToolTip("Requiere rol Engineer o Super Admin.") + self._vessel_edit.setEnabled(False) + self._serial_edit.setEnabled(False) + self._chk_autopilot.setEnabled(False) + self._chk_ecdis.setEnabled(False) + self._chk_no_flutter.setEnabled(False) + root.insertWidget(0, QLabel( + "⚠ Tu rol no permite construir instaladores. " + "Inicia sesión como Engineer o Super Admin." + )) + + # ── Handlers ────────────────────────────────────────────────────────────── + + def _on_generate_serial(self) -> None: + self._serial_edit.setText(_generate_serial()) + + def _on_browse_csv(self) -> None: + path, _ = QFileDialog.getSaveFileName( + self, "Registro de seriales", str(Path.home() / "serials.csv"), + "CSV (*.csv);;Todos los archivos (*)" + ) + if path: + self._csv_edit.setText(path) + + def _on_build(self) -> None: + vessel = self._vessel_edit.text().strip() + serial = self._serial_edit.text().strip().upper() + + if not vessel: + QMessageBox.warning(self, "Falta dato", "Introduce el nombre del buque.") + return + if not serial: + serial = _generate_serial() + self._serial_edit.setText(serial) + self._log.appendPlainText(f"[auto] Serial generado: {serial}") + + if not BUILD_SCRIPT.exists(): + QMessageBox.critical( + self, "Script no encontrado", + f"No se encontró:\n{BUILD_SCRIPT}\n\nVerifica que el repo esté completo." + ) + return + + argv = [sys.executable, str(BUILD_SCRIPT), "--vessel", vessel, "--serial", serial] + if not self._chk_autopilot.isChecked() or self._chk_no_flutter.isChecked(): + argv.append("--no-flutter") + if not self._chk_ecdis.isChecked(): + argv.append("--no-ecdis") + csv = self._csv_edit.text().strip() + if csv: + argv += ["--csv", csv] + + self._log.clear() + self._log.appendPlainText(f"$ {' '.join(shlex.quote(a) for a in argv)}\n") + self._set_running(True) + self._status_lbl.setText("Building…") + + self._thread = QThread(self) + self._worker = _BuildWorker(argv) + self._worker.moveToThread(self._thread) + self._thread.started.connect(self._worker.run) + self._worker.line.connect(self._log.appendPlainText) + self._worker.finished.connect(self._on_build_done) + self._worker.error.connect(self._on_build_error) + self._worker.finished.connect(self._thread.quit) + self._worker.error.connect(self._thread.quit) + self._thread.finished.connect(self._cleanup_thread) + self._thread.start() + + def _on_cancel(self) -> None: + if self._worker: + self._worker.cancel() + self._log.appendPlainText("\n[cancelado por el operador]") + + def _on_open_dist(self) -> None: + if DIST_DIR.exists(): + os.startfile(str(DIST_DIR)) # Windows Explorer + else: + QMessageBox.information(self, "Sin carpeta", f"No existe:\n{DIST_DIR}") + + def _on_build_done(self, code: int) -> None: + if code == 0: + self._status_lbl.setText("✓ Build completado") + self._open_btn.setEnabled(True) + self._log.appendPlainText( + f"\n✓ Pendrive listo en:\n{DIST_DIR}\n" + "Copie TODO el contenido de dist/ al pendrive USB." + ) + else: + self._status_lbl.setText(f"✗ Error (exit {code})") + self._set_running(False) + + def _on_build_error(self, msg: str) -> None: + self._log.appendPlainText(f"\n[ERROR] {msg}") + self._status_lbl.setText("✗ Error") + self._set_running(False) + + def _set_running(self, running: bool) -> None: + can_build = self._session.has(Capability.EDIT_COMMISSIONING) + self._build_btn.setEnabled(not running and can_build) + self._cancel_btn.setEnabled(running) + + def _cleanup_thread(self) -> None: + if self._thread: + self._thread.deleteLater() + self._thread = None + if self._worker: + self._worker.deleteLater() + self._worker = None diff --git a/arautopilot/studio/main_window.py b/arautopilot/studio/main_window.py index f4cab41..48258b9 100644 --- a/arautopilot/studio/main_window.py +++ b/arautopilot/studio/main_window.py @@ -1,34 +1,45 @@ -"""Studio main window (PySide6) -- Sprint 2.5. +"""Studio main window (PySide6). -Three areas: +Five tabs: + Overview — welcome + quick-start guide + Flash Console — compile & flash ESP32 firmware via PlatformIO + Project — vessel configuration + .appack compiler + Telemetría — live $PARP STATUS charts from the AR-Concentrador + Instalar J6412 — build USB pendrive installer images -- Sidebar (left) -- user + role + capabilities they hold. -- Central tab area -- Flash Console (Sprint 2.5) + placeholders for the - project configurator that lands in Sprint 4. -- Status bar -- session info + audit log path. +Sidebar shows the logged-in user, role, and RBAC capabilities. """ from __future__ import annotations +from pathlib import Path + from PySide6.QtCore import Qt +from PySide6.QtGui import QFont, QPixmap from PySide6.QtWidgets import ( + QFrame, QLabel, QListWidget, QMainWindow, QSplitter, QStatusBar, QTabWidget, - QTextEdit, QVBoxLayout, QWidget, ) from arautopilot.core.rbac import capabilities_of +from arautopilot.studio.ar_style import ACCENT_MID, GLOW, NAVY, TEXT_MUTED from arautopilot.studio.editors.project_editor import ProjectEditorWidget from arautopilot.studio.flash_console import FlashConsoleWidget +from arautopilot.studio.installer_widget import InstallerWidget from arautopilot.studio.session import Session +from arautopilot.studio.telemetry_widget import TelemetryWidget from arautopilot.version import __version__ +REPO_ROOT = Path(__file__).resolve().parents[2] +_LOGO_PATH = REPO_ROOT / "display" / "assets" / "images" / "ar_logo_full.png" + class StudioMainWindow(QMainWindow): """Top-level Studio window.""" @@ -37,71 +48,159 @@ class StudioMainWindow(QMainWindow): super().__init__() self._session = session self.setWindowTitle( - f"AR-Autopilot Studio v{__version__} -- " - f"{session.user.display_name} ({session.role.value})" + f"AR-Autopilot Studio v{__version__} — " + f"{session.user.display_name} ({session.role.value})" ) - self.resize(1100, 700) + self.resize(1200, 780) splitter = QSplitter(Qt.Orientation.Horizontal) splitter.addWidget(self._build_sidebar()) splitter.addWidget(self._build_central()) splitter.setStretchFactor(0, 0) splitter.setStretchFactor(1, 1) - splitter.setSizes([260, 840]) + splitter.setSizes([240, 960]) self.setCentralWidget(splitter) status = QStatusBar(self) status.showMessage(f"Audit log: {session.audit.path}") self.setStatusBar(status) - # ----- UI ------------------------------------------------------------ + # ── Sidebar ─────────────────────────────────────────────────────────────── + def _build_sidebar(self) -> QWidget: w = QWidget() + w.setMaximumWidth(260) layout = QVBoxLayout(w) - layout.setContentsMargins(8, 8, 8, 8) - layout.addWidget(QLabel( - f"{self._session.user.display_name}
" - f"{self._session.role.value}" - )) - layout.addWidget(QLabel("Capabilities")) + layout.setContentsMargins(10, 14, 10, 10) + layout.setSpacing(10) + + # Logo + if _LOGO_PATH.exists(): + logo_lbl = QLabel() + px = QPixmap(str(_LOGO_PATH)).scaledToWidth( + 160, Qt.TransformationMode.SmoothTransformation + ) + logo_lbl.setPixmap(px) + logo_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(logo_lbl) + else: + brand_lbl = QLabel("AR Electronics") + brand_lbl.setStyleSheet( + f"color:{GLOW}; font-size:16px; font-weight:bold; letter-spacing:2px;" + ) + brand_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(brand_lbl) + + # Separator + sep = QFrame() + sep.setFrameShape(QFrame.Shape.HLine) + sep.setStyleSheet(f"color:{ACCENT_MID};") + layout.addWidget(sep) + + # User info + role_label = QLabel( + f"" + f"{self._session.user.display_name}
" + f"" + f"{self._session.role.value}" + ) + role_label.setTextFormat(Qt.TextFormat.RichText) + role_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(role_label) + + sep2 = QFrame() + sep2.setFrameShape(QFrame.Shape.HLine) + sep2.setStyleSheet(f"color:{ACCENT_MID};") + layout.addWidget(sep2) + + # Capabilities + cap_title = QLabel("Permisos") + cap_title.setStyleSheet( + f"color:{ACCENT_MID}; font-weight:bold; font-size:10px; letter-spacing:1px;" + ) + layout.addWidget(cap_title) + caps = QListWidget() + caps.setStyleSheet("font-size:10px;") for cap in sorted(capabilities_of(self._session.role), key=lambda c: c.value): caps.addItem(cap.value) layout.addWidget(caps, stretch=1) + + # Version stamp + ver_lbl = QLabel(f"Studio v{__version__}") + ver_lbl.setStyleSheet(f"color:{TEXT_MUTED}; font-size:10px;") + ver_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(ver_lbl) + return w + # ── Central tabs ────────────────────────────────────────────────────────── + def _build_central(self) -> QWidget: tabs = QTabWidget() - - tabs.addTab(self._build_overview_tab(), "Overview") - tabs.addTab(FlashConsoleWidget(self._session), "Flash Console") - 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." - ), "Telemetry") + tabs.addTab(self._build_overview_tab(), "🧭 Overview") + tabs.addTab(FlashConsoleWidget(self._session), "⚡ Flash ESP32") + tabs.addTab(ProjectEditorWidget(self._session), "📋 Proyecto") + tabs.addTab(TelemetryWidget(self._session), "📡 Telemetría") + tabs.addTab(InstallerWidget(self._session), "💾 Instalar J6412") return tabs + # ── Overview tab ────────────────────────────────────────────────────────── + def _build_overview_tab(self) -> QWidget: w = QWidget() layout = QVBoxLayout(w) - layout.addWidget(QLabel( - "

AR-Autopilot Studio

" - "

Welcome. Use the Flash Console tab to compile and " - "flash firmware to an AR-NMEA-IO board.

" - "

The Project tab (Sprint 4) will let you configure a " - "vessel and produce a deployable .appack.

" - "

Every action you take is recorded in the audit log " - "(see status bar at the bottom).

" - )) - layout.addStretch(1) - return w + layout.setContentsMargins(24, 20, 24, 20) + + title = QLabel("AR-Autopilot Studio") + title.setFont(QFont("Segoe UI", 20, QFont.Weight.Bold)) + title.setStyleSheet(f"color:{GLOW}; letter-spacing:2px;") + layout.addWidget(title) + + subtitle = QLabel(f"v{__version__} — Herramienta de integración para el sistema AR-Autopilot") + subtitle.setStyleSheet(f"color:{TEXT_MUTED}; font-size:12px;") + layout.addWidget(subtitle) + + sep = QFrame() + sep.setFrameShape(QFrame.Shape.HLine) + sep.setStyleSheet(f"color:{ACCENT_MID}; margin: 10px 0;") + layout.addWidget(sep) + + guide = QLabel( + "" + f"" + f"" + "" + f"" + f"" + "" + f"" + f"" + "" + f"" + f"" + "
Flash ESP32
" + f"Compila y flashea el firmware al AR-Concentrador " + f"o al autopilot ESP32 via USB.
📋Proyecto
" + f"Configura el buque (tipo, dimensiones, actuador, " + f"sensores, ganancias PID) y genera un paquete .appack de despliegue.
📡Telemetría
" + f"Conecta al AR-Concentrador por puerto COM y ve " + f"en tiempo real el rumbo, setpoint y ángulo de timón.
💾Instalar J6412
" + f"Genera un pendrive USB que instala AR-ECDIS y " + f"AR-Autopilot Display en el mini PC J6412 con activación de licencia online." + "
" + ) + guide.setTextFormat(Qt.TextFormat.RichText) + guide.setWordWrap(True) + layout.addWidget(guide) + layout.addStretch(1) + + footer = QLabel( + f"" + "AR Electronics — Todos los derechos reservados." + ) + footer.setTextFormat(Qt.TextFormat.RichText) + footer.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(footer) - def _placeholder_tab(self, text: str) -> QWidget: - w = QWidget() - layout = QVBoxLayout(w) - edit = QTextEdit() - edit.setReadOnly(True) - edit.setPlainText(text) - layout.addWidget(edit) return w diff --git a/arautopilot/studio/telemetry_widget.py b/arautopilot/studio/telemetry_widget.py new file mode 100644 index 0000000..0645d66 --- /dev/null +++ b/arautopilot/studio/telemetry_widget.py @@ -0,0 +1,428 @@ +"""Telemetry widget — live $PARP STATUS viewer. + +Connects to the AR-Concentrador over USB serial and displays heading, +setpoint, and rudder angle as rolling time-series charts. No external +charting library required — all drawing is done with QPainter. + +Usage (embedded in StudioMainWindow): + from arautopilot.studio.telemetry_widget import TelemetryWidget + tabs.addTab(TelemetryWidget(session), "Telemetría") + +The widget works without a connected board: it stays in "waiting" state +and shows a message until a port is selected and connected. +""" + +from __future__ import annotations + +import collections +import re +import time +from typing import Deque + +from PySide6.QtCore import QByteArray, QIODevice, QTimer, Qt +from PySide6.QtGui import QColor, QFont, QPainter, QPen +from PySide6.QtSerialPort import QSerialPort, QSerialPortInfo +from PySide6.QtWidgets import ( + QComboBox, + QGroupBox, + QHBoxLayout, + QLabel, + QPushButton, + QVBoxLayout, + QWidget, +) + +from arautopilot.studio.ar_style import ACCENT, ACCENT_MID, ERROR, NAVY, OK, TEXT_MUTED, WARN +from arautopilot.studio.session import Session + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +BAUD_RATE = 115200 +WINDOW_SEC = 60 # seconds of history shown on chart +CHART_FPS = 5 # redraws per second (low is fine — data is 2 Hz) +MAX_SAMPLES = WINDOW_SEC * 10 # 10 samples/s max + +CHANNEL_COLORS = { + "heading": ACCENT_MID, + "setpoint": OK, + "rudder": WARN, +} + +# --------------------------------------------------------------------------- +# $PARP STATUS parser (Python re-implementation of parp_codec.dart) +# --------------------------------------------------------------------------- + +def _xor_checksum(body: str) -> int: + crc = 0 + for ch in body: + crc ^= ord(ch) + return crc + + +def _parse_parp_status(line: str) -> dict | None: + """ + Parse ``$PARP,STATUS,...*CRC`` → dict or None. + + Returns:: + + { + "mode": str, # "STANDBY" | "HEADING_HOLD" | "TRACK" + "setpoint": float, + "heading": float, + "rudder": float, + "ts": float, # time.monotonic() + } + """ + s = line.strip() + star = s.rfind("*") + if star < 0: + return None + body = s[1:star] if s.startswith("$") else s[:star] + crc_hex = s[star + 1:] + + try: + if _xor_checksum(body) != int(crc_hex, 16): + return None + except ValueError: + return None + + parts = body.split(",") + if len(parts) < 7 or parts[0] != "PARP" or parts[1] != "STATUS": + return None + + try: + return { + "mode": parts[2], + "setpoint": float(parts[3]), + "heading": float(parts[4]), + "rudder": float(parts[5]), + "ts": time.monotonic(), + } + except ValueError: + return None + + +# --------------------------------------------------------------------------- +# Rolling chart widget +# --------------------------------------------------------------------------- + +class _RollingChart(QWidget): + """ + A minimal scrolling time-series chart drawn with QPainter. + + Each channel is a ``deque`` of ``(timestamp_monotonic, value)`` pairs. + Y-range is passed in; X range is always the last *window_sec* seconds. + """ + + def __init__( + self, + title: str, + channels: dict[str, tuple[Deque, str]], # name → (deque, colour_hex) + y_min: float, + y_max: float, + y_unit: str = "", + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.setMinimumHeight(160) + self._title = title + self._channels = channels + self._y_min = y_min + self._y_max = y_max + self._y_unit = y_unit + + def paintEvent(self, _event) -> None: # noqa: N802 + w, h = self.width(), self.height() + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Background + p.fillRect(0, 0, w, h, QColor(NAVY)) + + # Margins + ml, mr, mt, mb = 48, 12, 28, 28 + cw = w - ml - mr + ch = h - mt - mb + if cw <= 0 or ch <= 0: + return + + now = time.monotonic() + t0 = now - WINDOW_SEC + + # Grid lines + grid_pen = QPen(QColor("#1E3A5F"), 1, Qt.PenStyle.DotLine) + p.setPen(grid_pen) + y_range = self._y_max - self._y_min or 1.0 + for fraction in (0.0, 0.25, 0.5, 0.75, 1.0): + gy = mt + int((1 - fraction) * ch) + p.drawLine(ml, gy, ml + cw, gy) + val = self._y_min + fraction * y_range + p.setPen(QColor(TEXT_MUTED)) + p.setFont(QFont("Segoe UI", 8)) + label = f"{val:.0f}{self._y_unit}" + p.drawText(0, gy - 6, ml - 4, 14, Qt.AlignmentFlag.AlignRight, label) + p.setPen(grid_pen) + + # Axes + p.setPen(QPen(QColor(ACCENT_MID), 1)) + p.drawLine(ml, mt, ml, mt + ch) + p.drawLine(ml, mt + ch, ml + cw, mt + ch) + + # Title + p.setPen(QColor(ACCENT_MID)) + p.setFont(QFont("Segoe UI", 9, QFont.Weight.Bold)) + p.drawText(ml, 0, cw, mt, Qt.AlignmentFlag.AlignCenter, self._title) + + def _tx(ts: float) -> int: + return ml + int((ts - t0) / WINDOW_SEC * cw) + + def _ty(v: float) -> int: + frac = (v - self._y_min) / y_range + return mt + ch - int(frac * ch) + + # Series + for name, (deque, colour) in self._channels.items(): + pts = [(ts, v) for ts, v in deque if ts >= t0] + if len(pts) < 2: + continue + pen = QPen(QColor(colour), 2) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) + p.setPen(pen) + path_pts = [(_tx(ts), _ty(v)) for ts, v in pts] + for i in range(1, len(path_pts)): + p.drawLine(*path_pts[i - 1], *path_pts[i]) + + p.end() + + +# --------------------------------------------------------------------------- +# Value display row +# --------------------------------------------------------------------------- + +class _ValueRow(QWidget): + """Compact name │ value display for the live readout strip.""" + + def __init__(self, label: str, unit: str, colour: str) -> None: + super().__init__() + h = QHBoxLayout(self) + h.setContentsMargins(0, 0, 0, 0) + lbl = QLabel(label) + lbl.setStyleSheet(f"color:{colour}; font-size:11px; min-width:80px;") + self._value = QLabel("---") + self._value.setStyleSheet( + f"color:{colour}; font-size:20px; font-weight:bold; " + "font-family:Consolas; min-width:80px;" + ) + unit_lbl = QLabel(unit) + unit_lbl.setStyleSheet(f"color:{TEXT_MUTED}; font-size:11px;") + h.addWidget(lbl) + h.addWidget(self._value) + h.addWidget(unit_lbl) + h.addStretch(1) + + def update_value(self, v: float) -> None: + self._value.setText(f"{v:6.1f}") + + +# --------------------------------------------------------------------------- +# Main widget +# --------------------------------------------------------------------------- + +class TelemetryWidget(QWidget): + """Live telemetry from the AR-Concentrador — embedded in Studio.""" + + def __init__(self, session: Session, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._session = session + self._port: QSerialPort | None = None + self._buf = bytearray() + + # Data stores + mk: type[Deque] = lambda: collections.deque(maxlen=MAX_SAMPLES) + self._hdg_data: Deque[tuple[float, float]] = mk() + self._spt_data: Deque[tuple[float, float]] = mk() + self._rud_data: Deque[tuple[float, float]] = mk() + self._mode_str = "—" + + self._build_ui() + + self._redraw_timer = QTimer(self) + self._redraw_timer.setInterval(1000 // CHART_FPS) + self._redraw_timer.timeout.connect(self._refresh_charts) + self._redraw_timer.start() + + # ── UI ─────────────────────────────────────────────────────────────────── + + def _build_ui(self) -> None: + root = QVBoxLayout(self) + root.setContentsMargins(12, 12, 12, 12) + root.setSpacing(10) + + # ── Connection bar ─────────────────────────────────────────────────── + conn = QGroupBox("Conexión al Concentrador") + ch = QHBoxLayout(conn) + + ch.addWidget(QLabel("Puerto RX:")) + self._port_combo = QComboBox() + self._port_combo.setMinimumWidth(200) + ch.addWidget(self._port_combo) + + refresh_btn = QPushButton("Actualizar") + refresh_btn.clicked.connect(self._refresh_ports) + ch.addWidget(refresh_btn) + + self._connect_btn = QPushButton("Conectar") + self._connect_btn.clicked.connect(self._on_connect) + ch.addWidget(self._connect_btn) + + self._conn_lbl = QLabel("● Desconectado") + self._conn_lbl.setStyleSheet(f"color:{TEXT_MUTED}; font-weight:bold;") + ch.addWidget(self._conn_lbl) + ch.addStretch(1) + + root.addWidget(conn) + + # ── Live readout strip ─────────────────────────────────────────────── + strip = QGroupBox("Datos en vivo") + sh = QHBoxLayout(strip) + + self._hdg_row = _ValueRow("RUMBO", "°", ACCENT_MID) + self._spt_row = _ValueRow("SETPOINT", "°", OK) + self._rud_row = _ValueRow("TIMÓN", "°", WARN) + self._mode_lbl = QLabel("Modo: —") + self._mode_lbl.setStyleSheet(f"color:{TEXT_MUTED}; font-size:12px;") + + sh.addWidget(self._hdg_row) + sh.addWidget(self._spt_row) + sh.addWidget(self._rud_row) + sh.addStretch(1) + sh.addWidget(self._mode_lbl) + root.addWidget(strip) + + # ── Charts ─────────────────────────────────────────────────────────── + self._hdg_chart = _RollingChart( + "RUMBO / SETPOINT (°)", + { + "Rumbo": (self._hdg_data, ACCENT_MID), + "Setpoint": (self._spt_data, OK), + }, + y_min=0, y_max=360, y_unit="°", + ) + self._rud_chart = _RollingChart( + "TIMÓN (°)", + { + "Timón": (self._rud_data, WARN), + }, + y_min=-40, y_max=40, y_unit="°", + ) + root.addWidget(self._hdg_chart, 2) + root.addWidget(self._rud_chart, 1) + + self._refresh_ports() + + # ── Ports ──────────────────────────────────────────────────────────────── + + def _refresh_ports(self) -> None: + self._port_combo.clear() + ports = QSerialPortInfo.availablePorts() + if not ports: + self._port_combo.addItem("(sin puertos)") + for info in ports: + label = info.portName() + if info.description(): + label += f" — {info.description()}" + self._port_combo.addItem(label, info.portName()) + + def _selected_port(self) -> str | None: + v = self._port_combo.currentData() + return str(v) if v else None + + # ── Connect / Disconnect ───────────────────────────────────────────────── + + def _on_connect(self) -> None: + if self._port and self._port.isOpen(): + self._disconnect() + return + + port_name = self._selected_port() + if not port_name: + return + + self._port = QSerialPort(self) + self._port.setPortName(port_name) + self._port.setBaudRate(BAUD_RATE) + self._port.setDataBits(QSerialPort.DataBits.Data8) + self._port.setParity(QSerialPort.Parity.NoParity) + self._port.setStopBits(QSerialPort.StopBits.OneStop) + self._port.setFlowControl(QSerialPort.FlowControl.NoFlowControl) + self._port.readyRead.connect(self._on_data) + self._port.errorOccurred.connect(self._on_serial_error) + + if self._port.open(QIODevice.OpenModeFlag.ReadOnly): + self._conn_lbl.setText("● Conectado") + self._conn_lbl.setStyleSheet(f"color:{OK}; font-weight:bold;") + self._connect_btn.setText("Desconectar") + else: + self._conn_lbl.setText(f"✗ Error: {self._port.errorString()}") + self._conn_lbl.setStyleSheet(f"color:{ERROR}; font-weight:bold;") + self._port = None + + def _disconnect(self) -> None: + if self._port: + self._port.close() + self._port = None + self._conn_lbl.setText("● Desconectado") + self._conn_lbl.setStyleSheet(f"color:{TEXT_MUTED}; font-weight:bold;") + self._connect_btn.setText("Conectar") + + # ── Serial data ────────────────────────────────────────────────────────── + + def _on_data(self) -> None: + if not self._port: + return + raw: QByteArray = self._port.readAll() + self._buf.extend(bytes(raw)) + + while b"\n" in self._buf: + idx = self._buf.index(b"\n") + line_bytes = self._buf[:idx] + self._buf = self._buf[idx + 1:] + try: + line = line_bytes.decode("ascii", errors="ignore").strip() + except Exception: + continue + status = _parse_parp_status(line) + if status: + self._ingest(status) + + def _ingest(self, s: dict) -> None: + ts = s["ts"] + self._hdg_data.append((ts, s["heading"])) + self._spt_data.append((ts, s["setpoint"])) + self._rud_data.append((ts, s["rudder"])) + self._mode_str = s["mode"] + + # Update live readout + self._hdg_row.update_value(s["heading"]) + self._spt_row.update_value(s["setpoint"]) + self._rud_row.update_value(s["rudder"]) + self._mode_lbl.setText(f"Modo: {s['mode'].replace('_', ' ')}") + + def _on_serial_error(self, error) -> None: + if error != QSerialPort.SerialPortError.NoError: + self._disconnect() + + # ── Chart refresh ──────────────────────────────────────────────────────── + + def _refresh_charts(self) -> None: + self._hdg_chart.update() + self._rud_chart.update() + + # ── Cleanup ────────────────────────────────────────────────────────────── + + def closeEvent(self, event) -> None: # noqa: N802 + self._disconnect() + super().closeEvent(event) diff --git a/pyproject.toml b/pyproject.toml index 0740d9c..8840f88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,11 +44,24 @@ dev = [ ] # Studio GUI -- Sprint 2.5+. Heavy (~80 MB), kept optional so the core can # be installed in lean environments (CI, headless test bench). +# PySide6 >= 6.6 includes QtSerialPort on all platforms — no extra dep needed. studio = [ "PySide6>=6.6", "pyserial>=3.5", "platformio>=6.1", ] +# Installer tooling — required on the developer's build machine. +installer = [ + "requests>=2.31", +] +# License server — deploy to arelectronics.com VPS. +license-server = [ + "fastapi>=0.111", + "uvicorn[standard]>=0.29", + "sqlalchemy>=2.0", + "pydantic>=2.7", + "python-dotenv>=1.0", +] [project.urls] Homepage = "https://github.com/alro65/AR-Autopilot"