b68cd64cf1
- flash_console: setTextFormat(Qt.TextFormat.RichText) — int overload removed in Qt6 - installer_widget: same fix + add Qt to QtCore import - project_editor/installer_widget: Session.has() → Session.can() - app.py: signal.signal(SIGINT, SIG_DFL) so Ctrl+C kills the process from terminal AR_electronics — AR-Autopilot Project
335 lines
12 KiB
Python
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, Qt, 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(Qt.TextFormat.RichText) # 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.can(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.can(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
|