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