feat(studio): AR Electronics branding + Telemetría + Instalar J6412 tabs

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
This commit is contained in:
2026-05-24 11:22:38 -04:00
parent de25dcee57
commit 5238bd31f0
6 changed files with 1203 additions and 44 deletions
+15
View File
@@ -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,
+270
View File
@@ -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)
+334
View File
@@ -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(
"<b style='color:#4A9FE8;font-size:14px;letter-spacing:1px'>"
"INSTALAR EN J6412</b><br/>"
"<span style='color:#8899AA;font-size:11px'>"
"Genera un pendrive USB con las apps AR Electronics y la licencia del buque.</span>"
)
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
+142 -43
View File
@@ -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"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"<b>{self._session.user.display_name}</b><br/>"
f"<i>{self._session.role.value}</i>"
))
layout.addWidget(QLabel("<b>Capabilities</b>"))
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"<span style='color:{GLOW};font-weight:bold;font-size:13px;'>"
f"{self._session.user.display_name}</span><br/>"
f"<span style='color:{TEXT_MUTED};font-size:11px;'>"
f"{self._session.role.value}</span>"
)
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(
"<h2>AR-Autopilot Studio</h2>"
"<p>Welcome. Use the <b>Flash Console</b> tab to compile and "
"flash firmware to an AR-NMEA-IO board.</p>"
"<p>The <b>Project</b> tab (Sprint 4) will let you configure a "
"vessel and produce a deployable <code>.appack</code>.</p>"
"<p>Every action you take is recorded in the audit log "
"(see status bar at the bottom).</p>"
))
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(
"<table cellspacing='8'>"
f"<tr><td style='color:{GLOW};font-size:16px;'>⚡</td>"
f"<td><b style='color:{ACCENT_MID}'>Flash ESP32</b><br/>"
f"<span style='color:{TEXT_MUTED}'>Compila y flashea el firmware al AR-Concentrador "
f"o al autopilot ESP32 via USB.</span></td></tr>"
"<tr><td></td><td style='height:8px'></td></tr>"
f"<tr><td style='color:{GLOW};font-size:16px;'>📋</td>"
f"<td><b style='color:{ACCENT_MID}'>Proyecto</b><br/>"
f"<span style='color:{TEXT_MUTED}'>Configura el buque (tipo, dimensiones, actuador, "
f"sensores, ganancias PID) y genera un paquete .appack de despliegue.</span></td></tr>"
"<tr><td></td><td style='height:8px'></td></tr>"
f"<tr><td style='color:{GLOW};font-size:16px;'>📡</td>"
f"<td><b style='color:{ACCENT_MID}'>Telemetría</b><br/>"
f"<span style='color:{TEXT_MUTED}'>Conecta al AR-Concentrador por puerto COM y ve "
f"en tiempo real el rumbo, setpoint y ángulo de timón.</span></td></tr>"
"<tr><td></td><td style='height:8px'></td></tr>"
f"<tr><td style='color:{GLOW};font-size:16px;'>💾</td>"
f"<td><b style='color:{ACCENT_MID}'>Instalar J6412</b><br/>"
f"<span style='color:{TEXT_MUTED}'>Genera un pendrive USB que instala AR-ECDIS y "
f"AR-Autopilot Display en el mini PC J6412 con activación de licencia online.</span>"
"</td></tr>"
"</table>"
)
guide.setTextFormat(Qt.TextFormat.RichText)
guide.setWordWrap(True)
layout.addWidget(guide)
layout.addStretch(1)
footer = QLabel(
f"<span style='color:{TEXT_MUTED};font-size:10px;'>"
"AR Electronics — Todos los derechos reservados.</span>"
)
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
+428
View File
@@ -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)
+13
View File
@@ -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"