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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user