diff --git a/.gitignore b/.gitignore index 5d65c5e..ffbfb94 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,8 @@ eggs/ .eggs/ lib/ lib64/ +# Exception: Flutter display app source is in display/lib/ — track it +!display/lib/ parts/ sdist/ var/ diff --git a/arautopilot/core/sensor_config.py b/arautopilot/core/sensor_config.py new file mode 100644 index 0000000..9919bbf --- /dev/null +++ b/arautopilot/core/sensor_config.py @@ -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 diff --git a/arautopilot/core/vessel_config.py b/arautopilot/core/vessel_config.py index 6b3a38d..8e435de 100644 --- a/arautopilot/core/vessel_config.py +++ b/arautopilot/core/vessel_config.py @@ -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.", + ) diff --git a/arautopilot/studio/app.py b/arautopilot/studio/app.py index d6bcfe1..17f637f 100644 --- a/arautopilot/studio/app.py +++ b/arautopilot/studio/app.py @@ -22,6 +22,8 @@ import argparse import sys from pathlib import Path +REPO_ROOT = Path(__file__).resolve().parents[2] + from arautopilot.core.audit import AuditLog from arautopilot.core.user_store import UserStore, seed_demo_users from arautopilot.studio.session import studio_data_dir @@ -81,6 +83,19 @@ def run(argv: list[str] | None = None) -> int: app = QApplication(sys.argv) app.setApplicationName("AR-Autopilot Studio") + # Apply AR Electronics brand theme + from arautopilot.studio.ar_style import apply_ar_style # noqa: PLC0415 + apply_ar_style(app) + + # Window icon (logo) + from PySide6.QtGui import QIcon # noqa: PLC0415 + from pathlib import Path as _Path # noqa: PLC0415 + _logo = REPO_ROOT / "display" / "assets" / "images" / "ar_logo_full.png" + if not _logo.exists(): + _logo = _Path(__file__).resolve().parents[2] / "display" / "assets" / "images" / "ar_logo_full.png" + if _logo.exists(): + app.setWindowIcon(QIcon(str(_logo))) + if len(user_store) == 0: QMessageBox.information( None, diff --git a/arautopilot/studio/ar_style.py b/arautopilot/studio/ar_style.py new file mode 100644 index 0000000..aa4752d --- /dev/null +++ b/arautopilot/studio/ar_style.py @@ -0,0 +1,270 @@ +"""AR Electronics global Qt stylesheet and palette constants. + +Apply once with:: + + from arautopilot.studio.ar_style import apply_ar_style + apply_ar_style(app) # QApplication instance + +All Studio widgets inherit the style automatically. +""" + +from __future__ import annotations + +from PySide6.QtGui import QColor, QPalette +from PySide6.QtWidgets import QApplication + +# --------------------------------------------------------------------------- +# Brand colours (matches Flutter AutopilotTheme and web CSS vars) +# --------------------------------------------------------------------------- +NAVY = "#0D1B2A" +PANEL = "#1A2B3C" +PANEL_LIGHT = "#243447" +BORDER = "#2B3F5C" +ACCENT = "#2563EB" +ACCENT_MID = "#4A9FE8" +GLOW = "#60B8FF" +TEXT_MAIN = "#E2E8F0" +TEXT_MUTED = "#8899AA" +TEXT_DIM = "#445566" +OK = "#22C55E" +WARN = "#F59E0B" +ERROR = "#EF4444" + +AR_QSS = f""" +/* ── Root ──────────────────────────────────────────────────────────────── */ +QMainWindow, QDialog, QWidget {{ + background-color: {NAVY}; + color: {TEXT_MAIN}; + font-family: "Segoe UI", "Inter", sans-serif; + font-size: 12px; +}} + +/* ── Group boxes ─────────────────────────────────────────────────────── */ +QGroupBox {{ + border: 1px solid {BORDER}; + border-radius: 5px; + margin-top: 10px; + padding-top: 10px; + color: {ACCENT_MID}; + font-weight: bold; + font-size: 11px; + letter-spacing: 0.5px; +}} +QGroupBox::title {{ + subcontrol-origin: margin; + left: 10px; + padding: 0 5px; +}} + +/* ── Tabs ──────────────────────────────────────────────────────────────── */ +QTabWidget::pane {{ + border: 1px solid {BORDER}; + background-color: {NAVY}; + top: -1px; +}} +QTabBar::tab {{ + background: {PANEL}; + color: {TEXT_MUTED}; + padding: 7px 18px; + border: 1px solid {BORDER}; + border-bottom: none; + margin-right: 2px; + border-top-left-radius: 4px; + border-top-right-radius: 4px; +}} +QTabBar::tab:selected {{ + background: {NAVY}; + color: {ACCENT_MID}; + border-bottom: 2px solid {ACCENT}; +}} +QTabBar::tab:hover:!selected {{ + color: {TEXT_MAIN}; +}} + +/* ── Buttons ───────────────────────────────────────────────────────────── */ +QPushButton {{ + background-color: {PANEL}; + color: {TEXT_MAIN}; + border: 1px solid {ACCENT}; + border-radius: 4px; + padding: 5px 16px; + min-width: 60px; +}} +QPushButton:hover {{ + background-color: {ACCENT}; + color: white; +}} +QPushButton:pressed {{ + background-color: #1a4db5; +}} +QPushButton:disabled {{ + color: {TEXT_DIM}; + border-color: {BORDER}; + background-color: {PANEL}; +}} +QPushButton#primary {{ + background-color: {ACCENT}; + color: white; + font-weight: bold; +}} +QPushButton#primary:hover {{ + background-color: {GLOW}; + color: {NAVY}; +}} + +/* ── Inputs ────────────────────────────────────────────────────────────── */ +QLineEdit, QTextEdit, QPlainTextEdit {{ + background-color: {PANEL}; + color: {TEXT_MAIN}; + border: 1px solid {BORDER}; + border-radius: 3px; + padding: 4px 7px; + selection-background-color: {ACCENT}; +}} +QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus {{ + border-color: {ACCENT_MID}; +}} +QComboBox {{ + background-color: {PANEL}; + color: {TEXT_MAIN}; + border: 1px solid {BORDER}; + border-radius: 3px; + padding: 4px 7px; + selection-background-color: {ACCENT}; +}} +QComboBox:focus {{ + border-color: {ACCENT_MID}; +}} +QComboBox::drop-down {{ + border-left: 1px solid {BORDER}; + width: 20px; +}} +QComboBox QAbstractItemView {{ + background-color: {PANEL}; + color: {TEXT_MAIN}; + selection-background-color: {ACCENT}; + outline: none; +}} +QSpinBox, QDoubleSpinBox {{ + background-color: {PANEL}; + color: {TEXT_MAIN}; + border: 1px solid {BORDER}; + border-radius: 3px; + padding: 3px 6px; + selection-background-color: {ACCENT}; +}} +QSpinBox:focus, QDoubleSpinBox:focus {{ + border-color: {ACCENT_MID}; +}} + +/* ── Lists ─────────────────────────────────────────────────────────────── */ +QListWidget {{ + background-color: {PANEL}; + color: {TEXT_MAIN}; + border: 1px solid {BORDER}; + border-radius: 3px; + outline: none; +}} +QListWidget::item:selected {{ + background-color: {ACCENT}; +}} + +/* ── Scrollbars ────────────────────────────────────────────────────────── */ +QScrollBar:vertical {{ + background: {NAVY}; + width: 8px; + border-radius: 4px; +}} +QScrollBar::handle:vertical {{ + background: {BORDER}; + border-radius: 4px; + min-height: 20px; +}} +QScrollBar::handle:vertical:hover {{ + background: {ACCENT_MID}; +}} +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ + height: 0; +}} +QScrollBar:horizontal {{ + background: {NAVY}; + height: 8px; +}} +QScrollBar::handle:horizontal {{ + background: {BORDER}; + border-radius: 4px; +}} + +/* ── Splitter ──────────────────────────────────────────────────────────── */ +QSplitter::handle {{ + background: {BORDER}; +}} + +/* ── Status bar ────────────────────────────────────────────────────────── */ +QStatusBar {{ + background-color: #0A1520; + color: {ACCENT_MID}; + font-size: 11px; +}} + +/* ── Checkboxes ────────────────────────────────────────────────────────── */ +QCheckBox {{ + color: {TEXT_MAIN}; + spacing: 6px; +}} +QCheckBox::indicator {{ + width: 14px; + height: 14px; + border: 1px solid {ACCENT}; + border-radius: 3px; + background: {PANEL}; +}} +QCheckBox::indicator:checked {{ + background: {ACCENT}; + image: none; +}} + +/* ── Labels ────────────────────────────────────────────────────────────── */ +QLabel {{ + color: {TEXT_MAIN}; +}} +QLabel[role="muted"] {{ + color: {TEXT_MUTED}; + font-size: 11px; +}} +QLabel[role="heading"] {{ + color: {ACCENT_MID}; + font-weight: bold; + font-size: 14px; + letter-spacing: 1px; +}} + +/* ── Message boxes ─────────────────────────────────────────────────────── */ +QMessageBox {{ + background-color: {PANEL}; +}} +QMessageBox QLabel {{ + color: {TEXT_MAIN}; +}} +""" + + +def apply_ar_style(app: QApplication) -> None: + """Apply the AR Electronics brand stylesheet + dark palette to *app*.""" + app.setStyleSheet(AR_QSS) + + pal = QPalette() + pal.setColor(QPalette.ColorRole.Window, QColor(NAVY)) + pal.setColor(QPalette.ColorRole.WindowText, QColor(TEXT_MAIN)) + pal.setColor(QPalette.ColorRole.Base, QColor(PANEL)) + pal.setColor(QPalette.ColorRole.AlternateBase, QColor(PANEL_LIGHT)) + pal.setColor(QPalette.ColorRole.Text, QColor(TEXT_MAIN)) + pal.setColor(QPalette.ColorRole.BrightText, QColor(GLOW)) + pal.setColor(QPalette.ColorRole.Button, QColor(PANEL)) + pal.setColor(QPalette.ColorRole.ButtonText, QColor(TEXT_MAIN)) + pal.setColor(QPalette.ColorRole.Highlight, QColor(ACCENT)) + pal.setColor(QPalette.ColorRole.HighlightedText, QColor("#FFFFFF")) + pal.setColor(QPalette.ColorRole.Link, QColor(ACCENT_MID)) + pal.setColor(QPalette.ColorRole.Midlight, QColor(BORDER)) + pal.setColor(QPalette.ColorRole.Dark, QColor("#0A1520")) + app.setPalette(pal) diff --git a/arautopilot/studio/compiler/appack.py b/arautopilot/studio/compiler/appack.py new file mode 100644 index 0000000..578e430 --- /dev/null +++ b/arautopilot/studio/compiler/appack.py @@ -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: + + / + ├── 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") diff --git a/arautopilot/studio/editors/project_editor.py b/arautopilot/studio/editors/project_editor.py new file mode 100644 index 0000000..5309ac5 --- /dev/null +++ b/arautopilot/studio/editors/project_editor.py @@ -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("Primary sensor")) + + 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("Redundant sensor (optional)")) + + 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("Inner loop (rudder position, 50 Hz)")) + 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("Outer loop (heading, 10 Hz)")) + 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 diff --git a/arautopilot/studio/installer_widget.py b/arautopilot/studio/installer_widget.py new file mode 100644 index 0000000..a75e2cc --- /dev/null +++ b/arautopilot/studio/installer_widget.py @@ -0,0 +1,334 @@ +"""Installer widget — "Instalar J6412" tab in AR-Autopilot Studio. + +Lets the integrator build a USB pendrive installer image without leaving +the Studio: + + 1. Enter vessel name + generate (or paste) a serial number. + 2. Choose which apps to bundle (AR-ECDIS, AR-Autopilot Display). + 3. Click "Build USB Image" → runs installer/build_usb.py in a worker thread. + 4. Watch the live log. When done, open the dist/ folder. + +RBAC: only Engineer and Super Admin can build installers. +""" + +from __future__ import annotations + +import os +import secrets +import shlex +import subprocess +import sys +from pathlib import Path + +from PySide6.QtCore import QObject, QThread, Signal +from PySide6.QtWidgets import ( + QCheckBox, + QFileDialog, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPlainTextEdit, + QPushButton, + QVBoxLayout, + QWidget, +) + +from arautopilot.core.rbac import Capability +from arautopilot.studio.session import Session + +REPO_ROOT = Path(__file__).resolve().parents[2] +INSTALLER_DIR = REPO_ROOT / "installer" +DIST_DIR = INSTALLER_DIR / "dist" +BUILD_SCRIPT = INSTALLER_DIR / "build_usb.py" + + +# --------------------------------------------------------------------------- +# Serial generator (inline — no dependency on installer/serial_generator.py) +# --------------------------------------------------------------------------- + +def _generate_serial() -> str: + raw = secrets.token_hex(6).upper() + return f"AR-{raw[0:4]}-{raw[4:8]}-{raw[8:12]}" + + +# --------------------------------------------------------------------------- +# Worker thread +# --------------------------------------------------------------------------- + +class _BuildWorker(QObject): + line = Signal(str) + finished = Signal(int) # exit code + error = Signal(str) + + def __init__(self, argv: list[str]) -> None: + super().__init__() + self._argv = argv + self._proc: subprocess.Popen | None = None + self._cancelled = False + + def run(self) -> None: + try: + env = os.environ.copy() + env["PYTHONIOENCODING"] = "utf-8" + self._proc = subprocess.Popen( + self._argv, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + env=env, + ) + assert self._proc.stdout + for raw in self._proc.stdout: + if self._cancelled: + break + self.line.emit(raw.rstrip("\n")) + code = self._proc.wait() + self.finished.emit(code) + except FileNotFoundError as exc: + self.error.emit(f"No se encontró el ejecutable: {exc}") + except Exception as exc: # noqa: BLE001 + self.error.emit(str(exc)) + + def cancel(self) -> None: + self._cancelled = True + if self._proc: + try: + self._proc.terminate() + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Widget +# --------------------------------------------------------------------------- + +class InstallerWidget(QWidget): + """'Instalar J6412' tab content.""" + + def __init__(self, session: Session, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._session = session + self._thread: QThread | None = None + self._worker: _BuildWorker | None = None + self._build_ui() + + # ── UI ─────────────────────────────────────────────────────────────────── + + def _build_ui(self) -> None: + root = QVBoxLayout(self) + root.setContentsMargins(12, 12, 12, 12) + root.setSpacing(12) + + # Header + hdr = QLabel( + "" + "INSTALAR EN J6412
" + "" + "Genera un pendrive USB con las apps AR Electronics y la licencia del buque." + ) + hdr.setTextFormat(1) # RichText + hdr.setWordWrap(True) + root.addWidget(hdr) + + # ── Config group ───────────────────────────────────────────────────── + cfg = QGroupBox("Configuración del paquete") + form = QVBoxLayout(cfg) + + # Vessel name + vessel_row = QHBoxLayout() + vessel_row.addWidget(QLabel("Nombre del buque:")) + self._vessel_edit = QLineEdit() + self._vessel_edit.setPlaceholderText("e.g. M/Y PACIFICO") + vessel_row.addWidget(self._vessel_edit, 1) + form.addLayout(vessel_row) + + # Serial number + serial_row = QHBoxLayout() + serial_row.addWidget(QLabel("Número de serie:")) + self._serial_edit = QLineEdit() + self._serial_edit.setPlaceholderText("AR-XXXX-XXXX-XXXX") + self._serial_edit.setMaximumWidth(220) + gen_btn = QPushButton("Generar") + gen_btn.setToolTip("Genera un nuevo número de serie aleatorio") + gen_btn.clicked.connect(self._on_generate_serial) + serial_row.addWidget(self._serial_edit) + serial_row.addWidget(gen_btn) + serial_row.addStretch(1) + form.addLayout(serial_row) + + # CSV log + csv_row = QHBoxLayout() + csv_row.addWidget(QLabel("Registro CSV:")) + self._csv_edit = QLineEdit() + self._csv_edit.setPlaceholderText("Opcional — ruta al archivo serials.csv") + browse_btn = QPushButton("…") + browse_btn.setFixedWidth(30) + browse_btn.clicked.connect(self._on_browse_csv) + csv_row.addWidget(self._csv_edit, 1) + csv_row.addWidget(browse_btn) + form.addLayout(csv_row) + + # App checkboxes + app_row = QHBoxLayout() + self._chk_autopilot = QCheckBox("AR-Autopilot Display (Flutter)") + self._chk_autopilot.setChecked(True) + self._chk_ecdis = QCheckBox("AR-ECDIS") + self._chk_ecdis.setChecked(True) + self._chk_no_flutter = QCheckBox("Omitir compilación Flutter (usar build existente)") + app_row.addWidget(self._chk_autopilot) + app_row.addWidget(self._chk_ecdis) + app_row.addStretch(1) + form.addLayout(app_row) + form.addWidget(self._chk_no_flutter) + + root.addWidget(cfg) + + # ── Action row ─────────────────────────────────────────────────────── + act = QHBoxLayout() + self._build_btn = QPushButton("▶ Build USB Image") + self._build_btn.setObjectName("primary") + self._build_btn.setToolTip("Compila e empaqueta el instalador USB") + self._build_btn.clicked.connect(self._on_build) + act.addWidget(self._build_btn) + + self._cancel_btn = QPushButton("Cancelar") + self._cancel_btn.setEnabled(False) + self._cancel_btn.clicked.connect(self._on_cancel) + act.addWidget(self._cancel_btn) + + self._open_btn = QPushButton("Abrir dist/") + self._open_btn.setEnabled(False) + self._open_btn.setToolTip(f"Abre la carpeta: {DIST_DIR}") + self._open_btn.clicked.connect(self._on_open_dist) + act.addWidget(self._open_btn) + + act.addStretch(1) + self._status_lbl = QLabel("") + act.addWidget(self._status_lbl) + root.addLayout(act) + + # ── Log ────────────────────────────────────────────────────────────── + self._log = QPlainTextEdit() + self._log.setReadOnly(True) + self._log.setStyleSheet( + "background:#0A1520; color:#C9D1D9; font-family:Consolas,'Cascadia Mono',monospace; font-size:10px;" + ) + self._log.setPlaceholderText("El log del proceso de build aparecerá aquí…") + root.addWidget(self._log, 1) + + # RBAC gate + can_build = self._session.has(Capability.EDIT_COMMISSIONING) + if not can_build: + self._build_btn.setEnabled(False) + self._build_btn.setToolTip("Requiere rol Engineer o Super Admin.") + self._vessel_edit.setEnabled(False) + self._serial_edit.setEnabled(False) + self._chk_autopilot.setEnabled(False) + self._chk_ecdis.setEnabled(False) + self._chk_no_flutter.setEnabled(False) + root.insertWidget(0, QLabel( + "⚠ Tu rol no permite construir instaladores. " + "Inicia sesión como Engineer o Super Admin." + )) + + # ── Handlers ────────────────────────────────────────────────────────────── + + def _on_generate_serial(self) -> None: + self._serial_edit.setText(_generate_serial()) + + def _on_browse_csv(self) -> None: + path, _ = QFileDialog.getSaveFileName( + self, "Registro de seriales", str(Path.home() / "serials.csv"), + "CSV (*.csv);;Todos los archivos (*)" + ) + if path: + self._csv_edit.setText(path) + + def _on_build(self) -> None: + vessel = self._vessel_edit.text().strip() + serial = self._serial_edit.text().strip().upper() + + if not vessel: + QMessageBox.warning(self, "Falta dato", "Introduce el nombre del buque.") + return + if not serial: + serial = _generate_serial() + self._serial_edit.setText(serial) + self._log.appendPlainText(f"[auto] Serial generado: {serial}") + + if not BUILD_SCRIPT.exists(): + QMessageBox.critical( + self, "Script no encontrado", + f"No se encontró:\n{BUILD_SCRIPT}\n\nVerifica que el repo esté completo." + ) + return + + argv = [sys.executable, str(BUILD_SCRIPT), "--vessel", vessel, "--serial", serial] + if not self._chk_autopilot.isChecked() or self._chk_no_flutter.isChecked(): + argv.append("--no-flutter") + if not self._chk_ecdis.isChecked(): + argv.append("--no-ecdis") + csv = self._csv_edit.text().strip() + if csv: + argv += ["--csv", csv] + + self._log.clear() + self._log.appendPlainText(f"$ {' '.join(shlex.quote(a) for a in argv)}\n") + self._set_running(True) + self._status_lbl.setText("Building…") + + self._thread = QThread(self) + self._worker = _BuildWorker(argv) + self._worker.moveToThread(self._thread) + self._thread.started.connect(self._worker.run) + self._worker.line.connect(self._log.appendPlainText) + self._worker.finished.connect(self._on_build_done) + self._worker.error.connect(self._on_build_error) + self._worker.finished.connect(self._thread.quit) + self._worker.error.connect(self._thread.quit) + self._thread.finished.connect(self._cleanup_thread) + self._thread.start() + + def _on_cancel(self) -> None: + if self._worker: + self._worker.cancel() + self._log.appendPlainText("\n[cancelado por el operador]") + + def _on_open_dist(self) -> None: + if DIST_DIR.exists(): + os.startfile(str(DIST_DIR)) # Windows Explorer + else: + QMessageBox.information(self, "Sin carpeta", f"No existe:\n{DIST_DIR}") + + def _on_build_done(self, code: int) -> None: + if code == 0: + self._status_lbl.setText("✓ Build completado") + self._open_btn.setEnabled(True) + self._log.appendPlainText( + f"\n✓ Pendrive listo en:\n{DIST_DIR}\n" + "Copie TODO el contenido de dist/ al pendrive USB." + ) + else: + self._status_lbl.setText(f"✗ Error (exit {code})") + self._set_running(False) + + def _on_build_error(self, msg: str) -> None: + self._log.appendPlainText(f"\n[ERROR] {msg}") + self._status_lbl.setText("✗ Error") + self._set_running(False) + + def _set_running(self, running: bool) -> None: + can_build = self._session.has(Capability.EDIT_COMMISSIONING) + self._build_btn.setEnabled(not running and can_build) + self._cancel_btn.setEnabled(running) + + def _cleanup_thread(self) -> None: + if self._thread: + self._thread.deleteLater() + self._thread = None + if self._worker: + self._worker.deleteLater() + self._worker = None diff --git a/arautopilot/studio/main_window.py b/arautopilot/studio/main_window.py index de10802..48258b9 100644 --- a/arautopilot/studio/main_window.py +++ b/arautopilot/studio/main_window.py @@ -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"{self._session.user.display_name}
" - f"{self._session.role.value}" - )) - layout.addWidget(QLabel("Capabilities")) + layout.setContentsMargins(10, 14, 10, 10) + layout.setSpacing(10) + + # Logo + if _LOGO_PATH.exists(): + logo_lbl = QLabel() + px = QPixmap(str(_LOGO_PATH)).scaledToWidth( + 160, Qt.TransformationMode.SmoothTransformation + ) + logo_lbl.setPixmap(px) + logo_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(logo_lbl) + else: + brand_lbl = QLabel("AR Electronics") + brand_lbl.setStyleSheet( + f"color:{GLOW}; font-size:16px; font-weight:bold; letter-spacing:2px;" + ) + brand_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(brand_lbl) + + # Separator + sep = QFrame() + sep.setFrameShape(QFrame.Shape.HLine) + sep.setStyleSheet(f"color:{ACCENT_MID};") + layout.addWidget(sep) + + # User info + role_label = QLabel( + f"" + f"{self._session.user.display_name}
" + f"" + f"{self._session.role.value}" + ) + role_label.setTextFormat(Qt.TextFormat.RichText) + role_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(role_label) + + sep2 = QFrame() + sep2.setFrameShape(QFrame.Shape.HLine) + sep2.setStyleSheet(f"color:{ACCENT_MID};") + layout.addWidget(sep2) + + # Capabilities + cap_title = QLabel("Permisos") + cap_title.setStyleSheet( + f"color:{ACCENT_MID}; font-weight:bold; font-size:10px; letter-spacing:1px;" + ) + layout.addWidget(cap_title) + caps = QListWidget() + caps.setStyleSheet("font-size:10px;") for cap in sorted(capabilities_of(self._session.role), key=lambda c: c.value): caps.addItem(cap.value) layout.addWidget(caps, stretch=1) + + # Version stamp + ver_lbl = QLabel(f"Studio v{__version__}") + ver_lbl.setStyleSheet(f"color:{TEXT_MUTED}; font-size:10px;") + ver_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(ver_lbl) + return w + # ── Central tabs ────────────────────────────────────────────────────────── + def _build_central(self) -> QWidget: tabs = QTabWidget() - - tabs.addTab(self._build_overview_tab(), "Overview") - tabs.addTab(FlashConsoleWidget(self._session), "Flash Console") - tabs.addTab(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( - "

