Files
AR-Autopilot/arautopilot/studio/installer_widget.py
T
alro65 5238bd31f0 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
2026-05-24 11:22:38 -04:00

335 lines
12 KiB
Python

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