Merge branch 'claude/elated-sammet-86a4ab'
This commit is contained in:
@@ -18,6 +18,8 @@ eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
# Exception: Flutter display app source is in display/lib/ — track it
|
||||
!display/lib/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Rudder angle sensor configuration.
|
||||
|
||||
Supports single and dual (redundant) sensor setups. The primary sensor
|
||||
is mandatory for closed-loop operation. The redundant sensor is optional
|
||||
but strongly recommended for Phase-1 marine deployments.
|
||||
|
||||
Sensor families supported in Phase 1:
|
||||
|
||||
- ``AS5048A_SPI`` — contactless magnetic absolute encoder, 14-bit, SPI.
|
||||
Recommended for new installations (brief session 2026-05-18).
|
||||
- ``POTENTIOMETER`` — resistive potentiometer 0-10 V or 4-20 mA.
|
||||
Supported for legacy actuators.
|
||||
|
||||
Both sensors use a polycarbonate linkage arm to the rudder stock, giving
|
||||
a proportional rotation to the rudder angle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class RudderSensorType(StrEnum):
|
||||
AS5048A_SPI = "as5048a_spi"
|
||||
"""AMS AS5048A contactless magnetic encoder — 14-bit, SPI interface.
|
||||
No mechanical contact; requires diametrically magnetised 6 mm magnet
|
||||
on the rotating shaft. Recommended for all new Phase-1 installations."""
|
||||
|
||||
POTENTIOMETER = "potentiometer"
|
||||
"""Resistive potentiometer with 0-10 V or 4-20 mA signal conditioning.
|
||||
Suitable for retrofit on existing actuators that already carry a pot."""
|
||||
|
||||
|
||||
class RudderSensorConfig(BaseModel):
|
||||
"""Configuration for one physical rudder angle sensor.
|
||||
|
||||
The ``full_scale_deg`` / ``zero_offset_deg`` pair maps the electrical
|
||||
range of the sensor to real rudder degrees. Calibration during
|
||||
commissioning (Sprint 7) will update these values in NVS.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
||||
|
||||
type: RudderSensorType
|
||||
"""Physical sensor technology."""
|
||||
|
||||
label: str = Field(
|
||||
default="",
|
||||
max_length=60,
|
||||
description="Free-form label, e.g. 'Primary – rudder stock' or 'Redundant – actuator arm'.",
|
||||
)
|
||||
|
||||
# SPI-specific (ignored for POTENTIOMETER)
|
||||
spi_cs_gpio: int = Field(
|
||||
default=10,
|
||||
ge=0,
|
||||
le=39,
|
||||
description="ESP32 GPIO pin used as SPI chip-select for AS5048A.",
|
||||
)
|
||||
|
||||
# Calibration
|
||||
full_scale_deg: float = Field(
|
||||
default=35.0,
|
||||
gt=0.0,
|
||||
le=45.0,
|
||||
description="Rudder angle (degrees) corresponding to full electrical output.",
|
||||
)
|
||||
zero_offset_deg: float = Field(
|
||||
default=0.0,
|
||||
ge=-10.0,
|
||||
le=10.0,
|
||||
description="Offset applied after scaling to correct mechanical amidships misalignment.",
|
||||
)
|
||||
|
||||
# Cross-validation (used by dual-sensor arbitrator)
|
||||
divergence_alarm_deg: float = Field(
|
||||
default=3.0,
|
||||
gt=0.0,
|
||||
le=15.0,
|
||||
description="Alarm threshold: raise SENSOR_DIVERGE if |A - B| exceeds this value.",
|
||||
)
|
||||
divergence_failover_deg: float = Field(
|
||||
default=6.0,
|
||||
gt=0.0,
|
||||
le=20.0,
|
||||
description="Failover threshold: switch to the other sensor if |A - B| exceeds this.",
|
||||
)
|
||||
|
||||
|
||||
class DualRudderSensorConfig(BaseModel):
|
||||
"""Primary + optional redundant rudder sensor pair.
|
||||
|
||||
When ``redundant`` is ``None`` the system operates in single-sensor
|
||||
mode (feedback_required still enforced). When ``redundant`` is
|
||||
present the firmware enables cross-validation and automatic failover.
|
||||
|
||||
Divergence thresholds are taken from the *primary* sensor's config so
|
||||
there is one canonical place to tune them.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
||||
|
||||
primary: RudderSensorConfig
|
||||
redundant: RudderSensorConfig | None = None
|
||||
|
||||
@property
|
||||
def has_redundancy(self) -> bool:
|
||||
return self.redundant is not None
|
||||
@@ -13,6 +13,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
from arautopilot.core.actuator_config import ActuatorConfig
|
||||
from arautopilot.core.ids import VesselId, new_vessel_id
|
||||
from arautopilot.core.pid_config import PidConfig
|
||||
from arautopilot.core.sensor_config import DualRudderSensorConfig, RudderSensorConfig, RudderSensorType
|
||||
|
||||
|
||||
class VesselType(StrEnum):
|
||||
@@ -60,3 +61,13 @@ class VesselConfig(BaseModel):
|
||||
|
||||
actuator: ActuatorConfig
|
||||
pid: PidConfig
|
||||
sensors: DualRudderSensorConfig = Field(
|
||||
default_factory=lambda: DualRudderSensorConfig(
|
||||
primary=RudderSensorConfig(
|
||||
type=RudderSensorType.AS5048A_SPI,
|
||||
label="Primary – rudder stock",
|
||||
spi_cs_gpio=10,
|
||||
)
|
||||
),
|
||||
description="Rudder angle sensor(s). Dual config enables cross-validation and failover.",
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,245 @@
|
||||
"""AR-Autopilot deployment package compiler — Sprint 4.
|
||||
|
||||
An ``.appack`` is a ZIP archive that contains everything needed to deploy
|
||||
a configured autopilot to a vessel:
|
||||
|
||||
<project_id>/
|
||||
├── manifest.json — metadata, checksums, schema version
|
||||
├── project.yaml — full ProjectConfig (human-readable)
|
||||
├── firmware_config.h — generated C header consumed by the firmware build
|
||||
└── install_notes.txt — human summary for the field engineer
|
||||
|
||||
The format is intentionally transparent (plain ZIP, readable YAML/JSON/C)
|
||||
so that a field engineer can inspect the package contents without special tools.
|
||||
|
||||
Typical workflow
|
||||
----------------
|
||||
1. Integrator configures a ``ProjectConfig`` in the Studio (Project tab).
|
||||
2. Clicks "Compile .appack…" → this compiler runs.
|
||||
3. Studio writes the .appack to disk.
|
||||
4. Field engineer transfers the .appack to the vessel.
|
||||
5. Future Sprint: Studio "Deploy" tab extracts the package, calls PlatformIO
|
||||
with the generated header, and flashes the board.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import zipfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
|
||||
from arautopilot.core.actuator_config import ActuatorType
|
||||
from arautopilot.core.project_config import ProjectConfig
|
||||
from arautopilot.core.sensor_config import DualRudderSensorConfig, RudderSensorType
|
||||
from arautopilot.version import __version__
|
||||
|
||||
_SCHEMA_VERSION = "0.1.0"
|
||||
|
||||
|
||||
class AppackCompiler:
|
||||
"""Compiles a ``ProjectConfig`` into an ``.appack`` deployment archive."""
|
||||
|
||||
def __init__(self, project: ProjectConfig) -> None:
|
||||
if not project.client_name.strip():
|
||||
raise ValueError("project.client_name must not be empty")
|
||||
if not project.project_name.strip():
|
||||
raise ValueError("project.project_name must not be empty")
|
||||
self._project = project
|
||||
|
||||
def compile(self, output_path: Path | str) -> Path:
|
||||
"""Build the ``.appack`` archive and write it to *output_path*.
|
||||
|
||||
Returns the resolved output path.
|
||||
"""
|
||||
out = Path(output_path)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
project_yaml = self._project.to_yaml()
|
||||
firmware_header = self._generate_firmware_header()
|
||||
install_notes = self._generate_install_notes()
|
||||
manifest = self._build_manifest(project_yaml, firmware_header)
|
||||
|
||||
prefix = str(self._project.project_id)
|
||||
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr(f"{prefix}/manifest.json", json.dumps(manifest, indent=2))
|
||||
zf.writestr(f"{prefix}/project.yaml", project_yaml)
|
||||
zf.writestr(f"{prefix}/firmware_config.h", firmware_header)
|
||||
zf.writestr(f"{prefix}/install_notes.txt", install_notes)
|
||||
|
||||
return out
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Manifest
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_manifest(self, project_yaml: str, firmware_header: str) -> dict:
|
||||
return {
|
||||
"appack_schema": _SCHEMA_VERSION,
|
||||
"arautopilot_version": __version__,
|
||||
"compiled_at": datetime.now(UTC).isoformat(),
|
||||
"project_id": str(self._project.project_id),
|
||||
"client_name": self._project.client_name,
|
||||
"project_name": self._project.project_name,
|
||||
"vessel_name": self._project.vessel.name,
|
||||
"vessel_type": self._project.vessel.type.value,
|
||||
"actuator_type": self._project.vessel.actuator.type.value,
|
||||
"checksums": {
|
||||
"project_yaml_sha256": _sha256(project_yaml),
|
||||
"firmware_config_h_sha256": _sha256(firmware_header),
|
||||
},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Firmware header generator
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _generate_firmware_header(self) -> str:
|
||||
p = self._project
|
||||
v = p.vessel
|
||||
a = v.actuator
|
||||
pid = v.pid
|
||||
sensors = v.sensors
|
||||
|
||||
actuator_enum = _actuator_type_to_enum(a.type)
|
||||
primary_sensor_enum = _sensor_type_to_enum(sensors.primary.type)
|
||||
|
||||
redundant_enabled = "1" if sensors.has_redundancy else "0"
|
||||
redundant_cs = sensors.redundant.spi_cs_gpio if sensors.redundant else 0
|
||||
redundant_sensor_enum = (
|
||||
_sensor_type_to_enum(sensors.redundant.type)
|
||||
if sensors.redundant
|
||||
else "RUDDER_SENSOR_NONE"
|
||||
)
|
||||
diverge_alarm = sensors.primary.divergence_alarm_deg
|
||||
diverge_failover = sensors.primary.divergence_failover_deg
|
||||
|
||||
lines = [
|
||||
"// AUTO-GENERATED by AR-Autopilot Studio — DO NOT EDIT BY HAND.",
|
||||
f"// Project : {p.project_name}",
|
||||
f"// Client : {p.client_name}",
|
||||
f"// Vessel : {v.name} ({v.type.value})",
|
||||
f"// Generated: {datetime.now(UTC).isoformat()}",
|
||||
"//",
|
||||
"#pragma once",
|
||||
"",
|
||||
"// --- Project identity -------------------------------------------------",
|
||||
f'#define AR_PROJECT_ID "{p.project_id}"',
|
||||
f'#define AR_CLIENT_NAME "{p.client_name}"',
|
||||
f'#define AR_PROJECT_NAME "{p.project_name}"',
|
||||
f'#define AR_VESSEL_NAME "{v.name}"',
|
||||
"",
|
||||
"// --- Vessel kinematics ------------------------------------------------",
|
||||
f"#define AR_VESSEL_LENGTH_M {v.length_m:.1f}f",
|
||||
f"#define AR_VESSEL_MAX_SPEED_KN {v.max_speed_kn:.1f}f",
|
||||
"",
|
||||
"// --- Actuator ---------------------------------------------------------",
|
||||
f"#define AR_ACTUATOR_TYPE {actuator_enum}",
|
||||
f"#define AR_RUDDER_ANGLE_LIMIT_DEG {a.max_rudder_angle_deg:.1f}f",
|
||||
f"#define AR_ACTUATOR_DEADBAND_PCT {a.deadband_pct:.1f}f",
|
||||
f"#define AR_ACTUATOR_MAX_RATE_DPS {a.max_rate_dps:.1f}f",
|
||||
f"#define AR_ACTUATOR_MAX_CURRENT_A {a.max_current_a:.1f}f",
|
||||
f"#define AR_ACTUATOR_ASYM_STBD {a.asymmetry_stbd_over_port:.3f}f",
|
||||
"",
|
||||
"// --- Rudder sensors ---------------------------------------------------",
|
||||
f"#define AR_SENSOR_PRIMARY_TYPE {primary_sensor_enum}",
|
||||
f"#define AR_SENSOR_PRIMARY_CS_GPIO {sensors.primary.spi_cs_gpio}",
|
||||
f"#define AR_SENSOR_PRIMARY_FSD_DEG {sensors.primary.full_scale_deg:.1f}f",
|
||||
f"#define AR_SENSOR_REDUNDANT {redundant_enabled}",
|
||||
f"#define AR_SENSOR_REDUNDANT_TYPE {redundant_sensor_enum}",
|
||||
f"#define AR_SENSOR_REDUNDANT_CS_GPIO {redundant_cs}",
|
||||
f"#define AR_SENSOR_DIVERGE_ALARM_DEG {diverge_alarm:.1f}f",
|
||||
f"#define AR_SENSOR_DIVERGE_FAILOVER_DEG {diverge_failover:.1f}f",
|
||||
"",
|
||||
"// --- PID base gains (integrator IP — not exposed to operator) ---------",
|
||||
f"#define AR_PID_INNER_KP {pid.inner_loop_base.kp:.4f}f",
|
||||
f"#define AR_PID_INNER_KI {pid.inner_loop_base.ki:.4f}f",
|
||||
f"#define AR_PID_INNER_KD {pid.inner_loop_base.kd:.4f}f",
|
||||
f"#define AR_PID_OUTER_KP {pid.outer_loop_base.kp:.4f}f",
|
||||
f"#define AR_PID_OUTER_KI {pid.outer_loop_base.ki:.4f}f",
|
||||
f"#define AR_PID_OUTER_KD {pid.outer_loop_base.kd:.4f}f",
|
||||
f"#define AR_PID_ROT_FF {pid.rot_feedforward_gain:.4f}f",
|
||||
f"#define AR_PID_DEADBAND_DEG {pid.setpoint_deadband_deg:.2f}f",
|
||||
f"#define AR_PID_RATE_LIMIT_DPS {pid.setpoint_rate_limit_dps:.2f}f",
|
||||
f"#define AR_PID_ANTI_WINDUP {pid.anti_windup_limit:.2f}f",
|
||||
"",
|
||||
"// End of generated header.",
|
||||
]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Install notes
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _generate_install_notes(self) -> str:
|
||||
p = self._project
|
||||
v = p.vessel
|
||||
a = v.actuator
|
||||
s = v.sensors
|
||||
return dedent(f"""\
|
||||
AR-Autopilot — Field Installation Notes
|
||||
========================================
|
||||
|
||||
Project : {p.project_name}
|
||||
Client : {p.client_name}
|
||||
Vessel : {v.name} ({v.type.value.replace("_", " ").title()})
|
||||
Generated: {datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC")}
|
||||
|
||||
ACTUATOR
|
||||
--------
|
||||
Type : {a.type.value.replace("_", " ").title()}
|
||||
Model / label : {a.name or "(not specified)"}
|
||||
Rudder limit : ±{a.max_rudder_angle_deg:.1f}° (software limit — verify ≤ mechanical stop)
|
||||
Deadband : {a.deadband_pct:.1f} %
|
||||
Max slew rate : {a.max_rate_dps:.1f} °/s
|
||||
Overcurrent : {a.max_current_a:.1f} A trip
|
||||
|
||||
SENSORS
|
||||
-------
|
||||
Primary sensor : {s.primary.type.value.upper()} GPIO CS={s.primary.spi_cs_gpio}
|
||||
Full-scale : ±{s.primary.full_scale_deg:.1f}°
|
||||
Redundant sensor: {"ENABLED" if s.has_redundancy else "DISABLED"}
|
||||
{"Redundant type : " + s.redundant.type.value.upper() + " GPIO CS=" + str(s.redundant.spi_cs_gpio) if s.redundant else ""}
|
||||
Divergence alarm : {s.primary.divergence_alarm_deg:.1f}°
|
||||
Divergence failover : {s.primary.divergence_failover_deg:.1f}°
|
||||
|
||||
COMMISSIONING STEPS (Sprint 7 wizard will automate these)
|
||||
-----------------------------------------------------------
|
||||
1. Flash firmware with this .appack (Flash Console or pio upload).
|
||||
2. Connect primary rudder sensor to SPI CS GPIO {s.primary.spi_cs_gpio}.
|
||||
{"3. Connect redundant sensor to SPI CS GPIO " + str(s.redundant.spi_cs_gpio) + "." if s.redundant else "3. (Redundant sensor not configured.)"}
|
||||
4. Verify rudder moves to ±{a.max_rudder_angle_deg:.1f}° and stops.
|
||||
5. Run Modbus client test: python tools/modbus_client_test.py
|
||||
6. Engage HEADING_HOLD and confirm heading capture.
|
||||
7. Tune PID gains via Modbus if required.
|
||||
|
||||
SUPPORT
|
||||
-------
|
||||
Contact: AR Suite — alro65@gmail.com
|
||||
""")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _sha256(text: str) -> str:
|
||||
return hashlib.sha256(text.encode()).hexdigest()
|
||||
|
||||
|
||||
def _actuator_type_to_enum(t: ActuatorType) -> str:
|
||||
return {
|
||||
ActuatorType.HYDRAULIC_REVERSIBLE: "ACTUATOR_HYDRAULIC_REVERSIBLE",
|
||||
ActuatorType.ELECTRIC_DC_REVERSIBLE: "ACTUATOR_ELECTRIC_DC",
|
||||
ActuatorType.SERVOMOTOR_FEEDBACK: "ACTUATOR_SERVOMOTOR",
|
||||
ActuatorType.STERNDRIVE_ANALOG: "ACTUATOR_STERNDRIVE_ANALOG",
|
||||
}.get(t, "ACTUATOR_UNKNOWN")
|
||||
|
||||
|
||||
def _sensor_type_to_enum(t: RudderSensorType) -> str:
|
||||
return {
|
||||
RudderSensorType.AS5048A_SPI: "RUDDER_SENSOR_AS5048A_SPI",
|
||||
RudderSensorType.POTENTIOMETER: "RUDDER_SENSOR_POTENTIOMETER",
|
||||
}.get(t, "RUDDER_SENSOR_UNKNOWN")
|
||||
@@ -0,0 +1,619 @@
|
||||
"""Project configurator widget — Sprint 4.
|
||||
|
||||
A scrollable form that lets the integrator (Engineer / Super Admin) create
|
||||
or edit a ``ProjectConfig`` and compile it into an ``.appack`` deployment
|
||||
package.
|
||||
|
||||
Layout
|
||||
------
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ PROJECT INFO client / project name / notes │
|
||||
│ VESSEL name / type / dimensions │
|
||||
│ ACTUATOR type / angle limit / deadband / … │
|
||||
│ SENSORS primary + optional redundant │
|
||||
│ PID GAINS inner + outer base (SA-gated) │
|
||||
│ ─────────────────────────────────────────────────── │
|
||||
│ [New] [Open…] [Save] [Compile .appack…] │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
|
||||
RBAC gates
|
||||
----------
|
||||
- ``EDIT_COMMISSIONING`` (Engineer+) : actuator + sensor + rudder limit.
|
||||
- ``EDIT_BASE_GAINS`` (Super Admin) : PID base gains fields.
|
||||
- ``EDIT_COMMISSIONING`` (Engineer+) : Compile .appack button.
|
||||
- Any role can load and read a project; only the above roles may edit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from arautopilot.core.actuator_config import ActuatorConfig, ActuatorType
|
||||
from arautopilot.core.pid_config import PidConfig, PidGains
|
||||
from arautopilot.core.project_config import ProjectConfig
|
||||
from arautopilot.core.rbac import Capability
|
||||
from arautopilot.core.sensor_config import (
|
||||
DualRudderSensorConfig,
|
||||
RudderSensorConfig,
|
||||
RudderSensorType,
|
||||
)
|
||||
from arautopilot.core.vessel_config import VesselConfig, VesselType
|
||||
from arautopilot.studio.session import Session
|
||||
|
||||
|
||||
class ProjectEditorWidget(QWidget):
|
||||
"""Full project config editor — fills the 'Project' tab in the Studio."""
|
||||
|
||||
def __init__(self, session: Session, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._session = session
|
||||
self._project: ProjectConfig | None = None
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Scrollable form area
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
content = QWidget()
|
||||
form_layout = QVBoxLayout(content)
|
||||
form_layout.setContentsMargins(12, 12, 12, 12)
|
||||
form_layout.setSpacing(12)
|
||||
|
||||
self._build_project_info_group(form_layout)
|
||||
self._build_vessel_group(form_layout)
|
||||
self._build_actuator_group(form_layout)
|
||||
self._build_sensor_group(form_layout)
|
||||
self._build_pid_group(form_layout)
|
||||
form_layout.addStretch(1)
|
||||
|
||||
scroll.setWidget(content)
|
||||
root.addWidget(scroll, stretch=1)
|
||||
root.addWidget(self._build_toolbar())
|
||||
|
||||
self._apply_rbac()
|
||||
self._load_defaults()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Group builders
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_project_info_group(self, parent: QVBoxLayout) -> None:
|
||||
box = QGroupBox("Project Info")
|
||||
fl = QFormLayout(box)
|
||||
|
||||
self._client_name = QLineEdit()
|
||||
self._client_name.setPlaceholderText("e.g. Astilleros del Sur S.A.")
|
||||
fl.addRow("Client name *", self._client_name)
|
||||
|
||||
self._project_name = QLineEdit()
|
||||
self._project_name.setPlaceholderText("e.g. M/Y Pacifica – Autopilot install")
|
||||
fl.addRow("Project name *", self._project_name)
|
||||
|
||||
self._notes = QTextEdit()
|
||||
self._notes.setPlaceholderText("Optional notes…")
|
||||
self._notes.setFixedHeight(72)
|
||||
fl.addRow("Notes", self._notes)
|
||||
|
||||
parent.addWidget(box)
|
||||
|
||||
def _build_vessel_group(self, parent: QVBoxLayout) -> None:
|
||||
box = QGroupBox("Vessel")
|
||||
fl = QFormLayout(box)
|
||||
|
||||
self._vessel_name = QLineEdit()
|
||||
self._vessel_name.setPlaceholderText("e.g. Pacifica")
|
||||
fl.addRow("Vessel name *", self._vessel_name)
|
||||
|
||||
self._vessel_type = QComboBox()
|
||||
for vt in VesselType:
|
||||
self._vessel_type.addItem(vt.value.replace("_", " ").title(), vt)
|
||||
fl.addRow("Type", self._vessel_type)
|
||||
|
||||
self._length_m = QDoubleSpinBox()
|
||||
self._length_m.setRange(5.0, 200.0)
|
||||
self._length_m.setSuffix(" m")
|
||||
self._length_m.setDecimals(1)
|
||||
self._length_m.setValue(30.0)
|
||||
fl.addRow("Length overall", self._length_m)
|
||||
|
||||
self._displacement_t = QDoubleSpinBox()
|
||||
self._displacement_t.setRange(0.0, 10_000.0)
|
||||
self._displacement_t.setSuffix(" t")
|
||||
self._displacement_t.setDecimals(1)
|
||||
fl.addRow("Displacement (0 = unknown)", self._displacement_t)
|
||||
|
||||
self._max_speed_kn = QDoubleSpinBox()
|
||||
self._max_speed_kn.setRange(1.0, 80.0)
|
||||
self._max_speed_kn.setSuffix(" kn")
|
||||
self._max_speed_kn.setDecimals(1)
|
||||
self._max_speed_kn.setValue(18.0)
|
||||
fl.addRow("Max speed", self._max_speed_kn)
|
||||
|
||||
parent.addWidget(box)
|
||||
|
||||
def _build_actuator_group(self, parent: QVBoxLayout) -> None:
|
||||
box = QGroupBox("Rudder Actuator")
|
||||
fl = QFormLayout(box)
|
||||
|
||||
self._act_type = QComboBox()
|
||||
phase1 = [
|
||||
ActuatorType.HYDRAULIC_REVERSIBLE,
|
||||
ActuatorType.ELECTRIC_DC_REVERSIBLE,
|
||||
ActuatorType.SERVOMOTOR_FEEDBACK,
|
||||
ActuatorType.STERNDRIVE_ANALOG,
|
||||
]
|
||||
for at in phase1:
|
||||
self._act_type.addItem(at.value.replace("_", " ").title(), at)
|
||||
fl.addRow("Type", self._act_type)
|
||||
|
||||
self._act_name = QLineEdit()
|
||||
self._act_name.setPlaceholderText("e.g. Hynautic K-21 + Capilano cylinder")
|
||||
fl.addRow("Model / label", self._act_name)
|
||||
|
||||
self._max_rudder_deg = QDoubleSpinBox()
|
||||
self._max_rudder_deg.setRange(15.0, 45.0)
|
||||
self._max_rudder_deg.setSuffix(" °")
|
||||
self._max_rudder_deg.setDecimals(1)
|
||||
self._max_rudder_deg.setValue(35.0)
|
||||
self._max_rudder_deg.setToolTip(
|
||||
"Software rudder angle limit. Must not exceed the mechanical stop.\n"
|
||||
"Requires Engineer or Super Admin to change."
|
||||
)
|
||||
fl.addRow("Rudder angle limit ⚠", self._max_rudder_deg)
|
||||
|
||||
self._deadband_pct = QDoubleSpinBox()
|
||||
self._deadband_pct.setRange(0.0, 30.0)
|
||||
self._deadband_pct.setSuffix(" %")
|
||||
self._deadband_pct.setDecimals(1)
|
||||
self._deadband_pct.setValue(5.0)
|
||||
fl.addRow("Deadband", self._deadband_pct)
|
||||
|
||||
self._max_rate_dps = QDoubleSpinBox()
|
||||
self._max_rate_dps.setRange(0.5, 15.0)
|
||||
self._max_rate_dps.setSuffix(" °/s")
|
||||
self._max_rate_dps.setDecimals(1)
|
||||
self._max_rate_dps.setValue(4.5)
|
||||
fl.addRow("Max slew rate", self._max_rate_dps)
|
||||
|
||||
self._max_current_a = QDoubleSpinBox()
|
||||
self._max_current_a.setRange(1.0, 200.0)
|
||||
self._max_current_a.setSuffix(" A")
|
||||
self._max_current_a.setDecimals(1)
|
||||
self._max_current_a.setValue(15.0)
|
||||
fl.addRow("Overcurrent limit", self._max_current_a)
|
||||
|
||||
parent.addWidget(box)
|
||||
self._actuator_box = box
|
||||
|
||||
def _build_sensor_group(self, parent: QVBoxLayout) -> None:
|
||||
box = QGroupBox("Rudder Angle Sensors")
|
||||
fl = QFormLayout(box)
|
||||
|
||||
fl.addRow(QLabel("<b>Primary sensor</b>"))
|
||||
|
||||
self._sens_primary_type = QComboBox()
|
||||
for st in RudderSensorType:
|
||||
self._sens_primary_type.addItem(st.value.replace("_", " ").upper(), st)
|
||||
fl.addRow("Type", self._sens_primary_type)
|
||||
|
||||
self._sens_primary_cs = QSpinBox()
|
||||
self._sens_primary_cs.setRange(0, 39)
|
||||
self._sens_primary_cs.setValue(10)
|
||||
self._sens_primary_cs.setToolTip("ESP32 GPIO used as SPI CS for AS5048A")
|
||||
fl.addRow("SPI CS GPIO", self._sens_primary_cs)
|
||||
|
||||
self._sens_primary_fsd = QDoubleSpinBox()
|
||||
self._sens_primary_fsd.setRange(1.0, 45.0)
|
||||
self._sens_primary_fsd.setSuffix(" °")
|
||||
self._sens_primary_fsd.setDecimals(1)
|
||||
self._sens_primary_fsd.setValue(35.0)
|
||||
fl.addRow("Full-scale angle", self._sens_primary_fsd)
|
||||
|
||||
fl.addRow(QLabel(""))
|
||||
fl.addRow(QLabel("<b>Redundant sensor (optional)</b>"))
|
||||
|
||||
self._sens_redundant_enabled = QCheckBox("Enable redundant sensor")
|
||||
fl.addRow("", self._sens_redundant_enabled)
|
||||
|
||||
self._sens_redundant_type = QComboBox()
|
||||
for st in RudderSensorType:
|
||||
self._sens_redundant_type.addItem(st.value.replace("_", " ").upper(), st)
|
||||
fl.addRow("Type", self._sens_redundant_type)
|
||||
|
||||
self._sens_redundant_cs = QSpinBox()
|
||||
self._sens_redundant_cs.setRange(0, 39)
|
||||
self._sens_redundant_cs.setValue(11)
|
||||
fl.addRow("SPI CS GPIO", self._sens_redundant_cs)
|
||||
|
||||
self._sens_redundant_fsd = QDoubleSpinBox()
|
||||
self._sens_redundant_fsd.setRange(1.0, 45.0)
|
||||
self._sens_redundant_fsd.setSuffix(" °")
|
||||
self._sens_redundant_fsd.setDecimals(1)
|
||||
self._sens_redundant_fsd.setValue(35.0)
|
||||
fl.addRow("Full-scale angle", self._sens_redundant_fsd)
|
||||
|
||||
self._sens_diverge_alarm = QDoubleSpinBox()
|
||||
self._sens_diverge_alarm.setRange(0.5, 15.0)
|
||||
self._sens_diverge_alarm.setSuffix(" °")
|
||||
self._sens_diverge_alarm.setDecimals(1)
|
||||
self._sens_diverge_alarm.setValue(3.0)
|
||||
fl.addRow("Divergence alarm threshold", self._sens_diverge_alarm)
|
||||
|
||||
self._sens_diverge_failover = QDoubleSpinBox()
|
||||
self._sens_diverge_failover.setRange(1.0, 20.0)
|
||||
self._sens_diverge_failover.setSuffix(" °")
|
||||
self._sens_diverge_failover.setDecimals(1)
|
||||
self._sens_diverge_failover.setValue(6.0)
|
||||
fl.addRow("Divergence failover threshold", self._sens_diverge_failover)
|
||||
|
||||
self._sens_redundant_enabled.toggled.connect(self._on_redundant_toggled)
|
||||
self._on_redundant_toggled(False)
|
||||
|
||||
parent.addWidget(box)
|
||||
self._sensor_box = box
|
||||
|
||||
def _build_pid_group(self, parent: QVBoxLayout) -> None:
|
||||
box = QGroupBox("PID Base Gains [Super Admin only]")
|
||||
fl = QFormLayout(box)
|
||||
|
||||
fl.addRow(QLabel("<b>Inner loop (rudder position, 50 Hz)</b>"))
|
||||
self._inner_kp = self._gain_spin("Inner Kp", fl)
|
||||
self._inner_ki = self._gain_spin("Inner Ki", fl)
|
||||
self._inner_kd = self._gain_spin("Inner Kd", fl)
|
||||
|
||||
fl.addRow(QLabel(""))
|
||||
fl.addRow(QLabel("<b>Outer loop (heading, 10 Hz)</b>"))
|
||||
self._outer_kp = self._gain_spin("Outer Kp", fl)
|
||||
self._outer_ki = self._gain_spin("Outer Ki", fl)
|
||||
self._outer_kd = self._gain_spin("Outer Kd", fl)
|
||||
|
||||
fl.addRow(QLabel(""))
|
||||
self._rot_ff = self._gain_spin("ROT feed-forward", fl)
|
||||
|
||||
parent.addWidget(box)
|
||||
self._pid_box = box
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Toolbar
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_toolbar(self) -> QWidget:
|
||||
bar = QWidget()
|
||||
bar.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
h = QHBoxLayout(bar)
|
||||
h.setContentsMargins(8, 4, 8, 4)
|
||||
|
||||
btn_new = QPushButton("New")
|
||||
btn_new.clicked.connect(self._on_new)
|
||||
h.addWidget(btn_new)
|
||||
|
||||
btn_open = QPushButton("Open…")
|
||||
btn_open.clicked.connect(self._on_open)
|
||||
h.addWidget(btn_open)
|
||||
|
||||
btn_save = QPushButton("Save")
|
||||
btn_save.clicked.connect(self._on_save)
|
||||
h.addWidget(btn_save)
|
||||
|
||||
h.addStretch(1)
|
||||
|
||||
self._btn_compile = QPushButton("Compile .appack…")
|
||||
self._btn_compile.setToolTip("Generate deployment package (Engineer+)")
|
||||
self._btn_compile.clicked.connect(self._on_compile)
|
||||
h.addWidget(self._btn_compile)
|
||||
|
||||
return bar
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# RBAC
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _apply_rbac(self) -> None:
|
||||
can_commission = self._session.has(Capability.EDIT_COMMISSIONING)
|
||||
can_gains = self._session.has(Capability.EDIT_BASE_GAINS)
|
||||
can_compile = self._session.has(Capability.EDIT_COMMISSIONING)
|
||||
|
||||
for w in [
|
||||
self._max_rudder_deg,
|
||||
self._deadband_pct,
|
||||
self._max_rate_dps,
|
||||
self._max_current_a,
|
||||
self._act_type,
|
||||
self._act_name,
|
||||
self._sens_primary_type,
|
||||
self._sens_primary_cs,
|
||||
self._sens_primary_fsd,
|
||||
self._sens_redundant_enabled,
|
||||
self._sens_redundant_type,
|
||||
self._sens_redundant_cs,
|
||||
self._sens_redundant_fsd,
|
||||
self._sens_diverge_alarm,
|
||||
self._sens_diverge_failover,
|
||||
]:
|
||||
w.setEnabled(can_commission)
|
||||
|
||||
for w in [
|
||||
self._inner_kp,
|
||||
self._inner_ki,
|
||||
self._inner_kd,
|
||||
self._outer_kp,
|
||||
self._outer_ki,
|
||||
self._outer_kd,
|
||||
self._rot_ff,
|
||||
]:
|
||||
w.setEnabled(can_gains)
|
||||
|
||||
self._btn_compile.setEnabled(can_compile)
|
||||
|
||||
if not can_gains:
|
||||
self._pid_box.setTitle("PID Base Gains [Super Admin only — read-only]")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Slots
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_redundant_toggled(self, enabled: bool) -> None:
|
||||
for w in [
|
||||
self._sens_redundant_type,
|
||||
self._sens_redundant_cs,
|
||||
self._sens_redundant_fsd,
|
||||
self._sens_diverge_alarm,
|
||||
self._sens_diverge_failover,
|
||||
]:
|
||||
w.setEnabled(enabled and self._session.has(Capability.EDIT_COMMISSIONING))
|
||||
|
||||
def _on_new(self) -> None:
|
||||
self._project = None
|
||||
self._load_defaults()
|
||||
|
||||
def _on_open(self) -> None:
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"Open project",
|
||||
str(Path.home()),
|
||||
"AR-Autopilot Project (*.yaml *.yml *.json);;All files (*)",
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
self._project = ProjectConfig.load(path)
|
||||
self._populate_from_project(self._project)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
QMessageBox.critical(self, "Load error", str(exc))
|
||||
|
||||
def _on_save(self) -> None:
|
||||
try:
|
||||
project = self._build_project()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
QMessageBox.critical(self, "Validation error", str(exc))
|
||||
return
|
||||
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"Save project",
|
||||
str(Path.home() / f"{project.project_name}.yaml"),
|
||||
"AR-Autopilot Project (*.yaml);;JSON (*.json)",
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
project.save_yaml(path) if path.endswith((".yaml", ".yml")) else project.save_json(path)
|
||||
self._project = project
|
||||
QMessageBox.information(self, "Saved", f"Project saved to:\n{path}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
QMessageBox.critical(self, "Save error", str(exc))
|
||||
|
||||
def _on_compile(self) -> None:
|
||||
if not self._session.has(Capability.EDIT_COMMISSIONING):
|
||||
QMessageBox.warning(self, "Access denied", "Engineer or Super Admin required.")
|
||||
return
|
||||
|
||||
try:
|
||||
project = self._build_project()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
QMessageBox.critical(self, "Validation error", str(exc))
|
||||
return
|
||||
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"Save .appack",
|
||||
str(Path.home() / f"{project.project_name}.appack"),
|
||||
"AR-Autopilot Package (*.appack)",
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
|
||||
try:
|
||||
from arautopilot.studio.compiler.appack import AppackCompiler
|
||||
compiler = AppackCompiler(project)
|
||||
out = compiler.compile(Path(path))
|
||||
self._session.audit.record(
|
||||
actor=self._session.user.username,
|
||||
action="compile_appack",
|
||||
detail=f"project={project.project_id} output={out}",
|
||||
)
|
||||
QMessageBox.information(
|
||||
self, "Compiled", f".appack written to:\n{out}"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
QMessageBox.critical(self, "Compile error", str(exc))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Build / populate helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_project(self) -> ProjectConfig:
|
||||
redundant: RudderSensorConfig | None = None
|
||||
if self._sens_redundant_enabled.isChecked():
|
||||
redundant = RudderSensorConfig(
|
||||
type=self._sens_redundant_type.currentData(),
|
||||
label="Redundant – actuator arm",
|
||||
spi_cs_gpio=self._sens_redundant_cs.value(),
|
||||
full_scale_deg=self._sens_redundant_fsd.value(),
|
||||
divergence_alarm_deg=self._sens_diverge_alarm.value(),
|
||||
divergence_failover_deg=self._sens_diverge_failover.value(),
|
||||
)
|
||||
|
||||
vessel = VesselConfig(
|
||||
name=self._vessel_name.text().strip() or "Unnamed vessel",
|
||||
type=self._vessel_type.currentData(),
|
||||
length_m=self._length_m.value(),
|
||||
displacement_t=self._displacement_t.value(),
|
||||
max_speed_kn=self._max_speed_kn.value(),
|
||||
actuator=ActuatorConfig(
|
||||
type=self._act_type.currentData(),
|
||||
name=self._act_name.text().strip(),
|
||||
max_rudder_angle_deg=self._max_rudder_deg.value(),
|
||||
deadband_pct=self._deadband_pct.value(),
|
||||
max_rate_dps=self._max_rate_dps.value(),
|
||||
max_current_a=self._max_current_a.value(),
|
||||
),
|
||||
pid=PidConfig(
|
||||
inner_loop_base=PidGains(
|
||||
kp=self._inner_kp.value(),
|
||||
ki=self._inner_ki.value(),
|
||||
kd=self._inner_kd.value(),
|
||||
),
|
||||
outer_loop_base=PidGains(
|
||||
kp=self._outer_kp.value(),
|
||||
ki=self._outer_ki.value(),
|
||||
kd=self._outer_kd.value(),
|
||||
),
|
||||
rot_feedforward_gain=self._rot_ff.value(),
|
||||
),
|
||||
sensors=DualRudderSensorConfig(
|
||||
primary=RudderSensorConfig(
|
||||
type=self._sens_primary_type.currentData(),
|
||||
label="Primary – rudder stock",
|
||||
spi_cs_gpio=self._sens_primary_cs.value(),
|
||||
full_scale_deg=self._sens_primary_fsd.value(),
|
||||
),
|
||||
redundant=redundant,
|
||||
),
|
||||
)
|
||||
|
||||
existing_id = self._project.project_id if self._project else None
|
||||
project = ProjectConfig(
|
||||
client_name=self._client_name.text().strip() or "Unknown client",
|
||||
project_name=self._project_name.text().strip() or "Unnamed project",
|
||||
notes=self._notes.toPlainText().strip(),
|
||||
vessel=vessel,
|
||||
)
|
||||
if existing_id is not None:
|
||||
object.__setattr__(project, "project_id", existing_id)
|
||||
return project
|
||||
|
||||
def _populate_from_project(self, p: ProjectConfig) -> None:
|
||||
self._client_name.setText(p.client_name)
|
||||
self._project_name.setText(p.project_name)
|
||||
self._notes.setPlainText(p.notes)
|
||||
|
||||
v = p.vessel
|
||||
self._vessel_name.setText(v.name)
|
||||
_set_combo(self._vessel_type, v.type)
|
||||
self._length_m.setValue(v.length_m)
|
||||
self._displacement_t.setValue(v.displacement_t)
|
||||
self._max_speed_kn.setValue(v.max_speed_kn)
|
||||
|
||||
a = v.actuator
|
||||
_set_combo(self._act_type, a.type)
|
||||
self._act_name.setText(a.name)
|
||||
self._max_rudder_deg.setValue(a.max_rudder_angle_deg)
|
||||
self._deadband_pct.setValue(a.deadband_pct)
|
||||
self._max_rate_dps.setValue(a.max_rate_dps)
|
||||
self._max_current_a.setValue(a.max_current_a)
|
||||
|
||||
sp = v.sensors.primary
|
||||
_set_combo(self._sens_primary_type, sp.type)
|
||||
self._sens_primary_cs.setValue(sp.spi_cs_gpio)
|
||||
self._sens_primary_fsd.setValue(sp.full_scale_deg)
|
||||
|
||||
sr = v.sensors.redundant
|
||||
self._sens_redundant_enabled.setChecked(sr is not None)
|
||||
if sr is not None:
|
||||
_set_combo(self._sens_redundant_type, sr.type)
|
||||
self._sens_redundant_cs.setValue(sr.spi_cs_gpio)
|
||||
self._sens_redundant_fsd.setValue(sr.full_scale_deg)
|
||||
self._sens_diverge_alarm.setValue(sr.divergence_alarm_deg)
|
||||
self._sens_diverge_failover.setValue(sr.divergence_failover_deg)
|
||||
|
||||
pid = v.pid
|
||||
self._inner_kp.setValue(pid.inner_loop_base.kp)
|
||||
self._inner_ki.setValue(pid.inner_loop_base.ki)
|
||||
self._inner_kd.setValue(pid.inner_loop_base.kd)
|
||||
self._outer_kp.setValue(pid.outer_loop_base.kp)
|
||||
self._outer_ki.setValue(pid.outer_loop_base.ki)
|
||||
self._outer_kd.setValue(pid.outer_loop_base.kd)
|
||||
self._rot_ff.setValue(pid.rot_feedforward_gain)
|
||||
|
||||
def _load_defaults(self) -> None:
|
||||
self._client_name.clear()
|
||||
self._project_name.clear()
|
||||
self._notes.clear()
|
||||
self._vessel_name.clear()
|
||||
self._vessel_type.setCurrentIndex(0)
|
||||
self._length_m.setValue(30.0)
|
||||
self._displacement_t.setValue(0.0)
|
||||
self._max_speed_kn.setValue(18.0)
|
||||
self._act_type.setCurrentIndex(0)
|
||||
self._act_name.clear()
|
||||
self._max_rudder_deg.setValue(35.0)
|
||||
self._deadband_pct.setValue(5.0)
|
||||
self._max_rate_dps.setValue(4.5)
|
||||
self._max_current_a.setValue(15.0)
|
||||
self._sens_primary_type.setCurrentIndex(0)
|
||||
self._sens_primary_cs.setValue(10)
|
||||
self._sens_primary_fsd.setValue(35.0)
|
||||
self._sens_redundant_enabled.setChecked(False)
|
||||
self._sens_redundant_cs.setValue(11)
|
||||
self._sens_redundant_fsd.setValue(35.0)
|
||||
self._sens_diverge_alarm.setValue(3.0)
|
||||
self._sens_diverge_failover.setValue(6.0)
|
||||
self._inner_kp.setValue(1.5)
|
||||
self._inner_ki.setValue(0.1)
|
||||
self._inner_kd.setValue(0.05)
|
||||
self._outer_kp.setValue(2.0)
|
||||
self._outer_ki.setValue(0.05)
|
||||
self._outer_kd.setValue(0.3)
|
||||
self._rot_ff.setValue(0.4)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _gain_spin(label: str, fl: QFormLayout) -> QDoubleSpinBox:
|
||||
spin = QDoubleSpinBox()
|
||||
spin.setRange(0.0, 100.0)
|
||||
spin.setDecimals(4)
|
||||
spin.setSingleStep(0.01)
|
||||
fl.addRow(label, spin)
|
||||
return spin
|
||||
|
||||
|
||||
def _set_combo(combo: QComboBox, value: object) -> None:
|
||||
for i in range(combo.count()):
|
||||
if combo.itemData(i) == value:
|
||||
combo.setCurrentIndex(i)
|
||||
return
|
||||
@@ -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,33 +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."""
|
||||
@@ -36,75 +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"<b>{self._session.user.display_name}</b><br/>"
|
||||
f"<i>{self._session.role.value}</i>"
|
||||
))
|
||||
layout.addWidget(QLabel("<b>Capabilities</b>"))
|
||||
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"<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.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(self._placeholder_tab(
|
||||
"Project configurator -- Sprint 4.\n\n"
|
||||
"Will let you create / edit a per-vessel ProjectConfig and "
|
||||
"compile it into an .appack for deployment."
|
||||
), "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(
|
||||
"<h2>AR-Autopilot Studio</h2>"
|
||||
"<p>Welcome. Use the <b>Flash Console</b> tab to compile and "
|
||||
"flash firmware to an AR-NMEA-IO board.</p>"
|
||||
"<p>The <b>Project</b> tab (Sprint 4) will let you configure a "
|
||||
"vessel and produce a deployable <code>.appack</code>.</p>"
|
||||
"<p>Every action you take is recorded in the audit log "
|
||||
"(see status bar at the bottom).</p>"
|
||||
))
|
||||
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(
|
||||
"<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
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Tests for AppackCompiler — Sprint 4."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from arautopilot.core.actuator_config import ActuatorConfig, ActuatorType
|
||||
from arautopilot.core.pid_config import PidConfig, PidGains
|
||||
from arautopilot.core.project_config import ProjectConfig
|
||||
from arautopilot.core.sensor_config import (
|
||||
DualRudderSensorConfig,
|
||||
RudderSensorConfig,
|
||||
RudderSensorType,
|
||||
)
|
||||
from arautopilot.core.vessel_config import VesselConfig, VesselType
|
||||
from arautopilot.studio.compiler.appack import AppackCompiler
|
||||
|
||||
|
||||
def _make_project(*, dual_sensors: bool = False) -> ProjectConfig:
|
||||
redundant = (
|
||||
RudderSensorConfig(
|
||||
type=RudderSensorType.AS5048A_SPI,
|
||||
label="Redundant – actuator arm",
|
||||
spi_cs_gpio=11,
|
||||
)
|
||||
if dual_sensors
|
||||
else None
|
||||
)
|
||||
return ProjectConfig(
|
||||
client_name="Test Client",
|
||||
project_name="Test Project",
|
||||
vessel=VesselConfig(
|
||||
name="Test Vessel",
|
||||
type=VesselType.YACHT_MOTOR_PLANEO,
|
||||
length_m=30.0,
|
||||
max_speed_kn=18.0,
|
||||
actuator=ActuatorConfig(
|
||||
type=ActuatorType.HYDRAULIC_REVERSIBLE,
|
||||
max_rudder_angle_deg=35.0,
|
||||
),
|
||||
pid=PidConfig(
|
||||
inner_loop_base=PidGains(kp=1.5, ki=0.1, kd=0.05),
|
||||
outer_loop_base=PidGains(kp=2.0, ki=0.05, kd=0.3),
|
||||
),
|
||||
sensors=DualRudderSensorConfig(
|
||||
primary=RudderSensorConfig(
|
||||
type=RudderSensorType.AS5048A_SPI,
|
||||
spi_cs_gpio=10,
|
||||
),
|
||||
redundant=redundant,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TestAppackCompiler:
|
||||
def test_compile_creates_file(self, tmp_path: Path) -> None:
|
||||
out = tmp_path / "test.appack"
|
||||
compiler = AppackCompiler(_make_project())
|
||||
result = compiler.compile(out)
|
||||
assert result == out
|
||||
assert out.exists()
|
||||
|
||||
def test_appack_is_valid_zip(self, tmp_path: Path) -> None:
|
||||
out = tmp_path / "test.appack"
|
||||
AppackCompiler(_make_project()).compile(out)
|
||||
assert zipfile.is_zipfile(out)
|
||||
|
||||
def test_appack_contains_required_files(self, tmp_path: Path) -> None:
|
||||
out = tmp_path / "test.appack"
|
||||
project = _make_project()
|
||||
AppackCompiler(project).compile(out)
|
||||
prefix = str(project.project_id)
|
||||
with zipfile.ZipFile(out) as zf:
|
||||
names = set(zf.namelist())
|
||||
assert f"{prefix}/manifest.json" in names
|
||||
assert f"{prefix}/project.yaml" in names
|
||||
assert f"{prefix}/firmware_config.h" in names
|
||||
assert f"{prefix}/install_notes.txt" in names
|
||||
|
||||
def test_manifest_fields(self, tmp_path: Path) -> None:
|
||||
out = tmp_path / "test.appack"
|
||||
project = _make_project()
|
||||
AppackCompiler(project).compile(out)
|
||||
prefix = str(project.project_id)
|
||||
with zipfile.ZipFile(out) as zf:
|
||||
manifest = json.loads(zf.read(f"{prefix}/manifest.json"))
|
||||
assert manifest["project_id"] == str(project.project_id)
|
||||
assert manifest["client_name"] == "Test Client"
|
||||
assert manifest["vessel_name"] == "Test Vessel"
|
||||
assert "checksums" in manifest
|
||||
assert "project_yaml_sha256" in manifest["checksums"]
|
||||
assert "firmware_config_h_sha256" in manifest["checksums"]
|
||||
|
||||
def test_firmware_header_contains_defines(self, tmp_path: Path) -> None:
|
||||
out = tmp_path / "test.appack"
|
||||
project = _make_project()
|
||||
AppackCompiler(project).compile(out)
|
||||
prefix = str(project.project_id)
|
||||
with zipfile.ZipFile(out) as zf:
|
||||
header = zf.read(f"{prefix}/firmware_config.h").decode()
|
||||
assert "#define AR_RUDDER_ANGLE_LIMIT_DEG" in header
|
||||
assert "#define AR_PID_INNER_KP" in header
|
||||
assert "#define AR_PID_OUTER_KP" in header
|
||||
assert "#define AR_SENSOR_PRIMARY_TYPE" in header
|
||||
assert "ACTUATOR_HYDRAULIC_REVERSIBLE" in header
|
||||
assert "RUDDER_SENSOR_AS5048A_SPI" in header
|
||||
assert "35.0f" in header
|
||||
|
||||
def test_firmware_header_dual_sensor(self, tmp_path: Path) -> None:
|
||||
out = tmp_path / "test.appack"
|
||||
project = _make_project(dual_sensors=True)
|
||||
AppackCompiler(project).compile(out)
|
||||
prefix = str(project.project_id)
|
||||
with zipfile.ZipFile(out) as zf:
|
||||
header = zf.read(f"{prefix}/firmware_config.h").decode()
|
||||
notes = zf.read(f"{prefix}/install_notes.txt").decode()
|
||||
assert "#define AR_SENSOR_REDUNDANT 1" in header
|
||||
assert "GPIO CS=11" in notes
|
||||
|
||||
def test_firmware_header_single_sensor(self, tmp_path: Path) -> None:
|
||||
out = tmp_path / "test.appack"
|
||||
project = _make_project(dual_sensors=False)
|
||||
AppackCompiler(project).compile(out)
|
||||
prefix = str(project.project_id)
|
||||
with zipfile.ZipFile(out) as zf:
|
||||
header = zf.read(f"{prefix}/firmware_config.h").decode()
|
||||
assert "#define AR_SENSOR_REDUNDANT 0" in header
|
||||
|
||||
def test_project_yaml_roundtrips(self, tmp_path: Path) -> None:
|
||||
out = tmp_path / "test.appack"
|
||||
project = _make_project()
|
||||
AppackCompiler(project).compile(out)
|
||||
prefix = str(project.project_id)
|
||||
with zipfile.ZipFile(out) as zf:
|
||||
yaml_text = zf.read(f"{prefix}/project.yaml").decode()
|
||||
restored = ProjectConfig.from_yaml(yaml_text)
|
||||
assert restored.client_name == project.client_name
|
||||
assert restored.vessel.name == project.vessel.name
|
||||
|
||||
def test_empty_client_name_raises(self) -> None:
|
||||
project = _make_project()
|
||||
object.__setattr__(project, "client_name", " ")
|
||||
with pytest.raises(ValueError, match="client_name"):
|
||||
AppackCompiler(project)
|
||||
|
||||
def test_output_parent_created(self, tmp_path: Path) -> None:
|
||||
out = tmp_path / "deep" / "nested" / "output.appack"
|
||||
AppackCompiler(_make_project()).compile(out)
|
||||
assert out.exists()
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Tests for RudderSensorConfig and DualRudderSensorConfig — Sprint 4."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from arautopilot.core.sensor_config import (
|
||||
DualRudderSensorConfig,
|
||||
RudderSensorConfig,
|
||||
RudderSensorType,
|
||||
)
|
||||
|
||||
|
||||
def _primary(
|
||||
sensor_type: RudderSensorType = RudderSensorType.AS5048A_SPI,
|
||||
cs: int = 10,
|
||||
fsd: float = 35.0,
|
||||
) -> RudderSensorConfig:
|
||||
return RudderSensorConfig(type=sensor_type, spi_cs_gpio=cs, full_scale_deg=fsd)
|
||||
|
||||
|
||||
def _redundant(cs: int = 11) -> RudderSensorConfig:
|
||||
return RudderSensorConfig(
|
||||
type=RudderSensorType.AS5048A_SPI,
|
||||
label="Redundant – actuator arm",
|
||||
spi_cs_gpio=cs,
|
||||
full_scale_deg=35.0,
|
||||
)
|
||||
|
||||
|
||||
class TestRudderSensorConfig:
|
||||
def test_as5048a_defaults(self) -> None:
|
||||
s = _primary()
|
||||
assert s.type == RudderSensorType.AS5048A_SPI
|
||||
assert s.spi_cs_gpio == 10
|
||||
assert s.full_scale_deg == 35.0
|
||||
assert s.zero_offset_deg == 0.0
|
||||
assert s.divergence_alarm_deg == 3.0
|
||||
assert s.divergence_failover_deg == 6.0
|
||||
|
||||
def test_potentiometer_type(self) -> None:
|
||||
s = _primary(sensor_type=RudderSensorType.POTENTIOMETER)
|
||||
assert s.type == RudderSensorType.POTENTIOMETER
|
||||
|
||||
def test_gpio_bounds(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
RudderSensorConfig(type=RudderSensorType.AS5048A_SPI, spi_cs_gpio=40)
|
||||
with pytest.raises(ValidationError):
|
||||
RudderSensorConfig(type=RudderSensorType.AS5048A_SPI, spi_cs_gpio=-1)
|
||||
|
||||
def test_full_scale_bounds(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
_primary(fsd=0.0)
|
||||
with pytest.raises(ValidationError):
|
||||
_primary(fsd=46.0)
|
||||
|
||||
def test_label_optional(self) -> None:
|
||||
s = RudderSensorConfig(type=RudderSensorType.AS5048A_SPI)
|
||||
assert s.label == ""
|
||||
|
||||
def test_extra_fields_forbidden(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
RudderSensorConfig(
|
||||
type=RudderSensorType.AS5048A_SPI,
|
||||
unknown_field="boom", # type: ignore[call-arg]
|
||||
)
|
||||
|
||||
def test_roundtrip_json(self) -> None:
|
||||
s = _primary()
|
||||
restored = RudderSensorConfig.model_validate_json(s.model_dump_json())
|
||||
assert restored == s
|
||||
|
||||
|
||||
class TestDualRudderSensorConfig:
|
||||
def test_single_sensor_mode(self) -> None:
|
||||
d = DualRudderSensorConfig(primary=_primary())
|
||||
assert d.redundant is None
|
||||
assert not d.has_redundancy
|
||||
|
||||
def test_dual_sensor_mode(self) -> None:
|
||||
d = DualRudderSensorConfig(primary=_primary(), redundant=_redundant())
|
||||
assert d.redundant is not None
|
||||
assert d.has_redundancy
|
||||
|
||||
def test_different_cs_pins(self) -> None:
|
||||
d = DualRudderSensorConfig(primary=_primary(cs=10), redundant=_redundant(cs=11))
|
||||
assert d.primary.spi_cs_gpio == 10
|
||||
assert d.redundant is not None
|
||||
assert d.redundant.spi_cs_gpio == 11
|
||||
|
||||
def test_roundtrip_yaml(self) -> None:
|
||||
import yaml
|
||||
|
||||
d = DualRudderSensorConfig(primary=_primary(), redundant=_redundant())
|
||||
data = yaml.safe_dump(d.model_dump(mode="json"))
|
||||
restored = DualRudderSensorConfig.model_validate(yaml.safe_load(data))
|
||||
assert restored == d
|
||||
@@ -0,0 +1,11 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
rules:
|
||||
- always_declare_return_types
|
||||
- avoid_print
|
||||
- prefer_const_constructors
|
||||
- prefer_const_declarations
|
||||
- prefer_final_fields
|
||||
- sort_child_properties_last
|
||||
- use_key_in_widget_constructors
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"_comment": "AR Electronics — Paleta oficial de marca. Usar en todas las apps.",
|
||||
"_version": "1.0.0",
|
||||
|
||||
"background": {
|
||||
"primary": "#0D1B2A",
|
||||
"secondary": "#1A2744",
|
||||
"card": "#162035",
|
||||
"surface": "#1E2D47"
|
||||
},
|
||||
|
||||
"accent": {
|
||||
"blue_electric": "#2563EB",
|
||||
"blue_neon": "#4A9FE8",
|
||||
"blue_dark": "#1A47A8",
|
||||
"blue_glow": "#60B8FF"
|
||||
},
|
||||
|
||||
"text": {
|
||||
"primary": "#E2E8F0",
|
||||
"secondary": "#A8B5C4",
|
||||
"muted": "#6B7A8D",
|
||||
"on_accent": "#FFFFFF"
|
||||
},
|
||||
|
||||
"status": {
|
||||
"ok": "#22C55E",
|
||||
"warning": "#F59E0B",
|
||||
"alarm": "#EF4444",
|
||||
"info": "#4A9FE8"
|
||||
},
|
||||
|
||||
"metallic": {
|
||||
"silver_light": "#C8D2DC",
|
||||
"silver_mid": "#A8B5C4",
|
||||
"silver_dark": "#6B7A8D"
|
||||
},
|
||||
|
||||
"flutter": {
|
||||
"_comment": "Valores listos para copiar en ThemeData de Flutter",
|
||||
"primaryColor": "0xFF2563EB",
|
||||
"scaffoldBackground": "0xFF0D1B2A",
|
||||
"cardColor": "0xFF162035",
|
||||
"accentColor": "0xFF4A9FE8"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
@@ -0,0 +1,205 @@
|
||||
// =============================================================================
|
||||
// data/autopilot_state.dart — Live autopilot data model
|
||||
// =============================================================================
|
||||
//
|
||||
// Dual-mode: demo simulation (Sprint 4) or live USB serial (Sprint 7).
|
||||
//
|
||||
// Default constructor starts in demo mode (animated vessel simulation).
|
||||
// Call [connectToSerial] to switch to live data from the AR-Concentrador.
|
||||
// If the serial link drops, the state automatically falls back to demo.
|
||||
//
|
||||
// The public API (fields + methods) is identical in both modes — the UI
|
||||
// never needs to know which mode is active; it reads [isConnected] for
|
||||
// the status indicator only.
|
||||
// =============================================================================
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../services/concentrador_service.dart';
|
||||
import '../services/parp_codec.dart';
|
||||
import '../widgets/themed/mode_selector.dart';
|
||||
|
||||
class AutopilotState extends ChangeNotifier {
|
||||
// ── Navigation data ──────────────────────────────────────────────────────────
|
||||
double headingDeg = 125.0;
|
||||
double setpointDeg = 125.0;
|
||||
double rudderDeg = 0.0;
|
||||
double sogKn = 6.2;
|
||||
double cogDeg = 127.0;
|
||||
double rotDpm = 0.0;
|
||||
double depthM = 42.5;
|
||||
|
||||
// ── Autopilot state ──────────────────────────────────────────────────────────
|
||||
AutopilotMode mode = AutopilotMode.standby;
|
||||
|
||||
/// True when the USB serial link to the concentrador is active.
|
||||
bool isConnected = false;
|
||||
|
||||
// ── Serial service ───────────────────────────────────────────────────────────
|
||||
ConcentradorService? _service;
|
||||
|
||||
// ── Demo simulation ──────────────────────────────────────────────────────────
|
||||
Timer? _demoTimer;
|
||||
final _rng = math.Random();
|
||||
|
||||
AutopilotState() {
|
||||
_startDemo();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serial connection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Connect to the AR-Concentrador via USB serial.
|
||||
///
|
||||
/// Stops the demo timer and switches to live data.
|
||||
/// Falls back to demo automatically if the link drops.
|
||||
///
|
||||
/// Throws if the ports cannot be opened (caller should catch and show error).
|
||||
Future<void> connectToSerial({
|
||||
required String rxPort,
|
||||
required String txPort,
|
||||
int stationId = 2,
|
||||
}) async {
|
||||
_demoTimer?.cancel();
|
||||
_demoTimer = null;
|
||||
|
||||
_service?.disconnect();
|
||||
_service = ConcentradorService(
|
||||
rxPort: rxPort,
|
||||
txPort: txPort,
|
||||
stationId: stationId,
|
||||
);
|
||||
_service!.onStatus = _onSerialStatus;
|
||||
_service!.onConnectionChanged = _onConnectionChanged;
|
||||
|
||||
await _service!.connect(); // may throw — caller handles
|
||||
}
|
||||
|
||||
/// Disconnect the serial link and return to demo mode.
|
||||
Future<void> disconnectSerial() async {
|
||||
await _service?.disconnect();
|
||||
_service = null;
|
||||
_startDemo();
|
||||
}
|
||||
|
||||
void _onConnectionChanged(bool connected) {
|
||||
isConnected = connected;
|
||||
if (!connected) {
|
||||
// Link dropped — fall back to animated demo so the UI stays alive.
|
||||
_startDemo();
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _onSerialStatus(ParpStatus status) {
|
||||
isConnected = true;
|
||||
headingDeg = status.headingDeg;
|
||||
setpointDeg = status.setpointDeg;
|
||||
rudderDeg = status.rudderDeg;
|
||||
mode = status.mode;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Demo simulation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void _startDemo() {
|
||||
_demoTimer?.cancel();
|
||||
_demoTimer = Timer.periodic(const Duration(milliseconds: 500), (_) => _tick());
|
||||
}
|
||||
|
||||
void _tick() {
|
||||
switch (mode) {
|
||||
case AutopilotMode.standby:
|
||||
headingDeg = (headingDeg + (_rng.nextDouble() - 0.5) * 0.5) % 360;
|
||||
if (headingDeg < 0) headingDeg += 360;
|
||||
rudderDeg = (rudderDeg + (_rng.nextDouble() - 0.5) * 0.8).clamp(-5.0, 5.0);
|
||||
rotDpm = rudderDeg * 0.3 + (_rng.nextDouble() - 0.5) * 0.2;
|
||||
|
||||
case AutopilotMode.headingHold:
|
||||
case AutopilotMode.trackKeep:
|
||||
final error = _angleDiff(setpointDeg, headingDeg);
|
||||
rudderDeg = (error * 1.2 + (_rng.nextDouble() - 0.5) * 0.5).clamp(-35.0, 35.0);
|
||||
headingDeg = (headingDeg + error * 0.025 + (_rng.nextDouble() - 0.5) * 0.08) % 360;
|
||||
if (headingDeg < 0) headingDeg += 360;
|
||||
rotDpm = error * 0.4;
|
||||
}
|
||||
|
||||
cogDeg = (headingDeg + rudderDeg * 0.15 + (_rng.nextDouble() - 0.5) * 0.3) % 360;
|
||||
if (cogDeg < 0) cogDeg += 360;
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
double _angleDiff(double target, double current) {
|
||||
double d = (target - current) % 360;
|
||||
if (d > 180) d -= 360;
|
||||
if (d < -180) d += 360;
|
||||
return d;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands — work in both demo and serial modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void engage() {
|
||||
setpointDeg = headingDeg;
|
||||
mode = AutopilotMode.headingHold;
|
||||
_service?.sendEngage(headingDeg);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void disengage() {
|
||||
mode = AutopilotMode.standby;
|
||||
_service?.sendDisengage();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void adjustSetpoint(double deltaDeg) {
|
||||
if (mode != AutopilotMode.headingHold) return;
|
||||
setpointDeg = (setpointDeg + deltaDeg) % 360;
|
||||
if (setpointDeg < 0) setpointDeg += 360;
|
||||
|
||||
// Route to the appropriate serial command
|
||||
if (_service != null && isConnected) {
|
||||
if (deltaDeg == -10) _service!.sendPortTen(setpointDeg);
|
||||
else if (deltaDeg == -1) _service!.sendPortOne(setpointDeg);
|
||||
else if (deltaDeg == 1) _service!.sendStbdOne(setpointDeg);
|
||||
else if (deltaDeg == 10) _service!.sendStbdTen(setpointDeg);
|
||||
else _service!.sendSetHeading(setpointDeg);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void selectMode(AutopilotMode newMode) {
|
||||
switch (newMode) {
|
||||
case AutopilotMode.standby:
|
||||
disengage();
|
||||
case AutopilotMode.headingHold:
|
||||
engage();
|
||||
case AutopilotMode.trackKeep:
|
||||
mode = AutopilotMode.trackKeep;
|
||||
setpointDeg = headingDeg;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Port discovery (delegate to service layer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static List<String> availablePorts() => ConcentradorService.availablePorts();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_demoTimer?.cancel();
|
||||
_service?.disconnect();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'data/autopilot_state.dart';
|
||||
import 'theme/theme_provider.dart';
|
||||
import 'screens/cockpit/cockpit_screen.dart';
|
||||
import 'screens/settings/appearance_settings.dart';
|
||||
import 'screens/settings/port_settings_screen.dart';
|
||||
|
||||
// SharedPreferences keys — must match port_settings_screen.dart
|
||||
const _kRxKey = 'port.rx';
|
||||
const _kTxKey = 'port.tx';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Load persisted theme before first frame.
|
||||
final themeProvider = await AutopilotThemeProvider.load();
|
||||
|
||||
// Create state object early so we can attempt auto-connect.
|
||||
final autopilotState = AutopilotState();
|
||||
|
||||
// Attempt to reconnect to the last-used COM ports silently.
|
||||
// If the ports are not available (hardware unplugged, different PC, etc.)
|
||||
// the exception is swallowed and the UI stays in demo mode.
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final rxPort = prefs.getString(_kRxKey);
|
||||
final txPort = prefs.getString(_kTxKey);
|
||||
if (rxPort != null && txPort != null) {
|
||||
await autopilotState.connectToSerial(rxPort: rxPort, txPort: txPort);
|
||||
}
|
||||
} catch (_) {
|
||||
// Hardware not available — stay in demo mode.
|
||||
}
|
||||
|
||||
runApp(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<AutopilotThemeProvider>.value(
|
||||
value: themeProvider,
|
||||
),
|
||||
ChangeNotifierProvider<AutopilotState>.value(
|
||||
value: autopilotState,
|
||||
),
|
||||
],
|
||||
child: const ArAutopilotApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class ArAutopilotApp extends StatelessWidget {
|
||||
const ArAutopilotApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'AR-Autopilot',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(useMaterial3: true),
|
||||
initialRoute: CockpitScreen.routeName,
|
||||
routes: {
|
||||
CockpitScreen.routeName: (_) => const CockpitScreen(),
|
||||
AppearanceSettingsScreen.routeName: (_) => const AppearanceSettingsScreen(),
|
||||
PortSettingsScreen.routeName: (_) => const PortSettingsScreen(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
// =============================================================================
|
||||
// screens/cockpit/cockpit_screen.dart — AR-Autopilot main cockpit view
|
||||
// =============================================================================
|
||||
//
|
||||
// Sprint 4: static layout with demo data (AutopilotState simulation).
|
||||
// Sprint 7: AutopilotState internals replaced by Modbus RTU over USB serial.
|
||||
//
|
||||
// Layout (top → bottom):
|
||||
// TopBar — logo, title, NMEA/GPS status, settings gear
|
||||
// ModeSelector — STANDBY | HDG HOLD | TRACK
|
||||
// CompassRose — dominant visual; shows heading + setpoint tick
|
||||
// DataStrip — SOG · COG · ROT · DEPTH
|
||||
// HeadingAdjust — << < [SET 048°] > >>
|
||||
// RudderRow — label + horizontal rudder indicator
|
||||
// ActionRow — [ ENGAGE ] [ DISENGAGE ]
|
||||
// =============================================================================
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../data/autopilot_state.dart';
|
||||
import '../../theme/autopilot_theme.dart';
|
||||
import '../../theme/theme_provider.dart';
|
||||
import '../../widgets/themed/compass_rose.dart';
|
||||
import '../../widgets/themed/disengage_button.dart';
|
||||
import '../../widgets/themed/engage_button.dart';
|
||||
import '../../widgets/themed/heading_adjust_bar.dart';
|
||||
import '../../widgets/themed/mode_selector.dart';
|
||||
import '../../widgets/themed/rudder_indicator.dart';
|
||||
import '../../widgets/themed/status_chip.dart';
|
||||
import '../settings/appearance_settings.dart';
|
||||
import '../settings/port_settings_screen.dart';
|
||||
|
||||
class CockpitScreen extends StatelessWidget {
|
||||
const CockpitScreen({super.key});
|
||||
|
||||
static const String routeName = '/';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = context.watch<AutopilotThemeProvider>().current;
|
||||
final ap = context.watch<AutopilotState>();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: theme.background,
|
||||
body: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
curve: Curves.easeInOut,
|
||||
decoration: theme.backgroundDecoration,
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_TopBar(theme: theme, ap: ap),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 0),
|
||||
child: ModeSelector(
|
||||
activeMode: ap.mode,
|
||||
onModeSelected: ap.selectMode,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _CockpitBody(theme: theme, ap: ap),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Top bar ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class _TopBar extends StatelessWidget {
|
||||
const _TopBar({required this.theme, required this.ap});
|
||||
|
||||
final AutopilotTheme theme;
|
||||
final AutopilotState ap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 54,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.backgroundMid.withValues(alpha: 0.9),
|
||||
border: Border(
|
||||
bottom: BorderSide(color: theme.panelBorder, width: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Logo — triple-tap cycles themes (design invariant from Sprint 3)
|
||||
_ThemeCycleLogo(theme: theme),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'AR-AUTOPILOT',
|
||||
style: TextStyle(
|
||||
color: theme.textMain,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.8,
|
||||
),
|
||||
),
|
||||
if (!ap.isConnected) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.warnColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: theme.warnColor.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'DEMO',
|
||||
style: TextStyle(
|
||||
color: theme.warnColor,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
StatusChip(
|
||||
theme: theme,
|
||||
label: 'NMEA',
|
||||
status: ap.isConnected ? StatusLevel.ok : StatusLevel.off,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
StatusChip(
|
||||
theme: theme,
|
||||
label: 'GPS',
|
||||
status: ap.isConnected ? StatusLevel.ok : StatusLevel.warn,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(
|
||||
Icons.settings_outlined,
|
||||
color: theme.textMuted,
|
||||
size: 22,
|
||||
),
|
||||
color: theme.backgroundMid,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: BorderSide(color: theme.panelBorder),
|
||||
),
|
||||
onSelected: (route) => Navigator.pushNamed(context, route),
|
||||
itemBuilder: (_) => [
|
||||
PopupMenuItem(
|
||||
value: PortSettingsScreen.routeName,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.usb, color: theme.accentMid, size: 18),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Puertos COM',
|
||||
style: TextStyle(color: theme.textMain, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: AppearanceSettingsScreen.routeName,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.palette_outlined,
|
||||
color: theme.accentMid, size: 18),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Apariencia',
|
||||
style: TextStyle(color: theme.textMain, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// AR Electronics logo that cycles themes on triple-tap.
|
||||
///
|
||||
/// Counts taps within 600 ms; three rapid taps rotate through the 4 themes.
|
||||
/// This implements the shortcut described in [AppearanceSettingsScreen].
|
||||
class _ThemeCycleLogo extends StatefulWidget {
|
||||
const _ThemeCycleLogo({required this.theme});
|
||||
|
||||
final AutopilotTheme theme;
|
||||
|
||||
@override
|
||||
State<_ThemeCycleLogo> createState() => _ThemeCycleLogoState();
|
||||
}
|
||||
|
||||
class _ThemeCycleLogoState extends State<_ThemeCycleLogo> {
|
||||
static const _kWindow = Duration(milliseconds: 600);
|
||||
static const _kIds = ['light', 'cyan', 'wine', 'ochre'];
|
||||
|
||||
int _taps = 0;
|
||||
Timer? _resetTimer;
|
||||
|
||||
void _onTap() {
|
||||
_resetTimer?.cancel();
|
||||
_taps++;
|
||||
if (_taps >= 3) {
|
||||
_taps = 0;
|
||||
final provider = context.read<AutopilotThemeProvider>();
|
||||
final idx = _kIds.indexOf(provider.current.id);
|
||||
provider.setTheme(_kIds[(idx + 1) % _kIds.length]);
|
||||
} else {
|
||||
_resetTimer = Timer(_kWindow, () => _taps = 0);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_resetTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: _onTap,
|
||||
child: Image.asset(
|
||||
'assets/images/ar_logo_full.png',
|
||||
height: 34,
|
||||
errorBuilder: (_, __, ___) => Icon(
|
||||
Icons.anchor,
|
||||
color: widget.theme.accentMid,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cockpit body ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _CockpitBody extends StatelessWidget {
|
||||
const _CockpitBody({required this.theme, required this.ap});
|
||||
|
||||
final AutopilotTheme theme;
|
||||
final AutopilotState ap;
|
||||
|
||||
bool get _engaged => ap.mode != AutopilotMode.standby;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Compass scales between 200 and 320 px based on available width.
|
||||
final compassSize =
|
||||
(constraints.maxWidth * 0.68).clamp(200.0, 320.0);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
|
||||
child: Column(
|
||||
children: [
|
||||
// ── Compass rose ─────────────────────────────────────────────
|
||||
Center(
|
||||
child: CompassRose(
|
||||
headingDeg: ap.headingDeg,
|
||||
setPointDeg: _engaged ? ap.setpointDeg : null,
|
||||
size: compassSize,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// ── Instrument data strip ────────────────────────────────────
|
||||
_DataStrip(theme: theme, ap: ap),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// ── Heading setpoint + adjust buttons ────────────────────────
|
||||
HeadingAdjustBar(
|
||||
setpointDeg: ap.setpointDeg,
|
||||
enabled: _engaged,
|
||||
onAdjust: ap.adjustSetpoint,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// ── Rudder indicator ─────────────────────────────────────────
|
||||
_RudderRow(theme: theme, rudderDeg: ap.rudderDeg),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── ENGAGE / DISENGAGE row ───────────────────────────────────
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: EngageButton(
|
||||
onPressed: !_engaged ? ap.engage : null,
|
||||
enabled: !_engaged,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: DisengageButton(
|
||||
onPressed: _engaged ? ap.disengage : null,
|
||||
enabled: _engaged,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data strip ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _DataStrip extends StatelessWidget {
|
||||
const _DataStrip({required this.theme, required this.ap});
|
||||
|
||||
final AutopilotTheme theme;
|
||||
final AutopilotState ap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
gradient: theme.panelBackground,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: theme.panelBorder),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_DataCell(
|
||||
theme: theme,
|
||||
label: 'SOG',
|
||||
value: '${ap.sogKn.toStringAsFixed(1)} kn',
|
||||
),
|
||||
_VerticalDivider(theme: theme),
|
||||
_DataCell(
|
||||
theme: theme,
|
||||
label: 'COG',
|
||||
value: '${ap.cogDeg.toStringAsFixed(0).padLeft(3, '0')}°',
|
||||
),
|
||||
_VerticalDivider(theme: theme),
|
||||
_DataCell(
|
||||
theme: theme,
|
||||
label: 'ROT',
|
||||
value: '${ap.rotDpm.toStringAsFixed(1)}°/m',
|
||||
),
|
||||
_VerticalDivider(theme: theme),
|
||||
_DataCell(
|
||||
theme: theme,
|
||||
label: 'PROF',
|
||||
value: '${ap.depthM.toStringAsFixed(1)} m',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VerticalDivider extends StatelessWidget {
|
||||
const _VerticalDivider({required this.theme});
|
||||
|
||||
final AutopilotTheme theme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
Container(width: 1, height: 30, color: theme.panelBorder);
|
||||
}
|
||||
|
||||
class _DataCell extends StatelessWidget {
|
||||
const _DataCell({
|
||||
required this.theme,
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final AutopilotTheme theme;
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: theme.textMuted,
|
||||
fontSize: 9,
|
||||
letterSpacing: 1.2,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: theme.textMain,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w300,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rudder row ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _RudderRow extends StatelessWidget {
|
||||
const _RudderRow({required this.theme, required this.rudderDeg});
|
||||
|
||||
final AutopilotTheme theme;
|
||||
final double rudderDeg;
|
||||
|
||||
String _label(double deg) {
|
||||
if (deg.abs() < 0.5) return 'CENTRO';
|
||||
final side = deg < 0 ? 'BABOR' : 'ESTRIBOR';
|
||||
return '${deg.abs().toStringAsFixed(1)}° $side';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'TIMÓN',
|
||||
style: TextStyle(
|
||||
color: theme.textMuted,
|
||||
fontSize: 9,
|
||||
letterSpacing: 1.2,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
_label(rudderDeg),
|
||||
style: TextStyle(
|
||||
color: theme.textSoft,
|
||||
fontSize: 12,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
RudderIndicator(rudderDeg: rudderDeg),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../theme/autopilot_theme.dart';
|
||||
import '../../theme/theme_provider.dart';
|
||||
import '../../theme/theme_registry.dart';
|
||||
|
||||
/// Appearance settings screen — "Ajustes → Apariencia".
|
||||
///
|
||||
/// Shows a 4-card grid where each card is a 200×120 px miniature preview
|
||||
/// of the theme (compass rose colours + a sample DODGE button).
|
||||
///
|
||||
/// Tapping a card applies the theme **immediately** with a 400 ms transition.
|
||||
///
|
||||
/// ## Triple-tap shortcut
|
||||
/// The AR-Autopilot logo in the topbar supports triple-tap to cycle through
|
||||
/// all 4 themes without opening this screen. Implement the gesture in the
|
||||
/// topbar widget, calling `provider.setTheme(nextId)`.
|
||||
///
|
||||
/// ## Sprint 5 placeholders
|
||||
/// "Auto day/night" and "Ambient light sensor" toggles are rendered but
|
||||
/// disabled — they are implemented in Sprint 5.
|
||||
class AppearanceSettingsScreen extends StatelessWidget {
|
||||
const AppearanceSettingsScreen({super.key});
|
||||
|
||||
static const String routeName = '/settings/appearance';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<AutopilotThemeProvider>();
|
||||
final theme = provider.current;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: theme.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: theme.backgroundMid,
|
||||
foregroundColor: theme.textMain,
|
||||
elevation: 0,
|
||||
title: Text(
|
||||
'Apariencia',
|
||||
style: TextStyle(color: theme.textMain, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
body: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
curve: Curves.easeInOut,
|
||||
decoration: theme.backgroundDecoration,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
_SectionLabel(label: 'Tema visual', theme: theme),
|
||||
const SizedBox(height: 12),
|
||||
// ── 4-card theme selector grid ───────────────────────────────
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: ThemeRegistry.all.map((t) {
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: _ThemeCard(
|
||||
theme: t,
|
||||
isSelected: t.id == theme.id,
|
||||
onTap: () => provider.setTheme(t.id),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Divider(color: theme.panelBorder, thickness: 1, height: 1),
|
||||
const SizedBox(height: 20),
|
||||
// ── Sprint 5 toggles (disabled placeholders) ─────────────────
|
||||
_ToggleRow(
|
||||
theme: theme,
|
||||
label: 'Cambio automático día/noche',
|
||||
subtitle: 'De día usa "Claro", de noche usa el seleccionado arriba.',
|
||||
value: false,
|
||||
onChanged: null, // Sprint 5
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_ToggleRow(
|
||||
theme: theme,
|
||||
label: 'Sensor de luz ambiental',
|
||||
subtitle:
|
||||
'Si el display tiene sensor, ajusta automáticamente entre claro y el oscuro.',
|
||||
value: false,
|
||||
onChanged: null, // Sprint 5
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private widgets ───────────────────────────────────────────────────────────
|
||||
|
||||
class _SectionLabel extends StatelessWidget {
|
||||
const _SectionLabel({required this.label, required this.theme});
|
||||
final String label;
|
||||
final AutopilotTheme theme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Text(
|
||||
label.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: theme.textMuted,
|
||||
fontSize: 11,
|
||||
letterSpacing: 1.2,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ThemeCard extends StatelessWidget {
|
||||
const _ThemeCard({
|
||||
required this.theme,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final AutopilotTheme theme;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
curve: Curves.easeInOut,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
gradient: theme.panelBackground,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isSelected ? theme.accentMid : theme.panelBorder,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
boxShadow: isSelected
|
||||
? theme.glowShadow(theme.accentGlowColor, theme.accentGlowRadius)
|
||||
: null,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_MiniCompass(theme: theme),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
theme.displayName,
|
||||
style: TextStyle(
|
||||
color: theme.textMain,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
width: 7,
|
||||
height: 7,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isSelected ? theme.accentMid : theme.textDisabled,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 40×40 miniature compass preview for the theme card.
|
||||
class _MiniCompass extends StatelessWidget {
|
||||
const _MiniCompass({required this.theme});
|
||||
final AutopilotTheme theme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: CustomPaint(painter: _MiniCompassPainter(theme: theme)),
|
||||
);
|
||||
}
|
||||
|
||||
class _MiniCompassPainter extends CustomPainter {
|
||||
const _MiniCompassPainter({required this.theme});
|
||||
final AutopilotTheme theme;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final r = size.width / 2 - 2;
|
||||
|
||||
// Outer ring
|
||||
canvas.drawCircle(
|
||||
center,
|
||||
r,
|
||||
Paint()
|
||||
..color = theme.accentMid
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.5,
|
||||
);
|
||||
|
||||
// Heading arc (accent colour)
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: center, radius: r - 4),
|
||||
-1.57, // top
|
||||
1.3,
|
||||
false,
|
||||
Paint()
|
||||
..color = theme.accentLight
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2.5
|
||||
..strokeCap = StrokeCap.round,
|
||||
);
|
||||
|
||||
// North mark (warm colour — nautical convention)
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: center, radius: r),
|
||||
-1.65,
|
||||
0.4,
|
||||
false,
|
||||
Paint()
|
||||
..color = theme.northColor
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 3
|
||||
..strokeCap = StrokeCap.round,
|
||||
);
|
||||
|
||||
// Set-point tick (operator intent — always amber)
|
||||
final sx = center.dx + (r - 3) * 0.65;
|
||||
final sy = center.dy - (r - 3) * 0.65;
|
||||
final ex = center.dx + r * 0.65;
|
||||
final ey = center.dy - r * 0.65;
|
||||
canvas.drawLine(
|
||||
Offset(sx, sy),
|
||||
Offset(ex, ey),
|
||||
Paint()
|
||||
..color = theme.setLight
|
||||
..strokeWidth = 2.5
|
||||
..strokeCap = StrokeCap.round,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_MiniCompassPainter old) => old.theme != theme;
|
||||
}
|
||||
|
||||
class _ToggleRow extends StatelessWidget {
|
||||
const _ToggleRow({
|
||||
required this.theme,
|
||||
required this.label,
|
||||
required this.subtitle,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final AutopilotTheme theme;
|
||||
final String label;
|
||||
final String subtitle;
|
||||
final bool value;
|
||||
final ValueChanged<bool>? onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = onChanged != null;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: enabled ? theme.textMain : theme.textDisabled,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(color: theme.textMuted, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Switch(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
activeColor: theme.accentMid,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// =============================================================================
|
||||
// screens/settings/port_settings_screen.dart — COM port configuration
|
||||
// =============================================================================
|
||||
//
|
||||
// Lets the operator choose which COM port is the concentrador RX-OUT
|
||||
// (the one the display reads from) and which is the TX-IN
|
||||
// (the one the display writes commands to).
|
||||
//
|
||||
// Ports are persisted in SharedPreferences and auto-applied at startup.
|
||||
//
|
||||
// Layout:
|
||||
// • Two dropdowns — RX port, TX port — populated from available COM ports
|
||||
// • [Conectar] button — tries to open both ports; shows error if it fails
|
||||
// • [Desconectar] button — drops to demo mode
|
||||
// • Status row — green ● CONECTADO / grey ● DEMO
|
||||
// =============================================================================
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../data/autopilot_state.dart';
|
||||
import '../../theme/autopilot_theme.dart';
|
||||
import '../../theme/theme_provider.dart';
|
||||
|
||||
class PortSettingsScreen extends StatefulWidget {
|
||||
const PortSettingsScreen({super.key});
|
||||
|
||||
static const String routeName = '/settings/ports';
|
||||
|
||||
@override
|
||||
State<PortSettingsScreen> createState() => _PortSettingsScreenState();
|
||||
}
|
||||
|
||||
class _PortSettingsScreenState extends State<PortSettingsScreen> {
|
||||
static const _kRxKey = 'port.rx';
|
||||
static const _kTxKey = 'port.tx';
|
||||
|
||||
List<String> _ports = [];
|
||||
String? _rxPort;
|
||||
String? _txPort;
|
||||
bool _connecting = false;
|
||||
String? _errorMsg;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPorts();
|
||||
}
|
||||
|
||||
Future<void> _loadPorts() async {
|
||||
final available = AutopilotState.availablePorts();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
setState(() {
|
||||
_ports = available;
|
||||
_rxPort = prefs.getString(_kRxKey);
|
||||
_txPort = prefs.getString(_kTxKey);
|
||||
// If saved port no longer exists, clear it.
|
||||
if (_rxPort != null && !_ports.contains(_rxPort)) _rxPort = null;
|
||||
if (_txPort != null && !_ports.contains(_txPort)) _txPort = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _connect() async {
|
||||
if (_rxPort == null || _txPort == null) return;
|
||||
setState(() {
|
||||
_connecting = true;
|
||||
_errorMsg = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final ap = context.read<AutopilotState>();
|
||||
await ap.connectToSerial(rxPort: _rxPort!, txPort: _txPort!);
|
||||
|
||||
// Persist the working configuration
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_kRxKey, _rxPort!);
|
||||
await prefs.setString(_kTxKey, _txPort!);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Conectado — RX: $_rxPort TX: $_txPort'),
|
||||
backgroundColor: Colors.green.shade700,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _errorMsg = 'Error al abrir puertos: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _connecting = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disconnect() async {
|
||||
final ap = context.read<AutopilotState>();
|
||||
await ap.disconnectSerial();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Desconectado — modo demo activo'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = context.watch<AutopilotThemeProvider>().current;
|
||||
final ap = context.watch<AutopilotState>();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: theme.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: theme.backgroundMid,
|
||||
foregroundColor: theme.textMain,
|
||||
elevation: 0,
|
||||
title: Text(
|
||||
'Conexión al Concentrador',
|
||||
style: TextStyle(color: theme.textMain, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
body: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
decoration: theme.backgroundDecoration,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
// ── Status ────────────────────────────────────────────────────────
|
||||
_StatusRow(theme: theme, connected: ap.isConnected),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── Port selection ────────────────────────────────────────────────
|
||||
_SectionLabel(label: 'Puerto RX (leer datos del concentrador)', theme: theme),
|
||||
const SizedBox(height: 8),
|
||||
_PortDropdown(
|
||||
theme: theme,
|
||||
value: _rxPort,
|
||||
ports: _ports,
|
||||
onChanged: (v) => setState(() => _rxPort = v),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_SectionLabel(label: 'Puerto TX (enviar comandos al concentrador)', theme: theme),
|
||||
const SizedBox(height: 8),
|
||||
_PortDropdown(
|
||||
theme: theme,
|
||||
value: _txPort,
|
||||
ports: _ports,
|
||||
onChanged: (v) => setState(() => _txPort = v),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Note
|
||||
Text(
|
||||
'Conectar el cable USB-OUT del concentrador al puerto RX,\n'
|
||||
'y el USB-IN al puerto TX. Ambos a 115 200 baud, 8N1.',
|
||||
style: TextStyle(color: theme.textMuted, fontSize: 11),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── Error message ─────────────────────────────────────────────────
|
||||
if (_errorMsg != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.disengageBackground.colors.first.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: theme.disengageBorder.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Text(
|
||||
_errorMsg!,
|
||||
style: TextStyle(color: theme.disengageText, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// ── Buttons ───────────────────────────────────────────────────────
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _ActionButton(
|
||||
theme: theme,
|
||||
label: _connecting ? 'Conectando…' : 'Conectar',
|
||||
enabled: !_connecting && _rxPort != null && _txPort != null,
|
||||
primary: true,
|
||||
onPressed: _connect,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _ActionButton(
|
||||
theme: theme,
|
||||
label: 'Desconectar',
|
||||
enabled: ap.isConnected && !_connecting,
|
||||
primary: false,
|
||||
onPressed: _disconnect,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Refresh ports ─────────────────────────────────────────────────
|
||||
TextButton(
|
||||
onPressed: _loadPorts,
|
||||
child: Text(
|
||||
'Actualizar lista de puertos',
|
||||
style: TextStyle(color: theme.textMuted, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private widgets ───────────────────────────────────────────────────────────
|
||||
|
||||
class _StatusRow extends StatelessWidget {
|
||||
const _StatusRow({required this.theme, required this.connected});
|
||||
final AutopilotTheme theme;
|
||||
final bool connected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = connected ? theme.okColor : theme.textMuted;
|
||||
final label = connected ? 'CONECTADO AL CONCENTRADOR' : 'MODO DEMO (sin conexión)';
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: color,
|
||||
boxShadow: connected
|
||||
? [BoxShadow(color: color.withValues(alpha: 0.5), blurRadius: 8)]
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionLabel extends StatelessWidget {
|
||||
const _SectionLabel({required this.label, required this.theme});
|
||||
final String label;
|
||||
final AutopilotTheme theme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Text(
|
||||
label.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: theme.textMuted,
|
||||
fontSize: 10,
|
||||
letterSpacing: 1.2,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _PortDropdown extends StatelessWidget {
|
||||
const _PortDropdown({
|
||||
required this.theme,
|
||||
required this.value,
|
||||
required this.ports,
|
||||
required this.onChanged,
|
||||
});
|
||||
final AutopilotTheme theme;
|
||||
final String? value;
|
||||
final List<String> ports;
|
||||
final ValueChanged<String?> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
gradient: theme.panelBackground,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: theme.panelBorder),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: ports.contains(value) ? value : null,
|
||||
hint: Text(
|
||||
ports.isEmpty ? 'Sin puertos disponibles' : 'Seleccionar puerto…',
|
||||
style: TextStyle(color: theme.textMuted, fontSize: 13),
|
||||
),
|
||||
dropdownColor: theme.backgroundMid,
|
||||
style: TextStyle(color: theme.textMain, fontSize: 13),
|
||||
icon: Icon(Icons.expand_more, color: theme.textMuted),
|
||||
isExpanded: true,
|
||||
items: ports
|
||||
.map((p) => DropdownMenuItem(value: p, child: Text(p)))
|
||||
.toList(),
|
||||
onChanged: onChanged,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionButton extends StatelessWidget {
|
||||
const _ActionButton({
|
||||
required this.theme,
|
||||
required this.label,
|
||||
required this.enabled,
|
||||
required this.primary,
|
||||
required this.onPressed,
|
||||
});
|
||||
final AutopilotTheme theme;
|
||||
final String label;
|
||||
final bool enabled;
|
||||
final bool primary;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = primary ? theme.okColor : theme.accentMid;
|
||||
return GestureDetector(
|
||||
onTap: enabled ? onPressed : null,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: enabled
|
||||
? color.withValues(alpha: 0.15)
|
||||
: theme.backgroundDeep,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
border: Border.all(
|
||||
color: enabled ? color : theme.panelBorder,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: enabled ? color : theme.textDisabled,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// =============================================================================
|
||||
// services/concentrador_service.dart — USB serial link to AR-Concentrador
|
||||
// =============================================================================
|
||||
//
|
||||
// The AR-Concentrador exposes two separate CH340N virtual COM ports:
|
||||
// rxPort — USB-OUT: concentrador broadcasts $PARP,STATUS + NMEA at 2 Hz
|
||||
// txPort — USB-IN : display sends $PARP commands to the concentrador
|
||||
//
|
||||
// Both ports run at 115 200 baud, 8N1, no flow control.
|
||||
//
|
||||
// Usage:
|
||||
// final svc = ConcentradorService(rxPort: 'COM3', txPort: 'COM4', stationId: 2);
|
||||
// svc.onStatus = (status) { ... };
|
||||
// svc.onConnectionChanged = (connected) { ... };
|
||||
// await svc.connect();
|
||||
// svc.sendEngage(headingDeg: 125.0);
|
||||
// svc.disconnect();
|
||||
// =============================================================================
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_libserialport/flutter_libserialport.dart';
|
||||
|
||||
import 'parp_codec.dart';
|
||||
|
||||
/// Callback type for status updates from the concentrador.
|
||||
typedef StatusCallback = void Function(ParpStatus status);
|
||||
|
||||
/// Callback type for connection state changes.
|
||||
typedef ConnectionCallback = void Function(bool connected);
|
||||
|
||||
class ConcentradorService {
|
||||
ConcentradorService({
|
||||
required this.rxPort,
|
||||
required this.txPort,
|
||||
this.stationId = 2,
|
||||
this.baudRate = 115200,
|
||||
});
|
||||
|
||||
final String rxPort;
|
||||
final String txPort;
|
||||
final int stationId;
|
||||
final int baudRate;
|
||||
|
||||
/// Called whenever a valid $PARP,STATUS sentence is received.
|
||||
StatusCallback? onStatus;
|
||||
|
||||
/// Called when the connection state changes.
|
||||
ConnectionCallback? onConnectionChanged;
|
||||
|
||||
SerialPort? _rx;
|
||||
SerialPort? _tx;
|
||||
SerialPortReader? _reader;
|
||||
StreamSubscription<Uint8List>? _rxSub;
|
||||
|
||||
bool _connected = false;
|
||||
bool get isConnected => _connected;
|
||||
|
||||
// Accumulation buffer for partial sentences
|
||||
final StringBuffer _buf = StringBuffer();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Open both serial ports and start listening for STATUS sentences.
|
||||
///
|
||||
/// Throws [SerialPortError] if either port cannot be opened.
|
||||
Future<void> connect() async {
|
||||
await disconnect(); // clean slate
|
||||
|
||||
_rx = SerialPort(rxPort);
|
||||
_tx = SerialPort(txPort);
|
||||
|
||||
_configPort(_rx!);
|
||||
_configPort(_tx!);
|
||||
|
||||
if (!_rx!.openRead()) {
|
||||
throw SerialPortError('Cannot open RX port $rxPort');
|
||||
}
|
||||
if (!_tx!.openWrite()) {
|
||||
throw SerialPortError('Cannot open TX port $txPort');
|
||||
}
|
||||
|
||||
_reader = SerialPortReader(_rx!);
|
||||
_rxSub = _reader!.stream.listen(
|
||||
_onData,
|
||||
onError: (_) => _handleDisconnect(),
|
||||
onDone: _handleDisconnect,
|
||||
);
|
||||
|
||||
_connected = true;
|
||||
onConnectionChanged?.call(true);
|
||||
}
|
||||
|
||||
/// Close both ports gracefully.
|
||||
Future<void> disconnect() async {
|
||||
_rxSub?.cancel();
|
||||
_rxSub = null;
|
||||
_reader = null;
|
||||
|
||||
_rx?.close();
|
||||
_tx?.close();
|
||||
_rx = null;
|
||||
_tx = null;
|
||||
|
||||
if (_connected) {
|
||||
_connected = false;
|
||||
onConnectionChanged?.call(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command senders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void sendEngage(double headingDeg) =>
|
||||
_send(ParpCodec.engage(stationId, headingDeg));
|
||||
|
||||
void sendDisengage() =>
|
||||
_send(ParpCodec.disengage(stationId));
|
||||
|
||||
void sendSetHeading(double headingDeg) =>
|
||||
_send(ParpCodec.setHeading(stationId, headingDeg));
|
||||
|
||||
void sendPortOne(double setpointDeg) =>
|
||||
_send(ParpCodec.portOne(stationId, setpointDeg));
|
||||
|
||||
void sendStbdOne(double setpointDeg) =>
|
||||
_send(ParpCodec.stbdOne(stationId, setpointDeg));
|
||||
|
||||
void sendPortTen(double setpointDeg) =>
|
||||
_send(ParpCodec.portTen(stationId, setpointDeg));
|
||||
|
||||
void sendStbdTen(double setpointDeg) =>
|
||||
_send(ParpCodec.stbdTen(stationId, setpointDeg));
|
||||
|
||||
void sendReqCmd() =>
|
||||
_send(ParpCodec.reqCmd(stationId));
|
||||
|
||||
void sendRelCmd() =>
|
||||
_send(ParpCodec.relCmd(stationId));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Port listing (for settings screen)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// All serial ports currently visible to the OS.
|
||||
static List<String> availablePorts() => SerialPort.availablePorts;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void _configPort(SerialPort port) {
|
||||
final cfg = SerialPortConfig()
|
||||
..baudRate = baudRate
|
||||
..bits = 8
|
||||
..stopBits = 1
|
||||
..parity = SerialPortParity.none
|
||||
..setFlowControl(SerialPortFlowControl.none);
|
||||
port.config = cfg;
|
||||
}
|
||||
|
||||
void _onData(Uint8List data) {
|
||||
_buf.write(String.fromCharCodes(data));
|
||||
final raw = _buf.toString();
|
||||
final lines = raw.split('\n');
|
||||
|
||||
// Keep the last (possibly incomplete) chunk in the buffer.
|
||||
_buf.clear();
|
||||
_buf.write(lines.last);
|
||||
|
||||
for (int i = 0; i < lines.length - 1; i++) {
|
||||
final line = lines[i].trim();
|
||||
if (line.isEmpty) continue;
|
||||
final status = ParpCodec.parseStatus(line);
|
||||
if (status != null) {
|
||||
onStatus?.call(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _send(String sentence) {
|
||||
if (_tx == null || !_connected) return;
|
||||
try {
|
||||
_tx!.write(Uint8List.fromList(sentence.codeUnits));
|
||||
} catch (_) {
|
||||
_handleDisconnect();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDisconnect() {
|
||||
disconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// =============================================================================
|
||||
// services/parp_codec.dart — $PARP sentence parser and builder
|
||||
// =============================================================================
|
||||
//
|
||||
// Protocol reference: docs/concentrador_protocol.md
|
||||
//
|
||||
// Inbound ($PARP,STATUS — broadcast by concentrador at 2 Hz):
|
||||
// $PARP,STATUS,<mode>,<setpoint>,<heading>,<rudder>,<commander>*XX\r\n
|
||||
// Example: $PARP,STATUS,HEADING_HOLD,125.0,125.3,2.5,01*3A
|
||||
//
|
||||
// Outbound ($PARP commands — sent by display to concentrador):
|
||||
// $PARP,<CMD>,<value>,<station_id>*XX\r\n
|
||||
// Example: $PARP,ENGAGE,0.0,02*XX
|
||||
// =============================================================================
|
||||
|
||||
import '../widgets/themed/mode_selector.dart';
|
||||
|
||||
/// Parsed content of a $PARP,STATUS sentence.
|
||||
class ParpStatus {
|
||||
const ParpStatus({
|
||||
required this.mode,
|
||||
required this.setpointDeg,
|
||||
required this.headingDeg,
|
||||
required this.rudderDeg,
|
||||
required this.commander,
|
||||
});
|
||||
|
||||
final AutopilotMode mode;
|
||||
final double setpointDeg;
|
||||
final double headingDeg;
|
||||
final double rudderDeg;
|
||||
final int commander; // station ID of the current commander (0 = none)
|
||||
}
|
||||
|
||||
/// Stateless NMEA $PARP sentence codec.
|
||||
abstract final class ParpCodec {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inbound parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse a complete NMEA sentence string (with or without leading $).
|
||||
///
|
||||
/// Returns [ParpStatus] if the sentence is a valid $PARP,STATUS with correct
|
||||
/// checksum. Returns null for any other sentence type or if CRC fails.
|
||||
static ParpStatus? parseStatus(String sentence) {
|
||||
// Strip whitespace / CRLF
|
||||
final s = sentence.trim();
|
||||
|
||||
// Locate checksum delimiter
|
||||
final starIdx = s.lastIndexOf('*');
|
||||
if (starIdx < 0) return null;
|
||||
|
||||
final body = s.startsWith('\$') ? s.substring(1, starIdx) : s.substring(0, starIdx);
|
||||
final crcHex = s.substring(starIdx + 1);
|
||||
|
||||
// Verify checksum
|
||||
if (!_crcOk(body, crcHex)) return null;
|
||||
|
||||
// Tokenise
|
||||
final parts = body.split(',');
|
||||
if (parts.length < 7) return null;
|
||||
if (parts[0] != 'PARP' || parts[1] != 'STATUS') return null;
|
||||
|
||||
final mode = _parseMode(parts[2]);
|
||||
final setpoint = double.tryParse(parts[3]);
|
||||
final heading = double.tryParse(parts[4]);
|
||||
final rudder = double.tryParse(parts[5]);
|
||||
final commander = int.tryParse(parts[6]);
|
||||
|
||||
if (setpoint == null || heading == null || rudder == null || commander == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ParpStatus(
|
||||
mode: mode,
|
||||
setpointDeg: setpoint,
|
||||
headingDeg: heading,
|
||||
rudderDeg: rudder,
|
||||
commander: commander,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Outbound builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static String engage(int stationId, double currentHeadingDeg) =>
|
||||
_build('ENGAGE', currentHeadingDeg, stationId);
|
||||
|
||||
static String disengage(int stationId) =>
|
||||
_build('DISENGAGE', 0.0, stationId);
|
||||
|
||||
static String setHeading(int stationId, double headingDeg) =>
|
||||
_build('SETHEADING', headingDeg, stationId);
|
||||
|
||||
static String portOne(int stationId, double currentSetpoint) =>
|
||||
_build('PORTONE', currentSetpoint, stationId);
|
||||
|
||||
static String stbdOne(int stationId, double currentSetpoint) =>
|
||||
_build('STBDONE', currentSetpoint, stationId);
|
||||
|
||||
static String portTen(int stationId, double currentSetpoint) =>
|
||||
_build('PORTTEN', currentSetpoint, stationId);
|
||||
|
||||
static String stbdTen(int stationId, double currentSetpoint) =>
|
||||
_build('STBDTEN', currentSetpoint, stationId);
|
||||
|
||||
static String reqCmd(int stationId) =>
|
||||
_build('REQCMD', 0.0, stationId);
|
||||
|
||||
static String relCmd(int stationId) =>
|
||||
_build('RELCMD', 0.0, stationId);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static String _build(String cmd, double value, int stationId) {
|
||||
final body = 'PARP,$cmd,${value.toStringAsFixed(1)},'
|
||||
'${stationId.toString().padLeft(2, '0')}';
|
||||
final crc = _computeCrc(body);
|
||||
return '\$$body*${crc.toRadixString(16).toUpperCase().padLeft(2, '0')}\r\n';
|
||||
}
|
||||
|
||||
static int _computeCrc(String body) {
|
||||
int crc = 0;
|
||||
for (final ch in body.codeUnits) {
|
||||
crc ^= ch;
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
static bool _crcOk(String body, String crcHex) {
|
||||
final expected = _computeCrc(body);
|
||||
final received = int.tryParse(crcHex, radix: 16);
|
||||
return received != null && received == expected;
|
||||
}
|
||||
|
||||
static AutopilotMode _parseMode(String raw) => switch (raw) {
|
||||
'HEADING_HOLD' => AutopilotMode.headingHold,
|
||||
'TRACK' => AutopilotMode.trackKeep,
|
||||
_ => AutopilotMode.standby,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Visual theme token set for the AR-Autopilot display app.
|
||||
///
|
||||
/// Every widget reads design tokens from [AutopilotTheme] via
|
||||
/// [AutopilotThemeProvider]. No widget hard-codes colors.
|
||||
///
|
||||
/// ## Design invariants (apply to ALL themes)
|
||||
/// 1. **DISENGAGE** is always the highest-contrast element on screen.
|
||||
/// If the palette makes red ambiguous, it changes to amber (wine theme).
|
||||
/// 2. **Action buttons** (DODGE, ±1°, ±10°) must be legible at rest —
|
||||
/// operators work with gloves in rain; hover/tap is not reliable.
|
||||
/// 3. **Compass heading text** uses [textMain] with [accentGlowColor] glow.
|
||||
/// 4. **Set-point** always uses [setLight] (amber/gold) — cognitively stable
|
||||
/// across palettes; represents "operator intent".
|
||||
/// 5. **North mark** is always a warm color ([northColor]) — nautical convention.
|
||||
/// 6. **Touch targets**: 48×48 px nominal, 60×60 px for critical controls.
|
||||
/// 7. **No glow in light mode**: [accentGlowRadius] == 0.0 for `light` theme.
|
||||
class AutopilotTheme {
|
||||
const AutopilotTheme({
|
||||
required this.id,
|
||||
required this.displayName,
|
||||
// Backgrounds
|
||||
required this.background,
|
||||
required this.backgroundMid,
|
||||
required this.backgroundDeep,
|
||||
required this.backgroundDeepest,
|
||||
this.backgroundGradient,
|
||||
// Panels
|
||||
required this.panelBackground,
|
||||
required this.panelBorder,
|
||||
// Text
|
||||
required this.textMain,
|
||||
required this.textMuted,
|
||||
required this.textSoft,
|
||||
required this.textDisabled,
|
||||
// Accent — heading arc, active mode indicator, rudder indicator
|
||||
required this.accentLight,
|
||||
required this.accentMid,
|
||||
required this.accentDark,
|
||||
required this.accentGlowRadius,
|
||||
required this.accentGlowColor,
|
||||
// Set-point / desired heading
|
||||
required this.setLight,
|
||||
required this.setDark,
|
||||
required this.setGlow,
|
||||
// Semantic states
|
||||
required this.okColor,
|
||||
required this.warnColor,
|
||||
required this.northColor,
|
||||
// DISENGAGE — always high contrast, always unambiguous
|
||||
required this.disengageBackground,
|
||||
required this.disengageText,
|
||||
required this.disengageBorder,
|
||||
required this.disengageGlow,
|
||||
// Action buttons (DODGE, ±1°, ±10°)
|
||||
required this.actionButtonBackground,
|
||||
required this.actionButtonBorder,
|
||||
required this.actionButtonText,
|
||||
required this.actionButtonGlow,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String displayName;
|
||||
|
||||
// ── Backgrounds ────────────────────────────────────────────────────────────
|
||||
/// Dominant background color. Used as solid fill (light) or radial
|
||||
/// gradient center (dark themes). See [backgroundDecoration].
|
||||
final Color background;
|
||||
final Color backgroundMid;
|
||||
final Color backgroundDeep;
|
||||
final Color backgroundDeepest;
|
||||
|
||||
/// Radial gradient for dark themes; `null` for the light theme
|
||||
/// (which uses a solid [background]).
|
||||
final Gradient? backgroundGradient;
|
||||
|
||||
// ── Panels ──────────────────────────────────────────────────────────────────
|
||||
final Gradient panelBackground;
|
||||
final Color panelBorder;
|
||||
|
||||
// ── Text ────────────────────────────────────────────────────────────────────
|
||||
final Color textMain;
|
||||
final Color textMuted;
|
||||
final Color textSoft;
|
||||
final Color textDisabled;
|
||||
|
||||
// ── Accent ──────────────────────────────────────────────────────────────────
|
||||
final Color accentLight;
|
||||
final Color accentMid;
|
||||
final Color accentDark;
|
||||
|
||||
/// Blur radius for accent glow effect. Zero in light mode (no glow).
|
||||
final double accentGlowRadius;
|
||||
final Color accentGlowColor;
|
||||
|
||||
// ── Set-point ───────────────────────────────────────────────────────────────
|
||||
final Color setLight;
|
||||
final Color setDark;
|
||||
final Color setGlow;
|
||||
|
||||
// ── Semantic states ─────────────────────────────────────────────────────────
|
||||
/// GPS OK, NMEA sentence valid, SOG within range.
|
||||
final Color okColor;
|
||||
|
||||
/// Heading error, cross-track error warning.
|
||||
final Color warnColor;
|
||||
|
||||
/// North mark on the compass rose. Always a warm color (never blue).
|
||||
final Color northColor;
|
||||
|
||||
// ── DISENGAGE ───────────────────────────────────────────────────────────────
|
||||
final Gradient disengageBackground;
|
||||
final Color disengageText;
|
||||
final Color disengageBorder;
|
||||
final Color disengageGlow;
|
||||
|
||||
// ── Action buttons (DODGE, ±1°, ±10°) ──────────────────────────────────────
|
||||
final Gradient actionButtonBackground;
|
||||
final Color actionButtonBorder;
|
||||
final Color actionButtonText;
|
||||
final Color actionButtonGlow;
|
||||
|
||||
// ── Computed helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/// [BoxDecoration] for the full-screen background container.
|
||||
BoxDecoration get backgroundDecoration => backgroundGradient != null
|
||||
? BoxDecoration(gradient: backgroundGradient)
|
||||
: BoxDecoration(color: background);
|
||||
|
||||
/// [BoxShadow] list for accented glow (empty in light mode).
|
||||
List<BoxShadow> glowShadow(Color color, double radius) => radius > 0
|
||||
? [BoxShadow(color: color, blurRadius: radius, spreadRadius: 1)]
|
||||
: const [];
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'autopilot_theme.dart';
|
||||
import 'theme_registry.dart';
|
||||
|
||||
/// Manages the active [AutopilotTheme] and persists the user's selection.
|
||||
///
|
||||
/// ## Persistence
|
||||
/// - Stored locally in [SharedPreferences] under [_kThemeKey].
|
||||
/// - **NOT sent to the ESP32** — firmware is colour-agnostic.
|
||||
/// - **NOT synchronised between displays** — each display (bridge / engine room)
|
||||
/// maintains its own independent preference.
|
||||
///
|
||||
/// ## Usage
|
||||
/// ```dart
|
||||
/// // In main.dart — wrap the widget tree:
|
||||
/// ChangeNotifierProvider<AutopilotThemeProvider>(
|
||||
/// create: (_) => await AutopilotThemeProvider.load(),
|
||||
/// child: const AutopilotApp(),
|
||||
/// )
|
||||
///
|
||||
/// // Read the current theme anywhere in the tree:
|
||||
/// final theme = context.watch<AutopilotThemeProvider>().current;
|
||||
///
|
||||
/// // Switch theme (e.g. from the Appearance screen):
|
||||
/// await context.read<AutopilotThemeProvider>().setTheme('wine');
|
||||
/// ```
|
||||
class AutopilotThemeProvider extends ChangeNotifier {
|
||||
static const String _kThemeKey = 'autopilot.theme.id';
|
||||
|
||||
AutopilotTheme _current;
|
||||
|
||||
AutopilotThemeProvider(this._current);
|
||||
|
||||
/// The currently active [AutopilotTheme].
|
||||
AutopilotTheme get current => _current;
|
||||
|
||||
/// Loads the persisted theme preference from [SharedPreferences].
|
||||
///
|
||||
/// Falls back to [ThemeRegistry.defaultId] if no preference is stored
|
||||
/// (e.g. first launch) or the stored id is no longer registered.
|
||||
static Future<AutopilotThemeProvider> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final id = prefs.getString(_kThemeKey) ?? ThemeRegistry.defaultId;
|
||||
return AutopilotThemeProvider(ThemeRegistry.byId(id));
|
||||
}
|
||||
|
||||
/// Switches to the theme identified by [id] and persists the selection.
|
||||
///
|
||||
/// Unknown ids silently fall back to [ThemeRegistry.defaultId].
|
||||
/// Notifies all listeners after the switch so the UI rebuilds.
|
||||
Future<void> setTheme(String id) async {
|
||||
_current = ThemeRegistry.byId(id);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_kThemeKey, _current.id);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'autopilot_theme.dart';
|
||||
import 'themes/light_theme.dart';
|
||||
import 'themes/cyan_theme.dart';
|
||||
import 'themes/wine_theme.dart';
|
||||
import 'themes/ochre_theme.dart';
|
||||
|
||||
/// Central registry of all factory [AutopilotTheme]s.
|
||||
///
|
||||
/// Themes are keyed by their [AutopilotTheme.id] string.
|
||||
///
|
||||
/// ## Display order
|
||||
/// `light → cyan → wine → ochre` (shown in the Appearance selector grid).
|
||||
///
|
||||
/// ## Custom themes (Sprint 9 / Tier 3)
|
||||
/// Architecture is ready for integrators to ship custom YAML themes in
|
||||
/// `/config/themes/*.yaml`. [ThemeRegistry] will merge them with the
|
||||
/// factory set at startup. Not active until Sprint 9.
|
||||
abstract final class ThemeRegistry {
|
||||
/// Theme id used when no preference is stored (first run).
|
||||
static const String defaultId = 'cyan';
|
||||
|
||||
static final Map<String, AutopilotTheme> _registry = {
|
||||
lightTheme.id: lightTheme,
|
||||
cyanTheme.id: cyanTheme,
|
||||
wineTheme.id: wineTheme,
|
||||
ochreTheme.id: ochreTheme,
|
||||
};
|
||||
|
||||
/// All available themes in UI display order.
|
||||
static List<AutopilotTheme> get all => const [
|
||||
lightTheme,
|
||||
cyanTheme,
|
||||
wineTheme,
|
||||
ochreTheme,
|
||||
];
|
||||
|
||||
/// Returns the theme for [id].
|
||||
/// Falls back to [defaultId] (cyan) if [id] is not registered.
|
||||
static AutopilotTheme byId(String id) =>
|
||||
_registry[id] ?? _registry[defaultId]!;
|
||||
|
||||
/// Whether [id] belongs to a registered theme.
|
||||
static bool contains(String id) => _registry.containsKey(id);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../autopilot_theme.dart';
|
||||
|
||||
/// **cyan** — Cockpit cian (default)
|
||||
///
|
||||
/// The default factory theme. Matches the visual language of professional
|
||||
/// marine electronics (Raymarine, Garmin, Simrad). Neon cyan on deep navy.
|
||||
/// Used for twilight / night operation with ambient lighting.
|
||||
const AutopilotTheme cyanTheme = AutopilotTheme(
|
||||
id: 'cyan',
|
||||
displayName: 'Cockpit cian',
|
||||
|
||||
background: Color(0xFF0D1822),
|
||||
backgroundMid: Color(0xFF0F172A),
|
||||
backgroundDeep: Color(0xFF0A1220),
|
||||
backgroundDeepest: Color(0xFF020610),
|
||||
backgroundGradient: RadialGradient(
|
||||
colors: [Color(0xFF0D1822), Color(0xFF050810)],
|
||||
stops: [0.0, 0.7],
|
||||
),
|
||||
|
||||
panelBackground: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xE6142030), Color(0xE608101C)], // rgba(20,32,48,0.9) → rgba(8,16,28,0.9)
|
||||
),
|
||||
panelBorder: Color(0x4038BDF8), // rgba(56,189,248,0.25)
|
||||
|
||||
textMain: Color(0xFFE0F2FE),
|
||||
textMuted: Color(0xFF64748B),
|
||||
textSoft: Color(0xFFCBD5E1),
|
||||
textDisabled: Color(0xFF475569),
|
||||
|
||||
accentLight: Color(0xFF7DD3FC),
|
||||
accentMid: Color(0xFF0EA5E9),
|
||||
accentDark: Color(0xFF0369A1),
|
||||
accentGlowRadius: 16.0,
|
||||
accentGlowColor: Color(0x9938BDF8), // rgba(56,189,248,0.6)
|
||||
|
||||
setLight: Color(0xFFFBBF24),
|
||||
setDark: Color(0xFFA16207),
|
||||
setGlow: Color(0x66FBBF24),
|
||||
|
||||
okColor: Color(0xFF4ADE80),
|
||||
warnColor: Color(0xFFFBBF24),
|
||||
northColor: Color(0xFFF87171),
|
||||
|
||||
disengageBackground: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFDC2626), Color(0xFF7F1D1D)],
|
||||
),
|
||||
disengageText: Color(0xFFFFFFFF),
|
||||
disengageBorder: Color(0xFFF87171),
|
||||
disengageGlow: Color(0x80DC2626),
|
||||
|
||||
actionButtonBackground: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFF1E4A72), Color(0xFF0C2540)], // improved contrast
|
||||
),
|
||||
actionButtonBorder: Color(0xFF38BDF8), // accented border, 1px
|
||||
actionButtonText: Color(0xFFE0F2FE),
|
||||
actionButtonGlow: Color(0x667DD3FC), // subtle text-shadow
|
||||
);
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../autopilot_theme.dart';
|
||||
|
||||
/// **light** — Claro (día)
|
||||
///
|
||||
/// Daytime operation under direct sunlight. Navy-blue accent on cream white.
|
||||
/// No glow effects — bloom is invisible and distracting in bright ambient light.
|
||||
const AutopilotTheme lightTheme = AutopilotTheme(
|
||||
id: 'light',
|
||||
displayName: 'Claro (día)',
|
||||
|
||||
background: Color(0xFFF5F4EE),
|
||||
backgroundMid: Color(0xFFFFFFFF),
|
||||
backgroundDeep: Color(0xFFF1EFE8),
|
||||
backgroundDeepest: Color(0xFFE8E6DD),
|
||||
backgroundGradient: null, // solid background — no gradient in light mode
|
||||
|
||||
panelBackground: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFFFFFFF), Color(0xFFFFFFFF)],
|
||||
),
|
||||
panelBorder: Color(0x1F000000), // rgba(0,0,0,0.12)
|
||||
|
||||
textMain: Color(0xFF2C2C2A),
|
||||
textMuted: Color(0xFF6B6862),
|
||||
textSoft: Color(0xFF2C2C2A),
|
||||
textDisabled: Color(0xFFB4B2A9),
|
||||
|
||||
accentLight: Color(0xFF378ADD),
|
||||
accentMid: Color(0xFF185FA5),
|
||||
accentDark: Color(0xFF0C447C),
|
||||
accentGlowRadius: 0.0, // no glow in daytime mode
|
||||
accentGlowColor: Colors.transparent,
|
||||
|
||||
setLight: Color(0xFFCA8A04),
|
||||
setDark: Color(0xFF854F0B),
|
||||
setGlow: Color(0x33CA8A04),
|
||||
|
||||
okColor: Color(0xFF15803D),
|
||||
warnColor: Color(0xFFCA8A04),
|
||||
northColor: Color(0xFFB91C1C),
|
||||
|
||||
disengageBackground: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFDC2626), Color(0xFF991B1B)],
|
||||
),
|
||||
disengageText: Color(0xFFFFFFFF),
|
||||
disengageBorder: Color(0xFFEF4444),
|
||||
disengageGlow: Color(0x4DDC2626),
|
||||
|
||||
actionButtonBackground: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFFFFFFF), Color(0xFFF1EFE8)],
|
||||
),
|
||||
actionButtonBorder: Color(0x33000000), // 0.2 alpha — more visible than 0.12
|
||||
actionButtonText: Color(0xFF2C2C2A),
|
||||
actionButtonGlow: Colors.transparent,
|
||||
);
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../autopilot_theme.dart';
|
||||
|
||||
/// **ochre** — Ocre
|
||||
///
|
||||
/// Classic cabin / aged leather aesthetic. Warm amber-gold accent on deep
|
||||
/// brown. DISENGAGE returns to red (which contrasts well against ochre).
|
||||
/// okColor is lime-green to stand out from the dominant gold palette.
|
||||
const AutopilotTheme ochreTheme = AutopilotTheme(
|
||||
id: 'ochre',
|
||||
displayName: 'Ocre',
|
||||
|
||||
background: Color(0xFF2A1A08),
|
||||
backgroundMid: Color(0xFF2A1A08),
|
||||
backgroundDeep: Color(0xFF1A1004),
|
||||
backgroundDeepest: Color(0xFF080502),
|
||||
backgroundGradient: RadialGradient(
|
||||
colors: [Color(0xFF2A1A08), Color(0xFF0F0904)],
|
||||
stops: [0.0, 0.7],
|
||||
),
|
||||
|
||||
panelBackground: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xEB32200C), Color(0xEB181008)], // rgba(50,32,12,0.92) → rgba(24,16,8,0.92)
|
||||
),
|
||||
panelBorder: Color(0x52D97706), // rgba(217,119,6,0.32)
|
||||
|
||||
textMain: Color(0xFFFEF3C7),
|
||||
textMuted: Color(0xFFA8855A),
|
||||
textSoft: Color(0xFFFCD34D),
|
||||
textDisabled: Color(0xFF6B5435),
|
||||
|
||||
accentLight: Color(0xFFFBBF24),
|
||||
accentMid: Color(0xFFD97706),
|
||||
accentDark: Color(0xFF92400E),
|
||||
accentGlowRadius: 18.0,
|
||||
accentGlowColor: Color(0xB3D97706),
|
||||
|
||||
setLight: Color(0xFFFDE68A),
|
||||
setDark: Color(0xFFB45309),
|
||||
setGlow: Color(0x66FDE68A),
|
||||
|
||||
okColor: Color(0xFF84CC16), // lime-green — contrasts with gold
|
||||
warnColor: Color(0xFFFDE68A),
|
||||
northColor: Color(0xFFFDE047),
|
||||
|
||||
// DISENGAGE: red — contrasts well on ochre background
|
||||
disengageBackground: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFDC2626), Color(0xFF7F1D1D)],
|
||||
),
|
||||
disengageText: Color(0xFFFFFFFF),
|
||||
disengageBorder: Color(0xFFF87171),
|
||||
disengageGlow: Color(0x99DC2626),
|
||||
|
||||
actionButtonBackground: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFF7A4E1A), Color(0xFF3A240C)],
|
||||
),
|
||||
actionButtonBorder: Color(0xFFFBBF24),
|
||||
actionButtonText: Color(0xFFFEF3C7),
|
||||
actionButtonGlow: Color(0x66FBBF24),
|
||||
);
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../autopilot_theme.dart';
|
||||
|
||||
/// **wine** — Vinotinto
|
||||
///
|
||||
/// Premium yacht / aesthetic preference palette. Deep crimson backgrounds
|
||||
/// with rose accent. IMPORTANT: DISENGAGE switches from red to amber-gold
|
||||
/// because red blends into the wine cockpit and loses its emergency signal.
|
||||
/// The amber still carries the "caution/critical action" cognitive association.
|
||||
const AutopilotTheme wineTheme = AutopilotTheme(
|
||||
id: 'wine',
|
||||
displayName: 'Vinotinto',
|
||||
|
||||
background: Color(0xFF2A0A14),
|
||||
backgroundMid: Color(0xFF2A0A14),
|
||||
backgroundDeep: Color(0xFF1A0610),
|
||||
backgroundDeepest: Color(0xFF080205),
|
||||
backgroundGradient: RadialGradient(
|
||||
colors: [Color(0xFF2A0A14), Color(0xFF0F0408)],
|
||||
stops: [0.0, 0.7],
|
||||
),
|
||||
|
||||
panelBackground: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xE630121C), Color(0xE614080E)], // rgba(48,18,28,0.9) → rgba(20,8,14,0.9)
|
||||
),
|
||||
panelBorder: Color(0x4DBE1746), // rgba(190,23,70,0.3)
|
||||
|
||||
textMain: Color(0xFFFDE0E7),
|
||||
textMuted: Color(0xFF8A5560),
|
||||
textSoft: Color(0xFFF9A8B8),
|
||||
textDisabled: Color(0xFF6A3848),
|
||||
|
||||
accentLight: Color(0xFFFB7185),
|
||||
accentMid: Color(0xFFE11D48),
|
||||
accentDark: Color(0xFF881337),
|
||||
accentGlowRadius: 18.0,
|
||||
accentGlowColor: Color(0xB3E11D48), // rgba(225,29,72,0.7)
|
||||
|
||||
setLight: Color(0xFFFBBF24),
|
||||
setDark: Color(0xFFA16207),
|
||||
setGlow: Color(0x66FBBF24),
|
||||
|
||||
okColor: Color(0xFFFBBF24), // gold not green — keeps warm palette
|
||||
warnColor: Color(0xFFFBBF24),
|
||||
northColor: Color(0xFFFDE047), // yellow not red — red is lost on wine background
|
||||
|
||||
// DISENGAGE: amber-gold instead of red — highest contrast on wine cockpit
|
||||
disengageBackground: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFFBBF24), Color(0xFFB45309)],
|
||||
),
|
||||
disengageText: Color(0xFF1C0A02), // near-black — maximum contrast on amber
|
||||
disengageBorder: Color(0xFFFDE047),
|
||||
disengageGlow: Color(0x99FBBF24),
|
||||
|
||||
actionButtonBackground: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFF7A1F3A), Color(0xFF3A0E1C)], // improved contrast
|
||||
),
|
||||
actionButtonBorder: Color(0xFFFB7185), // vivid rose border
|
||||
actionButtonText: Color(0xFFFDE0E7),
|
||||
actionButtonGlow: Color(0x66FB7185),
|
||||
);
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../theme/autopilot_theme.dart';
|
||||
import '../../theme/theme_provider.dart';
|
||||
|
||||
/// Full compass rose widget — the dominant visual element of the cockpit.
|
||||
///
|
||||
/// Displays:
|
||||
/// - Rotating compass ring with degree markings
|
||||
/// - Heading arc in [AutopilotTheme.accentLight]
|
||||
/// - Set-point tick in [AutopilotTheme.setLight]
|
||||
/// - North mark in [AutopilotTheme.northColor]
|
||||
/// - Centre heading readout in [AutopilotTheme.textMain] with accent glow
|
||||
///
|
||||
/// [headingDeg] is the current vessel heading (0–359.9°, magnetic).
|
||||
/// [setPointDeg] is the desired heading (the autopilot target).
|
||||
class CompassRose extends StatelessWidget {
|
||||
const CompassRose({
|
||||
super.key,
|
||||
required this.headingDeg,
|
||||
this.setPointDeg,
|
||||
this.size = 280,
|
||||
});
|
||||
|
||||
final double headingDeg;
|
||||
final double? setPointDeg;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = context.watch<AutopilotThemeProvider>().current;
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: CustomPaint(
|
||||
painter: _CompassPainter(
|
||||
theme: theme,
|
||||
headingDeg: headingDeg,
|
||||
setPointDeg: setPointDeg,
|
||||
),
|
||||
child: Center(
|
||||
child: _HeadingReadout(theme: theme, headingDeg: headingDeg),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeadingReadout extends StatelessWidget {
|
||||
const _HeadingReadout({required this.theme, required this.headingDeg});
|
||||
final AutopilotTheme theme;
|
||||
final double headingDeg;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = headingDeg.toStringAsFixed(1).padLeft(5, ' ');
|
||||
return Text(
|
||||
'$text°',
|
||||
style: TextStyle(
|
||||
color: theme.textMain,
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w200,
|
||||
letterSpacing: 2,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
shadows: theme.accentGlowRadius > 0
|
||||
? [Shadow(color: theme.accentGlowColor, blurRadius: theme.accentGlowRadius)]
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CompassPainter extends CustomPainter {
|
||||
const _CompassPainter({
|
||||
required this.theme,
|
||||
required this.headingDeg,
|
||||
this.setPointDeg,
|
||||
});
|
||||
|
||||
final AutopilotTheme theme;
|
||||
final double headingDeg;
|
||||
final double? setPointDeg;
|
||||
|
||||
double _toRad(double deg) => deg * math.pi / 180;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final radius = size.width / 2 - 8;
|
||||
|
||||
// Outer ring
|
||||
canvas.drawCircle(
|
||||
center,
|
||||
radius,
|
||||
Paint()
|
||||
..color = theme.panelBorder
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1,
|
||||
);
|
||||
|
||||
// Heading arc (±45° around top = current heading)
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: center, radius: radius - 8),
|
||||
_toRad(-90 - 45),
|
||||
_toRad(90),
|
||||
false,
|
||||
Paint()
|
||||
..color = theme.accentLight
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 4
|
||||
..strokeCap = StrokeCap.round,
|
||||
);
|
||||
|
||||
// North mark (always at top of the ring, rotated opposite to heading)
|
||||
final northAngle = _toRad(-headingDeg - 90);
|
||||
final nx = center.dx + radius * math.cos(northAngle);
|
||||
final ny = center.dy + radius * math.sin(northAngle);
|
||||
canvas.drawCircle(
|
||||
Offset(nx, ny),
|
||||
5,
|
||||
Paint()..color = theme.northColor,
|
||||
);
|
||||
|
||||
// Set-point tick
|
||||
if (setPointDeg != null) {
|
||||
final spAngle = _toRad(setPointDeg! - headingDeg - 90);
|
||||
canvas.drawLine(
|
||||
Offset(
|
||||
center.dx + (radius - 12) * math.cos(spAngle),
|
||||
center.dy + (radius - 12) * math.sin(spAngle),
|
||||
),
|
||||
Offset(
|
||||
center.dx + radius * math.cos(spAngle),
|
||||
center.dy + radius * math.sin(spAngle),
|
||||
),
|
||||
Paint()
|
||||
..color = theme.setLight
|
||||
..strokeWidth = 3
|
||||
..strokeCap = StrokeCap.round,
|
||||
);
|
||||
}
|
||||
|
||||
// Glow ring (dark themes only)
|
||||
if (theme.accentGlowRadius > 0) {
|
||||
canvas.drawCircle(
|
||||
center,
|
||||
radius - 8,
|
||||
Paint()
|
||||
..color = theme.accentGlowColor
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = theme.accentGlowRadius / 2
|
||||
..maskFilter = MaskFilter.blur(BlurStyle.normal, theme.accentGlowRadius / 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_CompassPainter old) =>
|
||||
old.headingDeg != headingDeg ||
|
||||
old.setPointDeg != setPointDeg ||
|
||||
old.theme != theme;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../theme/autopilot_theme.dart';
|
||||
import '../../theme/theme_provider.dart';
|
||||
|
||||
/// The DISENGAGE emergency button.
|
||||
///
|
||||
/// Always the highest-contrast element on screen.
|
||||
/// Minimum touch target: 60×60 px (critical control).
|
||||
///
|
||||
/// The gradient and glow colours adapt to the active theme automatically.
|
||||
/// In the wine theme the button is amber-gold (not red) — see [wineTheme].
|
||||
class DisengageButton extends StatelessWidget {
|
||||
const DisengageButton({
|
||||
super.key,
|
||||
required this.onPressed,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final VoidCallback? onPressed;
|
||||
final bool enabled;
|
||||
|
||||
/// Minimum touch target for a critical control.
|
||||
static const double kMinSize = 60.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = context.watch<AutopilotThemeProvider>().current;
|
||||
return _DisengageButtonContent(
|
||||
theme: theme,
|
||||
onPressed: enabled ? onPressed : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DisengageButtonContent extends StatefulWidget {
|
||||
const _DisengageButtonContent({
|
||||
required this.theme,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final AutopilotTheme theme;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
@override
|
||||
State<_DisengageButtonContent> createState() =>
|
||||
_DisengageButtonContentState();
|
||||
}
|
||||
|
||||
class _DisengageButtonContentState
|
||||
extends State<_DisengageButtonContent> {
|
||||
bool _pressed = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = widget.theme;
|
||||
final enabled = widget.onPressed != null;
|
||||
|
||||
return GestureDetector(
|
||||
onTapDown: enabled ? (_) => setState(() => _pressed = true) : null,
|
||||
onTapUp: enabled
|
||||
? (_) {
|
||||
setState(() => _pressed = false);
|
||||
widget.onPressed!();
|
||||
}
|
||||
: null,
|
||||
onTapCancel: () => setState(() => _pressed = false),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: DisengageButton.kMinSize,
|
||||
minHeight: DisengageButton.kMinSize,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
gradient: t.disengageBackground,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: t.disengageBorder, width: 1.5),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: t.disengageGlow,
|
||||
blurRadius: _pressed ? 4 : 12,
|
||||
spreadRadius: _pressed ? 0 : 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
'DISENGAGE',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: t.disengageText,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 1.2,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: t.disengageGlow,
|
||||
blurRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../theme/autopilot_theme.dart';
|
||||
import '../../theme/theme_provider.dart';
|
||||
|
||||
/// ENGAGE button — activates heading-hold at the current vessel heading.
|
||||
///
|
||||
/// Shown enabled (green, glowing) only when autopilot is in STANDBY.
|
||||
/// When already engaged, [enabled] is false and the button dims.
|
||||
///
|
||||
/// Minimum touch target: 60×60 px (critical control, see design invariant §6).
|
||||
class EngageButton extends StatefulWidget {
|
||||
const EngageButton({
|
||||
super.key,
|
||||
required this.onPressed,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final VoidCallback? onPressed;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
State<EngageButton> createState() => _EngageButtonState();
|
||||
}
|
||||
|
||||
class _EngageButtonState extends State<EngageButton> {
|
||||
bool _pressed = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = context.watch<AutopilotThemeProvider>().current;
|
||||
final enabled = widget.enabled && widget.onPressed != null;
|
||||
|
||||
return GestureDetector(
|
||||
onTapDown: enabled ? (_) => setState(() => _pressed = true) : null,
|
||||
onTapUp: enabled
|
||||
? (_) {
|
||||
setState(() => _pressed = false);
|
||||
widget.onPressed!();
|
||||
}
|
||||
: null,
|
||||
onTapCancel: () => setState(() => _pressed = false),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
constraints: const BoxConstraints(minWidth: 60, minHeight: 60),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
gradient: enabled
|
||||
? LinearGradient(
|
||||
colors: [
|
||||
theme.okColor.withValues(alpha: 0.65),
|
||||
theme.okColor.withValues(alpha: 0.35),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
)
|
||||
: LinearGradient(
|
||||
colors: [theme.panelBorder, theme.panelBorder],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: enabled ? theme.okColor : theme.panelBorder,
|
||||
width: 1.5,
|
||||
),
|
||||
boxShadow: enabled
|
||||
? [
|
||||
BoxShadow(
|
||||
color: theme.okColor
|
||||
.withValues(alpha: _pressed ? 0.2 : 0.45),
|
||||
blurRadius: _pressed ? 4 : 14,
|
||||
spreadRadius: _pressed ? 0 : 2,
|
||||
),
|
||||
]
|
||||
: [],
|
||||
),
|
||||
child: Text(
|
||||
'ENGAGE',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: enabled ? Colors.white : theme.textDisabled,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 1.2,
|
||||
shadows: enabled
|
||||
? [Shadow(color: theme.okColor, blurRadius: 4)]
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../theme/autopilot_theme.dart';
|
||||
import '../../theme/theme_provider.dart';
|
||||
|
||||
/// Four heading-adjust buttons around a central setpoint readout.
|
||||
///
|
||||
/// Layout (left → right):
|
||||
/// [ << 10° ] [ < 1° ] SET 048.0° [ 1° > ] [ 10° >> ]
|
||||
///
|
||||
/// All buttons are disabled when [enabled] is false (autopilot not engaged).
|
||||
/// The setpoint display dims when disabled to reinforce the inactive state.
|
||||
///
|
||||
/// [onAdjust] receives the signed delta in degrees (+1, −1, +10, −10).
|
||||
class HeadingAdjustBar extends StatelessWidget {
|
||||
const HeadingAdjustBar({
|
||||
super.key,
|
||||
required this.setpointDeg,
|
||||
required this.enabled,
|
||||
required this.onAdjust,
|
||||
});
|
||||
|
||||
final double setpointDeg;
|
||||
final bool enabled;
|
||||
final ValueChanged<double> onAdjust;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = context.watch<AutopilotThemeProvider>().current;
|
||||
return Row(
|
||||
children: [
|
||||
_AdjustButton(
|
||||
theme: theme, label: '<<\n10°', delta: -10,
|
||||
enabled: enabled, onAdjust: onAdjust),
|
||||
const SizedBox(width: 6),
|
||||
_AdjustButton(
|
||||
theme: theme, label: '<\n1°', delta: -1,
|
||||
enabled: enabled, onAdjust: onAdjust),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _SetpointDisplay(
|
||||
theme: theme,
|
||||
setpointDeg: setpointDeg,
|
||||
enabled: enabled,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
_AdjustButton(
|
||||
theme: theme, label: '1°\n>', delta: 1,
|
||||
enabled: enabled, onAdjust: onAdjust),
|
||||
const SizedBox(width: 6),
|
||||
_AdjustButton(
|
||||
theme: theme, label: '10°\n>>', delta: 10,
|
||||
enabled: enabled, onAdjust: onAdjust),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Setpoint display ──────────────────────────────────────────────────────────
|
||||
|
||||
class _SetpointDisplay extends StatelessWidget {
|
||||
const _SetpointDisplay({
|
||||
required this.theme,
|
||||
required this.setpointDeg,
|
||||
required this.enabled,
|
||||
});
|
||||
|
||||
final AutopilotTheme theme;
|
||||
final double setpointDeg;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 52,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
gradient: theme.panelBackground,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: enabled
|
||||
? theme.setLight.withValues(alpha: 0.5)
|
||||
: theme.panelBorder,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'SET',
|
||||
style: TextStyle(
|
||||
color: theme.textMuted,
|
||||
fontSize: 9,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${setpointDeg.toStringAsFixed(1)}°',
|
||||
style: TextStyle(
|
||||
color: enabled ? theme.setLight : theme.textDisabled,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w300,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
shadows: enabled && theme.accentGlowRadius > 0
|
||||
? [Shadow(color: theme.setGlow, blurRadius: 8)]
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Adjust button ─────────────────────────────────────────────────────────────
|
||||
|
||||
class _AdjustButton extends StatefulWidget {
|
||||
const _AdjustButton({
|
||||
required this.theme,
|
||||
required this.label,
|
||||
required this.delta,
|
||||
required this.enabled,
|
||||
required this.onAdjust,
|
||||
});
|
||||
|
||||
final AutopilotTheme theme;
|
||||
final String label;
|
||||
final double delta;
|
||||
final bool enabled;
|
||||
final ValueChanged<double> onAdjust;
|
||||
|
||||
@override
|
||||
State<_AdjustButton> createState() => _AdjustButtonState();
|
||||
}
|
||||
|
||||
class _AdjustButtonState extends State<_AdjustButton> {
|
||||
bool _pressed = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = widget.theme;
|
||||
final enabled = widget.enabled;
|
||||
|
||||
return GestureDetector(
|
||||
onTapDown: enabled ? (_) => setState(() => _pressed = true) : null,
|
||||
onTapUp: enabled
|
||||
? (_) {
|
||||
setState(() => _pressed = false);
|
||||
widget.onAdjust(widget.delta);
|
||||
}
|
||||
: null,
|
||||
onTapCancel: () => setState(() => _pressed = false),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
constraints: const BoxConstraints(minWidth: 48, minHeight: 52),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
gradient: enabled ? t.actionButtonBackground : null,
|
||||
color: enabled ? null : t.backgroundDeep,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: enabled && !_pressed
|
||||
? t.actionButtonBorder
|
||||
: t.panelBorder,
|
||||
),
|
||||
boxShadow: enabled && !_pressed
|
||||
? t.glowShadow(t.actionButtonGlow, t.accentGlowRadius / 2)
|
||||
: [],
|
||||
),
|
||||
child: Text(
|
||||
widget.label,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: enabled ? t.actionButtonText : t.textDisabled,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.35,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../theme/autopilot_theme.dart';
|
||||
import '../../theme/theme_provider.dart';
|
||||
|
||||
/// Autopilot mode selector — STANDBY / HEADING_HOLD / TRACK_KEEP.
|
||||
///
|
||||
/// The active mode is highlighted with [AutopilotTheme.accentMid].
|
||||
/// Inactive modes use muted text and panel border.
|
||||
/// Minimum touch target: 48×48 px per mode button.
|
||||
enum AutopilotMode { standby, headingHold, trackKeep }
|
||||
|
||||
extension AutopilotModeLabel on AutopilotMode {
|
||||
String get label => switch (this) {
|
||||
AutopilotMode.standby => 'STANDBY',
|
||||
AutopilotMode.headingHold => 'HDG HOLD',
|
||||
AutopilotMode.trackKeep => 'TRACK',
|
||||
};
|
||||
}
|
||||
|
||||
class ModeSelector extends StatelessWidget {
|
||||
const ModeSelector({
|
||||
super.key,
|
||||
required this.activeMode,
|
||||
required this.onModeSelected,
|
||||
});
|
||||
|
||||
final AutopilotMode activeMode;
|
||||
final ValueChanged<AutopilotMode> onModeSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = context.watch<AutopilotThemeProvider>().current;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: theme.panelBackground,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: theme.panelBorder),
|
||||
),
|
||||
child: Row(
|
||||
children: AutopilotMode.values.map((mode) {
|
||||
final isActive = mode == activeMode;
|
||||
return Expanded(
|
||||
child: _ModeButton(
|
||||
theme: theme,
|
||||
mode: mode,
|
||||
isActive: isActive,
|
||||
onTap: () => onModeSelected(mode),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ModeButton extends StatelessWidget {
|
||||
const _ModeButton({
|
||||
required this.theme,
|
||||
required this.mode,
|
||||
required this.isActive,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final AutopilotTheme theme;
|
||||
final AutopilotMode mode;
|
||||
final bool isActive;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
constraints: const BoxConstraints(minHeight: 48),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? theme.accentMid.withValues(alpha: 0.15) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
mode.label,
|
||||
style: TextStyle(
|
||||
color: isActive ? theme.accentLight : theme.textMuted,
|
||||
fontSize: 12,
|
||||
fontWeight: isActive ? FontWeight.w700 : FontWeight.w400,
|
||||
letterSpacing: 0.8,
|
||||
shadows: isActive && theme.accentGlowRadius > 0
|
||||
? [Shadow(color: theme.accentGlowColor, blurRadius: 6)]
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../theme/autopilot_theme.dart';
|
||||
import '../../theme/theme_provider.dart';
|
||||
|
||||
/// Rudder angle indicator — horizontal bar from –35° (port) to +35° (starboard).
|
||||
///
|
||||
/// [rudderDeg] is the actual rudder angle in degrees.
|
||||
/// Negative = port, positive = starboard.
|
||||
/// [limitDeg] is the hard stop (default 35°).
|
||||
class RudderIndicator extends StatelessWidget {
|
||||
const RudderIndicator({
|
||||
super.key,
|
||||
required this.rudderDeg,
|
||||
this.limitDeg = 35.0,
|
||||
this.height = 48,
|
||||
});
|
||||
|
||||
final double rudderDeg;
|
||||
final double limitDeg;
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = context.watch<AutopilotThemeProvider>().current;
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: CustomPaint(
|
||||
painter: _RudderPainter(
|
||||
theme: theme,
|
||||
rudderDeg: rudderDeg,
|
||||
limitDeg: limitDeg,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RudderPainter extends CustomPainter {
|
||||
const _RudderPainter({
|
||||
required this.theme,
|
||||
required this.rudderDeg,
|
||||
required this.limitDeg,
|
||||
});
|
||||
|
||||
final AutopilotTheme theme;
|
||||
final double rudderDeg;
|
||||
final double limitDeg;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final t = theme;
|
||||
final cx = size.width / 2;
|
||||
final cy = size.height / 2;
|
||||
final halfW = size.width / 2 - 12;
|
||||
|
||||
// Track
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Rect.fromCenter(center: Offset(cx, cy), width: size.width - 24, height: 6),
|
||||
const Radius.circular(3),
|
||||
),
|
||||
Paint()..color = t.panelBorder,
|
||||
);
|
||||
|
||||
// Centre line
|
||||
canvas.drawLine(
|
||||
Offset(cx, cy - 10),
|
||||
Offset(cx, cy + 10),
|
||||
Paint()
|
||||
..color = t.textMuted
|
||||
..strokeWidth = 1,
|
||||
);
|
||||
|
||||
// Rudder fill
|
||||
final fraction = (rudderDeg / limitDeg).clamp(-1.0, 1.0);
|
||||
final fillWidth = (halfW * fraction.abs()).clamp(0.0, halfW);
|
||||
if (fillWidth > 1) {
|
||||
final left = fraction < 0 ? cx - fillWidth : cx;
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(left, cy - 3, fillWidth, 6),
|
||||
const Radius.circular(2),
|
||||
),
|
||||
Paint()..color = t.accentMid,
|
||||
);
|
||||
}
|
||||
|
||||
// Indicator knob
|
||||
final kx = cx + halfW * fraction;
|
||||
canvas.drawCircle(
|
||||
Offset(kx, cy),
|
||||
9,
|
||||
Paint()..color = t.accentLight,
|
||||
);
|
||||
if (t.accentGlowRadius > 0) {
|
||||
canvas.drawCircle(
|
||||
Offset(kx, cy),
|
||||
9,
|
||||
Paint()
|
||||
..color = t.accentGlowColor
|
||||
..maskFilter = MaskFilter.blur(BlurStyle.normal, t.accentGlowRadius / 2),
|
||||
);
|
||||
}
|
||||
|
||||
// Labels
|
||||
final labelStyle = TextStyle(color: t.textMuted, fontSize: 10);
|
||||
_drawLabel(canvas, 'P', Offset(12, cy), labelStyle);
|
||||
_drawLabel(canvas, 'S', Offset(size.width - 12, cy), labelStyle);
|
||||
}
|
||||
|
||||
void _drawLabel(Canvas canvas, String text, Offset center, TextStyle style) {
|
||||
final span = TextSpan(text: text, style: style);
|
||||
final tp = TextPainter(
|
||||
text: span,
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
tp.paint(canvas, center - Offset(tp.width / 2, tp.height / 2));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_RudderPainter old) =>
|
||||
old.rudderDeg != rudderDeg || old.theme != theme;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../theme/autopilot_theme.dart';
|
||||
|
||||
/// Connection / data-source status indicator — coloured dot + label.
|
||||
///
|
||||
/// Used in the [CockpitScreen] top-bar to show NMEA and GPS link state.
|
||||
enum StatusLevel {
|
||||
/// Data valid and link active.
|
||||
ok,
|
||||
|
||||
/// Data stale, link degraded, or GPS fix lost.
|
||||
warn,
|
||||
|
||||
/// Link absent (serial port not open, concentrador not connected).
|
||||
off,
|
||||
}
|
||||
|
||||
class StatusChip extends StatelessWidget {
|
||||
const StatusChip({
|
||||
super.key,
|
||||
required this.theme,
|
||||
required this.label,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
final AutopilotTheme theme;
|
||||
final String label;
|
||||
final StatusLevel status;
|
||||
|
||||
Color get _dotColor => switch (status) {
|
||||
StatusLevel.ok => theme.okColor,
|
||||
StatusLevel.warn => theme.warnColor,
|
||||
StatusLevel.off => theme.textDisabled,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dot = _dotColor;
|
||||
final glowing = status == StatusLevel.ok && theme.accentGlowRadius > 0;
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 7,
|
||||
height: 7,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: dot,
|
||||
boxShadow: glowing
|
||||
? [BoxShadow(color: dot.withValues(alpha: 0.6), blurRadius: 6)]
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color:
|
||||
status == StatusLevel.off ? theme.textDisabled : theme.textMuted,
|
||||
fontSize: 10,
|
||||
letterSpacing: 0.5,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
name: ar_autopilot_display
|
||||
description: AR-Autopilot Flutter display app — marine autopilot cockpit UI for 30-40 m vessels.
|
||||
version: 0.4.0+4
|
||||
|
||||
environment:
|
||||
sdk: '>=3.3.0 <4.0.0'
|
||||
flutter: '>=3.19.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
# State management — theme provider uses ChangeNotifier, exposed via Provider
|
||||
provider: ^6.1.2
|
||||
# Theme persistence — stores selected theme id locally on the display device
|
||||
shared_preferences: ^2.3.2
|
||||
# USB serial communication with AR-Concentrador (CH340N virtual COM ports)
|
||||
flutter_libserialport: ^0.2.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^4.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
assets:
|
||||
- assets/images/ar_logo_full.png
|
||||
- assets/brand/
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:ar_autopilot_display/theme/theme_registry.dart';
|
||||
import 'package:ar_autopilot_display/theme/autopilot_theme.dart';
|
||||
|
||||
// ── WCAG 2.1 helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// WCAG 2.1 relative luminance of a colour.
|
||||
/// https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
|
||||
double _luminance(Color c) {
|
||||
double lin(int channel) {
|
||||
final s = channel / 255.0;
|
||||
return s <= 0.04045 ? s / 12.92 : pow((s + 0.055) / 1.055, 2.4).toDouble();
|
||||
}
|
||||
|
||||
return 0.2126 * lin(c.red) + 0.7152 * lin(c.green) + 0.0722 * lin(c.blue);
|
||||
}
|
||||
|
||||
/// WCAG 2.1 contrast ratio (symmetric).
|
||||
double _contrast(Color a, Color b) {
|
||||
final la = _luminance(a);
|
||||
final lb = _luminance(b);
|
||||
final lighter = max(la, lb);
|
||||
final darker = min(la, lb);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
/// Returns all colour stops from a [Gradient].
|
||||
List<Color> _gradientColors(Gradient g) {
|
||||
if (g is LinearGradient) return g.colors;
|
||||
if (g is RadialGradient) return g.colors;
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Returns the **maximum** contrast of [text] against any stop in [bg].
|
||||
///
|
||||
/// We test the best-case stop: the gradient stop that gives the highest
|
||||
/// contrast against the text colour. This ensures the design intent is
|
||||
/// achievable — the text will visually overlap the highest-contrast region.
|
||||
///
|
||||
/// For DISENGAGE (≥ 7:1 AAA): tests that at least one region of the gradient
|
||||
/// button delivers the required accessibility threshold.
|
||||
double _maxContrastVsGradient(Color text, Gradient bg) {
|
||||
final stops = _gradientColors(bg);
|
||||
if (stops.isEmpty) return 0;
|
||||
return stops.map((c) => _contrast(text, c)).reduce(max);
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
void main() {
|
||||
group('WCAG contrast — mandatory Sprint 4 gates', () {
|
||||
for (final theme in ThemeRegistry.all) {
|
||||
_contrastTests(theme);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _contrastTests(AutopilotTheme t) {
|
||||
group('Theme: ${t.id}', () {
|
||||
// ── DISENGAGE ─────────────────────────────────────────────────────────────
|
||||
test('DISENGAGE text vs button background ≥ 7:1 (WCAG AAA)', () {
|
||||
final ratio = _maxContrastVsGradient(t.disengageText, t.disengageBackground);
|
||||
expect(
|
||||
ratio,
|
||||
greaterThanOrEqualTo(7.0),
|
||||
reason:
|
||||
'${t.id}: DISENGAGE text contrast is ${ratio.toStringAsFixed(2)}:1 — '
|
||||
'must be ≥ 7:1 (AAA). Adjust disengageText or disengageBackground.',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Action buttons ────────────────────────────────────────────────────────
|
||||
test('Action button text vs button background ≥ 4.5:1 (WCAG AA)', () {
|
||||
final ratio = _maxContrastVsGradient(
|
||||
t.actionButtonText,
|
||||
t.actionButtonBackground,
|
||||
);
|
||||
expect(
|
||||
ratio,
|
||||
greaterThanOrEqualTo(4.5),
|
||||
reason:
|
||||
'${t.id}: action button text contrast is ${ratio.toStringAsFixed(2)}:1 — '
|
||||
'must be ≥ 4.5:1 (AA). Operators must read DODGE/±1°/±10° without hover.',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Body text ─────────────────────────────────────────────────────────────
|
||||
test('textMain vs background ≥ 4.5:1 (WCAG AA)', () {
|
||||
final ratio = _contrast(t.textMain, t.background);
|
||||
expect(
|
||||
ratio,
|
||||
greaterThanOrEqualTo(4.5),
|
||||
reason:
|
||||
'${t.id}: textMain contrast is ${ratio.toStringAsFixed(2)}:1 — '
|
||||
'must be ≥ 4.5:1 (AA).',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Set-point legibility ──────────────────────────────────────────────────
|
||||
test('setLight vs background ≥ 3:1 (large UI element)', () {
|
||||
final ratio = _contrast(t.setLight, t.background);
|
||||
expect(
|
||||
ratio,
|
||||
greaterThanOrEqualTo(3.0),
|
||||
reason:
|
||||
'${t.id}: setLight contrast is ${ratio.toStringAsFixed(2)}:1 — '
|
||||
'must be ≥ 3:1 (set-point is a large graphical element).',
|
||||
);
|
||||
});
|
||||
|
||||
// ── OK / Warn semantic colours ────────────────────────────────────────────
|
||||
test('okColor vs background ≥ 3:1 (status indicator)', () {
|
||||
final ratio = _contrast(t.okColor, t.background);
|
||||
expect(
|
||||
ratio,
|
||||
greaterThanOrEqualTo(3.0),
|
||||
reason:
|
||||
'${t.id}: okColor contrast is ${ratio.toStringAsFixed(2)}:1 — '
|
||||
'status indicators require ≥ 3:1.',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:ar_autopilot_display/theme/theme_provider.dart';
|
||||
import 'package:ar_autopilot_display/theme/theme_registry.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
// Reset SharedPreferences mock before each test
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
group('AutopilotThemeProvider — persistence', () {
|
||||
test('loads default theme (cyan) when no preference is stored', () async {
|
||||
final provider = await AutopilotThemeProvider.load();
|
||||
expect(provider.current.id, ThemeRegistry.defaultId);
|
||||
});
|
||||
|
||||
test('loads a previously persisted theme on startup', () async {
|
||||
SharedPreferences.setMockInitialValues({'autopilot.theme.id': 'wine'});
|
||||
final provider = await AutopilotThemeProvider.load();
|
||||
expect(provider.current.id, 'wine');
|
||||
});
|
||||
|
||||
test('persists selection so a fresh load returns the same theme', () async {
|
||||
final provider = await AutopilotThemeProvider.load();
|
||||
await provider.setTheme('light');
|
||||
|
||||
// Simulate app restart — reload from SharedPreferences
|
||||
final provider2 = await AutopilotThemeProvider.load();
|
||||
expect(provider2.current.id, 'light');
|
||||
});
|
||||
|
||||
test('cycling through all 4 themes persists each correctly', () async {
|
||||
final provider = await AutopilotThemeProvider.load();
|
||||
for (final id in ['light', 'wine', 'ochre', 'cyan']) {
|
||||
await provider.setTheme(id);
|
||||
final reloaded = await AutopilotThemeProvider.load();
|
||||
expect(reloaded.current.id, id,
|
||||
reason: 'After setting "$id", reload should return "$id"');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('AutopilotThemeProvider — state changes', () {
|
||||
test('setTheme updates current theme immediately', () async {
|
||||
final provider = await AutopilotThemeProvider.load();
|
||||
expect(provider.current.id, ThemeRegistry.defaultId);
|
||||
await provider.setTheme('ochre');
|
||||
expect(provider.current.id, 'ochre');
|
||||
});
|
||||
|
||||
test('setTheme with unknown id falls back to default (cyan)', () async {
|
||||
final provider = await AutopilotThemeProvider.load();
|
||||
await provider.setTheme('nonexistent_theme');
|
||||
expect(provider.current.id, ThemeRegistry.defaultId);
|
||||
});
|
||||
|
||||
test('setTheme notifies listeners', () async {
|
||||
final provider = await AutopilotThemeProvider.load();
|
||||
var notifyCount = 0;
|
||||
provider.addListener(() => notifyCount++);
|
||||
await provider.setTheme('wine');
|
||||
expect(notifyCount, 1);
|
||||
await provider.setTheme('ochre');
|
||||
expect(notifyCount, 2);
|
||||
});
|
||||
|
||||
test('initial load does not notify listeners', () async {
|
||||
final provider = await AutopilotThemeProvider.load();
|
||||
var notified = false;
|
||||
provider.addListener(() => notified = true);
|
||||
// No setTheme called — listeners must not have fired
|
||||
expect(notified, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:ar_autopilot_display/theme/theme_registry.dart';
|
||||
|
||||
void main() {
|
||||
group('ThemeRegistry — factory themes', () {
|
||||
test('exposes exactly 4 factory themes', () {
|
||||
expect(ThemeRegistry.all.length, 4);
|
||||
});
|
||||
|
||||
test('factory theme ids are light, cyan, wine, ochre', () {
|
||||
final ids = ThemeRegistry.all.map((t) => t.id).toList();
|
||||
expect(ids, containsAll(['light', 'cyan', 'wine', 'ochre']));
|
||||
});
|
||||
|
||||
test('display order is light → cyan → wine → ochre', () {
|
||||
final ids = ThemeRegistry.all.map((t) => t.id).toList();
|
||||
expect(ids, ['light', 'cyan', 'wine', 'ochre']);
|
||||
});
|
||||
|
||||
test('all themes have non-empty id and displayName', () {
|
||||
for (final t in ThemeRegistry.all) {
|
||||
expect(t.id, isNotEmpty, reason: 'id must not be empty');
|
||||
expect(t.displayName, isNotEmpty, reason: 'displayName must not be empty');
|
||||
}
|
||||
});
|
||||
|
||||
group('byId', () {
|
||||
test('returns correct theme for each known id', () {
|
||||
for (final t in ThemeRegistry.all) {
|
||||
final found = ThemeRegistry.byId(t.id);
|
||||
expect(found.id, t.id, reason: 'byId("${t.id}") should return that theme');
|
||||
}
|
||||
});
|
||||
|
||||
test('falls back to default (cyan) for unknown id', () {
|
||||
expect(ThemeRegistry.byId('does_not_exist').id, ThemeRegistry.defaultId);
|
||||
expect(ThemeRegistry.byId('').id, ThemeRegistry.defaultId);
|
||||
});
|
||||
});
|
||||
|
||||
test('default id is cyan', () {
|
||||
expect(ThemeRegistry.defaultId, 'cyan');
|
||||
});
|
||||
|
||||
test('contains() returns true for factory ids', () {
|
||||
for (final t in ThemeRegistry.all) {
|
||||
expect(ThemeRegistry.contains(t.id), isTrue);
|
||||
}
|
||||
});
|
||||
|
||||
test('contains() returns false for unknown id', () {
|
||||
expect(ThemeRegistry.contains('unknown_xyz'), isFalse);
|
||||
});
|
||||
|
||||
group('Token completeness — no null colours', () {
|
||||
for (final t in ThemeRegistry.all) {
|
||||
test('${t.id}: all Color tokens are non-null', () {
|
||||
// Backgrounds
|
||||
expect(t.background, isNotNull, reason: '${t.id}.background');
|
||||
expect(t.backgroundMid, isNotNull, reason: '${t.id}.backgroundMid');
|
||||
expect(t.backgroundDeep, isNotNull, reason: '${t.id}.backgroundDeep');
|
||||
expect(t.backgroundDeepest, isNotNull, reason: '${t.id}.backgroundDeepest');
|
||||
expect(t.panelBackground, isNotNull, reason: '${t.id}.panelBackground');
|
||||
expect(t.panelBorder, isNotNull, reason: '${t.id}.panelBorder');
|
||||
// Text
|
||||
expect(t.textMain, isNotNull, reason: '${t.id}.textMain');
|
||||
expect(t.textMuted, isNotNull, reason: '${t.id}.textMuted');
|
||||
expect(t.textSoft, isNotNull, reason: '${t.id}.textSoft');
|
||||
expect(t.textDisabled, isNotNull, reason: '${t.id}.textDisabled');
|
||||
// Accent
|
||||
expect(t.accentLight, isNotNull, reason: '${t.id}.accentLight');
|
||||
expect(t.accentMid, isNotNull, reason: '${t.id}.accentMid');
|
||||
expect(t.accentDark, isNotNull, reason: '${t.id}.accentDark');
|
||||
expect(t.accentGlowColor, isNotNull, reason: '${t.id}.accentGlowColor');
|
||||
// Set-point
|
||||
expect(t.setLight, isNotNull, reason: '${t.id}.setLight');
|
||||
expect(t.setDark, isNotNull, reason: '${t.id}.setDark');
|
||||
expect(t.setGlow, isNotNull, reason: '${t.id}.setGlow');
|
||||
// Semantic
|
||||
expect(t.okColor, isNotNull, reason: '${t.id}.okColor');
|
||||
expect(t.warnColor, isNotNull, reason: '${t.id}.warnColor');
|
||||
expect(t.northColor, isNotNull, reason: '${t.id}.northColor');
|
||||
// Disengage
|
||||
expect(t.disengageBackground, isNotNull, reason: '${t.id}.disengageBackground');
|
||||
expect(t.disengageText, isNotNull, reason: '${t.id}.disengageText');
|
||||
expect(t.disengageBorder, isNotNull, reason: '${t.id}.disengageBorder');
|
||||
expect(t.disengageGlow, isNotNull, reason: '${t.id}.disengageGlow');
|
||||
// Action buttons
|
||||
expect(t.actionButtonBackground, isNotNull, reason: '${t.id}.actionButtonBackground');
|
||||
expect(t.actionButtonBorder, isNotNull, reason: '${t.id}.actionButtonBorder');
|
||||
expect(t.actionButtonText, isNotNull, reason: '${t.id}.actionButtonText');
|
||||
expect(t.actionButtonGlow, isNotNull, reason: '${t.id}.actionButtonGlow');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
group('Glow rules', () {
|
||||
test('light theme has accentGlowRadius == 0 (no glow in daytime)', () {
|
||||
expect(ThemeRegistry.byId('light').accentGlowRadius, 0.0);
|
||||
});
|
||||
|
||||
test('dark themes have accentGlowRadius > 0', () {
|
||||
for (final id in ['cyan', 'wine', 'ochre']) {
|
||||
expect(
|
||||
ThemeRegistry.byId(id).accentGlowRadius,
|
||||
greaterThan(0),
|
||||
reason: '$id should have glow',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('Background gradient rules', () {
|
||||
test('light theme has no backgroundGradient (solid colour)', () {
|
||||
expect(ThemeRegistry.byId('light').backgroundGradient, isNull);
|
||||
});
|
||||
|
||||
test('dark themes have a backgroundGradient', () {
|
||||
for (final id in ['cyan', 'wine', 'ochre']) {
|
||||
expect(
|
||||
ThemeRegistry.byId(id).backgroundGradient,
|
||||
isNotNull,
|
||||
reason: '$id should have a radial background gradient',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// =============================================================================
|
||||
// rudder_actuator_electric.h -- reversible DC motor actuator (Sprint 4)
|
||||
// =============================================================================
|
||||
//
|
||||
// Drives a reversible DC motor with mechanical end-stops (Lewmar, Simpson
|
||||
// Lawrence) via H-bridge (DRV8833 or equivalent):
|
||||
// DO1 (GPIO) -- IN1 (port direction)
|
||||
// DO2 (GPIO) -- IN2 (starboard direction)
|
||||
// DO3 (GPIO) -- master power relay (ENA)
|
||||
// PWM channel -- motor speed (shared LEDC channel 1)
|
||||
//
|
||||
// Architecture identical to HydraulicRudderActuator; only the PWM mapping
|
||||
// differs (electric motors typically need a minimum useful duty to overcome
|
||||
// static friction — see AR_ACTUATOR_DEADBAND_PCT in firmware_config.h).
|
||||
// =============================================================================
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
#include <Preferences.h>
|
||||
#include <driver/ledc.h>
|
||||
|
||||
#include "hal/di_do.h"
|
||||
#include "hal/pinout.h"
|
||||
#include "hal/rudder_actuator_iface.h"
|
||||
#include "hal/rudder_sensor.h"
|
||||
#include "system/ar_log.h"
|
||||
|
||||
#ifndef AR_ACTUATOR_DEADBAND_PCT
|
||||
# define AR_ACTUATOR_DEADBAND_PCT 5.0f
|
||||
#endif
|
||||
|
||||
namespace arautopilot::hal {
|
||||
|
||||
class ElectricDcRudderActuator final : public IRudderActuator {
|
||||
public:
|
||||
static constexpr const char* NVS_NS = "ar_act";
|
||||
static constexpr const char* NVS_KEY = "angle_limit";
|
||||
static constexpr float DEFAULT_LIMIT_DEG = 35.0f;
|
||||
static constexpr float MIN_LIMIT_DEG = 15.0f;
|
||||
static constexpr float MAX_LIMIT_DEG = 45.0f;
|
||||
|
||||
void init() override {
|
||||
_prefs.begin(NVS_NS, false);
|
||||
_limit_deg = _prefs.getFloat(NVS_KEY, DEFAULT_LIMIT_DEG);
|
||||
_limit_deg = std::clamp(_limit_deg, MIN_LIMIT_DEG, MAX_LIMIT_DEG);
|
||||
_prefs.end();
|
||||
|
||||
pinMode(PIN_DO1, OUTPUT); digitalWrite(PIN_DO1, LOW);
|
||||
pinMode(PIN_DO2, OUTPUT); digitalWrite(PIN_DO2, LOW);
|
||||
pinMode(PIN_DO3, OUTPUT); digitalWrite(PIN_DO3, LOW);
|
||||
|
||||
ledc_timer_config_t timer{};
|
||||
timer.speed_mode = LEDC_LOW_SPEED_MODE;
|
||||
timer.duty_resolution = LEDC_TIMER_8_BIT;
|
||||
timer.timer_num = LEDC_TIMER_1;
|
||||
timer.freq_hz = 20000; // 20 kHz — inaudible for DC motor
|
||||
timer.clk_cfg = LEDC_AUTO_CLK;
|
||||
ledc_timer_config(&timer);
|
||||
|
||||
ledc_channel_config_t ch{};
|
||||
ch.speed_mode = LEDC_LOW_SPEED_MODE;
|
||||
ch.channel = LEDC_CHANNEL_1;
|
||||
ch.timer_sel = LEDC_TIMER_1;
|
||||
ch.gpio_num = PIN_ACTUATOR_PWM;
|
||||
ch.duty = 0;
|
||||
ledc_channel_config(&ch);
|
||||
|
||||
AR_LOGI("ElectricActuator", "init OK, limit=%.1f deg", _limit_deg);
|
||||
}
|
||||
|
||||
void power_on() override { digitalWrite(PIN_DO3, HIGH); _powered = true; }
|
||||
void power_off() override { _stop_outputs(); digitalWrite(PIN_DO3, LOW); _powered = false; }
|
||||
|
||||
bool command(float angle_deg) override {
|
||||
if (!_powered || _at_standby()) return false;
|
||||
|
||||
const float clamped = std::clamp(angle_deg, -_limit_deg, _limit_deg);
|
||||
if (clamped > 0.0f && _limit_stbd()) { _stop_outputs(); return false; }
|
||||
if (clamped < 0.0f && _limit_port()) { _stop_outputs(); return false; }
|
||||
|
||||
const float raw_pct = std::fabsf(clamped) / _limit_deg * 100.0f;
|
||||
// Apply deadband: below deadband -> 0, otherwise scale to [deadband, 100]
|
||||
const float db = AR_ACTUATOR_DEADBAND_PCT;
|
||||
const float duty_pct = (raw_pct < 0.5f) ? 0.0f
|
||||
: db + raw_pct * (100.0f - db) / 100.0f;
|
||||
|
||||
_set_pwm(static_cast<uint8_t>(std::min(duty_pct, 100.0f) * 255.0f / 100.0f));
|
||||
|
||||
if (clamped > 0.01f) {
|
||||
digitalWrite(PIN_DO1, LOW); digitalWrite(PIN_DO2, HIGH);
|
||||
} else if (clamped < -0.01f) {
|
||||
digitalWrite(PIN_DO2, LOW); digitalWrite(PIN_DO1, HIGH);
|
||||
} else {
|
||||
_stop_outputs();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
float actual_angle_deg() const override { return rudder_sensor_get_angle_deg(); }
|
||||
bool is_powered() const override { return _powered; }
|
||||
bool at_limit() const override { return _limit_port() || _limit_stbd(); }
|
||||
float angle_limit_deg() const override { return _limit_deg; }
|
||||
ActuatorType type() const override { return ActuatorType::ELECTRIC_DC; }
|
||||
|
||||
void set_angle_limit_deg(float limit_deg) override {
|
||||
_limit_deg = std::clamp(limit_deg, MIN_LIMIT_DEG, MAX_LIMIT_DEG);
|
||||
_prefs.begin(NVS_NS, false);
|
||||
_prefs.putFloat(NVS_KEY, _limit_deg);
|
||||
_prefs.end();
|
||||
AR_LOGI("ElectricActuator", "angle_limit updated -> %.1f deg", _limit_deg);
|
||||
}
|
||||
|
||||
private:
|
||||
Preferences _prefs;
|
||||
float _limit_deg{DEFAULT_LIMIT_DEG};
|
||||
bool _powered{false};
|
||||
|
||||
static bool _limit_port() { return digitalRead(PIN_DI2) == HIGH; }
|
||||
static bool _limit_stbd() { return digitalRead(PIN_DI3) == HIGH; }
|
||||
static bool _at_standby();
|
||||
|
||||
void _stop_outputs() {
|
||||
_set_pwm(0);
|
||||
digitalWrite(PIN_DO1, LOW);
|
||||
digitalWrite(PIN_DO2, LOW);
|
||||
}
|
||||
|
||||
static void _set_pwm(uint8_t duty) {
|
||||
ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_1, duty);
|
||||
ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_1);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace arautopilot::hal
|
||||
@@ -0,0 +1,52 @@
|
||||
// =============================================================================
|
||||
// rudder_actuator_factory.h -- selects concrete actuator at build time
|
||||
// =============================================================================
|
||||
//
|
||||
// The AR_ACTUATOR_TYPE macro is defined in firmware_config.h (generated by
|
||||
// the .appack compiler). If firmware_config.h is not present (standalone
|
||||
// build), falls back to ACTUATOR_HYDRAULIC_REVERSIBLE.
|
||||
//
|
||||
// Usage in main.cpp:
|
||||
//
|
||||
// #include "hal/rudder_actuator_factory.h"
|
||||
// // ...
|
||||
// IRudderActuator& actuator = arautopilot::hal::g_rudder_actuator;
|
||||
// actuator.init();
|
||||
// =============================================================================
|
||||
|
||||
#pragma once
|
||||
|
||||
// Pull in generated config if available
|
||||
#if __has_include("firmware_config.h")
|
||||
# include "firmware_config.h"
|
||||
#endif
|
||||
|
||||
// Actuator type constants (must match _actuator_type_to_enum in appack.py)
|
||||
#define ACTUATOR_HYDRAULIC_REVERSIBLE 0
|
||||
#define ACTUATOR_ELECTRIC_DC 1
|
||||
#define ACTUATOR_SERVOMOTOR 2
|
||||
#define ACTUATOR_STERNDRIVE_ANALOG 3
|
||||
#define ACTUATOR_TEST_RIG_MOTOR 4
|
||||
|
||||
#ifndef AR_ACTUATOR_TYPE
|
||||
# define AR_ACTUATOR_TYPE ACTUATOR_HYDRAULIC_REVERSIBLE
|
||||
#endif
|
||||
|
||||
#if AR_ACTUATOR_TYPE == ACTUATOR_HYDRAULIC_REVERSIBLE
|
||||
# include "hal/rudder_actuator_hydraulic.h"
|
||||
namespace arautopilot::hal {
|
||||
static HydraulicRudderActuator g_rudder_actuator;
|
||||
}
|
||||
#elif AR_ACTUATOR_TYPE == ACTUATOR_ELECTRIC_DC
|
||||
# include "hal/rudder_actuator_electric.h"
|
||||
namespace arautopilot::hal {
|
||||
static ElectricDcRudderActuator g_rudder_actuator;
|
||||
}
|
||||
#else
|
||||
// Fallback: hydraulic (compile warning so the integrator notices)
|
||||
# pragma message("AR_ACTUATOR_TYPE not recognised — defaulting to HydraulicRudderActuator")
|
||||
# include "hal/rudder_actuator_hydraulic.h"
|
||||
namespace arautopilot::hal {
|
||||
static HydraulicRudderActuator g_rudder_actuator;
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,148 @@
|
||||
// =============================================================================
|
||||
// rudder_actuator_hydraulic.h -- reversible hydraulic pump actuator (Sprint 4)
|
||||
// =============================================================================
|
||||
//
|
||||
// Drives a reversible hydraulic pump (Hynautic, Hypro, Octopus, Vetus,
|
||||
// Lecomble & Schmitt) via:
|
||||
// DO1 (GPIO) -- port direction solenoid
|
||||
// DO2 (GPIO) -- starboard direction solenoid
|
||||
// DO3 (GPIO) -- master power relay
|
||||
// PWM channel -- pump speed proportional to |angle_error|
|
||||
//
|
||||
// The software angle limit is loaded from NVS at init() and clamped to
|
||||
// [15, 45] degrees. It can be updated live via set_angle_limit_deg()
|
||||
// which also persists the new value to NVS.
|
||||
// =============================================================================
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
#include <Preferences.h>
|
||||
#include <driver/ledc.h>
|
||||
|
||||
#include "hal/di_do.h"
|
||||
#include "hal/pinout.h"
|
||||
#include "hal/rudder_actuator_iface.h"
|
||||
#include "hal/rudder_sensor.h"
|
||||
#include "system/ar_log.h"
|
||||
|
||||
namespace arautopilot::hal {
|
||||
|
||||
class HydraulicRudderActuator final : public IRudderActuator {
|
||||
public:
|
||||
// NVS namespace + key for the angle limit
|
||||
static constexpr const char* NVS_NS = "ar_act";
|
||||
static constexpr const char* NVS_KEY = "angle_limit";
|
||||
static constexpr float DEFAULT_LIMIT_DEG = 35.0f;
|
||||
static constexpr float MIN_LIMIT_DEG = 15.0f;
|
||||
static constexpr float MAX_LIMIT_DEG = 45.0f;
|
||||
|
||||
void init() override {
|
||||
// Load angle limit from NVS (falls back to firmware_config.h default)
|
||||
_prefs.begin(NVS_NS, false);
|
||||
_limit_deg = _prefs.getFloat(NVS_KEY, DEFAULT_LIMIT_DEG);
|
||||
_limit_deg = std::clamp(_limit_deg, MIN_LIMIT_DEG, MAX_LIMIT_DEG);
|
||||
_prefs.end();
|
||||
|
||||
// Configure direction + power GPIOs
|
||||
pinMode(PIN_DO1, OUTPUT); digitalWrite(PIN_DO1, LOW); // port
|
||||
pinMode(PIN_DO2, OUTPUT); digitalWrite(PIN_DO2, LOW); // starboard
|
||||
pinMode(PIN_DO3, OUTPUT); digitalWrite(PIN_DO3, LOW); // master power
|
||||
|
||||
// Configure LEDC PWM for pump speed
|
||||
ledc_timer_config_t timer{};
|
||||
timer.speed_mode = LEDC_LOW_SPEED_MODE;
|
||||
timer.duty_resolution = LEDC_TIMER_8_BIT;
|
||||
timer.timer_num = LEDC_TIMER_0;
|
||||
timer.freq_hz = 5000;
|
||||
timer.clk_cfg = LEDC_AUTO_CLK;
|
||||
ledc_timer_config(&timer);
|
||||
|
||||
ledc_channel_config_t ch{};
|
||||
ch.speed_mode = LEDC_LOW_SPEED_MODE;
|
||||
ch.channel = LEDC_CHANNEL_0;
|
||||
ch.timer_sel = LEDC_TIMER_0;
|
||||
ch.gpio_num = PIN_ACTUATOR_PWM;
|
||||
ch.duty = 0;
|
||||
ledc_channel_config(&ch);
|
||||
|
||||
AR_LOGI("HydraulicActuator", "init OK, limit=%.1f deg", _limit_deg);
|
||||
}
|
||||
|
||||
void power_on() override {
|
||||
digitalWrite(PIN_DO3, HIGH);
|
||||
_powered = true;
|
||||
}
|
||||
|
||||
void power_off() override {
|
||||
_stop_outputs();
|
||||
digitalWrite(PIN_DO3, LOW);
|
||||
_powered = false;
|
||||
}
|
||||
|
||||
bool command(float angle_deg) override {
|
||||
if (!_powered) return false;
|
||||
if (_at_standby()) return false;
|
||||
|
||||
// Software angle limit
|
||||
const float clamped = std::clamp(angle_deg, -_limit_deg, _limit_deg);
|
||||
|
||||
// Hard-stop if limit switch asserted in the commanded direction
|
||||
if (clamped > 0.0f && _limit_stbd()) { _stop_outputs(); return false; }
|
||||
if (clamped < 0.0f && _limit_port()) { _stop_outputs(); return false; }
|
||||
|
||||
const float duty_pct = std::fabsf(clamped) / _limit_deg * 100.0f;
|
||||
_set_pwm(static_cast<uint8_t>(duty_pct * 255.0f / 100.0f));
|
||||
|
||||
if (clamped > 0.01f) {
|
||||
digitalWrite(PIN_DO1, LOW); digitalWrite(PIN_DO2, HIGH);
|
||||
} else if (clamped < -0.01f) {
|
||||
digitalWrite(PIN_DO2, LOW); digitalWrite(PIN_DO1, HIGH);
|
||||
} else {
|
||||
_stop_outputs();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
float actual_angle_deg() const override {
|
||||
return rudder_sensor_get_angle_deg();
|
||||
}
|
||||
|
||||
bool is_powered() const override { return _powered; }
|
||||
bool at_limit() const override { return _limit_port() || _limit_stbd(); }
|
||||
float angle_limit_deg() const override { return _limit_deg; }
|
||||
ActuatorType type() const override { return ActuatorType::HYDRAULIC_REVERSIBLE; }
|
||||
|
||||
void set_angle_limit_deg(float limit_deg) override {
|
||||
_limit_deg = std::clamp(limit_deg, MIN_LIMIT_DEG, MAX_LIMIT_DEG);
|
||||
_prefs.begin(NVS_NS, false);
|
||||
_prefs.putFloat(NVS_KEY, _limit_deg);
|
||||
_prefs.end();
|
||||
AR_LOGI("HydraulicActuator", "angle_limit updated -> %.1f deg", _limit_deg);
|
||||
}
|
||||
|
||||
private:
|
||||
Preferences _prefs;
|
||||
float _limit_deg{DEFAULT_LIMIT_DEG};
|
||||
bool _powered{false};
|
||||
|
||||
static bool _limit_port() { return digitalRead(PIN_DI2) == HIGH; }
|
||||
static bool _limit_stbd() { return digitalRead(PIN_DI3) == HIGH; }
|
||||
static bool _at_standby(); // defined in modes/standby.cpp
|
||||
|
||||
void _stop_outputs() {
|
||||
_set_pwm(0);
|
||||
digitalWrite(PIN_DO1, LOW);
|
||||
digitalWrite(PIN_DO2, LOW);
|
||||
}
|
||||
|
||||
static void _set_pwm(uint8_t duty) {
|
||||
ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, duty);
|
||||
ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace arautopilot::hal
|
||||
@@ -0,0 +1,84 @@
|
||||
// =============================================================================
|
||||
// rudder_actuator_iface.h -- abstract rudder actuator interface (Sprint 4)
|
||||
// =============================================================================
|
||||
//
|
||||
// The PID inner loop knows only about IRudderActuator. The concrete
|
||||
// implementation (hydraulic, electric-DC, servo, or test-rig motor) is
|
||||
// selected at build time via the AR_ACTUATOR_TYPE macro produced by the
|
||||
// .appack firmware_config.h.
|
||||
//
|
||||
// Safety invariants (all implementations must honour):
|
||||
// 1. angle_limit_deg -- software limit loaded from NVS (or firmware_config.h
|
||||
// default). Commanded angles are clamped before
|
||||
// reaching the physical output.
|
||||
// 2. Limit switches -- DI2 (port) / DI3 (starboard) block motion in
|
||||
// the corresponding direction regardless of command.
|
||||
// 3. Master power -- DO3 must be HIGH before any motion.
|
||||
// 4. STANDBY guard -- implementations check current_mode() == STANDBY
|
||||
// and refuse motion.
|
||||
// =============================================================================
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace arautopilot::hal {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actuator type enumeration (mirrors .appack firmware_config.h values)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum class ActuatorType : uint8_t {
|
||||
HYDRAULIC_REVERSIBLE = 0, ///< Reversible hydraulic pump (Hynautic, Hypro …)
|
||||
ELECTRIC_DC = 1, ///< Reversible DC motor with mechanical end-stops
|
||||
SERVOMOTOR = 2, ///< Servomotor with built-in position feedback
|
||||
STERNDRIVE_ANALOG = 3, ///< Analog directional sterndrive
|
||||
TEST_RIG_MOTOR = 4, ///< HIL test rig: DC motor rotates compass platform
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Abstract interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class IRudderActuator {
|
||||
public:
|
||||
virtual ~IRudderActuator() = default;
|
||||
|
||||
/// One-time hardware initialisation (GPIOs, PWM channels …).
|
||||
virtual void init() = 0;
|
||||
|
||||
/// Engage master power relay. Required before command().
|
||||
virtual void power_on() = 0;
|
||||
|
||||
/// Drop master power relay and stop all outputs immediately.
|
||||
virtual void power_off() = 0;
|
||||
|
||||
/// Command the actuator toward *angle_deg* (positive = starboard).
|
||||
/// The implementation MUST clamp to [-angle_limit_deg, +angle_limit_deg]
|
||||
/// before driving any output.
|
||||
/// Returns true if the command was applied; false if an interlock blocked it.
|
||||
virtual bool command(float angle_deg) = 0;
|
||||
|
||||
/// Estimated actual rudder angle (degrees) from the sensor/feedback.
|
||||
/// Returns 0.0 if no feedback is available.
|
||||
virtual float actual_angle_deg() const = 0;
|
||||
|
||||
/// True if master power relay is currently energised.
|
||||
virtual bool is_powered() const = 0;
|
||||
|
||||
/// True if either limit switch is asserted.
|
||||
virtual bool at_limit() const = 0;
|
||||
|
||||
/// Current software angle limit (degrees, positive value).
|
||||
/// Can be updated at runtime via set_angle_limit_deg().
|
||||
virtual float angle_limit_deg() const = 0;
|
||||
|
||||
/// Update the software angle limit. Clamped to [15, 45] degrees.
|
||||
/// Persisted to NVS by the concrete implementation.
|
||||
virtual void set_angle_limit_deg(float limit_deg) = 0;
|
||||
|
||||
/// Actuator type tag for diagnostics.
|
||||
virtual ActuatorType type() const = 0;
|
||||
};
|
||||
|
||||
} // namespace arautopilot::hal
|
||||
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env python3
|
||||
# =============================================================================
|
||||
# installer/build_usb.py — Build a USB pendrive installer image
|
||||
# =============================================================================
|
||||
#
|
||||
# Developer tool that:
|
||||
# 1. Builds the Flutter Windows release (optional — skip with --no-flutter)
|
||||
# 2. Copies AR-ECDIS and AR-Autopilot binaries into dist/packages/
|
||||
# 3. Generates a fresh serial number and writes serial.key
|
||||
# 4. Creates autorun.inf and START_INSTALLER.bat
|
||||
#
|
||||
# Output: dist/ directory ready to be copied to a USB pendrive.
|
||||
#
|
||||
# Prerequisites (on the build machine):
|
||||
# - Flutter SDK in PATH (for AR-Autopilot Display)
|
||||
# - Python 3.11+
|
||||
# - AR-ECDIS webecdis cloned next to AR-Autopilot (or set --ecdis-dir)
|
||||
# - PyInstaller (pip install pyinstaller) — for AR-ECDIS .exe packaging
|
||||
#
|
||||
# Usage:
|
||||
# cd installer
|
||||
# python build_usb.py --vessel "BUQUE NORTE" --csv ../serials_log.csv
|
||||
# python build_usb.py --no-flutter --no-ecdis # quick test build
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
DISPLAY_DIR = REPO_ROOT / "display"
|
||||
INSTALLER_SRC = Path(__file__).resolve().parent / "src"
|
||||
DIST_DIR = Path(__file__).resolve().parent / "dist"
|
||||
|
||||
# Default location for the AR-ECDIS repo (sibling of AR-Autopilot)
|
||||
DEFAULT_ECDIS = REPO_ROOT.parent / "AR ECDIS" / "webecdis"
|
||||
|
||||
|
||||
def run(cmd: list[str], cwd: Path | None = None, check: bool = True):
|
||||
print(f" $ {' '.join(str(c) for c in cmd)}")
|
||||
subprocess.run(cmd, cwd=cwd, check=check)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 1 — Flutter Windows build
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_flutter(flutter_cmd: str = "flutter") -> Path:
|
||||
"""Build AR-Autopilot Display for Windows and return the build output dir."""
|
||||
print("\n[1/5] Building Flutter (Windows release)…")
|
||||
run([flutter_cmd, "build", "windows", "--release"], cwd=DISPLAY_DIR)
|
||||
build_out = DISPLAY_DIR / "build" / "windows" / "x64" / "runner" / "Release"
|
||||
if not build_out.exists():
|
||||
raise FileNotFoundError(f"Flutter build output not found: {build_out}")
|
||||
print(f" Output: {build_out}")
|
||||
return build_out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 2 — AR-ECDIS PyInstaller build
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_ecdis(ecdis_dir: Path) -> Path:
|
||||
"""Package AR-ECDIS with PyInstaller and return the dist directory."""
|
||||
print("\n[2/5] Building AR-ECDIS (PyInstaller)…")
|
||||
if not ecdis_dir.exists():
|
||||
print(f" AR-ECDIS dir not found ({ecdis_dir}) — skipping.")
|
||||
return Path()
|
||||
|
||||
main_py = ecdis_dir / "main.py"
|
||||
if not main_py.exists():
|
||||
print(f" AR-ECDIS main.py not found — skipping.")
|
||||
return Path()
|
||||
|
||||
run(
|
||||
[
|
||||
sys.executable, "-m", "PyInstaller",
|
||||
"--onedir",
|
||||
"--name", "AR-ECDIS",
|
||||
"--windowed",
|
||||
"--clean",
|
||||
str(main_py),
|
||||
],
|
||||
cwd=ecdis_dir,
|
||||
check=False, # non-fatal — missing PyInstaller is warned, not fatal
|
||||
)
|
||||
out = ecdis_dir / "dist" / "AR-ECDIS"
|
||||
if out.exists():
|
||||
print(f" Output: {out}")
|
||||
else:
|
||||
print(" PyInstaller output not found — AR-ECDIS skipped.")
|
||||
out = Path()
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 3 — Assemble dist/ tree
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def assemble_dist(
|
||||
flutter_build: Path,
|
||||
ecdis_build: Path,
|
||||
serial: str,
|
||||
) -> None:
|
||||
print("\n[3/5] Assembling USB installer tree…")
|
||||
|
||||
# Clean previous dist
|
||||
if DIST_DIR.exists():
|
||||
shutil.rmtree(DIST_DIR)
|
||||
DIST_DIR.mkdir(parents=True)
|
||||
|
||||
# Copy installer source files
|
||||
pkg_installer = DIST_DIR
|
||||
shutil.copytree(INSTALLER_SRC, pkg_installer / "src")
|
||||
|
||||
# Place the serial key
|
||||
(DIST_DIR / "serial.key").write_text(serial, encoding="utf-8")
|
||||
|
||||
# Copy app packages
|
||||
packages = DIST_DIR / "packages"
|
||||
packages.mkdir()
|
||||
|
||||
if flutter_build.exists():
|
||||
dest = packages / "AR-Autopilot"
|
||||
shutil.copytree(flutter_build, dest)
|
||||
print(f" AR-Autopilot → packages/AR-Autopilot/")
|
||||
|
||||
if ecdis_build.exists():
|
||||
dest = packages / "AR-ECDIS"
|
||||
shutil.copytree(ecdis_build, dest)
|
||||
print(f" AR-ECDIS → packages/AR-ECDIS/")
|
||||
|
||||
print(f" Serial key → serial.key ({serial})")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 4 — Write autorun + launcher batch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def write_autorun() -> None:
|
||||
print("\n[4/5] Writing autorun.inf and START_INSTALLER.bat…")
|
||||
|
||||
autorun = (
|
||||
"[autorun]\n"
|
||||
"label=AR Electronics Installer\n"
|
||||
"open=START_INSTALLER.bat\n"
|
||||
"icon=src\\install.py,0\n"
|
||||
)
|
||||
(DIST_DIR / "autorun.inf").write_text(autorun, encoding="utf-8")
|
||||
|
||||
batch = (
|
||||
"@echo off\n"
|
||||
"title AR Electronics — Instalador J6412\n"
|
||||
'echo Iniciando instalador AR Electronics...\n'
|
||||
'cd /d "%~dp0"\n'
|
||||
"python src\\install.py\n"
|
||||
"if errorlevel 1 (\n"
|
||||
" echo.\n"
|
||||
" echo ERROR: La instalacion fallo.\n"
|
||||
" pause\n"
|
||||
")\n"
|
||||
)
|
||||
(DIST_DIR / "START_INSTALLER.bat").write_text(batch, encoding="utf-8")
|
||||
|
||||
# README for field technicians
|
||||
readme = (
|
||||
"=== AR Electronics — Instalador J6412 ===\n\n"
|
||||
"1. Conecte este pendrive al mini PC J6412.\n"
|
||||
"2. Abra el explorador de archivos y ejecute START_INSTALLER.bat.\n"
|
||||
" (Si Windows pregunta, elija 'Más información' → 'Ejecutar de todas formas'.)\n"
|
||||
"3. El instalador solicitará permisos de administrador — acepte.\n"
|
||||
"4. Pulse INSTALAR y espere a que finalice.\n"
|
||||
"5. Reinicie el equipo.\n\n"
|
||||
"El sistema requiere conexión a internet para la activación de la licencia.\n\n"
|
||||
"Soporte: soporte@arelectronics.com\n"
|
||||
)
|
||||
(DIST_DIR / "LEAME.txt").write_text(readme, encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 5 — Summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def print_summary(serial: str) -> None:
|
||||
print("\n[5/5] Build complete.")
|
||||
print(f"\n Directorio de salida : {DIST_DIR}")
|
||||
print(f" Número de serie : {serial}")
|
||||
size_mb = sum(f.stat().st_size for f in DIST_DIR.rglob("*") if f.is_file()) / 1e6
|
||||
print(f" Tamaño total : {size_mb:.1f} MB")
|
||||
print("\n Copie todo el contenido de dist/ al pendrive USB.")
|
||||
print(" Asegúrese de que el pendrive tenga al menos 2 GB de espacio.\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="AR Electronics — USB installer builder"
|
||||
)
|
||||
parser.add_argument("--vessel", default="",
|
||||
help="Vessel name to tag the serial number with")
|
||||
parser.add_argument("--csv",
|
||||
help="Path to serials CSV log (created or appended)")
|
||||
parser.add_argument("--ecdis-dir", type=Path, default=DEFAULT_ECDIS,
|
||||
help=f"Path to AR-ECDIS webecdis directory (default: {DEFAULT_ECDIS})")
|
||||
parser.add_argument("--flutter", default="flutter",
|
||||
help="flutter command (default: 'flutter')")
|
||||
parser.add_argument("--no-flutter", action="store_true",
|
||||
help="Skip Flutter build (use existing build output)")
|
||||
parser.add_argument("--no-ecdis", action="store_true",
|
||||
help="Skip AR-ECDIS PyInstaller build")
|
||||
parser.add_argument("--serial",
|
||||
help="Use an existing serial number instead of generating one")
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=" * 60)
|
||||
print(" AR Electronics — USB Installer Builder")
|
||||
print("=" * 60)
|
||||
|
||||
# Resolve serial
|
||||
if args.serial:
|
||||
serial = args.serial
|
||||
print(f"\n Using existing serial: {serial}")
|
||||
else:
|
||||
# Import here to avoid circular issues if running as a module
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from serial_generator import generate_batch, write_csv # noqa: PLC0415
|
||||
|
||||
records = generate_batch(1, vessel=args.vessel)
|
||||
serial = records[0]["serial"]
|
||||
print(f"\n Generated serial: {serial}")
|
||||
if args.csv:
|
||||
write_csv(records, args.csv)
|
||||
|
||||
# Flutter build
|
||||
if args.no_flutter:
|
||||
flutter_build = DISPLAY_DIR / "build" / "windows" / "x64" / "runner" / "Release"
|
||||
print(f"\n[1/5] Skipping Flutter build — using {flutter_build}")
|
||||
else:
|
||||
flutter_build = build_flutter(args.flutter)
|
||||
|
||||
# AR-ECDIS build
|
||||
if args.no_ecdis:
|
||||
ecdis_build = Path()
|
||||
print("\n[2/5] Skipping AR-ECDIS build.")
|
||||
else:
|
||||
ecdis_build = build_ecdis(args.ecdis_dir)
|
||||
|
||||
# Assemble
|
||||
assemble_dist(flutter_build, ecdis_build, serial)
|
||||
write_autorun()
|
||||
print_summary(serial)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
# =============================================================================
|
||||
# installer/serial_generator.py — AR Electronics serial number generator
|
||||
# =============================================================================
|
||||
#
|
||||
# Developer tool. Generates a batch of unique serial numbers and optionally
|
||||
# writes them to a CSV log for the AR Electronics CRM.
|
||||
#
|
||||
# Format: AR-XXXX-XXXX-XXXX (hex groups, 48 bits of entropy ≈ 281 trillion)
|
||||
#
|
||||
# Usage:
|
||||
# python serial_generator.py 10 # generate 10 serials
|
||||
# python serial_generator.py 10 --csv serials.csv
|
||||
# python serial_generator.py 1 --vessel "MY YACHT NAME" --csv serials.csv
|
||||
# =============================================================================
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def generate_serial() -> str:
|
||||
"""Generate a single AR-XXXX-XXXX-XXXX serial number."""
|
||||
raw = secrets.token_hex(6).upper() # 6 bytes = 12 hex chars = 3 × 4
|
||||
return f"AR-{raw[0:4]}-{raw[4:8]}-{raw[8:12]}"
|
||||
|
||||
|
||||
def generate_batch(count: int, vessel: str = "") -> list[dict]:
|
||||
serials = []
|
||||
seen: set[str] = set()
|
||||
|
||||
while len(serials) < count:
|
||||
serial = generate_serial()
|
||||
if serial in seen:
|
||||
continue # collision (astronomically unlikely)
|
||||
seen.add(serial)
|
||||
serials.append(
|
||||
{
|
||||
"serial": serial,
|
||||
"vessel": vessel,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "unactivated",
|
||||
}
|
||||
)
|
||||
|
||||
return serials
|
||||
|
||||
|
||||
def write_key_file(serial: str, output_path: str) -> None:
|
||||
"""Write a single serial number to a serial.key file."""
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(serial)
|
||||
print(f" → {output_path}")
|
||||
|
||||
|
||||
def write_csv(records: list[dict], csv_path: str) -> None:
|
||||
"""Append records to a CSV log (creates file if missing)."""
|
||||
file_exists = os.path.exists(csv_path)
|
||||
with open(csv_path, "a", newline="", encoding="utf-8") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=["serial", "vessel", "created_at", "status"])
|
||||
if not file_exists:
|
||||
writer.writeheader()
|
||||
writer.writerows(records)
|
||||
print(f"\nAnexado a CSV: {csv_path}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="AR Electronics — Generador de números de serie"
|
||||
)
|
||||
parser.add_argument("count", type=int, nargs="?", default=1,
|
||||
help="Cantidad de seriales a generar (default: 1)")
|
||||
parser.add_argument("--vessel", default="",
|
||||
help="Nombre del buque para asignar al lote")
|
||||
parser.add_argument("--csv",
|
||||
help="Ruta al archivo CSV de registro (se crea o se añade)")
|
||||
parser.add_argument("--key-dir",
|
||||
help="Directorio donde escribir archivos serial.key individuales")
|
||||
args = parser.parse_args()
|
||||
|
||||
records = generate_batch(args.count, vessel=args.vessel)
|
||||
|
||||
print(f"\nSeriales generados ({args.count}):\n")
|
||||
for rec in records:
|
||||
vessel_info = f" [{rec['vessel']}]" if rec["vessel"] else ""
|
||||
print(f" {rec['serial']}{vessel_info}")
|
||||
|
||||
if args.csv:
|
||||
write_csv(records, args.csv)
|
||||
|
||||
if args.key_dir:
|
||||
os.makedirs(args.key_dir, exist_ok=True)
|
||||
for i, rec in enumerate(records):
|
||||
filename = f"serial_{i+1:03d}.key" if len(records) > 1 else "serial.key"
|
||||
write_key_file(rec["serial"], os.path.join(args.key_dir, filename))
|
||||
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,426 @@
|
||||
# =============================================================================
|
||||
# installer/src/install.py — AR Electronics J6412 installer
|
||||
# =============================================================================
|
||||
#
|
||||
# Tkinter GUI installer that:
|
||||
# 1. Validates the bundled serial number
|
||||
# 2. Installs AR-ECDIS and AR-Autopilot Display to Program Files
|
||||
# 3. Activates the license online
|
||||
# 4. Configures Windows autostart, shortcuts, and firewall rules
|
||||
#
|
||||
# Usage:
|
||||
# python install.py — interactive GUI mode
|
||||
# python install.py --silent — headless mode (for testing / scripted deploy)
|
||||
#
|
||||
# This file lives in the root of the USB pendrive alongside serial.key and
|
||||
# the packages/ directory. Run as Administrator for full functionality.
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from pathlib import Path
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
# ── Resolve installer root (directory containing this script) ─────────────────
|
||||
INSTALLER_DIR = Path(__file__).parent
|
||||
PACKAGES_DIR = INSTALLER_DIR / "packages"
|
||||
|
||||
APP_VERSION = "0.4.0"
|
||||
|
||||
# ── Late imports from sibling modules ─────────────────────────────────────────
|
||||
sys.path.insert(0, str(INSTALLER_DIR))
|
||||
from license import activate_online, read_serial, ActivationError # noqa: E402
|
||||
from sysconfig import ( # noqa: E402
|
||||
ECDIS_DIR, AUTOPILOT_DIR,
|
||||
ensure_install_dirs, configure_autostart,
|
||||
create_shortcuts, add_firewall_rules, list_com_ports,
|
||||
is_admin, require_admin,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Installation steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
STEPS = [
|
||||
("Verificar serial", "_step_verify_serial"),
|
||||
("Crear directorios", "_step_create_dirs"),
|
||||
("Instalar AR-ECDIS", "_step_install_ecdis"),
|
||||
("Instalar AR-Autopilot", "_step_install_autopilot"),
|
||||
("Activar licencia", "_step_activate_license"),
|
||||
("Configurar inicio", "_step_configure_autostart"),
|
||||
("Accesos directos", "_step_create_shortcuts"),
|
||||
("Reglas de firewall", "_step_firewall"),
|
||||
("Finalizar", "_step_finalize"),
|
||||
]
|
||||
|
||||
|
||||
class Installer:
|
||||
"""Core installation logic — UI-independent."""
|
||||
|
||||
def __init__(self, log_fn=print):
|
||||
self._log = log_fn
|
||||
self.serial: str | None = None
|
||||
|
||||
# ── Step implementations ─────────────────────────────────────────────────
|
||||
|
||||
def _step_verify_serial(self):
|
||||
self._log("Leyendo número de serie…")
|
||||
self.serial = read_serial(INSTALLER_DIR)
|
||||
self._log(f"Serial: {self.serial}")
|
||||
|
||||
def _step_create_dirs(self):
|
||||
self._log("Creando directorios en Program Files…")
|
||||
ensure_install_dirs()
|
||||
|
||||
def _step_install_ecdis(self):
|
||||
src = PACKAGES_DIR / "AR-ECDIS"
|
||||
if not src.exists():
|
||||
self._log("Paquete AR-ECDIS no encontrado — omitiendo.")
|
||||
return
|
||||
self._log(f"Copiando AR-ECDIS → {ECDIS_DIR}")
|
||||
if ECDIS_DIR.exists():
|
||||
shutil.rmtree(ECDIS_DIR)
|
||||
shutil.copytree(src, ECDIS_DIR)
|
||||
self._log("AR-ECDIS instalado correctamente.")
|
||||
|
||||
def _step_install_autopilot(self):
|
||||
src = PACKAGES_DIR / "AR-Autopilot"
|
||||
if not src.exists():
|
||||
self._log("Paquete AR-Autopilot no encontrado — omitiendo.")
|
||||
return
|
||||
self._log(f"Copiando AR-Autopilot → {AUTOPILOT_DIR}")
|
||||
if AUTOPILOT_DIR.exists():
|
||||
shutil.rmtree(AUTOPILOT_DIR)
|
||||
shutil.copytree(src, AUTOPILOT_DIR)
|
||||
self._log("AR-Autopilot instalado correctamente.")
|
||||
|
||||
def _step_activate_license(self):
|
||||
if self.serial is None:
|
||||
raise RuntimeError("Serial no disponible para activación.")
|
||||
self._log(f"Activando licencia en servidor AR Electronics…")
|
||||
result = activate_online(self.serial, app_version=APP_VERSION)
|
||||
self._log(
|
||||
f"Licencia activada — ID: {result.activation_id[:8]}…\n"
|
||||
f" Slot: {result.vessel_slot} | {result.licensed_to}"
|
||||
)
|
||||
|
||||
def _step_configure_autostart(self):
|
||||
self._log("Configurando inicio automático con Windows…")
|
||||
configure_autostart()
|
||||
|
||||
def _step_create_shortcuts(self):
|
||||
self._log("Creando accesos directos…")
|
||||
create_shortcuts()
|
||||
|
||||
def _step_firewall(self):
|
||||
if not is_admin():
|
||||
self._log("Sin privilegios de administrador — omitiendo reglas de firewall.")
|
||||
return
|
||||
self._log("Añadiendo reglas de firewall…")
|
||||
add_firewall_rules()
|
||||
|
||||
def _step_finalize(self):
|
||||
ports = list_com_ports()
|
||||
if ports:
|
||||
self._log(f"Puertos COM detectados: {', '.join(ports)}")
|
||||
self._log(
|
||||
"Conecte el concentrador y configure los puertos en\n"
|
||||
"AR-Autopilot → Ajustes → Puertos COM."
|
||||
)
|
||||
else:
|
||||
self._log("No se detectaron puertos COM — conecte el concentrador USB.")
|
||||
self._log("Instalación completada con éxito.")
|
||||
|
||||
# ── Public runner ────────────────────────────────────────────────────────
|
||||
|
||||
def run_all(self, progress_cb=None):
|
||||
"""
|
||||
Execute all steps sequentially.
|
||||
|
||||
:param progress_cb: optional callable(step_index, step_name, success, error)
|
||||
"""
|
||||
for idx, (name, method_name) in enumerate(STEPS):
|
||||
method = getattr(self, method_name)
|
||||
try:
|
||||
method()
|
||||
if progress_cb:
|
||||
progress_cb(idx, name, True, None)
|
||||
except ActivationError as exc:
|
||||
if progress_cb:
|
||||
progress_cb(idx, name, False, exc)
|
||||
raise
|
||||
except Exception as exc:
|
||||
if progress_cb:
|
||||
progress_cb(idx, name, False, exc)
|
||||
raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tkinter GUI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BRAND_NAVY = "#0D1B2A"
|
||||
BRAND_BLUE = "#2563EB"
|
||||
BRAND_GLOW = "#60B8FF"
|
||||
BRAND_TEXT = "#E2E8F0"
|
||||
BRAND_MUTED = "#8899AA"
|
||||
BRAND_GREEN = "#22C55E"
|
||||
BRAND_RED = "#EF4444"
|
||||
|
||||
|
||||
class InstallerWindow:
|
||||
def __init__(self):
|
||||
self.root = tk.Tk()
|
||||
self.root.title("AR Electronics — Instalador J6412")
|
||||
self.root.configure(bg=BRAND_NAVY)
|
||||
self.root.resizable(False, False)
|
||||
|
||||
# Centre on screen
|
||||
w, h = 560, 520
|
||||
sw = self.root.winfo_screenwidth()
|
||||
sh = self.root.winfo_screenheight()
|
||||
self.root.geometry(f"{w}x{h}+{(sw-w)//2}+{(sh-h)//2}")
|
||||
|
||||
self._build_ui()
|
||||
self._installer = Installer(log_fn=self._append_log)
|
||||
|
||||
# ── UI construction ──────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
# Header
|
||||
hdr = tk.Frame(self.root, bg=BRAND_NAVY)
|
||||
hdr.pack(fill="x", padx=0, pady=0)
|
||||
|
||||
tk.Label(
|
||||
hdr,
|
||||
text="AR Electronics",
|
||||
font=("Segoe UI", 18, "bold"),
|
||||
fg=BRAND_GLOW,
|
||||
bg=BRAND_NAVY,
|
||||
).pack(pady=(20, 0))
|
||||
|
||||
tk.Label(
|
||||
hdr,
|
||||
text="Instalador para J6412 Mini PC",
|
||||
font=("Segoe UI", 11),
|
||||
fg=BRAND_MUTED,
|
||||
bg=BRAND_NAVY,
|
||||
).pack(pady=(0, 12))
|
||||
|
||||
sep = tk.Frame(self.root, height=1, bg=BRAND_BLUE)
|
||||
sep.pack(fill="x", padx=20)
|
||||
|
||||
# Steps frame
|
||||
self._step_vars: list[tk.StringVar] = []
|
||||
steps_frame = tk.Frame(self.root, bg=BRAND_NAVY)
|
||||
steps_frame.pack(fill="x", padx=30, pady=14)
|
||||
|
||||
for _, (name, _) in enumerate(STEPS):
|
||||
var = tk.StringVar(value=f" ○ {name}")
|
||||
lbl = tk.Label(
|
||||
steps_frame,
|
||||
textvariable=var,
|
||||
font=("Consolas", 10),
|
||||
fg=BRAND_MUTED,
|
||||
bg=BRAND_NAVY,
|
||||
anchor="w",
|
||||
)
|
||||
lbl.pack(fill="x", pady=1)
|
||||
self._step_vars.append(var)
|
||||
|
||||
self._step_labels = steps_frame.winfo_children()
|
||||
|
||||
# Progress bar
|
||||
pb_frame = tk.Frame(self.root, bg=BRAND_NAVY)
|
||||
pb_frame.pack(fill="x", padx=30, pady=(0, 8))
|
||||
|
||||
style = ttk.Style()
|
||||
style.theme_use("clam")
|
||||
style.configure(
|
||||
"AR.Horizontal.TProgressbar",
|
||||
troughcolor=BRAND_NAVY,
|
||||
bordercolor=BRAND_BLUE,
|
||||
background=BRAND_GLOW,
|
||||
lightcolor=BRAND_GLOW,
|
||||
darkcolor=BRAND_BLUE,
|
||||
)
|
||||
self._progress = ttk.Progressbar(
|
||||
pb_frame,
|
||||
style="AR.Horizontal.TProgressbar",
|
||||
maximum=len(STEPS),
|
||||
length=500,
|
||||
)
|
||||
self._progress.pack(fill="x")
|
||||
|
||||
# Log text
|
||||
log_frame = tk.Frame(self.root, bg="#0A1520")
|
||||
log_frame.pack(fill="both", expand=True, padx=20, pady=(0, 12))
|
||||
|
||||
self._log_text = tk.Text(
|
||||
log_frame,
|
||||
height=7,
|
||||
font=("Consolas", 9),
|
||||
bg="#0A1520",
|
||||
fg=BRAND_MUTED,
|
||||
relief="flat",
|
||||
state="disabled",
|
||||
wrap="word",
|
||||
)
|
||||
self._log_text.pack(fill="both", expand=True, padx=8, pady=6)
|
||||
|
||||
# Buttons
|
||||
btn_frame = tk.Frame(self.root, bg=BRAND_NAVY)
|
||||
btn_frame.pack(fill="x", padx=20, pady=(0, 20))
|
||||
|
||||
self._install_btn = tk.Button(
|
||||
btn_frame,
|
||||
text="INSTALAR",
|
||||
font=("Segoe UI", 10, "bold"),
|
||||
bg=BRAND_BLUE,
|
||||
fg="white",
|
||||
activebackground=BRAND_GLOW,
|
||||
relief="flat",
|
||||
padx=24,
|
||||
pady=8,
|
||||
cursor="hand2",
|
||||
command=self._start_install,
|
||||
)
|
||||
self._install_btn.pack(side="left")
|
||||
|
||||
self._cancel_btn = tk.Button(
|
||||
btn_frame,
|
||||
text="Cancelar",
|
||||
font=("Segoe UI", 10),
|
||||
bg=BRAND_NAVY,
|
||||
fg=BRAND_MUTED,
|
||||
activeforeground=BRAND_TEXT,
|
||||
relief="flat",
|
||||
padx=16,
|
||||
pady=8,
|
||||
cursor="hand2",
|
||||
command=self.root.destroy,
|
||||
)
|
||||
self._cancel_btn.pack(side="left", padx=(10, 0))
|
||||
|
||||
self._status_lbl = tk.Label(
|
||||
btn_frame,
|
||||
text="",
|
||||
font=("Segoe UI", 9),
|
||||
fg=BRAND_MUTED,
|
||||
bg=BRAND_NAVY,
|
||||
)
|
||||
self._status_lbl.pack(side="right")
|
||||
|
||||
# ── Install thread ───────────────────────────────────────────────────────
|
||||
|
||||
def _start_install(self):
|
||||
self._install_btn.configure(state="disabled")
|
||||
self._cancel_btn.configure(state="disabled")
|
||||
threading.Thread(target=self._run_install, daemon=True).start()
|
||||
|
||||
def _run_install(self):
|
||||
try:
|
||||
self._installer.run_all(progress_cb=self._on_step)
|
||||
self.root.after(0, self._on_success)
|
||||
except Exception as exc:
|
||||
self.root.after(0, self._on_failure, str(exc))
|
||||
|
||||
def _on_step(self, idx: int, name: str, success: bool, error):
|
||||
def update():
|
||||
if success:
|
||||
self._step_vars[idx].set(f" ✓ {name}")
|
||||
self._step_labels[idx].configure(fg=BRAND_GREEN)
|
||||
else:
|
||||
self._step_vars[idx].set(f" ✗ {name}")
|
||||
self._step_labels[idx].configure(fg=BRAND_RED)
|
||||
self._progress["value"] = idx + 1
|
||||
|
||||
self.root.after(0, update)
|
||||
|
||||
def _on_success(self):
|
||||
self._status_lbl.configure(text="Instalación completada", fg=BRAND_GREEN)
|
||||
self._cancel_btn.configure(state="normal", text="Cerrar")
|
||||
messagebox.showinfo(
|
||||
"AR Electronics",
|
||||
"Instalación completada con éxito.\n\n"
|
||||
"AR-ECDIS y AR-Autopilot están listos.\n"
|
||||
"Reinicie el equipo para activar el inicio automático.",
|
||||
)
|
||||
|
||||
def _on_failure(self, msg: str):
|
||||
self._status_lbl.configure(text="Error en la instalación", fg=BRAND_RED)
|
||||
self._install_btn.configure(state="normal")
|
||||
self._cancel_btn.configure(state="normal")
|
||||
messagebox.showerror(
|
||||
"Error de instalación",
|
||||
f"La instalación no se completó:\n\n{msg}\n\n"
|
||||
"Verifique la conexión a internet y vuelva a intentarlo.\n"
|
||||
"Si el problema persiste, contacte a AR Electronics.",
|
||||
)
|
||||
|
||||
# ── Log output ───────────────────────────────────────────────────────────
|
||||
|
||||
def _append_log(self, text: str):
|
||||
def _do():
|
||||
self._log_text.configure(state="normal")
|
||||
self._log_text.insert("end", text + "\n")
|
||||
self._log_text.see("end")
|
||||
self._log_text.configure(state="disabled")
|
||||
|
||||
self.root.after(0, _do)
|
||||
|
||||
# ── Main loop ────────────────────────────────────────────────────────────
|
||||
|
||||
def run(self):
|
||||
self.root.mainloop()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Silent / headless mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_silent():
|
||||
installer = Installer()
|
||||
errors = []
|
||||
|
||||
def cb(idx, name, success, error):
|
||||
icon = "✓" if success else "✗"
|
||||
print(f" [{icon}] {name}")
|
||||
if error:
|
||||
errors.append(str(error))
|
||||
|
||||
try:
|
||||
installer.run_all(progress_cb=cb)
|
||||
print("\nInstalación completada con éxito.")
|
||||
except Exception as exc:
|
||||
print(f"\nERROR: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="AR Electronics J6412 Installer")
|
||||
parser.add_argument(
|
||||
"--silent", action="store_true", help="Run without GUI (headless mode)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-admin-check", action="store_true", help="Skip UAC elevation request"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.no_admin_check:
|
||||
require_admin()
|
||||
|
||||
if args.silent:
|
||||
run_silent()
|
||||
else:
|
||||
InstallerWindow().run()
|
||||
@@ -0,0 +1,246 @@
|
||||
# =============================================================================
|
||||
# installer/src/license.py — Serial-number activation client
|
||||
# =============================================================================
|
||||
#
|
||||
# Reads the serial number bundled with this installer package (serial.key),
|
||||
# collects a hardware fingerprint (Windows Machine GUID + primary MAC address),
|
||||
# POSTs to the AR Electronics license server, and caches the activation token
|
||||
# locally in %APPDATA%\AR Electronics\license.json.
|
||||
#
|
||||
# Offline grace period: 30 days without contacting the server.
|
||||
# Duplicate activations on a different machine are rejected server-side.
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import platform
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import uuid
|
||||
import winreg
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SERVER_BASE_URL = "https://license.arelectronics.com"
|
||||
ACTIVATE_ROUTE = "/api/v1/activate"
|
||||
VALIDATE_ROUTE = "/api/v1/validate"
|
||||
OFFLINE_GRACE = 30 # days allowed without server contact
|
||||
APP_DATA_DIR = Path.home() / "AppData" / "Roaming" / "AR Electronics"
|
||||
LICENSE_CACHE = APP_DATA_DIR / "license.json"
|
||||
SERIAL_FILE_NAME = "serial.key" # placed next to install.py by build_usb.py
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hardware fingerprint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _machine_guid() -> str:
|
||||
"""Read the Windows Machine GUID from the registry (stable across reboots)."""
|
||||
try:
|
||||
key = winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE,
|
||||
r"SOFTWARE\Microsoft\Cryptography",
|
||||
)
|
||||
value, _ = winreg.QueryValueEx(key, "MachineGuid")
|
||||
winreg.CloseKey(key)
|
||||
return value
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _primary_mac() -> str:
|
||||
"""Return the MAC address of the adapter used for the default route."""
|
||||
try:
|
||||
# Connect to an external address (no data sent) to discover default adapter
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
|
||||
# Find the MAC for this IP using ipconfig output
|
||||
result = subprocess.run(
|
||||
["ipconfig", "/all"], capture_output=True, text=True, timeout=5
|
||||
)
|
||||
blocks = result.stdout.split("\n\n")
|
||||
for block in blocks:
|
||||
if ip in block:
|
||||
m = re.search(
|
||||
r"Physical Address[.\s]+:\s*([0-9A-F-]{17})", block, re.IGNORECASE
|
||||
)
|
||||
if m:
|
||||
return m.group(1).replace("-", ":").upper()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback — uuid.getnode() uses whichever adapter Python finds first
|
||||
raw = uuid.getnode()
|
||||
return ":".join(f"{(raw >> (i * 8)) & 0xFF:02X}" for i in range(5, -1, -1))
|
||||
|
||||
|
||||
def hardware_fingerprint() -> str:
|
||||
"""Stable, anonymised hardware fingerprint for this machine."""
|
||||
raw = f"{_machine_guid()}|{_primary_mac()}|{platform.node()}"
|
||||
return hashlib.sha256(raw.encode()).hexdigest()[:32]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serial number helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def read_serial(installer_dir: Path) -> str:
|
||||
"""
|
||||
Read the serial number from ``serial.key`` next to the installer.
|
||||
|
||||
Raises FileNotFoundError if the file is missing, ValueError if malformed.
|
||||
"""
|
||||
serial_path = installer_dir / SERIAL_FILE_NAME
|
||||
if not serial_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Serial key file not found: {serial_path}\n"
|
||||
"This installer package may be incomplete."
|
||||
)
|
||||
serial = serial_path.read_text(encoding="utf-8").strip()
|
||||
if not _valid_serial_format(serial):
|
||||
raise ValueError(f"Malformed serial number: {serial!r}")
|
||||
return serial
|
||||
|
||||
|
||||
def _valid_serial_format(serial: str) -> bool:
|
||||
"""Validate format: AR-XXXX-XXXX-XXXX (hex groups)."""
|
||||
pattern = r"^AR-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$"
|
||||
return bool(re.match(pattern, serial, re.IGNORECASE))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Activation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ActivationError(Exception):
|
||||
"""Raised when activation is refused or fails."""
|
||||
|
||||
|
||||
class ActivationResult:
|
||||
def __init__(self, data: dict):
|
||||
self.activation_id: str = data["activation_id"]
|
||||
self.vessel_slot: int = data.get("vessel_slot", 1)
|
||||
self.licensed_to: str = data.get("licensed_to", "")
|
||||
self.activated_at: str = data.get("activated_at", "")
|
||||
self.expires_at: str | None = data.get("expires_at")
|
||||
|
||||
|
||||
def activate_online(serial: str, app_version: str = "0.4.0") -> ActivationResult:
|
||||
"""
|
||||
POST activation request to the AR Electronics license server.
|
||||
|
||||
On success caches the response in LICENSE_CACHE.
|
||||
Raises ActivationError on any refusal or connection problem.
|
||||
"""
|
||||
hw_id = hardware_fingerprint()
|
||||
payload = {
|
||||
"serial": serial,
|
||||
"hardware_id": hw_id,
|
||||
"app_version": app_version,
|
||||
"platform": platform.system(),
|
||||
"hostname": platform.node(),
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
SERVER_BASE_URL + ACTIVATE_ROUTE,
|
||||
json=payload,
|
||||
timeout=15,
|
||||
)
|
||||
except requests.ConnectionError:
|
||||
raise ActivationError("No se pudo conectar con el servidor de licencias.\n"
|
||||
"Verifique la conexión a internet e intente de nuevo.")
|
||||
except requests.Timeout:
|
||||
raise ActivationError("El servidor de licencias no respondió (timeout 15 s).")
|
||||
|
||||
if resp.status_code == 200:
|
||||
result = ActivationResult(resp.json())
|
||||
_cache_activation(serial, hw_id, resp.json())
|
||||
return result
|
||||
|
||||
# Server returned an error
|
||||
try:
|
||||
msg = resp.json().get("detail", resp.text)
|
||||
except Exception:
|
||||
msg = resp.text
|
||||
raise ActivationError(f"Activación rechazada ({resp.status_code}): {msg}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Local cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cache_activation(serial: str, hardware_id: str, server_data: dict) -> None:
|
||||
APP_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cache = {
|
||||
"serial": serial,
|
||||
"hardware_id": hardware_id,
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"server_data": server_data,
|
||||
}
|
||||
LICENSE_CACHE.write_text(json.dumps(cache, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def load_cached_license() -> dict | None:
|
||||
"""Return cached license dict or None if missing / expired."""
|
||||
if not LICENSE_CACHE.exists():
|
||||
return None
|
||||
try:
|
||||
cache = json.loads(LICENSE_CACHE.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
# Validate hardware fingerprint matches this machine
|
||||
if cache.get("hardware_id") != hardware_fingerprint():
|
||||
return None
|
||||
|
||||
# Check offline grace period
|
||||
cached_at = datetime.fromisoformat(cache["cached_at"])
|
||||
if datetime.now(timezone.utc) - cached_at > timedelta(days=OFFLINE_GRACE):
|
||||
return None # Cache expired — must re-validate online
|
||||
|
||||
return cache
|
||||
|
||||
|
||||
def is_activated() -> bool:
|
||||
"""Quick check: is this machine currently activated (cache or online)?"""
|
||||
cache = load_cached_license()
|
||||
if cache is not None:
|
||||
return True
|
||||
# Try a fast online validate
|
||||
hw_id = hardware_fingerprint()
|
||||
serial = _serial_from_cache()
|
||||
if serial is None:
|
||||
return False
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{SERVER_BASE_URL}{VALIDATE_ROUTE}/{serial}",
|
||||
params={"hardware_id": hw_id},
|
||||
timeout=8,
|
||||
)
|
||||
if resp.status_code == 200 and resp.json().get("active"):
|
||||
_cache_activation(serial, hw_id, resp.json())
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _serial_from_cache() -> str | None:
|
||||
if not LICENSE_CACHE.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(LICENSE_CACHE.read_text(encoding="utf-8")).get("serial")
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,201 @@
|
||||
# =============================================================================
|
||||
# installer/src/sysconfig.py — Windows system configuration helpers
|
||||
# =============================================================================
|
||||
#
|
||||
# All functions target Windows 10/11 (tested on J6412 with Windows 10 IoT).
|
||||
# Requires the installer to be run as Administrator for firewall and registry
|
||||
# operations; shortcuts and HKCU run-key work without elevation.
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import winreg
|
||||
from pathlib import Path
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Install paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
INSTALL_ROOT = Path(os.environ.get("ProgramFiles", "C:\\Program Files")) / "AR Electronics"
|
||||
ECDIS_DIR = INSTALL_ROOT / "AR-ECDIS"
|
||||
AUTOPILOT_DIR = INSTALL_ROOT / "AR-Autopilot"
|
||||
APPDATA_DIR = Path.home() / "AppData" / "Roaming" / "AR Electronics"
|
||||
START_MENU = Path(os.environ.get("APPDATA", "")) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "AR Electronics"
|
||||
|
||||
|
||||
def ensure_install_dirs() -> None:
|
||||
"""Create install directory tree (requires admin)."""
|
||||
for d in (INSTALL_ROOT, ECDIS_DIR, AUTOPILOT_DIR, APPDATA_DIR, START_MENU):
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-start (HKCU — no admin required)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_AUTORUN_KEY = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Run"
|
||||
_AR_ECDIS_VALUE = "AR-ECDIS"
|
||||
_AR_AUTOPILOT_VALUE = "AR-Autopilot"
|
||||
|
||||
|
||||
def register_autostart(name: str, exe_path: Path, args: str = "") -> None:
|
||||
"""
|
||||
Add an entry to HKCU Run so the app starts with Windows.
|
||||
|
||||
Does NOT require administrator rights (current-user key).
|
||||
"""
|
||||
cmd = f'"{exe_path}" {args}'.strip()
|
||||
key = winreg.OpenKey(
|
||||
winreg.HKEY_CURRENT_USER,
|
||||
_AUTORUN_KEY,
|
||||
access=winreg.KEY_SET_VALUE,
|
||||
)
|
||||
winreg.SetValueEx(key, name, 0, winreg.REG_SZ, cmd)
|
||||
winreg.CloseKey(key)
|
||||
|
||||
|
||||
def unregister_autostart(name: str) -> None:
|
||||
"""Remove an auto-start entry (best-effort, ignores missing keys)."""
|
||||
try:
|
||||
key = winreg.OpenKey(
|
||||
winreg.HKEY_CURRENT_USER,
|
||||
_AUTORUN_KEY,
|
||||
access=winreg.KEY_SET_VALUE,
|
||||
)
|
||||
winreg.DeleteValue(key, name)
|
||||
winreg.CloseKey(key)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def configure_autostart() -> None:
|
||||
"""Register both AR Electronics apps in the Windows autostart."""
|
||||
ecdis_exe = ECDIS_DIR / "AR-ECDIS.exe"
|
||||
autopilot_exe = AUTOPILOT_DIR / "ar_autopilot_display.exe"
|
||||
|
||||
if ecdis_exe.exists():
|
||||
register_autostart(_AR_ECDIS_VALUE, ecdis_exe)
|
||||
|
||||
if autopilot_exe.exists():
|
||||
register_autostart(_AR_AUTOPILOT_VALUE, autopilot_exe)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Desktop and Start Menu shortcuts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_shortcut(target: Path, shortcut_path: Path, icon: Path | None = None) -> None:
|
||||
"""
|
||||
Create a .lnk shortcut using PowerShell WScript.Shell.
|
||||
|
||||
No admin required; works on standard user accounts.
|
||||
"""
|
||||
icon_str = f'$s.IconLocation = "{icon}"' if icon else ""
|
||||
script = (
|
||||
f'$s=(New-Object -COM WScript.Shell).CreateShortcut("{shortcut_path}");'
|
||||
f'$s.TargetPath="{target}";'
|
||||
f'{icon_str};'
|
||||
f'$s.Save()'
|
||||
)
|
||||
subprocess.run(["powershell", "-Command", script], check=True, capture_output=True)
|
||||
|
||||
|
||||
def create_shortcuts() -> None:
|
||||
"""Create Start Menu and Desktop shortcuts for both apps."""
|
||||
START_MENU.mkdir(parents=True, exist_ok=True)
|
||||
desktop = Path.home() / "Desktop"
|
||||
|
||||
apps = [
|
||||
("AR-ECDIS", ECDIS_DIR / "AR-ECDIS.exe"),
|
||||
("AR-Autopilot", AUTOPILOT_DIR / "ar_autopilot_display.exe"),
|
||||
]
|
||||
for name, exe in apps:
|
||||
if not exe.exists():
|
||||
continue
|
||||
lnk = f"{name}.lnk"
|
||||
_make_shortcut(exe, START_MENU / lnk, icon=exe)
|
||||
_make_shortcut(exe, desktop / lnk, icon=exe)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Windows Firewall rules (requires admin)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def add_firewall_rules() -> None:
|
||||
"""
|
||||
Allow inbound TCP on ports used by AR Electronics services.
|
||||
|
||||
Requires the installer to be running as Administrator.
|
||||
"""
|
||||
rules = [
|
||||
("AR-ECDIS Web", "TCP", "8080"), # AR-ECDIS FastAPI backend
|
||||
("AR-ECDIS WebSocket","TCP", "8080"),
|
||||
]
|
||||
for name, proto, port in rules:
|
||||
subprocess.run(
|
||||
[
|
||||
"netsh", "advfirewall", "firewall", "add", "rule",
|
||||
f"name={name}",
|
||||
"dir=in",
|
||||
"action=allow",
|
||||
f"protocol={proto}",
|
||||
f"localport={port}",
|
||||
],
|
||||
check=False, # non-fatal if rule already exists
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# COM port detection (informational — no forced assignment)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def list_com_ports() -> list[str]:
|
||||
"""
|
||||
Return list of detected COM ports on the system.
|
||||
|
||||
On Windows this queries the registry rather than requiring PySerial.
|
||||
"""
|
||||
ports: list[str] = []
|
||||
try:
|
||||
key = winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE,
|
||||
r"HARDWARE\DEVICEMAP\SERIALCOMM",
|
||||
)
|
||||
i = 0
|
||||
while True:
|
||||
try:
|
||||
_, port_name, _ = winreg.EnumValue(key, i)
|
||||
ports.append(port_name)
|
||||
i += 1
|
||||
except OSError:
|
||||
break
|
||||
winreg.CloseKey(key)
|
||||
except OSError:
|
||||
pass
|
||||
return sorted(ports)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Administrator check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def is_admin() -> bool:
|
||||
"""Return True if the current process has administrator privileges."""
|
||||
try:
|
||||
return ctypes.windll.shell32.IsUserAnAdmin() != 0
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
|
||||
def require_admin() -> None:
|
||||
"""Re-launch this process with UAC elevation if not already admin."""
|
||||
if not is_admin():
|
||||
ctypes.windll.shell32.ShellExecuteW(
|
||||
None, "runas", sys.executable, " ".join(sys.argv), None, 1
|
||||
)
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,10 @@
|
||||
# AR Electronics License Server — environment variables
|
||||
# Copy this file to .env and fill in values before starting the server.
|
||||
|
||||
# Database — SQLite (default) or PostgreSQL
|
||||
DATABASE_URL=sqlite:///./ar_licenses.db
|
||||
# DATABASE_URL=postgresql://user:password@localhost:5432/ar_licenses
|
||||
|
||||
# Admin API key — change this to a strong random value in production!
|
||||
# Generate one: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
ADMIN_API_KEY=change-me-in-production
|
||||
@@ -0,0 +1 @@
|
||||
# AR Electronics License Server package
|
||||
@@ -0,0 +1,33 @@
|
||||
# =============================================================================
|
||||
# license_server/database.py — SQLAlchemy engine + session factory
|
||||
# =============================================================================
|
||||
|
||||
import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
|
||||
# Default: SQLite in same directory. Set DATABASE_URL env var for PostgreSQL.
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"sqlite:///./ar_licenses.db",
|
||||
)
|
||||
|
||||
# connect_args only needed for SQLite (allows multi-threaded use by FastAPI)
|
||||
_connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
|
||||
|
||||
engine = create_engine(DATABASE_URL, connect_args=_connect_args)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db():
|
||||
"""FastAPI dependency that yields a database session."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,305 @@
|
||||
# =============================================================================
|
||||
# license_server/main.py — AR Electronics License Server
|
||||
# =============================================================================
|
||||
#
|
||||
# FastAPI REST service that manages serial number activations for all
|
||||
# AR Electronics products deployed on J6412 mini PCs.
|
||||
#
|
||||
# Endpoints (public):
|
||||
# POST /api/v1/activate — activate a serial on a machine
|
||||
# GET /api/v1/validate/{serial} — check activation status
|
||||
#
|
||||
# Endpoints (admin — require X-Admin-Key header):
|
||||
# GET /api/v1/admin/licenses — list all issued licenses
|
||||
# GET /api/v1/admin/activations — list all activations
|
||||
# POST /api/v1/admin/issue — issue a new serial number
|
||||
# DELETE /api/v1/admin/revoke/{serial} — revoke a license
|
||||
#
|
||||
# Run:
|
||||
# uvicorn license_server.main:app --host 0.0.0.0 --port 8888 --reload
|
||||
#
|
||||
# Environment variables:
|
||||
# DATABASE_URL — SQLAlchemy URL (default: sqlite:///./ar_licenses.db)
|
||||
# ADMIN_API_KEY — Secret key for /admin/* endpoints
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .database import Base, engine, get_db
|
||||
from .models import Activation, License
|
||||
from .schemas import (
|
||||
ActivationRequest,
|
||||
ActivationAdminRecord,
|
||||
ActivationResponse,
|
||||
LicenseAdminRecord,
|
||||
ValidateResponse,
|
||||
)
|
||||
|
||||
# ── Create tables on startup ─────────────────────────────────────────────────
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# ── App ──────────────────────────────────────────────────────────────────────
|
||||
app = FastAPI(
|
||||
title="AR Electronics License Server",
|
||||
description="Serial-number activation and validation for J6412 deployments.",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["GET", "POST", "DELETE"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
ADMIN_KEY = os.getenv("ADMIN_API_KEY", "change-me-in-production")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def require_admin(x_admin_key: str = Header(...)):
|
||||
if x_admin_key != ADMIN_KEY:
|
||||
raise HTTPException(status_code=403, detail="Invalid admin key.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin request schemas (defined here — too small for schemas.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class IssueRequest(BaseModel):
|
||||
serial: str
|
||||
vessel: str = ""
|
||||
notes: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public — Activation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post("/api/v1/activate", response_model=ActivationResponse, tags=["Public"])
|
||||
def activate(request: ActivationRequest, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Activate a serial number on a specific hardware machine.
|
||||
|
||||
- First activation: accepted, creates a new Activation row.
|
||||
- Same hardware re-activating the same serial: accepted, updates last_seen.
|
||||
- Different hardware trying to activate an already-activated serial: rejected.
|
||||
"""
|
||||
serial = request.serial.upper()
|
||||
|
||||
# Verify the serial exists and is not revoked
|
||||
lic = db.query(License).filter(License.serial == serial).first()
|
||||
if lic is None:
|
||||
raise HTTPException(status_code=404, detail="Serial number not found.")
|
||||
if not lic.is_active:
|
||||
raise HTTPException(status_code=403, detail="This license has been revoked.")
|
||||
|
||||
hw_id = request.hardware_id
|
||||
|
||||
# Check for an existing activation
|
||||
existing = (
|
||||
db.query(Activation)
|
||||
.filter(Activation.serial == serial, Activation.revoked == False) # noqa: E712
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing is not None:
|
||||
if existing.hardware_id == hw_id:
|
||||
# Same machine re-activating — refresh heartbeat
|
||||
existing.last_seen_at = datetime.now(timezone.utc)
|
||||
existing.app_version = request.app_version or existing.app_version
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
return ActivationResponse(
|
||||
activation_id = existing.activation_id,
|
||||
vessel_slot = existing.vessel_slot,
|
||||
licensed_to = existing.licensed_to,
|
||||
activated_at = existing.activated_at,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"Este número de serie ya está activado en otro equipo. "
|
||||
"Contacte a AR Electronics para transferir la licencia."
|
||||
),
|
||||
)
|
||||
|
||||
# New activation
|
||||
activation = Activation(
|
||||
activation_id = str(uuid.uuid4()),
|
||||
serial = serial,
|
||||
hardware_id = hw_id,
|
||||
app_version = request.app_version,
|
||||
platform = request.platform,
|
||||
hostname = request.hostname,
|
||||
vessel_slot = 1,
|
||||
licensed_to = lic.vessel,
|
||||
)
|
||||
db.add(activation)
|
||||
db.commit()
|
||||
db.refresh(activation)
|
||||
|
||||
return ActivationResponse(
|
||||
activation_id = activation.activation_id,
|
||||
vessel_slot = activation.vessel_slot,
|
||||
licensed_to = activation.licensed_to,
|
||||
activated_at = activation.activated_at,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public — Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/v1/validate/{serial}", response_model=ValidateResponse, tags=["Public"])
|
||||
def validate(
|
||||
serial: str,
|
||||
hardware_id: str = Query(..., min_length=16),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Check whether a (serial, hardware_id) pair is currently active.
|
||||
|
||||
Called by the installed app on each boot to refresh its offline cache.
|
||||
"""
|
||||
serial = serial.upper()
|
||||
|
||||
activation = (
|
||||
db.query(Activation)
|
||||
.filter(
|
||||
Activation.serial == serial,
|
||||
Activation.hardware_id == hardware_id,
|
||||
Activation.revoked == False, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if activation is None:
|
||||
raise HTTPException(status_code=404, detail="No active activation found.")
|
||||
|
||||
activation.last_seen_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
return ValidateResponse(
|
||||
serial = activation.serial,
|
||||
active = True,
|
||||
hardware_id = activation.hardware_id,
|
||||
activation_id = activation.activation_id,
|
||||
vessel_slot = activation.vessel_slot,
|
||||
licensed_to = activation.licensed_to,
|
||||
activated_at = activation.activated_at,
|
||||
last_seen_at = activation.last_seen_at,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin — Issue new serial
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post("/api/v1/admin/issue",
|
||||
tags=["Admin"],
|
||||
dependencies=[Depends(require_admin)])
|
||||
def issue_license(request: IssueRequest, db: Session = Depends(get_db)):
|
||||
"""Register a pre-generated serial number in the database."""
|
||||
serial = request.serial.upper()
|
||||
if db.query(License).filter(License.serial == serial).first():
|
||||
raise HTTPException(status_code=409, detail="Serial already in database.")
|
||||
lic = License(serial=serial, vessel=request.vessel, notes=request.notes, is_active=True)
|
||||
db.add(lic)
|
||||
db.commit()
|
||||
return {"status": "issued", "serial": lic.serial}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin — List licenses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/v1/admin/licenses",
|
||||
response_model=list[LicenseAdminRecord],
|
||||
tags=["Admin"],
|
||||
dependencies=[Depends(require_admin)])
|
||||
def list_licenses(db: Session = Depends(get_db)):
|
||||
licenses = db.query(License).order_by(License.issued_at.desc()).all()
|
||||
result = []
|
||||
for lic in licenses:
|
||||
count = db.query(Activation).filter(
|
||||
Activation.serial == lic.serial, Activation.revoked == False # noqa: E712
|
||||
).count()
|
||||
result.append(
|
||||
LicenseAdminRecord(
|
||||
serial = lic.serial,
|
||||
vessel = lic.vessel,
|
||||
issued_at = lic.issued_at,
|
||||
is_active = lic.is_active,
|
||||
activations = count,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin — List activations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/v1/admin/activations",
|
||||
response_model=list[ActivationAdminRecord],
|
||||
tags=["Admin"],
|
||||
dependencies=[Depends(require_admin)])
|
||||
def list_activations(db: Session = Depends(get_db)):
|
||||
rows = db.query(Activation).order_by(Activation.activated_at.desc()).all()
|
||||
return [
|
||||
ActivationAdminRecord(
|
||||
activation_id = r.activation_id,
|
||||
serial = r.serial,
|
||||
hardware_id = r.hardware_id,
|
||||
app_version = r.app_version,
|
||||
platform = r.platform,
|
||||
hostname = r.hostname,
|
||||
activated_at = r.activated_at,
|
||||
last_seen_at = r.last_seen_at,
|
||||
vessel_slot = r.vessel_slot,
|
||||
revoked = r.revoked,
|
||||
licensed_to = r.licensed_to,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin — Revoke
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.delete("/api/v1/admin/revoke/{serial}",
|
||||
tags=["Admin"],
|
||||
dependencies=[Depends(require_admin)])
|
||||
def revoke_license(serial: str, db: Session = Depends(get_db)):
|
||||
"""Revoke a license — all activations are invalidated immediately."""
|
||||
serial = serial.upper()
|
||||
lic = db.query(License).filter(License.serial == serial).first()
|
||||
if lic is None:
|
||||
raise HTTPException(status_code=404, detail="Serial not found.")
|
||||
lic.is_active = False
|
||||
db.query(Activation).filter(Activation.serial == serial).update({"revoked": True})
|
||||
db.commit()
|
||||
return {"status": "revoked", "serial": serial}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/health", tags=["System"])
|
||||
def health():
|
||||
return {"status": "ok", "service": "AR Electronics License Server"}
|
||||
@@ -0,0 +1,51 @@
|
||||
# =============================================================================
|
||||
# license_server/models.py — SQLAlchemy ORM models
|
||||
# =============================================================================
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .database import Base
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class License(Base):
|
||||
"""One row per serial number issued by AR Electronics."""
|
||||
|
||||
__tablename__ = "licenses"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
serial: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, index=True)
|
||||
vessel: Mapped[str] = mapped_column(String(120), default="")
|
||||
issued_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
notes: Mapped[str] = mapped_column(String(500), default="")
|
||||
|
||||
|
||||
class Activation(Base):
|
||||
"""One row per successful hardware activation of a serial number."""
|
||||
|
||||
__tablename__ = "activations"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
activation_id: Mapped[str] = mapped_column(String(36), unique=True, default=_uuid, index=True)
|
||||
serial: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
hardware_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
app_version: Mapped[str] = mapped_column(String(20), default="")
|
||||
platform: Mapped[str] = mapped_column(String(20), default="")
|
||||
hostname: Mapped[str] = mapped_column(String(120), default="")
|
||||
activated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
vessel_slot: Mapped[int] = mapped_column(Integer, default=1)
|
||||
revoked: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
licensed_to: Mapped[str] = mapped_column(String(120), default="")
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi>=0.111.0
|
||||
uvicorn[standard]>=0.29.0
|
||||
sqlalchemy>=2.0.0
|
||||
pydantic>=2.7.0
|
||||
python-dotenv>=1.0.0
|
||||
@@ -0,0 +1,69 @@
|
||||
# =============================================================================
|
||||
# license_server/schemas.py — Pydantic v2 request/response schemas
|
||||
# =============================================================================
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Activation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ActivationRequest(BaseModel):
|
||||
serial: str = Field(..., pattern=r"^AR-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}$")
|
||||
hardware_id: str = Field(..., min_length=16, max_length=64)
|
||||
app_version: str = Field(default="", max_length=20)
|
||||
platform: str = Field(default="", max_length=20)
|
||||
hostname: str = Field(default="", max_length=120)
|
||||
|
||||
|
||||
class ActivationResponse(BaseModel):
|
||||
activation_id: str
|
||||
vessel_slot: int
|
||||
licensed_to: str
|
||||
activated_at: datetime
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ValidateResponse(BaseModel):
|
||||
serial: str
|
||||
active: bool
|
||||
hardware_id: str
|
||||
activation_id: str
|
||||
vessel_slot: int
|
||||
licensed_to: str
|
||||
activated_at: datetime
|
||||
last_seen_at: datetime
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LicenseAdminRecord(BaseModel):
|
||||
serial: str
|
||||
vessel: str
|
||||
issued_at: datetime
|
||||
is_active: bool
|
||||
activations: int
|
||||
|
||||
|
||||
class ActivationAdminRecord(BaseModel):
|
||||
activation_id: str
|
||||
serial: str
|
||||
hardware_id: str
|
||||
app_version: str
|
||||
platform: str
|
||||
hostname: str
|
||||
activated_at: datetime
|
||||
last_seen_at: datetime
|
||||
vessel_slot: int
|
||||
revoked: bool
|
||||
licensed_to: str
|
||||
@@ -44,11 +44,24 @@ dev = [
|
||||
]
|
||||
# Studio GUI -- Sprint 2.5+. Heavy (~80 MB), kept optional so the core can
|
||||
# be installed in lean environments (CI, headless test bench).
|
||||
# PySide6 >= 6.6 includes QtSerialPort on all platforms — no extra dep needed.
|
||||
studio = [
|
||||
"PySide6>=6.6",
|
||||
"pyserial>=3.5",
|
||||
"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]
|
||||
Homepage = "https://github.com/alro65/AR-Autopilot"
|
||||
|
||||
Reference in New Issue
Block a user