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
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
from arautopilot.core.audit import AuditLog
|
from arautopilot.core.audit import AuditLog
|
||||||
from arautopilot.core.user_store import UserStore, seed_demo_users
|
from arautopilot.core.user_store import UserStore, seed_demo_users
|
||||||
from arautopilot.studio.session import studio_data_dir
|
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 = QApplication(sys.argv)
|
||||||
app.setApplicationName("AR-Autopilot Studio")
|
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:
|
if len(user_store) == 0:
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
None,
|
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.
|
Sidebar shows the logged-in user, role, and RBAC capabilities.
|
||||||
- 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.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtGui import QFont, QPixmap
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
QFrame,
|
||||||
QLabel,
|
QLabel,
|
||||||
QListWidget,
|
QListWidget,
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
QSplitter,
|
QSplitter,
|
||||||
QStatusBar,
|
QStatusBar,
|
||||||
QTabWidget,
|
QTabWidget,
|
||||||
QTextEdit,
|
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from arautopilot.core.rbac import capabilities_of
|
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.editors.project_editor import ProjectEditorWidget
|
||||||
from arautopilot.studio.flash_console import FlashConsoleWidget
|
from arautopilot.studio.flash_console import FlashConsoleWidget
|
||||||
|
from arautopilot.studio.installer_widget import InstallerWidget
|
||||||
from arautopilot.studio.session import Session
|
from arautopilot.studio.session import Session
|
||||||
|
from arautopilot.studio.telemetry_widget import TelemetryWidget
|
||||||
from arautopilot.version import __version__
|
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):
|
class StudioMainWindow(QMainWindow):
|
||||||
"""Top-level Studio window."""
|
"""Top-level Studio window."""
|
||||||
@@ -37,71 +48,159 @@ class StudioMainWindow(QMainWindow):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self._session = session
|
self._session = session
|
||||||
self.setWindowTitle(
|
self.setWindowTitle(
|
||||||
f"AR-Autopilot Studio v{__version__} -- "
|
f"AR-Autopilot Studio v{__version__} — "
|
||||||
f"{session.user.display_name} ({session.role.value})"
|
f"{session.user.display_name} ({session.role.value})"
|
||||||
)
|
)
|
||||||
self.resize(1100, 700)
|
self.resize(1200, 780)
|
||||||
|
|
||||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||||
splitter.addWidget(self._build_sidebar())
|
splitter.addWidget(self._build_sidebar())
|
||||||
splitter.addWidget(self._build_central())
|
splitter.addWidget(self._build_central())
|
||||||
splitter.setStretchFactor(0, 0)
|
splitter.setStretchFactor(0, 0)
|
||||||
splitter.setStretchFactor(1, 1)
|
splitter.setStretchFactor(1, 1)
|
||||||
splitter.setSizes([260, 840])
|
splitter.setSizes([240, 960])
|
||||||
self.setCentralWidget(splitter)
|
self.setCentralWidget(splitter)
|
||||||
|
|
||||||
status = QStatusBar(self)
|
status = QStatusBar(self)
|
||||||
status.showMessage(f"Audit log: {session.audit.path}")
|
status.showMessage(f"Audit log: {session.audit.path}")
|
||||||
self.setStatusBar(status)
|
self.setStatusBar(status)
|
||||||
|
|
||||||
# ----- UI ------------------------------------------------------------
|
# ── Sidebar ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _build_sidebar(self) -> QWidget:
|
def _build_sidebar(self) -> QWidget:
|
||||||
w = QWidget()
|
w = QWidget()
|
||||||
|
w.setMaximumWidth(260)
|
||||||
layout = QVBoxLayout(w)
|
layout = QVBoxLayout(w)
|
||||||
layout.setContentsMargins(8, 8, 8, 8)
|
layout.setContentsMargins(10, 14, 10, 10)
|
||||||
layout.addWidget(QLabel(
|
layout.setSpacing(10)
|
||||||
f"<b>{self._session.user.display_name}</b><br/>"
|
|
||||||
f"<i>{self._session.role.value}</i>"
|
# Logo
|
||||||
))
|
if _LOGO_PATH.exists():
|
||||||
layout.addWidget(QLabel("<b>Capabilities</b>"))
|
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 = QListWidget()
|
||||||
|
caps.setStyleSheet("font-size:10px;")
|
||||||
for cap in sorted(capabilities_of(self._session.role), key=lambda c: c.value):
|
for cap in sorted(capabilities_of(self._session.role), key=lambda c: c.value):
|
||||||
caps.addItem(cap.value)
|
caps.addItem(cap.value)
|
||||||
layout.addWidget(caps, stretch=1)
|
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
|
return w
|
||||||
|
|
||||||
|
# ── Central tabs ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _build_central(self) -> QWidget:
|
def _build_central(self) -> QWidget:
|
||||||
tabs = QTabWidget()
|
tabs = QTabWidget()
|
||||||
|
tabs.addTab(self._build_overview_tab(), "🧭 Overview")
|
||||||
tabs.addTab(self._build_overview_tab(), "Overview")
|
tabs.addTab(FlashConsoleWidget(self._session), "⚡ Flash ESP32")
|
||||||
tabs.addTab(FlashConsoleWidget(self._session), "Flash Console")
|
tabs.addTab(ProjectEditorWidget(self._session), "📋 Proyecto")
|
||||||
tabs.addTab(ProjectEditorWidget(self._session), "Project")
|
tabs.addTab(TelemetryWidget(self._session), "📡 Telemetría")
|
||||||
tabs.addTab(self._placeholder_tab(
|
tabs.addTab(InstallerWidget(self._session), "💾 Instalar J6412")
|
||||||
"Telemetry -- Sprint 4.\n\n"
|
|
||||||
"Live Modbus telemetry from the connected AR-NMEA-IO board."
|
|
||||||
), "Telemetry")
|
|
||||||
return tabs
|
return tabs
|
||||||
|
|
||||||
|
# ── Overview tab ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _build_overview_tab(self) -> QWidget:
|
def _build_overview_tab(self) -> QWidget:
|
||||||
w = QWidget()
|
w = QWidget()
|
||||||
layout = QVBoxLayout(w)
|
layout = QVBoxLayout(w)
|
||||||
layout.addWidget(QLabel(
|
layout.setContentsMargins(24, 20, 24, 20)
|
||||||
"<h2>AR-Autopilot Studio</h2>"
|
|
||||||
"<p>Welcome. Use the <b>Flash Console</b> tab to compile and "
|
title = QLabel("AR-Autopilot Studio")
|
||||||
"flash firmware to an AR-NMEA-IO board.</p>"
|
title.setFont(QFont("Segoe UI", 20, QFont.Weight.Bold))
|
||||||
"<p>The <b>Project</b> tab (Sprint 4) will let you configure a "
|
title.setStyleSheet(f"color:{GLOW}; letter-spacing:2px;")
|
||||||
"vessel and produce a deployable <code>.appack</code>.</p>"
|
layout.addWidget(title)
|
||||||
"<p>Every action you take is recorded in the audit log "
|
|
||||||
"(see status bar at the bottom).</p>"
|
subtitle = QLabel(f"v{__version__} — Herramienta de integración para el sistema AR-Autopilot")
|
||||||
))
|
subtitle.setStyleSheet(f"color:{TEXT_MUTED}; font-size:12px;")
|
||||||
layout.addStretch(1)
|
layout.addWidget(subtitle)
|
||||||
return w
|
|
||||||
|
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
|
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
|
# Studio GUI -- Sprint 2.5+. Heavy (~80 MB), kept optional so the core can
|
||||||
# be installed in lean environments (CI, headless test bench).
|
# be installed in lean environments (CI, headless test bench).
|
||||||
|
# PySide6 >= 6.6 includes QtSerialPort on all platforms — no extra dep needed.
|
||||||
studio = [
|
studio = [
|
||||||
"PySide6>=6.6",
|
"PySide6>=6.6",
|
||||||
"pyserial>=3.5",
|
"pyserial>=3.5",
|
||||||
"platformio>=6.1",
|
"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]
|
[project.urls]
|
||||||
Homepage = "https://github.com/alro65/AR-Autopilot"
|
Homepage = "https://github.com/alro65/AR-Autopilot"
|
||||||
|
|||||||
Reference in New Issue
Block a user