AR-Autopilot Studio

" - "

Welcome. Use the Flash Console tab to compile and " - "flash firmware to an AR-NMEA-IO board.

" - "

The Project tab (Sprint 4) will let you configure a " - "vessel and produce a deployable .appack.

" - "

Every action you take is recorded in the audit log " - "(see status bar at the bottom).

" - )) - layout.addStretch(1) - return w + layout.setContentsMargins(24, 20, 24, 20) + + title = QLabel("AR-Autopilot Studio") + title.setFont(QFont("Segoe UI", 20, QFont.Weight.Bold)) + title.setStyleSheet(f"color:{GLOW}; letter-spacing:2px;") + layout.addWidget(title) + + subtitle = QLabel(f"v{__version__} — Herramienta de integración para el sistema AR-Autopilot") + subtitle.setStyleSheet(f"color:{TEXT_MUTED}; font-size:12px;") + layout.addWidget(subtitle) + + sep = QFrame() + sep.setFrameShape(QFrame.Shape.HLine) + sep.setStyleSheet(f"color:{ACCENT_MID}; margin: 10px 0;") + layout.addWidget(sep) + + guide = QLabel( + "" + f"" + f"" + "" + f"" + f"" + "" + f"" + f"" + "" + f"" + f"" + "
Flash ESP32
" + f"Compila y flashea el firmware al AR-Concentrador " + f"o al autopilot ESP32 via USB.
📋Proyecto
" + f"Configura el buque (tipo, dimensiones, actuador, " + f"sensores, ganancias PID) y genera un paquete .appack de despliegue.
📡Telemetría
" + f"Conecta al AR-Concentrador por puerto COM y ve " + f"en tiempo real el rumbo, setpoint y ángulo de timón.
💾Instalar J6412
" + f"Genera un pendrive USB que instala AR-ECDIS y " + f"AR-Autopilot Display en el mini PC J6412 con activación de licencia online." + "
" + ) + guide.setTextFormat(Qt.TextFormat.RichText) + guide.setWordWrap(True) + layout.addWidget(guide) + layout.addStretch(1) + + footer = QLabel( + f"" + "AR Electronics — Todos los derechos reservados." + ) + 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 diff --git a/arautopilot/studio/telemetry_widget.py b/arautopilot/studio/telemetry_widget.py new file mode 100644 index 0000000..0645d66 --- /dev/null +++ b/arautopilot/studio/telemetry_widget.py @@ -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) diff --git a/arautopilot/tests/test_appack_compiler.py b/arautopilot/tests/test_appack_compiler.py new file mode 100644 index 0000000..d3e24d8 --- /dev/null +++ b/arautopilot/tests/test_appack_compiler.py @@ -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() diff --git a/arautopilot/tests/test_sensor_config.py b/arautopilot/tests/test_sensor_config.py new file mode 100644 index 0000000..438aec4 --- /dev/null +++ b/arautopilot/tests/test_sensor_config.py @@ -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 diff --git a/display/analysis_options.yaml b/display/analysis_options.yaml new file mode 100644 index 0000000..fccfe05 --- /dev/null +++ b/display/analysis_options.yaml @@ -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 diff --git a/display/assets/brand/brand_colors.json b/display/assets/brand/brand_colors.json new file mode 100644 index 0000000..797895e --- /dev/null +++ b/display/assets/brand/brand_colors.json @@ -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" + } +} diff --git a/display/assets/images/ar_logo_full.png b/display/assets/images/ar_logo_full.png new file mode 100644 index 0000000..42ad7e9 Binary files /dev/null and b/display/assets/images/ar_logo_full.png differ diff --git a/display/lib/data/autopilot_state.dart b/display/lib/data/autopilot_state.dart new file mode 100644 index 0000000..f74ccf6 --- /dev/null +++ b/display/lib/data/autopilot_state.dart @@ -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 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 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 availablePorts() => ConcentradorService.availablePorts(); + + @override + void dispose() { + _demoTimer?.cancel(); + _service?.disconnect(); + super.dispose(); + } +} diff --git a/display/lib/main.dart b/display/lib/main.dart new file mode 100644 index 0000000..62cb01c --- /dev/null +++ b/display/lib/main.dart @@ -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 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.value( + value: themeProvider, + ), + ChangeNotifierProvider.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(), + }, + ); + } +} diff --git a/display/lib/screens/cockpit/cockpit_screen.dart b/display/lib/screens/cockpit/cockpit_screen.dart new file mode 100644 index 0000000..72db742 --- /dev/null +++ b/display/lib/screens/cockpit/cockpit_screen.dart @@ -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().current; + final ap = context.watch(); + + 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( + 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(); + 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), + ], + ); + } +} diff --git a/display/lib/screens/settings/appearance_settings.dart b/display/lib/screens/settings/appearance_settings.dart new file mode 100644 index 0000000..57fdc41 --- /dev/null +++ b/display/lib/screens/settings/appearance_settings.dart @@ -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(); + 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? 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, + ), + ], + ); + } +} diff --git a/display/lib/screens/settings/port_settings_screen.dart b/display/lib/screens/settings/port_settings_screen.dart new file mode 100644 index 0000000..c97f123 --- /dev/null +++ b/display/lib/screens/settings/port_settings_screen.dart @@ -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 createState() => _PortSettingsScreenState(); +} + +class _PortSettingsScreenState extends State { + static const _kRxKey = 'port.rx'; + static const _kTxKey = 'port.tx'; + + List _ports = []; + String? _rxPort; + String? _txPort; + bool _connecting = false; + String? _errorMsg; + + @override + void initState() { + super.initState(); + _loadPorts(); + } + + Future _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 _connect() async { + if (_rxPort == null || _txPort == null) return; + setState(() { + _connecting = true; + _errorMsg = null; + }); + + try { + final ap = context.read(); + 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 _disconnect() async { + final ap = context.read(); + 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().current; + final ap = context.watch(); + + 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 ports; + final ValueChanged 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( + 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, + ), + ), + ), + ); + } +} diff --git a/display/lib/services/concentrador_service.dart b/display/lib/services/concentrador_service.dart new file mode 100644 index 0000000..47ee7be --- /dev/null +++ b/display/lib/services/concentrador_service.dart @@ -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? _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 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 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 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(); + } +} diff --git a/display/lib/services/parp_codec.dart b/display/lib/services/parp_codec.dart new file mode 100644 index 0000000..985787f --- /dev/null +++ b/display/lib/services/parp_codec.dart @@ -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,,,,,*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,,,*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, + }; +} diff --git a/display/lib/theme/autopilot_theme.dart b/display/lib/theme/autopilot_theme.dart new file mode 100644 index 0000000..fa497a4 --- /dev/null +++ b/display/lib/theme/autopilot_theme.dart @@ -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 glowShadow(Color color, double radius) => radius > 0 + ? [BoxShadow(color: color, blurRadius: radius, spreadRadius: 1)] + : const []; +} diff --git a/display/lib/theme/theme_provider.dart b/display/lib/theme/theme_provider.dart new file mode 100644 index 0000000..a3f3dd4 --- /dev/null +++ b/display/lib/theme/theme_provider.dart @@ -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( +/// create: (_) => await AutopilotThemeProvider.load(), +/// child: const AutopilotApp(), +/// ) +/// +/// // Read the current theme anywhere in the tree: +/// final theme = context.watch().current; +/// +/// // Switch theme (e.g. from the Appearance screen): +/// await context.read().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 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 setTheme(String id) async { + _current = ThemeRegistry.byId(id); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kThemeKey, _current.id); + notifyListeners(); + } +} diff --git a/display/lib/theme/theme_registry.dart b/display/lib/theme/theme_registry.dart new file mode 100644 index 0000000..14eea8d --- /dev/null +++ b/display/lib/theme/theme_registry.dart @@ -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 _registry = { + lightTheme.id: lightTheme, + cyanTheme.id: cyanTheme, + wineTheme.id: wineTheme, + ochreTheme.id: ochreTheme, + }; + + /// All available themes in UI display order. + static List 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); +} diff --git a/display/lib/theme/themes/cyan_theme.dart b/display/lib/theme/themes/cyan_theme.dart new file mode 100644 index 0000000..f80196e --- /dev/null +++ b/display/lib/theme/themes/cyan_theme.dart @@ -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 +); diff --git a/display/lib/theme/themes/light_theme.dart b/display/lib/theme/themes/light_theme.dart new file mode 100644 index 0000000..72f5a8b --- /dev/null +++ b/display/lib/theme/themes/light_theme.dart @@ -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, +); diff --git a/display/lib/theme/themes/ochre_theme.dart b/display/lib/theme/themes/ochre_theme.dart new file mode 100644 index 0000000..d7ab0cd --- /dev/null +++ b/display/lib/theme/themes/ochre_theme.dart @@ -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), +); diff --git a/display/lib/theme/themes/wine_theme.dart b/display/lib/theme/themes/wine_theme.dart new file mode 100644 index 0000000..a7d41f0 --- /dev/null +++ b/display/lib/theme/themes/wine_theme.dart @@ -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), +); diff --git a/display/lib/widgets/themed/compass_rose.dart b/display/lib/widgets/themed/compass_rose.dart new file mode 100644 index 0000000..d82da58 --- /dev/null +++ b/display/lib/widgets/themed/compass_rose.dart @@ -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().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; +} diff --git a/display/lib/widgets/themed/disengage_button.dart b/display/lib/widgets/themed/disengage_button.dart new file mode 100644 index 0000000..a7a7c28 --- /dev/null +++ b/display/lib/widgets/themed/disengage_button.dart @@ -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().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, + ), + ], + ), + ), + ), + ); + } +} diff --git a/display/lib/widgets/themed/engage_button.dart b/display/lib/widgets/themed/engage_button.dart new file mode 100644 index 0000000..b704b11 --- /dev/null +++ b/display/lib/widgets/themed/engage_button.dart @@ -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 createState() => _EngageButtonState(); +} + +class _EngageButtonState extends State { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + final theme = context.watch().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, + ), + ), + ), + ); + } +} diff --git a/display/lib/widgets/themed/heading_adjust_bar.dart b/display/lib/widgets/themed/heading_adjust_bar.dart new file mode 100644 index 0000000..488ae7a --- /dev/null +++ b/display/lib/widgets/themed/heading_adjust_bar.dart @@ -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 onAdjust; + + @override + Widget build(BuildContext context) { + final theme = context.watch().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 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()], + ), + ), + ), + ); + } +} diff --git a/display/lib/widgets/themed/mode_selector.dart b/display/lib/widgets/themed/mode_selector.dart new file mode 100644 index 0000000..27d9e5d --- /dev/null +++ b/display/lib/widgets/themed/mode_selector.dart @@ -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 onModeSelected; + + @override + Widget build(BuildContext context) { + final theme = context.watch().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, + ), + ), + ), + ); + } +} diff --git a/display/lib/widgets/themed/rudder_indicator.dart b/display/lib/widgets/themed/rudder_indicator.dart new file mode 100644 index 0000000..26c6e60 --- /dev/null +++ b/display/lib/widgets/themed/rudder_indicator.dart @@ -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().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; +} diff --git a/display/lib/widgets/themed/status_chip.dart b/display/lib/widgets/themed/status_chip.dart new file mode 100644 index 0000000..c697280 --- /dev/null +++ b/display/lib/widgets/themed/status_chip.dart @@ -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, + ), + ), + ], + ); + } +} diff --git a/display/pubspec.yaml b/display/pubspec.yaml new file mode 100644 index 0000000..dc79a1e --- /dev/null +++ b/display/pubspec.yaml @@ -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/ diff --git a/display/test/theme/theme_contrast_test.dart b/display/test/theme/theme_contrast_test.dart new file mode 100644 index 0000000..862abd4 --- /dev/null +++ b/display/test/theme/theme_contrast_test.dart @@ -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 _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.', + ); + }); + }); +} diff --git a/display/test/theme/theme_provider_test.dart b/display/test/theme/theme_provider_test.dart new file mode 100644 index 0000000..85f8f0e --- /dev/null +++ b/display/test/theme/theme_provider_test.dart @@ -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); + }); + }); +} diff --git a/display/test/theme/theme_registry_test.dart b/display/test/theme/theme_registry_test.dart new file mode 100644 index 0000000..6af43a6 --- /dev/null +++ b/display/test/theme/theme_registry_test.dart @@ -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', + ); + } + }); + }); + }); +} diff --git a/firmware/ar_autopilot_v1/src/hal/rudder_actuator_electric.h b/firmware/ar_autopilot_v1/src/hal/rudder_actuator_electric.h new file mode 100644 index 0000000..ba10acb --- /dev/null +++ b/firmware/ar_autopilot_v1/src/hal/rudder_actuator_electric.h @@ -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 +#include +#include + +#include +#include + +#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(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 diff --git a/firmware/ar_autopilot_v1/src/hal/rudder_actuator_factory.h b/firmware/ar_autopilot_v1/src/hal/rudder_actuator_factory.h new file mode 100644 index 0000000..6936504 --- /dev/null +++ b/firmware/ar_autopilot_v1/src/hal/rudder_actuator_factory.h @@ -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 diff --git a/firmware/ar_autopilot_v1/src/hal/rudder_actuator_hydraulic.h b/firmware/ar_autopilot_v1/src/hal/rudder_actuator_hydraulic.h new file mode 100644 index 0000000..bd52730 --- /dev/null +++ b/firmware/ar_autopilot_v1/src/hal/rudder_actuator_hydraulic.h @@ -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 +#include +#include + +#include +#include + +#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(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 diff --git a/firmware/ar_autopilot_v1/src/hal/rudder_actuator_iface.h b/firmware/ar_autopilot_v1/src/hal/rudder_actuator_iface.h new file mode 100644 index 0000000..35599b1 --- /dev/null +++ b/firmware/ar_autopilot_v1/src/hal/rudder_actuator_iface.h @@ -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 + +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 diff --git a/installer/build_usb.py b/installer/build_usb.py new file mode 100644 index 0000000..e8223b0 --- /dev/null +++ b/installer/build_usb.py @@ -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() diff --git a/installer/serial_generator.py b/installer/serial_generator.py new file mode 100644 index 0000000..66495f4 --- /dev/null +++ b/installer/serial_generator.py @@ -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() diff --git a/installer/src/install.py b/installer/src/install.py new file mode 100644 index 0000000..5f35f90 --- /dev/null +++ b/installer/src/install.py @@ -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() diff --git a/installer/src/license.py b/installer/src/license.py new file mode 100644 index 0000000..3a3cc91 --- /dev/null +++ b/installer/src/license.py @@ -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 diff --git a/installer/src/sysconfig.py b/installer/src/sysconfig.py new file mode 100644 index 0000000..b70578b --- /dev/null +++ b/installer/src/sysconfig.py @@ -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) diff --git a/license_server/.env.example b/license_server/.env.example new file mode 100644 index 0000000..976827f --- /dev/null +++ b/license_server/.env.example @@ -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 diff --git a/license_server/__init__.py b/license_server/__init__.py new file mode 100644 index 0000000..020f3fa --- /dev/null +++ b/license_server/__init__.py @@ -0,0 +1 @@ +# AR Electronics License Server package diff --git a/license_server/database.py b/license_server/database.py new file mode 100644 index 0000000..881ed18 --- /dev/null +++ b/license_server/database.py @@ -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() diff --git a/license_server/main.py b/license_server/main.py new file mode 100644 index 0000000..4a03a37 --- /dev/null +++ b/license_server/main.py @@ -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"} diff --git a/license_server/models.py b/license_server/models.py new file mode 100644 index 0000000..7d01a8c --- /dev/null +++ b/license_server/models.py @@ -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="") diff --git a/license_server/requirements.txt b/license_server/requirements.txt new file mode 100644 index 0000000..1cfae29 --- /dev/null +++ b/license_server/requirements.txt @@ -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 diff --git a/license_server/schemas.py b/license_server/schemas.py new file mode 100644 index 0000000..2788170 --- /dev/null +++ b/license_server/schemas.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 0740d9c..8840f88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"