diff --git a/arautopilot/studio/app.py b/arautopilot/studio/app.py
index d6bcfe1..17f637f 100644
--- a/arautopilot/studio/app.py
+++ b/arautopilot/studio/app.py
@@ -22,6 +22,8 @@ import argparse
import sys
from pathlib import Path
+REPO_ROOT = Path(__file__).resolve().parents[2]
+
from arautopilot.core.audit import AuditLog
from arautopilot.core.user_store import UserStore, seed_demo_users
from arautopilot.studio.session import studio_data_dir
@@ -81,6 +83,19 @@ def run(argv: list[str] | None = None) -> int:
app = QApplication(sys.argv)
app.setApplicationName("AR-Autopilot Studio")
+ # Apply AR Electronics brand theme
+ from arautopilot.studio.ar_style import apply_ar_style # noqa: PLC0415
+ apply_ar_style(app)
+
+ # Window icon (logo)
+ from PySide6.QtGui import QIcon # noqa: PLC0415
+ from pathlib import Path as _Path # noqa: PLC0415
+ _logo = REPO_ROOT / "display" / "assets" / "images" / "ar_logo_full.png"
+ if not _logo.exists():
+ _logo = _Path(__file__).resolve().parents[2] / "display" / "assets" / "images" / "ar_logo_full.png"
+ if _logo.exists():
+ app.setWindowIcon(QIcon(str(_logo)))
+
if len(user_store) == 0:
QMessageBox.information(
None,
diff --git a/arautopilot/studio/ar_style.py b/arautopilot/studio/ar_style.py
new file mode 100644
index 0000000..aa4752d
--- /dev/null
+++ b/arautopilot/studio/ar_style.py
@@ -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)
diff --git a/arautopilot/studio/installer_widget.py b/arautopilot/studio/installer_widget.py
new file mode 100644
index 0000000..a75e2cc
--- /dev/null
+++ b/arautopilot/studio/installer_widget.py
@@ -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(
+ ""
+ "INSTALAR EN J6412
"
+ ""
+ "Genera un pendrive USB con las apps AR Electronics y la licencia del buque."
+ )
+ 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
diff --git a/arautopilot/studio/main_window.py b/arautopilot/studio/main_window.py
index f4cab41..48258b9 100644
--- a/arautopilot/studio/main_window.py
+++ b/arautopilot/studio/main_window.py
@@ -1,34 +1,45 @@
-"""Studio main window (PySide6) -- Sprint 2.5.
+"""Studio main window (PySide6).
-Three areas:
+Five tabs:
+ Overview — welcome + quick-start guide
+ Flash Console — compile & flash ESP32 firmware via PlatformIO
+ Project — vessel configuration + .appack compiler
+ Telemetría — live $PARP STATUS charts from the AR-Concentrador
+ Instalar J6412 — build USB pendrive installer images
-- Sidebar (left) -- user + role + capabilities they hold.
-- Central tab area -- Flash Console (Sprint 2.5) + placeholders for the
- project configurator that lands in Sprint 4.
-- Status bar -- session info + audit log path.
+Sidebar shows the logged-in user, role, and RBAC capabilities.
"""
from __future__ import annotations
+from pathlib import Path
+
from PySide6.QtCore import Qt
+from PySide6.QtGui import QFont, QPixmap
from PySide6.QtWidgets import (
+ QFrame,
QLabel,
QListWidget,
QMainWindow,
QSplitter,
QStatusBar,
QTabWidget,
- QTextEdit,
QVBoxLayout,
QWidget,
)
from arautopilot.core.rbac import capabilities_of
+from arautopilot.studio.ar_style import ACCENT_MID, GLOW, NAVY, TEXT_MUTED
from arautopilot.studio.editors.project_editor import ProjectEditorWidget
from arautopilot.studio.flash_console import FlashConsoleWidget
+from arautopilot.studio.installer_widget import InstallerWidget
from arautopilot.studio.session import Session
+from arautopilot.studio.telemetry_widget import TelemetryWidget
from arautopilot.version import __version__
+REPO_ROOT = Path(__file__).resolve().parents[2]
+_LOGO_PATH = REPO_ROOT / "display" / "assets" / "images" / "ar_logo_full.png"
+
class StudioMainWindow(QMainWindow):
"""Top-level Studio window."""
@@ -37,71 +48,159 @@ class StudioMainWindow(QMainWindow):
super().__init__()
self._session = session
self.setWindowTitle(
- f"AR-Autopilot Studio v{__version__} -- "
- f"{session.user.display_name} ({session.role.value})"
+ f"AR-Autopilot Studio v{__version__} — "
+ f"{session.user.display_name} ({session.role.value})"
)
- self.resize(1100, 700)
+ self.resize(1200, 780)
splitter = QSplitter(Qt.Orientation.Horizontal)
splitter.addWidget(self._build_sidebar())
splitter.addWidget(self._build_central())
splitter.setStretchFactor(0, 0)
splitter.setStretchFactor(1, 1)
- splitter.setSizes([260, 840])
+ splitter.setSizes([240, 960])
self.setCentralWidget(splitter)
status = QStatusBar(self)
status.showMessage(f"Audit log: {session.audit.path}")
self.setStatusBar(status)
- # ----- UI ------------------------------------------------------------
+ # ── Sidebar ───────────────────────────────────────────────────────────────
+
def _build_sidebar(self) -> QWidget:
w = QWidget()
+ w.setMaximumWidth(260)
layout = QVBoxLayout(w)
- layout.setContentsMargins(8, 8, 8, 8)
- layout.addWidget(QLabel(
- f"{self._session.user.display_name}
"
- f"{self._session.role.value}"
- ))
- layout.addWidget(QLabel("Capabilities"))
+ layout.setContentsMargins(10, 14, 10, 10)
+ layout.setSpacing(10)
+
+ # Logo
+ if _LOGO_PATH.exists():
+ logo_lbl = QLabel()
+ px = QPixmap(str(_LOGO_PATH)).scaledToWidth(
+ 160, Qt.TransformationMode.SmoothTransformation
+ )
+ logo_lbl.setPixmap(px)
+ logo_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ layout.addWidget(logo_lbl)
+ else:
+ brand_lbl = QLabel("AR Electronics")
+ brand_lbl.setStyleSheet(
+ f"color:{GLOW}; font-size:16px; font-weight:bold; letter-spacing:2px;"
+ )
+ brand_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ layout.addWidget(brand_lbl)
+
+ # Separator
+ sep = QFrame()
+ sep.setFrameShape(QFrame.Shape.HLine)
+ sep.setStyleSheet(f"color:{ACCENT_MID};")
+ layout.addWidget(sep)
+
+ # User info
+ role_label = QLabel(
+ f""
+ f"{self._session.user.display_name}
"
+ f""
+ f"{self._session.role.value}"
+ )
+ role_label.setTextFormat(Qt.TextFormat.RichText)
+ role_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ layout.addWidget(role_label)
+
+ sep2 = QFrame()
+ sep2.setFrameShape(QFrame.Shape.HLine)
+ sep2.setStyleSheet(f"color:{ACCENT_MID};")
+ layout.addWidget(sep2)
+
+ # Capabilities
+ cap_title = QLabel("Permisos")
+ cap_title.setStyleSheet(
+ f"color:{ACCENT_MID}; font-weight:bold; font-size:10px; letter-spacing:1px;"
+ )
+ layout.addWidget(cap_title)
+
caps = QListWidget()
+ caps.setStyleSheet("font-size:10px;")
for cap in sorted(capabilities_of(self._session.role), key=lambda c: c.value):
caps.addItem(cap.value)
layout.addWidget(caps, stretch=1)
+
+ # Version stamp
+ ver_lbl = QLabel(f"Studio v{__version__}")
+ ver_lbl.setStyleSheet(f"color:{TEXT_MUTED}; font-size:10px;")
+ ver_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ layout.addWidget(ver_lbl)
+
return w
+ # ── Central tabs ──────────────────────────────────────────────────────────
+
def _build_central(self) -> QWidget:
tabs = QTabWidget()
-
- tabs.addTab(self._build_overview_tab(), "Overview")
- tabs.addTab(FlashConsoleWidget(self._session), "Flash Console")
- tabs.addTab(ProjectEditorWidget(self._session), "Project")
- tabs.addTab(self._placeholder_tab(
- "Telemetry -- Sprint 4.\n\n"
- "Live Modbus telemetry from the connected AR-NMEA-IO board."
- ), "Telemetry")
+ tabs.addTab(self._build_overview_tab(), "🧭 Overview")
+ tabs.addTab(FlashConsoleWidget(self._session), "⚡ Flash ESP32")
+ tabs.addTab(ProjectEditorWidget(self._session), "📋 Proyecto")
+ tabs.addTab(TelemetryWidget(self._session), "📡 Telemetría")
+ tabs.addTab(InstallerWidget(self._session), "💾 Instalar J6412")
return tabs
+ # ── Overview tab ──────────────────────────────────────────────────────────
+
def _build_overview_tab(self) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
- layout.addWidget(QLabel(
- "
Welcome. Use the Flash Console tab to compile and " - "flash firmware to an AR-NMEA-IO board.
" - "The Project tab (Sprint 4) will let you configure a "
- "vessel and produce a deployable .appack.
Every action you take is recorded in the audit log " - "(see status bar at the bottom).
" - )) - layout.addStretch(1) - return w + layout.setContentsMargins(24, 20, 24, 20) + + title = QLabel("AR-Autopilot Studio") + title.setFont(QFont("Segoe UI", 20, QFont.Weight.Bold)) + title.setStyleSheet(f"color:{GLOW}; letter-spacing:2px;") + layout.addWidget(title) + + subtitle = QLabel(f"v{__version__} — Herramienta de integración para el sistema AR-Autopilot") + subtitle.setStyleSheet(f"color:{TEXT_MUTED}; font-size:12px;") + layout.addWidget(subtitle) + + sep = QFrame() + sep.setFrameShape(QFrame.Shape.HLine) + sep.setStyleSheet(f"color:{ACCENT_MID}; margin: 10px 0;") + layout.addWidget(sep) + + guide = QLabel( + "| ⚡ | " + f"Flash ESP32 " + f"Compila y flashea el firmware al AR-Concentrador " + f"o al autopilot ESP32 via USB. |
| 📋 | " + f"Proyecto " + f"Configura el buque (tipo, dimensiones, actuador, " + f"sensores, ganancias PID) y genera un paquete .appack de despliegue. |
| 📡 | " + f"Telemetría " + f"Conecta al AR-Concentrador por puerto COM y ve " + f"en tiempo real el rumbo, setpoint y ángulo de timón. |
| 💾 | " + f"Instalar J6412 " + f"Genera un pendrive USB que instala AR-ECDIS y " + f"AR-Autopilot Display en el mini PC J6412 con activación de licencia online." + " |