Merge branch 'claude/elated-sammet-86a4ab'

This commit is contained in:
2026-05-24 11:39:49 -04:00
57 changed files with 8097 additions and 48 deletions
+2
View File
@@ -18,6 +18,8 @@ eggs/
.eggs/
lib/
lib64/
# Exception: Flutter display app source is in display/lib/ — track it
!display/lib/
parts/
sdist/
var/
+110
View File
@@ -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
+11
View File
@@ -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.",
)
+15
View File
@@ -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,
+270
View File
@@ -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)
+245
View File
@@ -0,0 +1,245 @@
"""AR-Autopilot deployment package compiler — Sprint 4.
An ``.appack`` is a ZIP archive that contains everything needed to deploy
a configured autopilot to a vessel:
<project_id>/
├── manifest.json — metadata, checksums, schema version
├── project.yaml — full ProjectConfig (human-readable)
├── firmware_config.h — generated C header consumed by the firmware build
└── install_notes.txt — human summary for the field engineer
The format is intentionally transparent (plain ZIP, readable YAML/JSON/C)
so that a field engineer can inspect the package contents without special tools.
Typical workflow
----------------
1. Integrator configures a ``ProjectConfig`` in the Studio (Project tab).
2. Clicks "Compile .appack…" → this compiler runs.
3. Studio writes the .appack to disk.
4. Field engineer transfers the .appack to the vessel.
5. Future Sprint: Studio "Deploy" tab extracts the package, calls PlatformIO
with the generated header, and flashes the board.
"""
from __future__ import annotations
import hashlib
import json
import zipfile
from datetime import UTC, datetime
from pathlib import Path
from textwrap import dedent
from arautopilot.core.actuator_config import ActuatorType
from arautopilot.core.project_config import ProjectConfig
from arautopilot.core.sensor_config import DualRudderSensorConfig, RudderSensorType
from arautopilot.version import __version__
_SCHEMA_VERSION = "0.1.0"
class AppackCompiler:
"""Compiles a ``ProjectConfig`` into an ``.appack`` deployment archive."""
def __init__(self, project: ProjectConfig) -> None:
if not project.client_name.strip():
raise ValueError("project.client_name must not be empty")
if not project.project_name.strip():
raise ValueError("project.project_name must not be empty")
self._project = project
def compile(self, output_path: Path | str) -> Path:
"""Build the ``.appack`` archive and write it to *output_path*.
Returns the resolved output path.
"""
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
project_yaml = self._project.to_yaml()
firmware_header = self._generate_firmware_header()
install_notes = self._generate_install_notes()
manifest = self._build_manifest(project_yaml, firmware_header)
prefix = str(self._project.project_id)
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr(f"{prefix}/manifest.json", json.dumps(manifest, indent=2))
zf.writestr(f"{prefix}/project.yaml", project_yaml)
zf.writestr(f"{prefix}/firmware_config.h", firmware_header)
zf.writestr(f"{prefix}/install_notes.txt", install_notes)
return out
# ------------------------------------------------------------------
# Manifest
# ------------------------------------------------------------------
def _build_manifest(self, project_yaml: str, firmware_header: str) -> dict:
return {
"appack_schema": _SCHEMA_VERSION,
"arautopilot_version": __version__,
"compiled_at": datetime.now(UTC).isoformat(),
"project_id": str(self._project.project_id),
"client_name": self._project.client_name,
"project_name": self._project.project_name,
"vessel_name": self._project.vessel.name,
"vessel_type": self._project.vessel.type.value,
"actuator_type": self._project.vessel.actuator.type.value,
"checksums": {
"project_yaml_sha256": _sha256(project_yaml),
"firmware_config_h_sha256": _sha256(firmware_header),
},
}
# ------------------------------------------------------------------
# Firmware header generator
# ------------------------------------------------------------------
def _generate_firmware_header(self) -> str:
p = self._project
v = p.vessel
a = v.actuator
pid = v.pid
sensors = v.sensors
actuator_enum = _actuator_type_to_enum(a.type)
primary_sensor_enum = _sensor_type_to_enum(sensors.primary.type)
redundant_enabled = "1" if sensors.has_redundancy else "0"
redundant_cs = sensors.redundant.spi_cs_gpio if sensors.redundant else 0
redundant_sensor_enum = (
_sensor_type_to_enum(sensors.redundant.type)
if sensors.redundant
else "RUDDER_SENSOR_NONE"
)
diverge_alarm = sensors.primary.divergence_alarm_deg
diverge_failover = sensors.primary.divergence_failover_deg
lines = [
"// AUTO-GENERATED by AR-Autopilot Studio — DO NOT EDIT BY HAND.",
f"// Project : {p.project_name}",
f"// Client : {p.client_name}",
f"// Vessel : {v.name} ({v.type.value})",
f"// Generated: {datetime.now(UTC).isoformat()}",
"//",
"#pragma once",
"",
"// --- Project identity -------------------------------------------------",
f'#define AR_PROJECT_ID "{p.project_id}"',
f'#define AR_CLIENT_NAME "{p.client_name}"',
f'#define AR_PROJECT_NAME "{p.project_name}"',
f'#define AR_VESSEL_NAME "{v.name}"',
"",
"// --- Vessel kinematics ------------------------------------------------",
f"#define AR_VESSEL_LENGTH_M {v.length_m:.1f}f",
f"#define AR_VESSEL_MAX_SPEED_KN {v.max_speed_kn:.1f}f",
"",
"// --- Actuator ---------------------------------------------------------",
f"#define AR_ACTUATOR_TYPE {actuator_enum}",
f"#define AR_RUDDER_ANGLE_LIMIT_DEG {a.max_rudder_angle_deg:.1f}f",
f"#define AR_ACTUATOR_DEADBAND_PCT {a.deadband_pct:.1f}f",
f"#define AR_ACTUATOR_MAX_RATE_DPS {a.max_rate_dps:.1f}f",
f"#define AR_ACTUATOR_MAX_CURRENT_A {a.max_current_a:.1f}f",
f"#define AR_ACTUATOR_ASYM_STBD {a.asymmetry_stbd_over_port:.3f}f",
"",
"// --- Rudder sensors ---------------------------------------------------",
f"#define AR_SENSOR_PRIMARY_TYPE {primary_sensor_enum}",
f"#define AR_SENSOR_PRIMARY_CS_GPIO {sensors.primary.spi_cs_gpio}",
f"#define AR_SENSOR_PRIMARY_FSD_DEG {sensors.primary.full_scale_deg:.1f}f",
f"#define AR_SENSOR_REDUNDANT {redundant_enabled}",
f"#define AR_SENSOR_REDUNDANT_TYPE {redundant_sensor_enum}",
f"#define AR_SENSOR_REDUNDANT_CS_GPIO {redundant_cs}",
f"#define AR_SENSOR_DIVERGE_ALARM_DEG {diverge_alarm:.1f}f",
f"#define AR_SENSOR_DIVERGE_FAILOVER_DEG {diverge_failover:.1f}f",
"",
"// --- PID base gains (integrator IP — not exposed to operator) ---------",
f"#define AR_PID_INNER_KP {pid.inner_loop_base.kp:.4f}f",
f"#define AR_PID_INNER_KI {pid.inner_loop_base.ki:.4f}f",
f"#define AR_PID_INNER_KD {pid.inner_loop_base.kd:.4f}f",
f"#define AR_PID_OUTER_KP {pid.outer_loop_base.kp:.4f}f",
f"#define AR_PID_OUTER_KI {pid.outer_loop_base.ki:.4f}f",
f"#define AR_PID_OUTER_KD {pid.outer_loop_base.kd:.4f}f",
f"#define AR_PID_ROT_FF {pid.rot_feedforward_gain:.4f}f",
f"#define AR_PID_DEADBAND_DEG {pid.setpoint_deadband_deg:.2f}f",
f"#define AR_PID_RATE_LIMIT_DPS {pid.setpoint_rate_limit_dps:.2f}f",
f"#define AR_PID_ANTI_WINDUP {pid.anti_windup_limit:.2f}f",
"",
"// End of generated header.",
]
return "\n".join(lines) + "\n"
# ------------------------------------------------------------------
# Install notes
# ------------------------------------------------------------------
def _generate_install_notes(self) -> str:
p = self._project
v = p.vessel
a = v.actuator
s = v.sensors
return dedent(f"""\
AR-Autopilot — Field Installation Notes
========================================
Project : {p.project_name}
Client : {p.client_name}
Vessel : {v.name} ({v.type.value.replace("_", " ").title()})
Generated: {datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC")}
ACTUATOR
--------
Type : {a.type.value.replace("_", " ").title()}
Model / label : {a.name or "(not specified)"}
Rudder limit : ±{a.max_rudder_angle_deg:.1f}° (software limit — verify ≤ mechanical stop)
Deadband : {a.deadband_pct:.1f} %
Max slew rate : {a.max_rate_dps:.1f} °/s
Overcurrent : {a.max_current_a:.1f} A trip
SENSORS
-------
Primary sensor : {s.primary.type.value.upper()} GPIO CS={s.primary.spi_cs_gpio}
Full-scale : ±{s.primary.full_scale_deg:.1f}°
Redundant sensor: {"ENABLED" if s.has_redundancy else "DISABLED"}
{"Redundant type : " + s.redundant.type.value.upper() + " GPIO CS=" + str(s.redundant.spi_cs_gpio) if s.redundant else ""}
Divergence alarm : {s.primary.divergence_alarm_deg:.1f}°
Divergence failover : {s.primary.divergence_failover_deg:.1f}°
COMMISSIONING STEPS (Sprint 7 wizard will automate these)
-----------------------------------------------------------
1. Flash firmware with this .appack (Flash Console or pio upload).
2. Connect primary rudder sensor to SPI CS GPIO {s.primary.spi_cs_gpio}.
{"3. Connect redundant sensor to SPI CS GPIO " + str(s.redundant.spi_cs_gpio) + "." if s.redundant else "3. (Redundant sensor not configured.)"}
4. Verify rudder moves to ±{a.max_rudder_angle_deg:.1f}° and stops.
5. Run Modbus client test: python tools/modbus_client_test.py
6. Engage HEADING_HOLD and confirm heading capture.
7. Tune PID gains via Modbus if required.
SUPPORT
-------
Contact: AR Suite — alro65@gmail.com
""")
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _sha256(text: str) -> str:
return hashlib.sha256(text.encode()).hexdigest()
def _actuator_type_to_enum(t: ActuatorType) -> str:
return {
ActuatorType.HYDRAULIC_REVERSIBLE: "ACTUATOR_HYDRAULIC_REVERSIBLE",
ActuatorType.ELECTRIC_DC_REVERSIBLE: "ACTUATOR_ELECTRIC_DC",
ActuatorType.SERVOMOTOR_FEEDBACK: "ACTUATOR_SERVOMOTOR",
ActuatorType.STERNDRIVE_ANALOG: "ACTUATOR_STERNDRIVE_ANALOG",
}.get(t, "ACTUATOR_UNKNOWN")
def _sensor_type_to_enum(t: RudderSensorType) -> str:
return {
RudderSensorType.AS5048A_SPI: "RUDDER_SENSOR_AS5048A_SPI",
RudderSensorType.POTENTIOMETER: "RUDDER_SENSOR_POTENTIOMETER",
}.get(t, "RUDDER_SENSOR_UNKNOWN")
@@ -0,0 +1,619 @@
"""Project configurator widget — Sprint 4.
A scrollable form that lets the integrator (Engineer / Super Admin) create
or edit a ``ProjectConfig`` and compile it into an ``.appack`` deployment
package.
Layout
------
┌─────────────────────────────────────────────────────┐
│ PROJECT INFO client / project name / notes │
│ VESSEL name / type / dimensions │
│ ACTUATOR type / angle limit / deadband / … │
│ SENSORS primary + optional redundant │
│ PID GAINS inner + outer base (SA-gated) │
│ ─────────────────────────────────────────────────── │
│ [New] [Open…] [Save] [Compile .appack…] │
└─────────────────────────────────────────────────────┘
RBAC gates
----------
- ``EDIT_COMMISSIONING`` (Engineer+) : actuator + sensor + rudder limit.
- ``EDIT_BASE_GAINS`` (Super Admin) : PID base gains fields.
- ``EDIT_COMMISSIONING`` (Engineer+) : Compile .appack button.
- Any role can load and read a project; only the above roles may edit.
"""
from __future__ import annotations
from pathlib import Path
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDoubleSpinBox,
QFileDialog,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QScrollArea,
QSizePolicy,
QSpinBox,
QTextEdit,
QVBoxLayout,
QWidget,
)
from arautopilot.core.actuator_config import ActuatorConfig, ActuatorType
from arautopilot.core.pid_config import PidConfig, PidGains
from arautopilot.core.project_config import ProjectConfig
from arautopilot.core.rbac import Capability
from arautopilot.core.sensor_config import (
DualRudderSensorConfig,
RudderSensorConfig,
RudderSensorType,
)
from arautopilot.core.vessel_config import VesselConfig, VesselType
from arautopilot.studio.session import Session
class ProjectEditorWidget(QWidget):
"""Full project config editor — fills the 'Project' tab in the Studio."""
def __init__(self, session: Session, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._session = session
self._project: ProjectConfig | None = None
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
# Scrollable form area
scroll = QScrollArea()
scroll.setWidgetResizable(True)
content = QWidget()
form_layout = QVBoxLayout(content)
form_layout.setContentsMargins(12, 12, 12, 12)
form_layout.setSpacing(12)
self._build_project_info_group(form_layout)
self._build_vessel_group(form_layout)
self._build_actuator_group(form_layout)
self._build_sensor_group(form_layout)
self._build_pid_group(form_layout)
form_layout.addStretch(1)
scroll.setWidget(content)
root.addWidget(scroll, stretch=1)
root.addWidget(self._build_toolbar())
self._apply_rbac()
self._load_defaults()
# ------------------------------------------------------------------
# Group builders
# ------------------------------------------------------------------
def _build_project_info_group(self, parent: QVBoxLayout) -> None:
box = QGroupBox("Project Info")
fl = QFormLayout(box)
self._client_name = QLineEdit()
self._client_name.setPlaceholderText("e.g. Astilleros del Sur S.A.")
fl.addRow("Client name *", self._client_name)
self._project_name = QLineEdit()
self._project_name.setPlaceholderText("e.g. M/Y Pacifica Autopilot install")
fl.addRow("Project name *", self._project_name)
self._notes = QTextEdit()
self._notes.setPlaceholderText("Optional notes…")
self._notes.setFixedHeight(72)
fl.addRow("Notes", self._notes)
parent.addWidget(box)
def _build_vessel_group(self, parent: QVBoxLayout) -> None:
box = QGroupBox("Vessel")
fl = QFormLayout(box)
self._vessel_name = QLineEdit()
self._vessel_name.setPlaceholderText("e.g. Pacifica")
fl.addRow("Vessel name *", self._vessel_name)
self._vessel_type = QComboBox()
for vt in VesselType:
self._vessel_type.addItem(vt.value.replace("_", " ").title(), vt)
fl.addRow("Type", self._vessel_type)
self._length_m = QDoubleSpinBox()
self._length_m.setRange(5.0, 200.0)
self._length_m.setSuffix(" m")
self._length_m.setDecimals(1)
self._length_m.setValue(30.0)
fl.addRow("Length overall", self._length_m)
self._displacement_t = QDoubleSpinBox()
self._displacement_t.setRange(0.0, 10_000.0)
self._displacement_t.setSuffix(" t")
self._displacement_t.setDecimals(1)
fl.addRow("Displacement (0 = unknown)", self._displacement_t)
self._max_speed_kn = QDoubleSpinBox()
self._max_speed_kn.setRange(1.0, 80.0)
self._max_speed_kn.setSuffix(" kn")
self._max_speed_kn.setDecimals(1)
self._max_speed_kn.setValue(18.0)
fl.addRow("Max speed", self._max_speed_kn)
parent.addWidget(box)
def _build_actuator_group(self, parent: QVBoxLayout) -> None:
box = QGroupBox("Rudder Actuator")
fl = QFormLayout(box)
self._act_type = QComboBox()
phase1 = [
ActuatorType.HYDRAULIC_REVERSIBLE,
ActuatorType.ELECTRIC_DC_REVERSIBLE,
ActuatorType.SERVOMOTOR_FEEDBACK,
ActuatorType.STERNDRIVE_ANALOG,
]
for at in phase1:
self._act_type.addItem(at.value.replace("_", " ").title(), at)
fl.addRow("Type", self._act_type)
self._act_name = QLineEdit()
self._act_name.setPlaceholderText("e.g. Hynautic K-21 + Capilano cylinder")
fl.addRow("Model / label", self._act_name)
self._max_rudder_deg = QDoubleSpinBox()
self._max_rudder_deg.setRange(15.0, 45.0)
self._max_rudder_deg.setSuffix(" °")
self._max_rudder_deg.setDecimals(1)
self._max_rudder_deg.setValue(35.0)
self._max_rudder_deg.setToolTip(
"Software rudder angle limit. Must not exceed the mechanical stop.\n"
"Requires Engineer or Super Admin to change."
)
fl.addRow("Rudder angle limit ⚠", self._max_rudder_deg)
self._deadband_pct = QDoubleSpinBox()
self._deadband_pct.setRange(0.0, 30.0)
self._deadband_pct.setSuffix(" %")
self._deadband_pct.setDecimals(1)
self._deadband_pct.setValue(5.0)
fl.addRow("Deadband", self._deadband_pct)
self._max_rate_dps = QDoubleSpinBox()
self._max_rate_dps.setRange(0.5, 15.0)
self._max_rate_dps.setSuffix(" °/s")
self._max_rate_dps.setDecimals(1)
self._max_rate_dps.setValue(4.5)
fl.addRow("Max slew rate", self._max_rate_dps)
self._max_current_a = QDoubleSpinBox()
self._max_current_a.setRange(1.0, 200.0)
self._max_current_a.setSuffix(" A")
self._max_current_a.setDecimals(1)
self._max_current_a.setValue(15.0)
fl.addRow("Overcurrent limit", self._max_current_a)
parent.addWidget(box)
self._actuator_box = box
def _build_sensor_group(self, parent: QVBoxLayout) -> None:
box = QGroupBox("Rudder Angle Sensors")
fl = QFormLayout(box)
fl.addRow(QLabel("<b>Primary sensor</b>"))
self._sens_primary_type = QComboBox()
for st in RudderSensorType:
self._sens_primary_type.addItem(st.value.replace("_", " ").upper(), st)
fl.addRow("Type", self._sens_primary_type)
self._sens_primary_cs = QSpinBox()
self._sens_primary_cs.setRange(0, 39)
self._sens_primary_cs.setValue(10)
self._sens_primary_cs.setToolTip("ESP32 GPIO used as SPI CS for AS5048A")
fl.addRow("SPI CS GPIO", self._sens_primary_cs)
self._sens_primary_fsd = QDoubleSpinBox()
self._sens_primary_fsd.setRange(1.0, 45.0)
self._sens_primary_fsd.setSuffix(" °")
self._sens_primary_fsd.setDecimals(1)
self._sens_primary_fsd.setValue(35.0)
fl.addRow("Full-scale angle", self._sens_primary_fsd)
fl.addRow(QLabel(""))
fl.addRow(QLabel("<b>Redundant sensor (optional)</b>"))
self._sens_redundant_enabled = QCheckBox("Enable redundant sensor")
fl.addRow("", self._sens_redundant_enabled)
self._sens_redundant_type = QComboBox()
for st in RudderSensorType:
self._sens_redundant_type.addItem(st.value.replace("_", " ").upper(), st)
fl.addRow("Type", self._sens_redundant_type)
self._sens_redundant_cs = QSpinBox()
self._sens_redundant_cs.setRange(0, 39)
self._sens_redundant_cs.setValue(11)
fl.addRow("SPI CS GPIO", self._sens_redundant_cs)
self._sens_redundant_fsd = QDoubleSpinBox()
self._sens_redundant_fsd.setRange(1.0, 45.0)
self._sens_redundant_fsd.setSuffix(" °")
self._sens_redundant_fsd.setDecimals(1)
self._sens_redundant_fsd.setValue(35.0)
fl.addRow("Full-scale angle", self._sens_redundant_fsd)
self._sens_diverge_alarm = QDoubleSpinBox()
self._sens_diverge_alarm.setRange(0.5, 15.0)
self._sens_diverge_alarm.setSuffix(" °")
self._sens_diverge_alarm.setDecimals(1)
self._sens_diverge_alarm.setValue(3.0)
fl.addRow("Divergence alarm threshold", self._sens_diverge_alarm)
self._sens_diverge_failover = QDoubleSpinBox()
self._sens_diverge_failover.setRange(1.0, 20.0)
self._sens_diverge_failover.setSuffix(" °")
self._sens_diverge_failover.setDecimals(1)
self._sens_diverge_failover.setValue(6.0)
fl.addRow("Divergence failover threshold", self._sens_diverge_failover)
self._sens_redundant_enabled.toggled.connect(self._on_redundant_toggled)
self._on_redundant_toggled(False)
parent.addWidget(box)
self._sensor_box = box
def _build_pid_group(self, parent: QVBoxLayout) -> None:
box = QGroupBox("PID Base Gains [Super Admin only]")
fl = QFormLayout(box)
fl.addRow(QLabel("<b>Inner loop (rudder position, 50 Hz)</b>"))
self._inner_kp = self._gain_spin("Inner Kp", fl)
self._inner_ki = self._gain_spin("Inner Ki", fl)
self._inner_kd = self._gain_spin("Inner Kd", fl)
fl.addRow(QLabel(""))
fl.addRow(QLabel("<b>Outer loop (heading, 10 Hz)</b>"))
self._outer_kp = self._gain_spin("Outer Kp", fl)
self._outer_ki = self._gain_spin("Outer Ki", fl)
self._outer_kd = self._gain_spin("Outer Kd", fl)
fl.addRow(QLabel(""))
self._rot_ff = self._gain_spin("ROT feed-forward", fl)
parent.addWidget(box)
self._pid_box = box
# ------------------------------------------------------------------
# Toolbar
# ------------------------------------------------------------------
def _build_toolbar(self) -> QWidget:
bar = QWidget()
bar.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
h = QHBoxLayout(bar)
h.setContentsMargins(8, 4, 8, 4)
btn_new = QPushButton("New")
btn_new.clicked.connect(self._on_new)
h.addWidget(btn_new)
btn_open = QPushButton("Open…")
btn_open.clicked.connect(self._on_open)
h.addWidget(btn_open)
btn_save = QPushButton("Save")
btn_save.clicked.connect(self._on_save)
h.addWidget(btn_save)
h.addStretch(1)
self._btn_compile = QPushButton("Compile .appack…")
self._btn_compile.setToolTip("Generate deployment package (Engineer+)")
self._btn_compile.clicked.connect(self._on_compile)
h.addWidget(self._btn_compile)
return bar
# ------------------------------------------------------------------
# RBAC
# ------------------------------------------------------------------
def _apply_rbac(self) -> None:
can_commission = self._session.has(Capability.EDIT_COMMISSIONING)
can_gains = self._session.has(Capability.EDIT_BASE_GAINS)
can_compile = self._session.has(Capability.EDIT_COMMISSIONING)
for w in [
self._max_rudder_deg,
self._deadband_pct,
self._max_rate_dps,
self._max_current_a,
self._act_type,
self._act_name,
self._sens_primary_type,
self._sens_primary_cs,
self._sens_primary_fsd,
self._sens_redundant_enabled,
self._sens_redundant_type,
self._sens_redundant_cs,
self._sens_redundant_fsd,
self._sens_diverge_alarm,
self._sens_diverge_failover,
]:
w.setEnabled(can_commission)
for w in [
self._inner_kp,
self._inner_ki,
self._inner_kd,
self._outer_kp,
self._outer_ki,
self._outer_kd,
self._rot_ff,
]:
w.setEnabled(can_gains)
self._btn_compile.setEnabled(can_compile)
if not can_gains:
self._pid_box.setTitle("PID Base Gains [Super Admin only — read-only]")
# ------------------------------------------------------------------
# Slots
# ------------------------------------------------------------------
def _on_redundant_toggled(self, enabled: bool) -> None:
for w in [
self._sens_redundant_type,
self._sens_redundant_cs,
self._sens_redundant_fsd,
self._sens_diverge_alarm,
self._sens_diverge_failover,
]:
w.setEnabled(enabled and self._session.has(Capability.EDIT_COMMISSIONING))
def _on_new(self) -> None:
self._project = None
self._load_defaults()
def _on_open(self) -> None:
path, _ = QFileDialog.getOpenFileName(
self,
"Open project",
str(Path.home()),
"AR-Autopilot Project (*.yaml *.yml *.json);;All files (*)",
)
if not path:
return
try:
self._project = ProjectConfig.load(path)
self._populate_from_project(self._project)
except Exception as exc: # noqa: BLE001
QMessageBox.critical(self, "Load error", str(exc))
def _on_save(self) -> None:
try:
project = self._build_project()
except Exception as exc: # noqa: BLE001
QMessageBox.critical(self, "Validation error", str(exc))
return
path, _ = QFileDialog.getSaveFileName(
self,
"Save project",
str(Path.home() / f"{project.project_name}.yaml"),
"AR-Autopilot Project (*.yaml);;JSON (*.json)",
)
if not path:
return
try:
project.save_yaml(path) if path.endswith((".yaml", ".yml")) else project.save_json(path)
self._project = project
QMessageBox.information(self, "Saved", f"Project saved to:\n{path}")
except Exception as exc: # noqa: BLE001
QMessageBox.critical(self, "Save error", str(exc))
def _on_compile(self) -> None:
if not self._session.has(Capability.EDIT_COMMISSIONING):
QMessageBox.warning(self, "Access denied", "Engineer or Super Admin required.")
return
try:
project = self._build_project()
except Exception as exc: # noqa: BLE001
QMessageBox.critical(self, "Validation error", str(exc))
return
path, _ = QFileDialog.getSaveFileName(
self,
"Save .appack",
str(Path.home() / f"{project.project_name}.appack"),
"AR-Autopilot Package (*.appack)",
)
if not path:
return
try:
from arautopilot.studio.compiler.appack import AppackCompiler
compiler = AppackCompiler(project)
out = compiler.compile(Path(path))
self._session.audit.record(
actor=self._session.user.username,
action="compile_appack",
detail=f"project={project.project_id} output={out}",
)
QMessageBox.information(
self, "Compiled", f".appack written to:\n{out}"
)
except Exception as exc: # noqa: BLE001
QMessageBox.critical(self, "Compile error", str(exc))
# ------------------------------------------------------------------
# Build / populate helpers
# ------------------------------------------------------------------
def _build_project(self) -> ProjectConfig:
redundant: RudderSensorConfig | None = None
if self._sens_redundant_enabled.isChecked():
redundant = RudderSensorConfig(
type=self._sens_redundant_type.currentData(),
label="Redundant actuator arm",
spi_cs_gpio=self._sens_redundant_cs.value(),
full_scale_deg=self._sens_redundant_fsd.value(),
divergence_alarm_deg=self._sens_diverge_alarm.value(),
divergence_failover_deg=self._sens_diverge_failover.value(),
)
vessel = VesselConfig(
name=self._vessel_name.text().strip() or "Unnamed vessel",
type=self._vessel_type.currentData(),
length_m=self._length_m.value(),
displacement_t=self._displacement_t.value(),
max_speed_kn=self._max_speed_kn.value(),
actuator=ActuatorConfig(
type=self._act_type.currentData(),
name=self._act_name.text().strip(),
max_rudder_angle_deg=self._max_rudder_deg.value(),
deadband_pct=self._deadband_pct.value(),
max_rate_dps=self._max_rate_dps.value(),
max_current_a=self._max_current_a.value(),
),
pid=PidConfig(
inner_loop_base=PidGains(
kp=self._inner_kp.value(),
ki=self._inner_ki.value(),
kd=self._inner_kd.value(),
),
outer_loop_base=PidGains(
kp=self._outer_kp.value(),
ki=self._outer_ki.value(),
kd=self._outer_kd.value(),
),
rot_feedforward_gain=self._rot_ff.value(),
),
sensors=DualRudderSensorConfig(
primary=RudderSensorConfig(
type=self._sens_primary_type.currentData(),
label="Primary rudder stock",
spi_cs_gpio=self._sens_primary_cs.value(),
full_scale_deg=self._sens_primary_fsd.value(),
),
redundant=redundant,
),
)
existing_id = self._project.project_id if self._project else None
project = ProjectConfig(
client_name=self._client_name.text().strip() or "Unknown client",
project_name=self._project_name.text().strip() or "Unnamed project",
notes=self._notes.toPlainText().strip(),
vessel=vessel,
)
if existing_id is not None:
object.__setattr__(project, "project_id", existing_id)
return project
def _populate_from_project(self, p: ProjectConfig) -> None:
self._client_name.setText(p.client_name)
self._project_name.setText(p.project_name)
self._notes.setPlainText(p.notes)
v = p.vessel
self._vessel_name.setText(v.name)
_set_combo(self._vessel_type, v.type)
self._length_m.setValue(v.length_m)
self._displacement_t.setValue(v.displacement_t)
self._max_speed_kn.setValue(v.max_speed_kn)
a = v.actuator
_set_combo(self._act_type, a.type)
self._act_name.setText(a.name)
self._max_rudder_deg.setValue(a.max_rudder_angle_deg)
self._deadband_pct.setValue(a.deadband_pct)
self._max_rate_dps.setValue(a.max_rate_dps)
self._max_current_a.setValue(a.max_current_a)
sp = v.sensors.primary
_set_combo(self._sens_primary_type, sp.type)
self._sens_primary_cs.setValue(sp.spi_cs_gpio)
self._sens_primary_fsd.setValue(sp.full_scale_deg)
sr = v.sensors.redundant
self._sens_redundant_enabled.setChecked(sr is not None)
if sr is not None:
_set_combo(self._sens_redundant_type, sr.type)
self._sens_redundant_cs.setValue(sr.spi_cs_gpio)
self._sens_redundant_fsd.setValue(sr.full_scale_deg)
self._sens_diverge_alarm.setValue(sr.divergence_alarm_deg)
self._sens_diverge_failover.setValue(sr.divergence_failover_deg)
pid = v.pid
self._inner_kp.setValue(pid.inner_loop_base.kp)
self._inner_ki.setValue(pid.inner_loop_base.ki)
self._inner_kd.setValue(pid.inner_loop_base.kd)
self._outer_kp.setValue(pid.outer_loop_base.kp)
self._outer_ki.setValue(pid.outer_loop_base.ki)
self._outer_kd.setValue(pid.outer_loop_base.kd)
self._rot_ff.setValue(pid.rot_feedforward_gain)
def _load_defaults(self) -> None:
self._client_name.clear()
self._project_name.clear()
self._notes.clear()
self._vessel_name.clear()
self._vessel_type.setCurrentIndex(0)
self._length_m.setValue(30.0)
self._displacement_t.setValue(0.0)
self._max_speed_kn.setValue(18.0)
self._act_type.setCurrentIndex(0)
self._act_name.clear()
self._max_rudder_deg.setValue(35.0)
self._deadband_pct.setValue(5.0)
self._max_rate_dps.setValue(4.5)
self._max_current_a.setValue(15.0)
self._sens_primary_type.setCurrentIndex(0)
self._sens_primary_cs.setValue(10)
self._sens_primary_fsd.setValue(35.0)
self._sens_redundant_enabled.setChecked(False)
self._sens_redundant_cs.setValue(11)
self._sens_redundant_fsd.setValue(35.0)
self._sens_diverge_alarm.setValue(3.0)
self._sens_diverge_failover.setValue(6.0)
self._inner_kp.setValue(1.5)
self._inner_ki.setValue(0.1)
self._inner_kd.setValue(0.05)
self._outer_kp.setValue(2.0)
self._outer_ki.setValue(0.05)
self._outer_kd.setValue(0.3)
self._rot_ff.setValue(0.4)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _gain_spin(label: str, fl: QFormLayout) -> QDoubleSpinBox:
spin = QDoubleSpinBox()
spin.setRange(0.0, 100.0)
spin.setDecimals(4)
spin.setSingleStep(0.01)
fl.addRow(label, spin)
return spin
def _set_combo(combo: QComboBox, value: object) -> None:
for i in range(combo.count()):
if combo.itemData(i) == value:
combo.setCurrentIndex(i)
return
+334
View File
@@ -0,0 +1,334 @@
"""Installer widget — "Instalar J6412" tab in AR-Autopilot Studio.
Lets the integrator build a USB pendrive installer image without leaving
the Studio:
1. Enter vessel name + generate (or paste) a serial number.
2. Choose which apps to bundle (AR-ECDIS, AR-Autopilot Display).
3. Click "Build USB Image" → runs installer/build_usb.py in a worker thread.
4. Watch the live log. When done, open the dist/ folder.
RBAC: only Engineer and Super Admin can build installers.
"""
from __future__ import annotations
import os
import secrets
import shlex
import subprocess
import sys
from pathlib import Path
from PySide6.QtCore import QObject, QThread, Signal
from PySide6.QtWidgets import (
QCheckBox,
QFileDialog,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPlainTextEdit,
QPushButton,
QVBoxLayout,
QWidget,
)
from arautopilot.core.rbac import Capability
from arautopilot.studio.session import Session
REPO_ROOT = Path(__file__).resolve().parents[2]
INSTALLER_DIR = REPO_ROOT / "installer"
DIST_DIR = INSTALLER_DIR / "dist"
BUILD_SCRIPT = INSTALLER_DIR / "build_usb.py"
# ---------------------------------------------------------------------------
# Serial generator (inline — no dependency on installer/serial_generator.py)
# ---------------------------------------------------------------------------
def _generate_serial() -> str:
raw = secrets.token_hex(6).upper()
return f"AR-{raw[0:4]}-{raw[4:8]}-{raw[8:12]}"
# ---------------------------------------------------------------------------
# Worker thread
# ---------------------------------------------------------------------------
class _BuildWorker(QObject):
line = Signal(str)
finished = Signal(int) # exit code
error = Signal(str)
def __init__(self, argv: list[str]) -> None:
super().__init__()
self._argv = argv
self._proc: subprocess.Popen | None = None
self._cancelled = False
def run(self) -> None:
try:
env = os.environ.copy()
env["PYTHONIOENCODING"] = "utf-8"
self._proc = subprocess.Popen(
self._argv,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
env=env,
)
assert self._proc.stdout
for raw in self._proc.stdout:
if self._cancelled:
break
self.line.emit(raw.rstrip("\n"))
code = self._proc.wait()
self.finished.emit(code)
except FileNotFoundError as exc:
self.error.emit(f"No se encontró el ejecutable: {exc}")
except Exception as exc: # noqa: BLE001
self.error.emit(str(exc))
def cancel(self) -> None:
self._cancelled = True
if self._proc:
try:
self._proc.terminate()
except Exception:
pass
# ---------------------------------------------------------------------------
# Widget
# ---------------------------------------------------------------------------
class InstallerWidget(QWidget):
"""'Instalar J6412' tab content."""
def __init__(self, session: Session, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._session = session
self._thread: QThread | None = None
self._worker: _BuildWorker | None = None
self._build_ui()
# ── UI ───────────────────────────────────────────────────────────────────
def _build_ui(self) -> None:
root = QVBoxLayout(self)
root.setContentsMargins(12, 12, 12, 12)
root.setSpacing(12)
# Header
hdr = QLabel(
"<b style='color:#4A9FE8;font-size:14px;letter-spacing:1px'>"
"INSTALAR EN J6412</b><br/>"
"<span style='color:#8899AA;font-size:11px'>"
"Genera un pendrive USB con las apps AR Electronics y la licencia del buque.</span>"
)
hdr.setTextFormat(1) # RichText
hdr.setWordWrap(True)
root.addWidget(hdr)
# ── Config group ─────────────────────────────────────────────────────
cfg = QGroupBox("Configuración del paquete")
form = QVBoxLayout(cfg)
# Vessel name
vessel_row = QHBoxLayout()
vessel_row.addWidget(QLabel("Nombre del buque:"))
self._vessel_edit = QLineEdit()
self._vessel_edit.setPlaceholderText("e.g. M/Y PACIFICO")
vessel_row.addWidget(self._vessel_edit, 1)
form.addLayout(vessel_row)
# Serial number
serial_row = QHBoxLayout()
serial_row.addWidget(QLabel("Número de serie:"))
self._serial_edit = QLineEdit()
self._serial_edit.setPlaceholderText("AR-XXXX-XXXX-XXXX")
self._serial_edit.setMaximumWidth(220)
gen_btn = QPushButton("Generar")
gen_btn.setToolTip("Genera un nuevo número de serie aleatorio")
gen_btn.clicked.connect(self._on_generate_serial)
serial_row.addWidget(self._serial_edit)
serial_row.addWidget(gen_btn)
serial_row.addStretch(1)
form.addLayout(serial_row)
# CSV log
csv_row = QHBoxLayout()
csv_row.addWidget(QLabel("Registro CSV:"))
self._csv_edit = QLineEdit()
self._csv_edit.setPlaceholderText("Opcional — ruta al archivo serials.csv")
browse_btn = QPushButton("")
browse_btn.setFixedWidth(30)
browse_btn.clicked.connect(self._on_browse_csv)
csv_row.addWidget(self._csv_edit, 1)
csv_row.addWidget(browse_btn)
form.addLayout(csv_row)
# App checkboxes
app_row = QHBoxLayout()
self._chk_autopilot = QCheckBox("AR-Autopilot Display (Flutter)")
self._chk_autopilot.setChecked(True)
self._chk_ecdis = QCheckBox("AR-ECDIS")
self._chk_ecdis.setChecked(True)
self._chk_no_flutter = QCheckBox("Omitir compilación Flutter (usar build existente)")
app_row.addWidget(self._chk_autopilot)
app_row.addWidget(self._chk_ecdis)
app_row.addStretch(1)
form.addLayout(app_row)
form.addWidget(self._chk_no_flutter)
root.addWidget(cfg)
# ── Action row ───────────────────────────────────────────────────────
act = QHBoxLayout()
self._build_btn = QPushButton("▶ Build USB Image")
self._build_btn.setObjectName("primary")
self._build_btn.setToolTip("Compila e empaqueta el instalador USB")
self._build_btn.clicked.connect(self._on_build)
act.addWidget(self._build_btn)
self._cancel_btn = QPushButton("Cancelar")
self._cancel_btn.setEnabled(False)
self._cancel_btn.clicked.connect(self._on_cancel)
act.addWidget(self._cancel_btn)
self._open_btn = QPushButton("Abrir dist/")
self._open_btn.setEnabled(False)
self._open_btn.setToolTip(f"Abre la carpeta: {DIST_DIR}")
self._open_btn.clicked.connect(self._on_open_dist)
act.addWidget(self._open_btn)
act.addStretch(1)
self._status_lbl = QLabel("")
act.addWidget(self._status_lbl)
root.addLayout(act)
# ── Log ──────────────────────────────────────────────────────────────
self._log = QPlainTextEdit()
self._log.setReadOnly(True)
self._log.setStyleSheet(
"background:#0A1520; color:#C9D1D9; font-family:Consolas,'Cascadia Mono',monospace; font-size:10px;"
)
self._log.setPlaceholderText("El log del proceso de build aparecerá aquí…")
root.addWidget(self._log, 1)
# RBAC gate
can_build = self._session.has(Capability.EDIT_COMMISSIONING)
if not can_build:
self._build_btn.setEnabled(False)
self._build_btn.setToolTip("Requiere rol Engineer o Super Admin.")
self._vessel_edit.setEnabled(False)
self._serial_edit.setEnabled(False)
self._chk_autopilot.setEnabled(False)
self._chk_ecdis.setEnabled(False)
self._chk_no_flutter.setEnabled(False)
root.insertWidget(0, QLabel(
"⚠ Tu rol no permite construir instaladores. "
"Inicia sesión como Engineer o Super Admin."
))
# ── Handlers ──────────────────────────────────────────────────────────────
def _on_generate_serial(self) -> None:
self._serial_edit.setText(_generate_serial())
def _on_browse_csv(self) -> None:
path, _ = QFileDialog.getSaveFileName(
self, "Registro de seriales", str(Path.home() / "serials.csv"),
"CSV (*.csv);;Todos los archivos (*)"
)
if path:
self._csv_edit.setText(path)
def _on_build(self) -> None:
vessel = self._vessel_edit.text().strip()
serial = self._serial_edit.text().strip().upper()
if not vessel:
QMessageBox.warning(self, "Falta dato", "Introduce el nombre del buque.")
return
if not serial:
serial = _generate_serial()
self._serial_edit.setText(serial)
self._log.appendPlainText(f"[auto] Serial generado: {serial}")
if not BUILD_SCRIPT.exists():
QMessageBox.critical(
self, "Script no encontrado",
f"No se encontró:\n{BUILD_SCRIPT}\n\nVerifica que el repo esté completo."
)
return
argv = [sys.executable, str(BUILD_SCRIPT), "--vessel", vessel, "--serial", serial]
if not self._chk_autopilot.isChecked() or self._chk_no_flutter.isChecked():
argv.append("--no-flutter")
if not self._chk_ecdis.isChecked():
argv.append("--no-ecdis")
csv = self._csv_edit.text().strip()
if csv:
argv += ["--csv", csv]
self._log.clear()
self._log.appendPlainText(f"$ {' '.join(shlex.quote(a) for a in argv)}\n")
self._set_running(True)
self._status_lbl.setText("Building…")
self._thread = QThread(self)
self._worker = _BuildWorker(argv)
self._worker.moveToThread(self._thread)
self._thread.started.connect(self._worker.run)
self._worker.line.connect(self._log.appendPlainText)
self._worker.finished.connect(self._on_build_done)
self._worker.error.connect(self._on_build_error)
self._worker.finished.connect(self._thread.quit)
self._worker.error.connect(self._thread.quit)
self._thread.finished.connect(self._cleanup_thread)
self._thread.start()
def _on_cancel(self) -> None:
if self._worker:
self._worker.cancel()
self._log.appendPlainText("\n[cancelado por el operador]")
def _on_open_dist(self) -> None:
if DIST_DIR.exists():
os.startfile(str(DIST_DIR)) # Windows Explorer
else:
QMessageBox.information(self, "Sin carpeta", f"No existe:\n{DIST_DIR}")
def _on_build_done(self, code: int) -> None:
if code == 0:
self._status_lbl.setText("✓ Build completado")
self._open_btn.setEnabled(True)
self._log.appendPlainText(
f"\n✓ Pendrive listo en:\n{DIST_DIR}\n"
"Copie TODO el contenido de dist/ al pendrive USB."
)
else:
self._status_lbl.setText(f"✗ Error (exit {code})")
self._set_running(False)
def _on_build_error(self, msg: str) -> None:
self._log.appendPlainText(f"\n[ERROR] {msg}")
self._status_lbl.setText("✗ Error")
self._set_running(False)
def _set_running(self, running: bool) -> None:
can_build = self._session.has(Capability.EDIT_COMMISSIONING)
self._build_btn.setEnabled(not running and can_build)
self._cancel_btn.setEnabled(running)
def _cleanup_thread(self) -> None:
if self._thread:
self._thread.deleteLater()
self._thread = None
if self._worker:
self._worker.deleteLater()
self._worker = None
+144 -48
View File
@@ -1,33 +1,45 @@
"""Studio main window (PySide6) -- Sprint 2.5.
"""Studio main window (PySide6).
Three areas:
Five tabs:
Overview — welcome + quick-start guide
Flash Console — compile & flash ESP32 firmware via PlatformIO
Project — vessel configuration + .appack compiler
Telemetría — live $PARP STATUS charts from the AR-Concentrador
Instalar J6412 — build USB pendrive installer images
- Sidebar (left) -- user + role + capabilities they hold.
- Central tab area -- Flash Console (Sprint 2.5) + placeholders for the
project configurator that lands in Sprint 4.
- Status bar -- session info + audit log path.
Sidebar shows the logged-in user, role, and RBAC capabilities.
"""
from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import Qt
from PySide6.QtGui import QFont, QPixmap
from PySide6.QtWidgets import (
QFrame,
QLabel,
QListWidget,
QMainWindow,
QSplitter,
QStatusBar,
QTabWidget,
QTextEdit,
QVBoxLayout,
QWidget,
)
from arautopilot.core.rbac import capabilities_of
from arautopilot.studio.ar_style import ACCENT_MID, GLOW, NAVY, TEXT_MUTED
from arautopilot.studio.editors.project_editor import ProjectEditorWidget
from arautopilot.studio.flash_console import FlashConsoleWidget
from arautopilot.studio.installer_widget import InstallerWidget
from arautopilot.studio.session import Session
from arautopilot.studio.telemetry_widget import TelemetryWidget
from arautopilot.version import __version__
REPO_ROOT = Path(__file__).resolve().parents[2]
_LOGO_PATH = REPO_ROOT / "display" / "assets" / "images" / "ar_logo_full.png"
class StudioMainWindow(QMainWindow):
"""Top-level Studio window."""
@@ -36,75 +48,159 @@ class StudioMainWindow(QMainWindow):
super().__init__()
self._session = session
self.setWindowTitle(
f"AR-Autopilot Studio v{__version__} -- "
f"{session.user.display_name} ({session.role.value})"
f"AR-Autopilot Studio v{__version__} "
f"{session.user.display_name} ({session.role.value})"
)
self.resize(1100, 700)
self.resize(1200, 780)
splitter = QSplitter(Qt.Orientation.Horizontal)
splitter.addWidget(self._build_sidebar())
splitter.addWidget(self._build_central())
splitter.setStretchFactor(0, 0)
splitter.setStretchFactor(1, 1)
splitter.setSizes([260, 840])
splitter.setSizes([240, 960])
self.setCentralWidget(splitter)
status = QStatusBar(self)
status.showMessage(f"Audit log: {session.audit.path}")
self.setStatusBar(status)
# ----- UI ------------------------------------------------------------
# ── Sidebar ───────────────────────────────────────────────────────────────
def _build_sidebar(self) -> QWidget:
w = QWidget()
w.setMaximumWidth(260)
layout = QVBoxLayout(w)
layout.setContentsMargins(8, 8, 8, 8)
layout.addWidget(QLabel(
f"<b>{self._session.user.display_name}</b><br/>"
f"<i>{self._session.role.value}</i>"
))
layout.addWidget(QLabel("<b>Capabilities</b>"))
layout.setContentsMargins(10, 14, 10, 10)
layout.setSpacing(10)
# Logo
if _LOGO_PATH.exists():
logo_lbl = QLabel()
px = QPixmap(str(_LOGO_PATH)).scaledToWidth(
160, Qt.TransformationMode.SmoothTransformation
)
logo_lbl.setPixmap(px)
logo_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(logo_lbl)
else:
brand_lbl = QLabel("AR Electronics")
brand_lbl.setStyleSheet(
f"color:{GLOW}; font-size:16px; font-weight:bold; letter-spacing:2px;"
)
brand_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(brand_lbl)
# Separator
sep = QFrame()
sep.setFrameShape(QFrame.Shape.HLine)
sep.setStyleSheet(f"color:{ACCENT_MID};")
layout.addWidget(sep)
# User info
role_label = QLabel(
f"<span style='color:{GLOW};font-weight:bold;font-size:13px;'>"
f"{self._session.user.display_name}</span><br/>"
f"<span style='color:{TEXT_MUTED};font-size:11px;'>"
f"{self._session.role.value}</span>"
)
role_label.setTextFormat(Qt.TextFormat.RichText)
role_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(role_label)
sep2 = QFrame()
sep2.setFrameShape(QFrame.Shape.HLine)
sep2.setStyleSheet(f"color:{ACCENT_MID};")
layout.addWidget(sep2)
# Capabilities
cap_title = QLabel("Permisos")
cap_title.setStyleSheet(
f"color:{ACCENT_MID}; font-weight:bold; font-size:10px; letter-spacing:1px;"
)
layout.addWidget(cap_title)
caps = QListWidget()
caps.setStyleSheet("font-size:10px;")
for cap in sorted(capabilities_of(self._session.role), key=lambda c: c.value):
caps.addItem(cap.value)
layout.addWidget(caps, stretch=1)
# Version stamp
ver_lbl = QLabel(f"Studio v{__version__}")
ver_lbl.setStyleSheet(f"color:{TEXT_MUTED}; font-size:10px;")
ver_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(ver_lbl)
return w
# ── Central tabs ──────────────────────────────────────────────────────────
def _build_central(self) -> QWidget:
tabs = QTabWidget()
tabs.addTab(self._build_overview_tab(), "Overview")
tabs.addTab(FlashConsoleWidget(self._session), "Flash Console")
tabs.addTab(self._placeholder_tab(
"Project configurator -- Sprint 4.\n\n"
"Will let you create / edit a per-vessel ProjectConfig and "
"compile it into an .appack for deployment."
), "Project")
tabs.addTab(self._placeholder_tab(
"Telemetry -- Sprint 4.\n\n"
"Live Modbus telemetry from the connected AR-NMEA-IO board."
), "Telemetry")
tabs.addTab(self._build_overview_tab(), "🧭 Overview")
tabs.addTab(FlashConsoleWidget(self._session), "⚡ Flash ESP32")
tabs.addTab(ProjectEditorWidget(self._session), "📋 Proyecto")
tabs.addTab(TelemetryWidget(self._session), "📡 Telemetría")
tabs.addTab(InstallerWidget(self._session), "💾 Instalar J6412")
return tabs
# ── Overview tab ──────────────────────────────────────────────────────────
def _build_overview_tab(self) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
layout.addWidget(QLabel(
"<h2>AR-Autopilot Studio</h2>"
"<p>Welcome. Use the <b>Flash Console</b> tab to compile and "
"flash firmware to an AR-NMEA-IO board.</p>"
"<p>The <b>Project</b> tab (Sprint 4) will let you configure a "
"vessel and produce a deployable <code>.appack</code>.</p>"
"<p>Every action you take is recorded in the audit log "
"(see status bar at the bottom).</p>"
))
layout.addStretch(1)
return w
layout.setContentsMargins(24, 20, 24, 20)
title = QLabel("AR-Autopilot Studio")
title.setFont(QFont("Segoe UI", 20, QFont.Weight.Bold))
title.setStyleSheet(f"color:{GLOW}; letter-spacing:2px;")
layout.addWidget(title)
subtitle = QLabel(f"v{__version__} — Herramienta de integración para el sistema AR-Autopilot")
subtitle.setStyleSheet(f"color:{TEXT_MUTED}; font-size:12px;")
layout.addWidget(subtitle)
sep = QFrame()
sep.setFrameShape(QFrame.Shape.HLine)
sep.setStyleSheet(f"color:{ACCENT_MID}; margin: 10px 0;")
layout.addWidget(sep)
guide = QLabel(
"<table cellspacing='8'>"
f"<tr><td style='color:{GLOW};font-size:16px;'>⚡</td>"
f"<td><b style='color:{ACCENT_MID}'>Flash ESP32</b><br/>"
f"<span style='color:{TEXT_MUTED}'>Compila y flashea el firmware al AR-Concentrador "
f"o al autopilot ESP32 via USB.</span></td></tr>"
"<tr><td></td><td style='height:8px'></td></tr>"
f"<tr><td style='color:{GLOW};font-size:16px;'>📋</td>"
f"<td><b style='color:{ACCENT_MID}'>Proyecto</b><br/>"
f"<span style='color:{TEXT_MUTED}'>Configura el buque (tipo, dimensiones, actuador, "
f"sensores, ganancias PID) y genera un paquete .appack de despliegue.</span></td></tr>"
"<tr><td></td><td style='height:8px'></td></tr>"
f"<tr><td style='color:{GLOW};font-size:16px;'>📡</td>"
f"<td><b style='color:{ACCENT_MID}'>Telemetría</b><br/>"
f"<span style='color:{TEXT_MUTED}'>Conecta al AR-Concentrador por puerto COM y ve "
f"en tiempo real el rumbo, setpoint y ángulo de timón.</span></td></tr>"
"<tr><td></td><td style='height:8px'></td></tr>"
f"<tr><td style='color:{GLOW};font-size:16px;'>💾</td>"
f"<td><b style='color:{ACCENT_MID}'>Instalar J6412</b><br/>"
f"<span style='color:{TEXT_MUTED}'>Genera un pendrive USB que instala AR-ECDIS y "
f"AR-Autopilot Display en el mini PC J6412 con activación de licencia online.</span>"
"</td></tr>"
"</table>"
)
guide.setTextFormat(Qt.TextFormat.RichText)
guide.setWordWrap(True)
layout.addWidget(guide)
layout.addStretch(1)
footer = QLabel(
f"<span style='color:{TEXT_MUTED};font-size:10px;'>"
"AR Electronics — Todos los derechos reservados.</span>"
)
footer.setTextFormat(Qt.TextFormat.RichText)
footer.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(footer)
def _placeholder_tab(self, text: str) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
edit = QTextEdit()
edit.setReadOnly(True)
edit.setPlainText(text)
layout.addWidget(edit)
return w
+428
View File
@@ -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)
+154
View File
@@ -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()
+98
View File
@@ -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
+11
View File
@@ -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
+46
View File
@@ -0,0 +1,46 @@
{
"_comment": "AR Electronics — Paleta oficial de marca. Usar en todas las apps.",
"_version": "1.0.0",
"background": {
"primary": "#0D1B2A",
"secondary": "#1A2744",
"card": "#162035",
"surface": "#1E2D47"
},
"accent": {
"blue_electric": "#2563EB",
"blue_neon": "#4A9FE8",
"blue_dark": "#1A47A8",
"blue_glow": "#60B8FF"
},
"text": {
"primary": "#E2E8F0",
"secondary": "#A8B5C4",
"muted": "#6B7A8D",
"on_accent": "#FFFFFF"
},
"status": {
"ok": "#22C55E",
"warning": "#F59E0B",
"alarm": "#EF4444",
"info": "#4A9FE8"
},
"metallic": {
"silver_light": "#C8D2DC",
"silver_mid": "#A8B5C4",
"silver_dark": "#6B7A8D"
},
"flutter": {
"_comment": "Valores listos para copiar en ThemeData de Flutter",
"primaryColor": "0xFF2563EB",
"scaffoldBackground": "0xFF0D1B2A",
"cardColor": "0xFF162035",
"accentColor": "0xFF4A9FE8"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+205
View File
@@ -0,0 +1,205 @@
// =============================================================================
// data/autopilot_state.dart — Live autopilot data model
// =============================================================================
//
// Dual-mode: demo simulation (Sprint 4) or live USB serial (Sprint 7).
//
// Default constructor starts in demo mode (animated vessel simulation).
// Call [connectToSerial] to switch to live data from the AR-Concentrador.
// If the serial link drops, the state automatically falls back to demo.
//
// The public API (fields + methods) is identical in both modes — the UI
// never needs to know which mode is active; it reads [isConnected] for
// the status indicator only.
// =============================================================================
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import '../services/concentrador_service.dart';
import '../services/parp_codec.dart';
import '../widgets/themed/mode_selector.dart';
class AutopilotState extends ChangeNotifier {
// ── Navigation data ──────────────────────────────────────────────────────────
double headingDeg = 125.0;
double setpointDeg = 125.0;
double rudderDeg = 0.0;
double sogKn = 6.2;
double cogDeg = 127.0;
double rotDpm = 0.0;
double depthM = 42.5;
// ── Autopilot state ──────────────────────────────────────────────────────────
AutopilotMode mode = AutopilotMode.standby;
/// True when the USB serial link to the concentrador is active.
bool isConnected = false;
// ── Serial service ───────────────────────────────────────────────────────────
ConcentradorService? _service;
// ── Demo simulation ──────────────────────────────────────────────────────────
Timer? _demoTimer;
final _rng = math.Random();
AutopilotState() {
_startDemo();
}
// ---------------------------------------------------------------------------
// Serial connection
// ---------------------------------------------------------------------------
/// Connect to the AR-Concentrador via USB serial.
///
/// Stops the demo timer and switches to live data.
/// Falls back to demo automatically if the link drops.
///
/// Throws if the ports cannot be opened (caller should catch and show error).
Future<void> connectToSerial({
required String rxPort,
required String txPort,
int stationId = 2,
}) async {
_demoTimer?.cancel();
_demoTimer = null;
_service?.disconnect();
_service = ConcentradorService(
rxPort: rxPort,
txPort: txPort,
stationId: stationId,
);
_service!.onStatus = _onSerialStatus;
_service!.onConnectionChanged = _onConnectionChanged;
await _service!.connect(); // may throw — caller handles
}
/// Disconnect the serial link and return to demo mode.
Future<void> disconnectSerial() async {
await _service?.disconnect();
_service = null;
_startDemo();
}
void _onConnectionChanged(bool connected) {
isConnected = connected;
if (!connected) {
// Link dropped — fall back to animated demo so the UI stays alive.
_startDemo();
}
notifyListeners();
}
void _onSerialStatus(ParpStatus status) {
isConnected = true;
headingDeg = status.headingDeg;
setpointDeg = status.setpointDeg;
rudderDeg = status.rudderDeg;
mode = status.mode;
notifyListeners();
}
// ---------------------------------------------------------------------------
// Demo simulation
// ---------------------------------------------------------------------------
void _startDemo() {
_demoTimer?.cancel();
_demoTimer = Timer.periodic(const Duration(milliseconds: 500), (_) => _tick());
}
void _tick() {
switch (mode) {
case AutopilotMode.standby:
headingDeg = (headingDeg + (_rng.nextDouble() - 0.5) * 0.5) % 360;
if (headingDeg < 0) headingDeg += 360;
rudderDeg = (rudderDeg + (_rng.nextDouble() - 0.5) * 0.8).clamp(-5.0, 5.0);
rotDpm = rudderDeg * 0.3 + (_rng.nextDouble() - 0.5) * 0.2;
case AutopilotMode.headingHold:
case AutopilotMode.trackKeep:
final error = _angleDiff(setpointDeg, headingDeg);
rudderDeg = (error * 1.2 + (_rng.nextDouble() - 0.5) * 0.5).clamp(-35.0, 35.0);
headingDeg = (headingDeg + error * 0.025 + (_rng.nextDouble() - 0.5) * 0.08) % 360;
if (headingDeg < 0) headingDeg += 360;
rotDpm = error * 0.4;
}
cogDeg = (headingDeg + rudderDeg * 0.15 + (_rng.nextDouble() - 0.5) * 0.3) % 360;
if (cogDeg < 0) cogDeg += 360;
notifyListeners();
}
double _angleDiff(double target, double current) {
double d = (target - current) % 360;
if (d > 180) d -= 360;
if (d < -180) d += 360;
return d;
}
// ---------------------------------------------------------------------------
// Commands — work in both demo and serial modes
// ---------------------------------------------------------------------------
void engage() {
setpointDeg = headingDeg;
mode = AutopilotMode.headingHold;
_service?.sendEngage(headingDeg);
notifyListeners();
}
void disengage() {
mode = AutopilotMode.standby;
_service?.sendDisengage();
notifyListeners();
}
void adjustSetpoint(double deltaDeg) {
if (mode != AutopilotMode.headingHold) return;
setpointDeg = (setpointDeg + deltaDeg) % 360;
if (setpointDeg < 0) setpointDeg += 360;
// Route to the appropriate serial command
if (_service != null && isConnected) {
if (deltaDeg == -10) _service!.sendPortTen(setpointDeg);
else if (deltaDeg == -1) _service!.sendPortOne(setpointDeg);
else if (deltaDeg == 1) _service!.sendStbdOne(setpointDeg);
else if (deltaDeg == 10) _service!.sendStbdTen(setpointDeg);
else _service!.sendSetHeading(setpointDeg);
}
notifyListeners();
}
void selectMode(AutopilotMode newMode) {
switch (newMode) {
case AutopilotMode.standby:
disengage();
case AutopilotMode.headingHold:
engage();
case AutopilotMode.trackKeep:
mode = AutopilotMode.trackKeep;
setpointDeg = headingDeg;
notifyListeners();
}
}
// ---------------------------------------------------------------------------
// Port discovery (delegate to service layer)
// ---------------------------------------------------------------------------
static List<String> availablePorts() => ConcentradorService.availablePorts();
@override
void dispose() {
_demoTimer?.cancel();
_service?.disconnect();
super.dispose();
}
}
+70
View File
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'data/autopilot_state.dart';
import 'theme/theme_provider.dart';
import 'screens/cockpit/cockpit_screen.dart';
import 'screens/settings/appearance_settings.dart';
import 'screens/settings/port_settings_screen.dart';
// SharedPreferences keys — must match port_settings_screen.dart
const _kRxKey = 'port.rx';
const _kTxKey = 'port.tx';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Load persisted theme before first frame.
final themeProvider = await AutopilotThemeProvider.load();
// Create state object early so we can attempt auto-connect.
final autopilotState = AutopilotState();
// Attempt to reconnect to the last-used COM ports silently.
// If the ports are not available (hardware unplugged, different PC, etc.)
// the exception is swallowed and the UI stays in demo mode.
try {
final prefs = await SharedPreferences.getInstance();
final rxPort = prefs.getString(_kRxKey);
final txPort = prefs.getString(_kTxKey);
if (rxPort != null && txPort != null) {
await autopilotState.connectToSerial(rxPort: rxPort, txPort: txPort);
}
} catch (_) {
// Hardware not available — stay in demo mode.
}
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider<AutopilotThemeProvider>.value(
value: themeProvider,
),
ChangeNotifierProvider<AutopilotState>.value(
value: autopilotState,
),
],
child: const ArAutopilotApp(),
),
);
}
class ArAutopilotApp extends StatelessWidget {
const ArAutopilotApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'AR-Autopilot',
debugShowCheckedModeBanner: false,
theme: ThemeData(useMaterial3: true),
initialRoute: CockpitScreen.routeName,
routes: {
CockpitScreen.routeName: (_) => const CockpitScreen(),
AppearanceSettingsScreen.routeName: (_) => const AppearanceSettingsScreen(),
PortSettingsScreen.routeName: (_) => const PortSettingsScreen(),
},
);
}
}
@@ -0,0 +1,465 @@
// =============================================================================
// screens/cockpit/cockpit_screen.dart — AR-Autopilot main cockpit view
// =============================================================================
//
// Sprint 4: static layout with demo data (AutopilotState simulation).
// Sprint 7: AutopilotState internals replaced by Modbus RTU over USB serial.
//
// Layout (top → bottom):
// TopBar — logo, title, NMEA/GPS status, settings gear
// ModeSelector — STANDBY | HDG HOLD | TRACK
// CompassRose — dominant visual; shows heading + setpoint tick
// DataStrip — SOG · COG · ROT · DEPTH
// HeadingAdjust — << < [SET 048°] > >>
// RudderRow — label + horizontal rudder indicator
// ActionRow — [ ENGAGE ] [ DISENGAGE ]
// =============================================================================
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../data/autopilot_state.dart';
import '../../theme/autopilot_theme.dart';
import '../../theme/theme_provider.dart';
import '../../widgets/themed/compass_rose.dart';
import '../../widgets/themed/disengage_button.dart';
import '../../widgets/themed/engage_button.dart';
import '../../widgets/themed/heading_adjust_bar.dart';
import '../../widgets/themed/mode_selector.dart';
import '../../widgets/themed/rudder_indicator.dart';
import '../../widgets/themed/status_chip.dart';
import '../settings/appearance_settings.dart';
import '../settings/port_settings_screen.dart';
class CockpitScreen extends StatelessWidget {
const CockpitScreen({super.key});
static const String routeName = '/';
@override
Widget build(BuildContext context) {
final theme = context.watch<AutopilotThemeProvider>().current;
final ap = context.watch<AutopilotState>();
return Scaffold(
backgroundColor: theme.background,
body: AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
decoration: theme.backgroundDecoration,
child: SafeArea(
child: Column(
children: [
_TopBar(theme: theme, ap: ap),
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 0),
child: ModeSelector(
activeMode: ap.mode,
onModeSelected: ap.selectMode,
),
),
Expanded(
child: _CockpitBody(theme: theme, ap: ap),
),
],
),
),
),
);
}
}
// ── Top bar ───────────────────────────────────────────────────────────────────
class _TopBar extends StatelessWidget {
const _TopBar({required this.theme, required this.ap});
final AutopilotTheme theme;
final AutopilotState ap;
@override
Widget build(BuildContext context) {
return Container(
height: 54,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: theme.backgroundMid.withValues(alpha: 0.9),
border: Border(
bottom: BorderSide(color: theme.panelBorder, width: 0.5),
),
),
child: Row(
children: [
// Logo — triple-tap cycles themes (design invariant from Sprint 3)
_ThemeCycleLogo(theme: theme),
const SizedBox(width: 10),
Text(
'AR-AUTOPILOT',
style: TextStyle(
color: theme.textMain,
fontSize: 14,
fontWeight: FontWeight.w700,
letterSpacing: 1.8,
),
),
if (!ap.isConnected) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: theme.warnColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: theme.warnColor.withValues(alpha: 0.4),
),
),
child: Text(
'DEMO',
style: TextStyle(
color: theme.warnColor,
fontSize: 9,
fontWeight: FontWeight.w700,
letterSpacing: 1,
),
),
),
],
const Spacer(),
StatusChip(
theme: theme,
label: 'NMEA',
status: ap.isConnected ? StatusLevel.ok : StatusLevel.off,
),
const SizedBox(width: 14),
StatusChip(
theme: theme,
label: 'GPS',
status: ap.isConnected ? StatusLevel.ok : StatusLevel.warn,
),
const SizedBox(width: 16),
PopupMenuButton<String>(
icon: Icon(
Icons.settings_outlined,
color: theme.textMuted,
size: 22,
),
color: theme.backgroundMid,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: theme.panelBorder),
),
onSelected: (route) => Navigator.pushNamed(context, route),
itemBuilder: (_) => [
PopupMenuItem(
value: PortSettingsScreen.routeName,
child: Row(
children: [
Icon(Icons.usb, color: theme.accentMid, size: 18),
const SizedBox(width: 10),
Text(
'Puertos COM',
style: TextStyle(color: theme.textMain, fontSize: 13),
),
],
),
),
PopupMenuItem(
value: AppearanceSettingsScreen.routeName,
child: Row(
children: [
Icon(Icons.palette_outlined,
color: theme.accentMid, size: 18),
const SizedBox(width: 10),
Text(
'Apariencia',
style: TextStyle(color: theme.textMain, fontSize: 13),
),
],
),
),
],
),
],
),
);
}
}
/// AR Electronics logo that cycles themes on triple-tap.
///
/// Counts taps within 600 ms; three rapid taps rotate through the 4 themes.
/// This implements the shortcut described in [AppearanceSettingsScreen].
class _ThemeCycleLogo extends StatefulWidget {
const _ThemeCycleLogo({required this.theme});
final AutopilotTheme theme;
@override
State<_ThemeCycleLogo> createState() => _ThemeCycleLogoState();
}
class _ThemeCycleLogoState extends State<_ThemeCycleLogo> {
static const _kWindow = Duration(milliseconds: 600);
static const _kIds = ['light', 'cyan', 'wine', 'ochre'];
int _taps = 0;
Timer? _resetTimer;
void _onTap() {
_resetTimer?.cancel();
_taps++;
if (_taps >= 3) {
_taps = 0;
final provider = context.read<AutopilotThemeProvider>();
final idx = _kIds.indexOf(provider.current.id);
provider.setTheme(_kIds[(idx + 1) % _kIds.length]);
} else {
_resetTimer = Timer(_kWindow, () => _taps = 0);
}
}
@override
void dispose() {
_resetTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _onTap,
child: Image.asset(
'assets/images/ar_logo_full.png',
height: 34,
errorBuilder: (_, __, ___) => Icon(
Icons.anchor,
color: widget.theme.accentMid,
size: 28,
),
),
);
}
}
// ── Cockpit body ──────────────────────────────────────────────────────────────
class _CockpitBody extends StatelessWidget {
const _CockpitBody({required this.theme, required this.ap});
final AutopilotTheme theme;
final AutopilotState ap;
bool get _engaged => ap.mode != AutopilotMode.standby;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
// Compass scales between 200 and 320 px based on available width.
final compassSize =
(constraints.maxWidth * 0.68).clamp(200.0, 320.0);
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
child: Column(
children: [
// ── Compass rose ─────────────────────────────────────────────
Center(
child: CompassRose(
headingDeg: ap.headingDeg,
setPointDeg: _engaged ? ap.setpointDeg : null,
size: compassSize,
),
),
const SizedBox(height: 14),
// ── Instrument data strip ────────────────────────────────────
_DataStrip(theme: theme, ap: ap),
const SizedBox(height: 14),
// ── Heading setpoint + adjust buttons ────────────────────────
HeadingAdjustBar(
setpointDeg: ap.setpointDeg,
enabled: _engaged,
onAdjust: ap.adjustSetpoint,
),
const SizedBox(height: 14),
// ── Rudder indicator ─────────────────────────────────────────
_RudderRow(theme: theme, rudderDeg: ap.rudderDeg),
const SizedBox(height: 20),
// ── ENGAGE / DISENGAGE row ───────────────────────────────────
Row(
children: [
Expanded(
child: EngageButton(
onPressed: !_engaged ? ap.engage : null,
enabled: !_engaged,
),
),
const SizedBox(width: 14),
Expanded(
child: DisengageButton(
onPressed: _engaged ? ap.disengage : null,
enabled: _engaged,
),
),
],
),
],
),
);
},
);
}
}
// ── Data strip ────────────────────────────────────────────────────────────────
class _DataStrip extends StatelessWidget {
const _DataStrip({required this.theme, required this.ap});
final AutopilotTheme theme;
final AutopilotState ap;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
gradient: theme.panelBackground,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.panelBorder),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_DataCell(
theme: theme,
label: 'SOG',
value: '${ap.sogKn.toStringAsFixed(1)} kn',
),
_VerticalDivider(theme: theme),
_DataCell(
theme: theme,
label: 'COG',
value: '${ap.cogDeg.toStringAsFixed(0).padLeft(3, '0')}°',
),
_VerticalDivider(theme: theme),
_DataCell(
theme: theme,
label: 'ROT',
value: '${ap.rotDpm.toStringAsFixed(1)}°/m',
),
_VerticalDivider(theme: theme),
_DataCell(
theme: theme,
label: 'PROF',
value: '${ap.depthM.toStringAsFixed(1)} m',
),
],
),
);
}
}
class _VerticalDivider extends StatelessWidget {
const _VerticalDivider({required this.theme});
final AutopilotTheme theme;
@override
Widget build(BuildContext context) =>
Container(width: 1, height: 30, color: theme.panelBorder);
}
class _DataCell extends StatelessWidget {
const _DataCell({
required this.theme,
required this.label,
required this.value,
});
final AutopilotTheme theme;
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
color: theme.textMuted,
fontSize: 9,
letterSpacing: 1.2,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 3),
Text(
value,
style: TextStyle(
color: theme.textMain,
fontSize: 15,
fontWeight: FontWeight.w300,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
);
}
}
// ── Rudder row ────────────────────────────────────────────────────────────────
class _RudderRow extends StatelessWidget {
const _RudderRow({required this.theme, required this.rudderDeg});
final AutopilotTheme theme;
final double rudderDeg;
String _label(double deg) {
if (deg.abs() < 0.5) return 'CENTRO';
final side = deg < 0 ? 'BABOR' : 'ESTRIBOR';
return '${deg.abs().toStringAsFixed(1)}° $side';
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Text(
'TIMÓN',
style: TextStyle(
color: theme.textMuted,
fontSize: 9,
letterSpacing: 1.2,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
Text(
_label(rudderDeg),
style: TextStyle(
color: theme.textSoft,
fontSize: 12,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
const SizedBox(height: 6),
RudderIndicator(rudderDeg: rudderDeg),
],
);
}
}
@@ -0,0 +1,307 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../theme/autopilot_theme.dart';
import '../../theme/theme_provider.dart';
import '../../theme/theme_registry.dart';
/// Appearance settings screen — "Ajustes → Apariencia".
///
/// Shows a 4-card grid where each card is a 200×120 px miniature preview
/// of the theme (compass rose colours + a sample DODGE button).
///
/// Tapping a card applies the theme **immediately** with a 400 ms transition.
///
/// ## Triple-tap shortcut
/// The AR-Autopilot logo in the topbar supports triple-tap to cycle through
/// all 4 themes without opening this screen. Implement the gesture in the
/// topbar widget, calling `provider.setTheme(nextId)`.
///
/// ## Sprint 5 placeholders
/// "Auto day/night" and "Ambient light sensor" toggles are rendered but
/// disabled — they are implemented in Sprint 5.
class AppearanceSettingsScreen extends StatelessWidget {
const AppearanceSettingsScreen({super.key});
static const String routeName = '/settings/appearance';
@override
Widget build(BuildContext context) {
final provider = context.watch<AutopilotThemeProvider>();
final theme = provider.current;
return Scaffold(
backgroundColor: theme.background,
appBar: AppBar(
backgroundColor: theme.backgroundMid,
foregroundColor: theme.textMain,
elevation: 0,
title: Text(
'Apariencia',
style: TextStyle(color: theme.textMain, fontWeight: FontWeight.w600),
),
),
body: AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
decoration: theme.backgroundDecoration,
child: ListView(
padding: const EdgeInsets.all(20),
children: [
_SectionLabel(label: 'Tema visual', theme: theme),
const SizedBox(height: 12),
// ── 4-card theme selector grid ───────────────────────────────
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: ThemeRegistry.all.map((t) {
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: _ThemeCard(
theme: t,
isSelected: t.id == theme.id,
onTap: () => provider.setTheme(t.id),
),
),
);
}).toList(),
),
const SizedBox(height: 28),
Divider(color: theme.panelBorder, thickness: 1, height: 1),
const SizedBox(height: 20),
// ── Sprint 5 toggles (disabled placeholders) ─────────────────
_ToggleRow(
theme: theme,
label: 'Cambio automático día/noche',
subtitle: 'De día usa "Claro", de noche usa el seleccionado arriba.',
value: false,
onChanged: null, // Sprint 5
),
const SizedBox(height: 16),
_ToggleRow(
theme: theme,
label: 'Sensor de luz ambiental',
subtitle:
'Si el display tiene sensor, ajusta automáticamente entre claro y el oscuro.',
value: false,
onChanged: null, // Sprint 5
),
const SizedBox(height: 32),
],
),
),
);
}
}
// ── Private widgets ───────────────────────────────────────────────────────────
class _SectionLabel extends StatelessWidget {
const _SectionLabel({required this.label, required this.theme});
final String label;
final AutopilotTheme theme;
@override
Widget build(BuildContext context) => Text(
label.toUpperCase(),
style: TextStyle(
color: theme.textMuted,
fontSize: 11,
letterSpacing: 1.2,
fontWeight: FontWeight.w600,
),
);
}
class _ThemeCard extends StatelessWidget {
const _ThemeCard({
required this.theme,
required this.isSelected,
required this.onTap,
});
final AutopilotTheme theme;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
height: 120,
decoration: BoxDecoration(
gradient: theme.panelBackground,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected ? theme.accentMid : theme.panelBorder,
width: isSelected ? 2 : 1,
),
boxShadow: isSelected
? theme.glowShadow(theme.accentGlowColor, theme.accentGlowRadius)
: null,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_MiniCompass(theme: theme),
const SizedBox(height: 6),
Text(
theme.displayName,
style: TextStyle(
color: theme.textMain,
fontSize: 9,
fontWeight: FontWeight.w600,
letterSpacing: 0.3,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 5),
AnimatedContainer(
duration: const Duration(milliseconds: 400),
width: 7,
height: 7,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSelected ? theme.accentMid : theme.textDisabled,
),
),
],
),
),
);
}
}
/// 40×40 miniature compass preview for the theme card.
class _MiniCompass extends StatelessWidget {
const _MiniCompass({required this.theme});
final AutopilotTheme theme;
@override
Widget build(BuildContext context) => SizedBox(
width: 40,
height: 40,
child: CustomPaint(painter: _MiniCompassPainter(theme: theme)),
);
}
class _MiniCompassPainter extends CustomPainter {
const _MiniCompassPainter({required this.theme});
final AutopilotTheme theme;
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final r = size.width / 2 - 2;
// Outer ring
canvas.drawCircle(
center,
r,
Paint()
..color = theme.accentMid
..style = PaintingStyle.stroke
..strokeWidth = 1.5,
);
// Heading arc (accent colour)
canvas.drawArc(
Rect.fromCircle(center: center, radius: r - 4),
-1.57, // top
1.3,
false,
Paint()
..color = theme.accentLight
..style = PaintingStyle.stroke
..strokeWidth = 2.5
..strokeCap = StrokeCap.round,
);
// North mark (warm colour — nautical convention)
canvas.drawArc(
Rect.fromCircle(center: center, radius: r),
-1.65,
0.4,
false,
Paint()
..color = theme.northColor
..style = PaintingStyle.stroke
..strokeWidth = 3
..strokeCap = StrokeCap.round,
);
// Set-point tick (operator intent — always amber)
final sx = center.dx + (r - 3) * 0.65;
final sy = center.dy - (r - 3) * 0.65;
final ex = center.dx + r * 0.65;
final ey = center.dy - r * 0.65;
canvas.drawLine(
Offset(sx, sy),
Offset(ex, ey),
Paint()
..color = theme.setLight
..strokeWidth = 2.5
..strokeCap = StrokeCap.round,
);
}
@override
bool shouldRepaint(_MiniCompassPainter old) => old.theme != theme;
}
class _ToggleRow extends StatelessWidget {
const _ToggleRow({
required this.theme,
required this.label,
required this.subtitle,
required this.value,
required this.onChanged,
});
final AutopilotTheme theme;
final String label;
final String subtitle;
final bool value;
final ValueChanged<bool>? onChanged;
@override
Widget build(BuildContext context) {
final enabled = onChanged != null;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
color: enabled ? theme.textMain : theme.textDisabled,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(color: theme.textMuted, fontSize: 12),
),
],
),
),
const SizedBox(width: 12),
Switch(
value: value,
onChanged: onChanged,
activeColor: theme.accentMid,
),
],
);
}
}
@@ -0,0 +1,367 @@
// =============================================================================
// screens/settings/port_settings_screen.dart — COM port configuration
// =============================================================================
//
// Lets the operator choose which COM port is the concentrador RX-OUT
// (the one the display reads from) and which is the TX-IN
// (the one the display writes commands to).
//
// Ports are persisted in SharedPreferences and auto-applied at startup.
//
// Layout:
// • Two dropdowns — RX port, TX port — populated from available COM ports
// • [Conectar] button — tries to open both ports; shows error if it fails
// • [Desconectar] button — drops to demo mode
// • Status row — green ● CONECTADO / grey ● DEMO
// =============================================================================
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../data/autopilot_state.dart';
import '../../theme/autopilot_theme.dart';
import '../../theme/theme_provider.dart';
class PortSettingsScreen extends StatefulWidget {
const PortSettingsScreen({super.key});
static const String routeName = '/settings/ports';
@override
State<PortSettingsScreen> createState() => _PortSettingsScreenState();
}
class _PortSettingsScreenState extends State<PortSettingsScreen> {
static const _kRxKey = 'port.rx';
static const _kTxKey = 'port.tx';
List<String> _ports = [];
String? _rxPort;
String? _txPort;
bool _connecting = false;
String? _errorMsg;
@override
void initState() {
super.initState();
_loadPorts();
}
Future<void> _loadPorts() async {
final available = AutopilotState.availablePorts();
final prefs = await SharedPreferences.getInstance();
setState(() {
_ports = available;
_rxPort = prefs.getString(_kRxKey);
_txPort = prefs.getString(_kTxKey);
// If saved port no longer exists, clear it.
if (_rxPort != null && !_ports.contains(_rxPort)) _rxPort = null;
if (_txPort != null && !_ports.contains(_txPort)) _txPort = null;
});
}
Future<void> _connect() async {
if (_rxPort == null || _txPort == null) return;
setState(() {
_connecting = true;
_errorMsg = null;
});
try {
final ap = context.read<AutopilotState>();
await ap.connectToSerial(rxPort: _rxPort!, txPort: _txPort!);
// Persist the working configuration
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kRxKey, _rxPort!);
await prefs.setString(_kTxKey, _txPort!);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Conectado — RX: $_rxPort TX: $_txPort'),
backgroundColor: Colors.green.shade700,
duration: const Duration(seconds: 3),
),
);
Navigator.pop(context);
}
} catch (e) {
setState(() => _errorMsg = 'Error al abrir puertos: $e');
} finally {
if (mounted) setState(() => _connecting = false);
}
}
Future<void> _disconnect() async {
final ap = context.read<AutopilotState>();
await ap.disconnectSerial();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Desconectado — modo demo activo'),
duration: Duration(seconds: 2),
),
);
Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
final theme = context.watch<AutopilotThemeProvider>().current;
final ap = context.watch<AutopilotState>();
return Scaffold(
backgroundColor: theme.background,
appBar: AppBar(
backgroundColor: theme.backgroundMid,
foregroundColor: theme.textMain,
elevation: 0,
title: Text(
'Conexión al Concentrador',
style: TextStyle(color: theme.textMain, fontWeight: FontWeight.w600),
),
),
body: AnimatedContainer(
duration: const Duration(milliseconds: 400),
decoration: theme.backgroundDecoration,
child: ListView(
padding: const EdgeInsets.all(20),
children: [
// ── Status ────────────────────────────────────────────────────────
_StatusRow(theme: theme, connected: ap.isConnected),
const SizedBox(height: 24),
// ── Port selection ────────────────────────────────────────────────
_SectionLabel(label: 'Puerto RX (leer datos del concentrador)', theme: theme),
const SizedBox(height: 8),
_PortDropdown(
theme: theme,
value: _rxPort,
ports: _ports,
onChanged: (v) => setState(() => _rxPort = v),
),
const SizedBox(height: 16),
_SectionLabel(label: 'Puerto TX (enviar comandos al concentrador)', theme: theme),
const SizedBox(height: 8),
_PortDropdown(
theme: theme,
value: _txPort,
ports: _ports,
onChanged: (v) => setState(() => _txPort = v),
),
const SizedBox(height: 8),
// Note
Text(
'Conectar el cable USB-OUT del concentrador al puerto RX,\n'
'y el USB-IN al puerto TX. Ambos a 115 200 baud, 8N1.',
style: TextStyle(color: theme.textMuted, fontSize: 11),
),
const SizedBox(height: 24),
// ── Error message ─────────────────────────────────────────────────
if (_errorMsg != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.disengageBackground.colors.first.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: theme.disengageBorder.withValues(alpha: 0.4)),
),
child: Text(
_errorMsg!,
style: TextStyle(color: theme.disengageText, fontSize: 12),
),
),
const SizedBox(height: 16),
],
// ── Buttons ───────────────────────────────────────────────────────
Row(
children: [
Expanded(
child: _ActionButton(
theme: theme,
label: _connecting ? 'Conectando…' : 'Conectar',
enabled: !_connecting && _rxPort != null && _txPort != null,
primary: true,
onPressed: _connect,
),
),
const SizedBox(width: 12),
Expanded(
child: _ActionButton(
theme: theme,
label: 'Desconectar',
enabled: ap.isConnected && !_connecting,
primary: false,
onPressed: _disconnect,
),
),
],
),
const SizedBox(height: 16),
// ── Refresh ports ─────────────────────────────────────────────────
TextButton(
onPressed: _loadPorts,
child: Text(
'Actualizar lista de puertos',
style: TextStyle(color: theme.textMuted, fontSize: 12),
),
),
],
),
),
);
}
}
// ── Private widgets ───────────────────────────────────────────────────────────
class _StatusRow extends StatelessWidget {
const _StatusRow({required this.theme, required this.connected});
final AutopilotTheme theme;
final bool connected;
@override
Widget build(BuildContext context) {
final color = connected ? theme.okColor : theme.textMuted;
final label = connected ? 'CONECTADO AL CONCENTRADOR' : 'MODO DEMO (sin conexión)';
return Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color,
boxShadow: connected
? [BoxShadow(color: color.withValues(alpha: 0.5), blurRadius: 8)]
: null,
),
),
const SizedBox(width: 10),
Text(
label,
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
),
),
],
);
}
}
class _SectionLabel extends StatelessWidget {
const _SectionLabel({required this.label, required this.theme});
final String label;
final AutopilotTheme theme;
@override
Widget build(BuildContext context) => Text(
label.toUpperCase(),
style: TextStyle(
color: theme.textMuted,
fontSize: 10,
letterSpacing: 1.2,
fontWeight: FontWeight.w600,
),
);
}
class _PortDropdown extends StatelessWidget {
const _PortDropdown({
required this.theme,
required this.value,
required this.ports,
required this.onChanged,
});
final AutopilotTheme theme;
final String? value;
final List<String> ports;
final ValueChanged<String?> onChanged;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
gradient: theme.panelBackground,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: theme.panelBorder),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: ports.contains(value) ? value : null,
hint: Text(
ports.isEmpty ? 'Sin puertos disponibles' : 'Seleccionar puerto…',
style: TextStyle(color: theme.textMuted, fontSize: 13),
),
dropdownColor: theme.backgroundMid,
style: TextStyle(color: theme.textMain, fontSize: 13),
icon: Icon(Icons.expand_more, color: theme.textMuted),
isExpanded: true,
items: ports
.map((p) => DropdownMenuItem(value: p, child: Text(p)))
.toList(),
onChanged: onChanged,
),
),
);
}
}
class _ActionButton extends StatelessWidget {
const _ActionButton({
required this.theme,
required this.label,
required this.enabled,
required this.primary,
required this.onPressed,
});
final AutopilotTheme theme;
final String label;
final bool enabled;
final bool primary;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final color = primary ? theme.okColor : theme.accentMid;
return GestureDetector(
onTap: enabled ? onPressed : null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(vertical: 14),
alignment: Alignment.center,
decoration: BoxDecoration(
color: enabled
? color.withValues(alpha: 0.15)
: theme.backgroundDeep,
borderRadius: BorderRadius.circular(7),
border: Border.all(
color: enabled ? color : theme.panelBorder,
width: 1.5,
),
),
child: Text(
label,
style: TextStyle(
color: enabled ? color : theme.textDisabled,
fontSize: 13,
fontWeight: FontWeight.w700,
letterSpacing: 1,
),
),
),
);
}
}
@@ -0,0 +1,197 @@
// =============================================================================
// services/concentrador_service.dart — USB serial link to AR-Concentrador
// =============================================================================
//
// The AR-Concentrador exposes two separate CH340N virtual COM ports:
// rxPort — USB-OUT: concentrador broadcasts $PARP,STATUS + NMEA at 2 Hz
// txPort — USB-IN : display sends $PARP commands to the concentrador
//
// Both ports run at 115 200 baud, 8N1, no flow control.
//
// Usage:
// final svc = ConcentradorService(rxPort: 'COM3', txPort: 'COM4', stationId: 2);
// svc.onStatus = (status) { ... };
// svc.onConnectionChanged = (connected) { ... };
// await svc.connect();
// svc.sendEngage(headingDeg: 125.0);
// svc.disconnect();
// =============================================================================
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter_libserialport/flutter_libserialport.dart';
import 'parp_codec.dart';
/// Callback type for status updates from the concentrador.
typedef StatusCallback = void Function(ParpStatus status);
/// Callback type for connection state changes.
typedef ConnectionCallback = void Function(bool connected);
class ConcentradorService {
ConcentradorService({
required this.rxPort,
required this.txPort,
this.stationId = 2,
this.baudRate = 115200,
});
final String rxPort;
final String txPort;
final int stationId;
final int baudRate;
/// Called whenever a valid $PARP,STATUS sentence is received.
StatusCallback? onStatus;
/// Called when the connection state changes.
ConnectionCallback? onConnectionChanged;
SerialPort? _rx;
SerialPort? _tx;
SerialPortReader? _reader;
StreamSubscription<Uint8List>? _rxSub;
bool _connected = false;
bool get isConnected => _connected;
// Accumulation buffer for partial sentences
final StringBuffer _buf = StringBuffer();
// ---------------------------------------------------------------------------
// Connection lifecycle
// ---------------------------------------------------------------------------
/// Open both serial ports and start listening for STATUS sentences.
///
/// Throws [SerialPortError] if either port cannot be opened.
Future<void> connect() async {
await disconnect(); // clean slate
_rx = SerialPort(rxPort);
_tx = SerialPort(txPort);
_configPort(_rx!);
_configPort(_tx!);
if (!_rx!.openRead()) {
throw SerialPortError('Cannot open RX port $rxPort');
}
if (!_tx!.openWrite()) {
throw SerialPortError('Cannot open TX port $txPort');
}
_reader = SerialPortReader(_rx!);
_rxSub = _reader!.stream.listen(
_onData,
onError: (_) => _handleDisconnect(),
onDone: _handleDisconnect,
);
_connected = true;
onConnectionChanged?.call(true);
}
/// Close both ports gracefully.
Future<void> disconnect() async {
_rxSub?.cancel();
_rxSub = null;
_reader = null;
_rx?.close();
_tx?.close();
_rx = null;
_tx = null;
if (_connected) {
_connected = false;
onConnectionChanged?.call(false);
}
}
// ---------------------------------------------------------------------------
// Command senders
// ---------------------------------------------------------------------------
void sendEngage(double headingDeg) =>
_send(ParpCodec.engage(stationId, headingDeg));
void sendDisengage() =>
_send(ParpCodec.disengage(stationId));
void sendSetHeading(double headingDeg) =>
_send(ParpCodec.setHeading(stationId, headingDeg));
void sendPortOne(double setpointDeg) =>
_send(ParpCodec.portOne(stationId, setpointDeg));
void sendStbdOne(double setpointDeg) =>
_send(ParpCodec.stbdOne(stationId, setpointDeg));
void sendPortTen(double setpointDeg) =>
_send(ParpCodec.portTen(stationId, setpointDeg));
void sendStbdTen(double setpointDeg) =>
_send(ParpCodec.stbdTen(stationId, setpointDeg));
void sendReqCmd() =>
_send(ParpCodec.reqCmd(stationId));
void sendRelCmd() =>
_send(ParpCodec.relCmd(stationId));
// ---------------------------------------------------------------------------
// Port listing (for settings screen)
// ---------------------------------------------------------------------------
/// All serial ports currently visible to the OS.
static List<String> availablePorts() => SerialPort.availablePorts;
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
void _configPort(SerialPort port) {
final cfg = SerialPortConfig()
..baudRate = baudRate
..bits = 8
..stopBits = 1
..parity = SerialPortParity.none
..setFlowControl(SerialPortFlowControl.none);
port.config = cfg;
}
void _onData(Uint8List data) {
_buf.write(String.fromCharCodes(data));
final raw = _buf.toString();
final lines = raw.split('\n');
// Keep the last (possibly incomplete) chunk in the buffer.
_buf.clear();
_buf.write(lines.last);
for (int i = 0; i < lines.length - 1; i++) {
final line = lines[i].trim();
if (line.isEmpty) continue;
final status = ParpCodec.parseStatus(line);
if (status != null) {
onStatus?.call(status);
}
}
}
void _send(String sentence) {
if (_tx == null || !_connected) return;
try {
_tx!.write(Uint8List.fromList(sentence.codeUnits));
} catch (_) {
_handleDisconnect();
}
}
void _handleDisconnect() {
disconnect();
}
}
+144
View File
@@ -0,0 +1,144 @@
// =============================================================================
// services/parp_codec.dart — $PARP sentence parser and builder
// =============================================================================
//
// Protocol reference: docs/concentrador_protocol.md
//
// Inbound ($PARP,STATUS — broadcast by concentrador at 2 Hz):
// $PARP,STATUS,<mode>,<setpoint>,<heading>,<rudder>,<commander>*XX\r\n
// Example: $PARP,STATUS,HEADING_HOLD,125.0,125.3,2.5,01*3A
//
// Outbound ($PARP commands — sent by display to concentrador):
// $PARP,<CMD>,<value>,<station_id>*XX\r\n
// Example: $PARP,ENGAGE,0.0,02*XX
// =============================================================================
import '../widgets/themed/mode_selector.dart';
/// Parsed content of a $PARP,STATUS sentence.
class ParpStatus {
const ParpStatus({
required this.mode,
required this.setpointDeg,
required this.headingDeg,
required this.rudderDeg,
required this.commander,
});
final AutopilotMode mode;
final double setpointDeg;
final double headingDeg;
final double rudderDeg;
final int commander; // station ID of the current commander (0 = none)
}
/// Stateless NMEA $PARP sentence codec.
abstract final class ParpCodec {
// ---------------------------------------------------------------------------
// Inbound parser
// ---------------------------------------------------------------------------
/// Parse a complete NMEA sentence string (with or without leading $).
///
/// Returns [ParpStatus] if the sentence is a valid $PARP,STATUS with correct
/// checksum. Returns null for any other sentence type or if CRC fails.
static ParpStatus? parseStatus(String sentence) {
// Strip whitespace / CRLF
final s = sentence.trim();
// Locate checksum delimiter
final starIdx = s.lastIndexOf('*');
if (starIdx < 0) return null;
final body = s.startsWith('\$') ? s.substring(1, starIdx) : s.substring(0, starIdx);
final crcHex = s.substring(starIdx + 1);
// Verify checksum
if (!_crcOk(body, crcHex)) return null;
// Tokenise
final parts = body.split(',');
if (parts.length < 7) return null;
if (parts[0] != 'PARP' || parts[1] != 'STATUS') return null;
final mode = _parseMode(parts[2]);
final setpoint = double.tryParse(parts[3]);
final heading = double.tryParse(parts[4]);
final rudder = double.tryParse(parts[5]);
final commander = int.tryParse(parts[6]);
if (setpoint == null || heading == null || rudder == null || commander == null) {
return null;
}
return ParpStatus(
mode: mode,
setpointDeg: setpoint,
headingDeg: heading,
rudderDeg: rudder,
commander: commander,
);
}
// ---------------------------------------------------------------------------
// Outbound builders
// ---------------------------------------------------------------------------
static String engage(int stationId, double currentHeadingDeg) =>
_build('ENGAGE', currentHeadingDeg, stationId);
static String disengage(int stationId) =>
_build('DISENGAGE', 0.0, stationId);
static String setHeading(int stationId, double headingDeg) =>
_build('SETHEADING', headingDeg, stationId);
static String portOne(int stationId, double currentSetpoint) =>
_build('PORTONE', currentSetpoint, stationId);
static String stbdOne(int stationId, double currentSetpoint) =>
_build('STBDONE', currentSetpoint, stationId);
static String portTen(int stationId, double currentSetpoint) =>
_build('PORTTEN', currentSetpoint, stationId);
static String stbdTen(int stationId, double currentSetpoint) =>
_build('STBDTEN', currentSetpoint, stationId);
static String reqCmd(int stationId) =>
_build('REQCMD', 0.0, stationId);
static String relCmd(int stationId) =>
_build('RELCMD', 0.0, stationId);
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
static String _build(String cmd, double value, int stationId) {
final body = 'PARP,$cmd,${value.toStringAsFixed(1)},'
'${stationId.toString().padLeft(2, '0')}';
final crc = _computeCrc(body);
return '\$$body*${crc.toRadixString(16).toUpperCase().padLeft(2, '0')}\r\n';
}
static int _computeCrc(String body) {
int crc = 0;
for (final ch in body.codeUnits) {
crc ^= ch;
}
return crc;
}
static bool _crcOk(String body, String crcHex) {
final expected = _computeCrc(body);
final received = int.tryParse(crcHex, radix: 16);
return received != null && received == expected;
}
static AutopilotMode _parseMode(String raw) => switch (raw) {
'HEADING_HOLD' => AutopilotMode.headingHold,
'TRACK' => AutopilotMode.trackKeep,
_ => AutopilotMode.standby,
};
}
+135
View File
@@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
/// Visual theme token set for the AR-Autopilot display app.
///
/// Every widget reads design tokens from [AutopilotTheme] via
/// [AutopilotThemeProvider]. No widget hard-codes colors.
///
/// ## Design invariants (apply to ALL themes)
/// 1. **DISENGAGE** is always the highest-contrast element on screen.
/// If the palette makes red ambiguous, it changes to amber (wine theme).
/// 2. **Action buttons** (DODGE, ±1°, ±10°) must be legible at rest —
/// operators work with gloves in rain; hover/tap is not reliable.
/// 3. **Compass heading text** uses [textMain] with [accentGlowColor] glow.
/// 4. **Set-point** always uses [setLight] (amber/gold) — cognitively stable
/// across palettes; represents "operator intent".
/// 5. **North mark** is always a warm color ([northColor]) — nautical convention.
/// 6. **Touch targets**: 48×48 px nominal, 60×60 px for critical controls.
/// 7. **No glow in light mode**: [accentGlowRadius] == 0.0 for `light` theme.
class AutopilotTheme {
const AutopilotTheme({
required this.id,
required this.displayName,
// Backgrounds
required this.background,
required this.backgroundMid,
required this.backgroundDeep,
required this.backgroundDeepest,
this.backgroundGradient,
// Panels
required this.panelBackground,
required this.panelBorder,
// Text
required this.textMain,
required this.textMuted,
required this.textSoft,
required this.textDisabled,
// Accent — heading arc, active mode indicator, rudder indicator
required this.accentLight,
required this.accentMid,
required this.accentDark,
required this.accentGlowRadius,
required this.accentGlowColor,
// Set-point / desired heading
required this.setLight,
required this.setDark,
required this.setGlow,
// Semantic states
required this.okColor,
required this.warnColor,
required this.northColor,
// DISENGAGE — always high contrast, always unambiguous
required this.disengageBackground,
required this.disengageText,
required this.disengageBorder,
required this.disengageGlow,
// Action buttons (DODGE, ±1°, ±10°)
required this.actionButtonBackground,
required this.actionButtonBorder,
required this.actionButtonText,
required this.actionButtonGlow,
});
final String id;
final String displayName;
// ── Backgrounds ────────────────────────────────────────────────────────────
/// Dominant background color. Used as solid fill (light) or radial
/// gradient center (dark themes). See [backgroundDecoration].
final Color background;
final Color backgroundMid;
final Color backgroundDeep;
final Color backgroundDeepest;
/// Radial gradient for dark themes; `null` for the light theme
/// (which uses a solid [background]).
final Gradient? backgroundGradient;
// ── Panels ──────────────────────────────────────────────────────────────────
final Gradient panelBackground;
final Color panelBorder;
// ── Text ────────────────────────────────────────────────────────────────────
final Color textMain;
final Color textMuted;
final Color textSoft;
final Color textDisabled;
// ── Accent ──────────────────────────────────────────────────────────────────
final Color accentLight;
final Color accentMid;
final Color accentDark;
/// Blur radius for accent glow effect. Zero in light mode (no glow).
final double accentGlowRadius;
final Color accentGlowColor;
// ── Set-point ───────────────────────────────────────────────────────────────
final Color setLight;
final Color setDark;
final Color setGlow;
// ── Semantic states ─────────────────────────────────────────────────────────
/// GPS OK, NMEA sentence valid, SOG within range.
final Color okColor;
/// Heading error, cross-track error warning.
final Color warnColor;
/// North mark on the compass rose. Always a warm color (never blue).
final Color northColor;
// ── DISENGAGE ───────────────────────────────────────────────────────────────
final Gradient disengageBackground;
final Color disengageText;
final Color disengageBorder;
final Color disengageGlow;
// ── Action buttons (DODGE, ±1°, ±10°) ──────────────────────────────────────
final Gradient actionButtonBackground;
final Color actionButtonBorder;
final Color actionButtonText;
final Color actionButtonGlow;
// ── Computed helpers ────────────────────────────────────────────────────────
/// [BoxDecoration] for the full-screen background container.
BoxDecoration get backgroundDecoration => backgroundGradient != null
? BoxDecoration(gradient: backgroundGradient)
: BoxDecoration(color: background);
/// [BoxShadow] list for accented glow (empty in light mode).
List<BoxShadow> glowShadow(Color color, double radius) => radius > 0
? [BoxShadow(color: color, blurRadius: radius, spreadRadius: 1)]
: const [];
}
+59
View File
@@ -0,0 +1,59 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'autopilot_theme.dart';
import 'theme_registry.dart';
/// Manages the active [AutopilotTheme] and persists the user's selection.
///
/// ## Persistence
/// - Stored locally in [SharedPreferences] under [_kThemeKey].
/// - **NOT sent to the ESP32** — firmware is colour-agnostic.
/// - **NOT synchronised between displays** — each display (bridge / engine room)
/// maintains its own independent preference.
///
/// ## Usage
/// ```dart
/// // In main.dart — wrap the widget tree:
/// ChangeNotifierProvider<AutopilotThemeProvider>(
/// create: (_) => await AutopilotThemeProvider.load(),
/// child: const AutopilotApp(),
/// )
///
/// // Read the current theme anywhere in the tree:
/// final theme = context.watch<AutopilotThemeProvider>().current;
///
/// // Switch theme (e.g. from the Appearance screen):
/// await context.read<AutopilotThemeProvider>().setTheme('wine');
/// ```
class AutopilotThemeProvider extends ChangeNotifier {
static const String _kThemeKey = 'autopilot.theme.id';
AutopilotTheme _current;
AutopilotThemeProvider(this._current);
/// The currently active [AutopilotTheme].
AutopilotTheme get current => _current;
/// Loads the persisted theme preference from [SharedPreferences].
///
/// Falls back to [ThemeRegistry.defaultId] if no preference is stored
/// (e.g. first launch) or the stored id is no longer registered.
static Future<AutopilotThemeProvider> load() async {
final prefs = await SharedPreferences.getInstance();
final id = prefs.getString(_kThemeKey) ?? ThemeRegistry.defaultId;
return AutopilotThemeProvider(ThemeRegistry.byId(id));
}
/// Switches to the theme identified by [id] and persists the selection.
///
/// Unknown ids silently fall back to [ThemeRegistry.defaultId].
/// Notifies all listeners after the switch so the UI rebuilds.
Future<void> setTheme(String id) async {
_current = ThemeRegistry.byId(id);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kThemeKey, _current.id);
notifyListeners();
}
}
+44
View File
@@ -0,0 +1,44 @@
import 'autopilot_theme.dart';
import 'themes/light_theme.dart';
import 'themes/cyan_theme.dart';
import 'themes/wine_theme.dart';
import 'themes/ochre_theme.dart';
/// Central registry of all factory [AutopilotTheme]s.
///
/// Themes are keyed by their [AutopilotTheme.id] string.
///
/// ## Display order
/// `light → cyan → wine → ochre` (shown in the Appearance selector grid).
///
/// ## Custom themes (Sprint 9 / Tier 3)
/// Architecture is ready for integrators to ship custom YAML themes in
/// `/config/themes/*.yaml`. [ThemeRegistry] will merge them with the
/// factory set at startup. Not active until Sprint 9.
abstract final class ThemeRegistry {
/// Theme id used when no preference is stored (first run).
static const String defaultId = 'cyan';
static final Map<String, AutopilotTheme> _registry = {
lightTheme.id: lightTheme,
cyanTheme.id: cyanTheme,
wineTheme.id: wineTheme,
ochreTheme.id: ochreTheme,
};
/// All available themes in UI display order.
static List<AutopilotTheme> get all => const [
lightTheme,
cyanTheme,
wineTheme,
ochreTheme,
];
/// Returns the theme for [id].
/// Falls back to [defaultId] (cyan) if [id] is not registered.
static AutopilotTheme byId(String id) =>
_registry[id] ?? _registry[defaultId]!;
/// Whether [id] belongs to a registered theme.
static bool contains(String id) => _registry.containsKey(id);
}
+65
View File
@@ -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
);
+61
View File
@@ -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,
);
+66
View File
@@ -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),
);
+67
View File
@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import '../autopilot_theme.dart';
/// **wine** — Vinotinto
///
/// Premium yacht / aesthetic preference palette. Deep crimson backgrounds
/// with rose accent. IMPORTANT: DISENGAGE switches from red to amber-gold
/// because red blends into the wine cockpit and loses its emergency signal.
/// The amber still carries the "caution/critical action" cognitive association.
const AutopilotTheme wineTheme = AutopilotTheme(
id: 'wine',
displayName: 'Vinotinto',
background: Color(0xFF2A0A14),
backgroundMid: Color(0xFF2A0A14),
backgroundDeep: Color(0xFF1A0610),
backgroundDeepest: Color(0xFF080205),
backgroundGradient: RadialGradient(
colors: [Color(0xFF2A0A14), Color(0xFF0F0408)],
stops: [0.0, 0.7],
),
panelBackground: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xE630121C), Color(0xE614080E)], // rgba(48,18,28,0.9) → rgba(20,8,14,0.9)
),
panelBorder: Color(0x4DBE1746), // rgba(190,23,70,0.3)
textMain: Color(0xFFFDE0E7),
textMuted: Color(0xFF8A5560),
textSoft: Color(0xFFF9A8B8),
textDisabled: Color(0xFF6A3848),
accentLight: Color(0xFFFB7185),
accentMid: Color(0xFFE11D48),
accentDark: Color(0xFF881337),
accentGlowRadius: 18.0,
accentGlowColor: Color(0xB3E11D48), // rgba(225,29,72,0.7)
setLight: Color(0xFFFBBF24),
setDark: Color(0xFFA16207),
setGlow: Color(0x66FBBF24),
okColor: Color(0xFFFBBF24), // gold not green — keeps warm palette
warnColor: Color(0xFFFBBF24),
northColor: Color(0xFFFDE047), // yellow not red — red is lost on wine background
// DISENGAGE: amber-gold instead of red — highest contrast on wine cockpit
disengageBackground: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFBBF24), Color(0xFFB45309)],
),
disengageText: Color(0xFF1C0A02), // near-black — maximum contrast on amber
disengageBorder: Color(0xFFFDE047),
disengageGlow: Color(0x99FBBF24),
actionButtonBackground: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF7A1F3A), Color(0xFF3A0E1C)], // improved contrast
),
actionButtonBorder: Color(0xFFFB7185), // vivid rose border
actionButtonText: Color(0xFFFDE0E7),
actionButtonGlow: Color(0x66FB7185),
);
@@ -0,0 +1,165 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../theme/autopilot_theme.dart';
import '../../theme/theme_provider.dart';
/// Full compass rose widget — the dominant visual element of the cockpit.
///
/// Displays:
/// - Rotating compass ring with degree markings
/// - Heading arc in [AutopilotTheme.accentLight]
/// - Set-point tick in [AutopilotTheme.setLight]
/// - North mark in [AutopilotTheme.northColor]
/// - Centre heading readout in [AutopilotTheme.textMain] with accent glow
///
/// [headingDeg] is the current vessel heading (0359.9°, magnetic).
/// [setPointDeg] is the desired heading (the autopilot target).
class CompassRose extends StatelessWidget {
const CompassRose({
super.key,
required this.headingDeg,
this.setPointDeg,
this.size = 280,
});
final double headingDeg;
final double? setPointDeg;
final double size;
@override
Widget build(BuildContext context) {
final theme = context.watch<AutopilotThemeProvider>().current;
return SizedBox(
width: size,
height: size,
child: CustomPaint(
painter: _CompassPainter(
theme: theme,
headingDeg: headingDeg,
setPointDeg: setPointDeg,
),
child: Center(
child: _HeadingReadout(theme: theme, headingDeg: headingDeg),
),
),
);
}
}
class _HeadingReadout extends StatelessWidget {
const _HeadingReadout({required this.theme, required this.headingDeg});
final AutopilotTheme theme;
final double headingDeg;
@override
Widget build(BuildContext context) {
final text = headingDeg.toStringAsFixed(1).padLeft(5, ' ');
return Text(
'$text°',
style: TextStyle(
color: theme.textMain,
fontSize: 36,
fontWeight: FontWeight.w200,
letterSpacing: 2,
fontFeatures: const [FontFeature.tabularFigures()],
shadows: theme.accentGlowRadius > 0
? [Shadow(color: theme.accentGlowColor, blurRadius: theme.accentGlowRadius)]
: null,
),
);
}
}
class _CompassPainter extends CustomPainter {
const _CompassPainter({
required this.theme,
required this.headingDeg,
this.setPointDeg,
});
final AutopilotTheme theme;
final double headingDeg;
final double? setPointDeg;
double _toRad(double deg) => deg * math.pi / 180;
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 8;
// Outer ring
canvas.drawCircle(
center,
radius,
Paint()
..color = theme.panelBorder
..style = PaintingStyle.stroke
..strokeWidth = 1,
);
// Heading arc (±45° around top = current heading)
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius - 8),
_toRad(-90 - 45),
_toRad(90),
false,
Paint()
..color = theme.accentLight
..style = PaintingStyle.stroke
..strokeWidth = 4
..strokeCap = StrokeCap.round,
);
// North mark (always at top of the ring, rotated opposite to heading)
final northAngle = _toRad(-headingDeg - 90);
final nx = center.dx + radius * math.cos(northAngle);
final ny = center.dy + radius * math.sin(northAngle);
canvas.drawCircle(
Offset(nx, ny),
5,
Paint()..color = theme.northColor,
);
// Set-point tick
if (setPointDeg != null) {
final spAngle = _toRad(setPointDeg! - headingDeg - 90);
canvas.drawLine(
Offset(
center.dx + (radius - 12) * math.cos(spAngle),
center.dy + (radius - 12) * math.sin(spAngle),
),
Offset(
center.dx + radius * math.cos(spAngle),
center.dy + radius * math.sin(spAngle),
),
Paint()
..color = theme.setLight
..strokeWidth = 3
..strokeCap = StrokeCap.round,
);
}
// Glow ring (dark themes only)
if (theme.accentGlowRadius > 0) {
canvas.drawCircle(
center,
radius - 8,
Paint()
..color = theme.accentGlowColor
..style = PaintingStyle.stroke
..strokeWidth = theme.accentGlowRadius / 2
..maskFilter = MaskFilter.blur(BlurStyle.normal, theme.accentGlowRadius / 2),
);
}
}
@override
bool shouldRepaint(_CompassPainter old) =>
old.headingDeg != headingDeg ||
old.setPointDeg != setPointDeg ||
old.theme != theme;
}
@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../theme/autopilot_theme.dart';
import '../../theme/theme_provider.dart';
/// The DISENGAGE emergency button.
///
/// Always the highest-contrast element on screen.
/// Minimum touch target: 60×60 px (critical control).
///
/// The gradient and glow colours adapt to the active theme automatically.
/// In the wine theme the button is amber-gold (not red) — see [wineTheme].
class DisengageButton extends StatelessWidget {
const DisengageButton({
super.key,
required this.onPressed,
this.enabled = true,
});
final VoidCallback? onPressed;
final bool enabled;
/// Minimum touch target for a critical control.
static const double kMinSize = 60.0;
@override
Widget build(BuildContext context) {
final theme = context.watch<AutopilotThemeProvider>().current;
return _DisengageButtonContent(
theme: theme,
onPressed: enabled ? onPressed : null,
);
}
}
class _DisengageButtonContent extends StatefulWidget {
const _DisengageButtonContent({
required this.theme,
required this.onPressed,
});
final AutopilotTheme theme;
final VoidCallback? onPressed;
@override
State<_DisengageButtonContent> createState() =>
_DisengageButtonContentState();
}
class _DisengageButtonContentState
extends State<_DisengageButtonContent> {
bool _pressed = false;
@override
Widget build(BuildContext context) {
final t = widget.theme;
final enabled = widget.onPressed != null;
return GestureDetector(
onTapDown: enabled ? (_) => setState(() => _pressed = true) : null,
onTapUp: enabled
? (_) {
setState(() => _pressed = false);
widget.onPressed!();
}
: null,
onTapCancel: () => setState(() => _pressed = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
constraints: const BoxConstraints(
minWidth: DisengageButton.kMinSize,
minHeight: DisengageButton.kMinSize,
),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: BoxDecoration(
gradient: t.disengageBackground,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: t.disengageBorder, width: 1.5),
boxShadow: [
BoxShadow(
color: t.disengageGlow,
blurRadius: _pressed ? 4 : 12,
spreadRadius: _pressed ? 0 : 2,
),
],
),
child: Text(
'DISENGAGE',
textAlign: TextAlign.center,
style: TextStyle(
color: t.disengageText,
fontSize: 13,
fontWeight: FontWeight.w800,
letterSpacing: 1.2,
shadows: [
Shadow(
color: t.disengageGlow,
blurRadius: 4,
),
],
),
),
),
);
}
}
@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../theme/autopilot_theme.dart';
import '../../theme/theme_provider.dart';
/// ENGAGE button — activates heading-hold at the current vessel heading.
///
/// Shown enabled (green, glowing) only when autopilot is in STANDBY.
/// When already engaged, [enabled] is false and the button dims.
///
/// Minimum touch target: 60×60 px (critical control, see design invariant §6).
class EngageButton extends StatefulWidget {
const EngageButton({
super.key,
required this.onPressed,
this.enabled = true,
});
final VoidCallback? onPressed;
final bool enabled;
@override
State<EngageButton> createState() => _EngageButtonState();
}
class _EngageButtonState extends State<EngageButton> {
bool _pressed = false;
@override
Widget build(BuildContext context) {
final theme = context.watch<AutopilotThemeProvider>().current;
final enabled = widget.enabled && widget.onPressed != null;
return GestureDetector(
onTapDown: enabled ? (_) => setState(() => _pressed = true) : null,
onTapUp: enabled
? (_) {
setState(() => _pressed = false);
widget.onPressed!();
}
: null,
onTapCancel: () => setState(() => _pressed = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
constraints: const BoxConstraints(minWidth: 60, minHeight: 60),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: BoxDecoration(
gradient: enabled
? LinearGradient(
colors: [
theme.okColor.withValues(alpha: 0.65),
theme.okColor.withValues(alpha: 0.35),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
)
: LinearGradient(
colors: [theme.panelBorder, theme.panelBorder],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: enabled ? theme.okColor : theme.panelBorder,
width: 1.5,
),
boxShadow: enabled
? [
BoxShadow(
color: theme.okColor
.withValues(alpha: _pressed ? 0.2 : 0.45),
blurRadius: _pressed ? 4 : 14,
spreadRadius: _pressed ? 0 : 2,
),
]
: [],
),
child: Text(
'ENGAGE',
textAlign: TextAlign.center,
style: TextStyle(
color: enabled ? Colors.white : theme.textDisabled,
fontSize: 13,
fontWeight: FontWeight.w800,
letterSpacing: 1.2,
shadows: enabled
? [Shadow(color: theme.okColor, blurRadius: 4)]
: null,
),
),
),
);
}
}
@@ -0,0 +1,188 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../theme/autopilot_theme.dart';
import '../../theme/theme_provider.dart';
/// Four heading-adjust buttons around a central setpoint readout.
///
/// Layout (left → right):
/// [ << 10° ] [ < 1° ] SET 048.0° [ 1° > ] [ 10° >> ]
///
/// All buttons are disabled when [enabled] is false (autopilot not engaged).
/// The setpoint display dims when disabled to reinforce the inactive state.
///
/// [onAdjust] receives the signed delta in degrees (+1, 1, +10, 10).
class HeadingAdjustBar extends StatelessWidget {
const HeadingAdjustBar({
super.key,
required this.setpointDeg,
required this.enabled,
required this.onAdjust,
});
final double setpointDeg;
final bool enabled;
final ValueChanged<double> onAdjust;
@override
Widget build(BuildContext context) {
final theme = context.watch<AutopilotThemeProvider>().current;
return Row(
children: [
_AdjustButton(
theme: theme, label: '<<\n10°', delta: -10,
enabled: enabled, onAdjust: onAdjust),
const SizedBox(width: 6),
_AdjustButton(
theme: theme, label: '<\n', 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: '\n>', delta: 1,
enabled: enabled, onAdjust: onAdjust),
const SizedBox(width: 6),
_AdjustButton(
theme: theme, label: '10°\n>>', delta: 10,
enabled: enabled, onAdjust: onAdjust),
],
);
}
}
// ── Setpoint display ──────────────────────────────────────────────────────────
class _SetpointDisplay extends StatelessWidget {
const _SetpointDisplay({
required this.theme,
required this.setpointDeg,
required this.enabled,
});
final AutopilotTheme theme;
final double setpointDeg;
final bool enabled;
@override
Widget build(BuildContext context) {
return Container(
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: theme.panelBackground,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: enabled
? theme.setLight.withValues(alpha: 0.5)
: theme.panelBorder,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'SET',
style: TextStyle(
color: theme.textMuted,
fontSize: 9,
letterSpacing: 1.2,
),
),
const SizedBox(height: 2),
Text(
'${setpointDeg.toStringAsFixed(1)}°',
style: TextStyle(
color: enabled ? theme.setLight : theme.textDisabled,
fontSize: 20,
fontWeight: FontWeight.w300,
fontFeatures: const [FontFeature.tabularFigures()],
shadows: enabled && theme.accentGlowRadius > 0
? [Shadow(color: theme.setGlow, blurRadius: 8)]
: null,
),
),
],
),
);
}
}
// ── Adjust button ─────────────────────────────────────────────────────────────
class _AdjustButton extends StatefulWidget {
const _AdjustButton({
required this.theme,
required this.label,
required this.delta,
required this.enabled,
required this.onAdjust,
});
final AutopilotTheme theme;
final String label;
final double delta;
final bool enabled;
final ValueChanged<double> onAdjust;
@override
State<_AdjustButton> createState() => _AdjustButtonState();
}
class _AdjustButtonState extends State<_AdjustButton> {
bool _pressed = false;
@override
Widget build(BuildContext context) {
final t = widget.theme;
final enabled = widget.enabled;
return GestureDetector(
onTapDown: enabled ? (_) => setState(() => _pressed = true) : null,
onTapUp: enabled
? (_) {
setState(() => _pressed = false);
widget.onAdjust(widget.delta);
}
: null,
onTapCancel: () => setState(() => _pressed = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
constraints: const BoxConstraints(minWidth: 48, minHeight: 52),
padding: const EdgeInsets.symmetric(horizontal: 10),
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: enabled ? t.actionButtonBackground : null,
color: enabled ? null : t.backgroundDeep,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: enabled && !_pressed
? t.actionButtonBorder
: t.panelBorder,
),
boxShadow: enabled && !_pressed
? t.glowShadow(t.actionButtonGlow, t.accentGlowRadius / 2)
: [],
),
child: Text(
widget.label,
textAlign: TextAlign.center,
style: TextStyle(
color: enabled ? t.actionButtonText : t.textDisabled,
fontSize: 11,
fontWeight: FontWeight.w600,
height: 1.35,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
);
}
}
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../theme/autopilot_theme.dart';
import '../../theme/theme_provider.dart';
/// Autopilot mode selector STANDBY / HEADING_HOLD / TRACK_KEEP.
///
/// The active mode is highlighted with [AutopilotTheme.accentMid].
/// Inactive modes use muted text and panel border.
/// Minimum touch target: 48×48 px per mode button.
enum AutopilotMode { standby, headingHold, trackKeep }
extension AutopilotModeLabel on AutopilotMode {
String get label => switch (this) {
AutopilotMode.standby => 'STANDBY',
AutopilotMode.headingHold => 'HDG HOLD',
AutopilotMode.trackKeep => 'TRACK',
};
}
class ModeSelector extends StatelessWidget {
const ModeSelector({
super.key,
required this.activeMode,
required this.onModeSelected,
});
final AutopilotMode activeMode;
final ValueChanged<AutopilotMode> onModeSelected;
@override
Widget build(BuildContext context) {
final theme = context.watch<AutopilotThemeProvider>().current;
return Container(
decoration: BoxDecoration(
gradient: theme.panelBackground,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.panelBorder),
),
child: Row(
children: AutopilotMode.values.map((mode) {
final isActive = mode == activeMode;
return Expanded(
child: _ModeButton(
theme: theme,
mode: mode,
isActive: isActive,
onTap: () => onModeSelected(mode),
),
);
}).toList(),
),
);
}
}
class _ModeButton extends StatelessWidget {
const _ModeButton({
required this.theme,
required this.mode,
required this.isActive,
required this.onTap,
});
final AutopilotTheme theme;
final AutopilotMode mode;
final bool isActive;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
constraints: const BoxConstraints(minHeight: 48),
alignment: Alignment.center,
decoration: BoxDecoration(
color: isActive ? theme.accentMid.withValues(alpha: 0.15) : Colors.transparent,
borderRadius: BorderRadius.circular(6),
),
child: Text(
mode.label,
style: TextStyle(
color: isActive ? theme.accentLight : theme.textMuted,
fontSize: 12,
fontWeight: isActive ? FontWeight.w700 : FontWeight.w400,
letterSpacing: 0.8,
shadows: isActive && theme.accentGlowRadius > 0
? [Shadow(color: theme.accentGlowColor, blurRadius: 6)]
: null,
),
),
),
);
}
}
@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../theme/autopilot_theme.dart';
import '../../theme/theme_provider.dart';
/// Rudder angle indicator horizontal bar from 35° (port) to +35° (starboard).
///
/// [rudderDeg] is the actual rudder angle in degrees.
/// Negative = port, positive = starboard.
/// [limitDeg] is the hard stop (default 35°).
class RudderIndicator extends StatelessWidget {
const RudderIndicator({
super.key,
required this.rudderDeg,
this.limitDeg = 35.0,
this.height = 48,
});
final double rudderDeg;
final double limitDeg;
final double height;
@override
Widget build(BuildContext context) {
final theme = context.watch<AutopilotThemeProvider>().current;
return SizedBox(
height: height,
child: CustomPaint(
painter: _RudderPainter(
theme: theme,
rudderDeg: rudderDeg,
limitDeg: limitDeg,
),
),
);
}
}
class _RudderPainter extends CustomPainter {
const _RudderPainter({
required this.theme,
required this.rudderDeg,
required this.limitDeg,
});
final AutopilotTheme theme;
final double rudderDeg;
final double limitDeg;
@override
void paint(Canvas canvas, Size size) {
final t = theme;
final cx = size.width / 2;
final cy = size.height / 2;
final halfW = size.width / 2 - 12;
// Track
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromCenter(center: Offset(cx, cy), width: size.width - 24, height: 6),
const Radius.circular(3),
),
Paint()..color = t.panelBorder,
);
// Centre line
canvas.drawLine(
Offset(cx, cy - 10),
Offset(cx, cy + 10),
Paint()
..color = t.textMuted
..strokeWidth = 1,
);
// Rudder fill
final fraction = (rudderDeg / limitDeg).clamp(-1.0, 1.0);
final fillWidth = (halfW * fraction.abs()).clamp(0.0, halfW);
if (fillWidth > 1) {
final left = fraction < 0 ? cx - fillWidth : cx;
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(left, cy - 3, fillWidth, 6),
const Radius.circular(2),
),
Paint()..color = t.accentMid,
);
}
// Indicator knob
final kx = cx + halfW * fraction;
canvas.drawCircle(
Offset(kx, cy),
9,
Paint()..color = t.accentLight,
);
if (t.accentGlowRadius > 0) {
canvas.drawCircle(
Offset(kx, cy),
9,
Paint()
..color = t.accentGlowColor
..maskFilter = MaskFilter.blur(BlurStyle.normal, t.accentGlowRadius / 2),
);
}
// Labels
final labelStyle = TextStyle(color: t.textMuted, fontSize: 10);
_drawLabel(canvas, 'P', Offset(12, cy), labelStyle);
_drawLabel(canvas, 'S', Offset(size.width - 12, cy), labelStyle);
}
void _drawLabel(Canvas canvas, String text, Offset center, TextStyle style) {
final span = TextSpan(text: text, style: style);
final tp = TextPainter(
text: span,
textDirection: TextDirection.ltr,
)..layout();
tp.paint(canvas, center - Offset(tp.width / 2, tp.height / 2));
}
@override
bool shouldRepaint(_RudderPainter old) =>
old.rudderDeg != rudderDeg || old.theme != theme;
}
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import '../../theme/autopilot_theme.dart';
/// Connection / data-source status indicator coloured dot + label.
///
/// Used in the [CockpitScreen] top-bar to show NMEA and GPS link state.
enum StatusLevel {
/// Data valid and link active.
ok,
/// Data stale, link degraded, or GPS fix lost.
warn,
/// Link absent (serial port not open, concentrador not connected).
off,
}
class StatusChip extends StatelessWidget {
const StatusChip({
super.key,
required this.theme,
required this.label,
required this.status,
});
final AutopilotTheme theme;
final String label;
final StatusLevel status;
Color get _dotColor => switch (status) {
StatusLevel.ok => theme.okColor,
StatusLevel.warn => theme.warnColor,
StatusLevel.off => theme.textDisabled,
};
@override
Widget build(BuildContext context) {
final dot = _dotColor;
final glowing = status == StatusLevel.ok && theme.accentGlowRadius > 0;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: dot,
boxShadow: glowing
? [BoxShadow(color: dot.withValues(alpha: 0.6), blurRadius: 6)]
: null,
),
),
const SizedBox(width: 5),
Text(
label,
style: TextStyle(
color:
status == StatusLevel.off ? theme.textDisabled : theme.textMuted,
fontSize: 10,
letterSpacing: 0.5,
fontWeight: FontWeight.w500,
),
),
],
);
}
}
+29
View File
@@ -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/
+127
View File
@@ -0,0 +1,127 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ar_autopilot_display/theme/theme_registry.dart';
import 'package:ar_autopilot_display/theme/autopilot_theme.dart';
// WCAG 2.1 helpers
/// WCAG 2.1 relative luminance of a colour.
/// https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
double _luminance(Color c) {
double lin(int channel) {
final s = channel / 255.0;
return s <= 0.04045 ? s / 12.92 : pow((s + 0.055) / 1.055, 2.4).toDouble();
}
return 0.2126 * lin(c.red) + 0.7152 * lin(c.green) + 0.0722 * lin(c.blue);
}
/// WCAG 2.1 contrast ratio (symmetric).
double _contrast(Color a, Color b) {
final la = _luminance(a);
final lb = _luminance(b);
final lighter = max(la, lb);
final darker = min(la, lb);
return (lighter + 0.05) / (darker + 0.05);
}
/// Returns all colour stops from a [Gradient].
List<Color> _gradientColors(Gradient g) {
if (g is LinearGradient) return g.colors;
if (g is RadialGradient) return g.colors;
return [];
}
/// Returns the **maximum** contrast of [text] against any stop in [bg].
///
/// We test the best-case stop: the gradient stop that gives the highest
/// contrast against the text colour. This ensures the design intent is
/// achievable the text will visually overlap the highest-contrast region.
///
/// For DISENGAGE ( 7:1 AAA): tests that at least one region of the gradient
/// button delivers the required accessibility threshold.
double _maxContrastVsGradient(Color text, Gradient bg) {
final stops = _gradientColors(bg);
if (stops.isEmpty) return 0;
return stops.map((c) => _contrast(text, c)).reduce(max);
}
// Tests
void main() {
group('WCAG contrast — mandatory Sprint 4 gates', () {
for (final theme in ThemeRegistry.all) {
_contrastTests(theme);
}
});
}
void _contrastTests(AutopilotTheme t) {
group('Theme: ${t.id}', () {
// DISENGAGE
test('DISENGAGE text vs button background ≥ 7:1 (WCAG AAA)', () {
final ratio = _maxContrastVsGradient(t.disengageText, t.disengageBackground);
expect(
ratio,
greaterThanOrEqualTo(7.0),
reason:
'${t.id}: DISENGAGE text contrast is ${ratio.toStringAsFixed(2)}:1 — '
'must be ≥ 7:1 (AAA). Adjust disengageText or disengageBackground.',
);
});
// Action buttons
test('Action button text vs button background ≥ 4.5:1 (WCAG AA)', () {
final ratio = _maxContrastVsGradient(
t.actionButtonText,
t.actionButtonBackground,
);
expect(
ratio,
greaterThanOrEqualTo(4.5),
reason:
'${t.id}: action button text contrast is ${ratio.toStringAsFixed(2)}:1 — '
'must be ≥ 4.5:1 (AA). Operators must read DODGE/±1°/±10° without hover.',
);
});
// Body text
test('textMain vs background ≥ 4.5:1 (WCAG AA)', () {
final ratio = _contrast(t.textMain, t.background);
expect(
ratio,
greaterThanOrEqualTo(4.5),
reason:
'${t.id}: textMain contrast is ${ratio.toStringAsFixed(2)}:1 — '
'must be ≥ 4.5:1 (AA).',
);
});
// Set-point legibility
test('setLight vs background ≥ 3:1 (large UI element)', () {
final ratio = _contrast(t.setLight, t.background);
expect(
ratio,
greaterThanOrEqualTo(3.0),
reason:
'${t.id}: setLight contrast is ${ratio.toStringAsFixed(2)}:1 — '
'must be ≥ 3:1 (set-point is a large graphical element).',
);
});
// OK / Warn semantic colours
test('okColor vs background ≥ 3:1 (status indicator)', () {
final ratio = _contrast(t.okColor, t.background);
expect(
ratio,
greaterThanOrEqualTo(3.0),
reason:
'${t.id}: okColor contrast is ${ratio.toStringAsFixed(2)}:1 — '
'status indicators require ≥ 3:1.',
);
});
});
}
@@ -0,0 +1,77 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:ar_autopilot_display/theme/theme_provider.dart';
import 'package:ar_autopilot_display/theme/theme_registry.dart';
void main() {
setUp(() {
// Reset SharedPreferences mock before each test
SharedPreferences.setMockInitialValues({});
});
group('AutopilotThemeProvider — persistence', () {
test('loads default theme (cyan) when no preference is stored', () async {
final provider = await AutopilotThemeProvider.load();
expect(provider.current.id, ThemeRegistry.defaultId);
});
test('loads a previously persisted theme on startup', () async {
SharedPreferences.setMockInitialValues({'autopilot.theme.id': 'wine'});
final provider = await AutopilotThemeProvider.load();
expect(provider.current.id, 'wine');
});
test('persists selection so a fresh load returns the same theme', () async {
final provider = await AutopilotThemeProvider.load();
await provider.setTheme('light');
// Simulate app restart reload from SharedPreferences
final provider2 = await AutopilotThemeProvider.load();
expect(provider2.current.id, 'light');
});
test('cycling through all 4 themes persists each correctly', () async {
final provider = await AutopilotThemeProvider.load();
for (final id in ['light', 'wine', 'ochre', 'cyan']) {
await provider.setTheme(id);
final reloaded = await AutopilotThemeProvider.load();
expect(reloaded.current.id, id,
reason: 'After setting "$id", reload should return "$id"');
}
});
});
group('AutopilotThemeProvider — state changes', () {
test('setTheme updates current theme immediately', () async {
final provider = await AutopilotThemeProvider.load();
expect(provider.current.id, ThemeRegistry.defaultId);
await provider.setTheme('ochre');
expect(provider.current.id, 'ochre');
});
test('setTheme with unknown id falls back to default (cyan)', () async {
final provider = await AutopilotThemeProvider.load();
await provider.setTheme('nonexistent_theme');
expect(provider.current.id, ThemeRegistry.defaultId);
});
test('setTheme notifies listeners', () async {
final provider = await AutopilotThemeProvider.load();
var notifyCount = 0;
provider.addListener(() => notifyCount++);
await provider.setTheme('wine');
expect(notifyCount, 1);
await provider.setTheme('ochre');
expect(notifyCount, 2);
});
test('initial load does not notify listeners', () async {
final provider = await AutopilotThemeProvider.load();
var notified = false;
provider.addListener(() => notified = true);
// No setTheme called listeners must not have fired
expect(notified, isFalse);
});
});
}
+130
View File
@@ -0,0 +1,130 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ar_autopilot_display/theme/theme_registry.dart';
void main() {
group('ThemeRegistry — factory themes', () {
test('exposes exactly 4 factory themes', () {
expect(ThemeRegistry.all.length, 4);
});
test('factory theme ids are light, cyan, wine, ochre', () {
final ids = ThemeRegistry.all.map((t) => t.id).toList();
expect(ids, containsAll(['light', 'cyan', 'wine', 'ochre']));
});
test('display order is light → cyan → wine → ochre', () {
final ids = ThemeRegistry.all.map((t) => t.id).toList();
expect(ids, ['light', 'cyan', 'wine', 'ochre']);
});
test('all themes have non-empty id and displayName', () {
for (final t in ThemeRegistry.all) {
expect(t.id, isNotEmpty, reason: 'id must not be empty');
expect(t.displayName, isNotEmpty, reason: 'displayName must not be empty');
}
});
group('byId', () {
test('returns correct theme for each known id', () {
for (final t in ThemeRegistry.all) {
final found = ThemeRegistry.byId(t.id);
expect(found.id, t.id, reason: 'byId("${t.id}") should return that theme');
}
});
test('falls back to default (cyan) for unknown id', () {
expect(ThemeRegistry.byId('does_not_exist').id, ThemeRegistry.defaultId);
expect(ThemeRegistry.byId('').id, ThemeRegistry.defaultId);
});
});
test('default id is cyan', () {
expect(ThemeRegistry.defaultId, 'cyan');
});
test('contains() returns true for factory ids', () {
for (final t in ThemeRegistry.all) {
expect(ThemeRegistry.contains(t.id), isTrue);
}
});
test('contains() returns false for unknown id', () {
expect(ThemeRegistry.contains('unknown_xyz'), isFalse);
});
group('Token completeness — no null colours', () {
for (final t in ThemeRegistry.all) {
test('${t.id}: all Color tokens are non-null', () {
// Backgrounds
expect(t.background, isNotNull, reason: '${t.id}.background');
expect(t.backgroundMid, isNotNull, reason: '${t.id}.backgroundMid');
expect(t.backgroundDeep, isNotNull, reason: '${t.id}.backgroundDeep');
expect(t.backgroundDeepest, isNotNull, reason: '${t.id}.backgroundDeepest');
expect(t.panelBackground, isNotNull, reason: '${t.id}.panelBackground');
expect(t.panelBorder, isNotNull, reason: '${t.id}.panelBorder');
// Text
expect(t.textMain, isNotNull, reason: '${t.id}.textMain');
expect(t.textMuted, isNotNull, reason: '${t.id}.textMuted');
expect(t.textSoft, isNotNull, reason: '${t.id}.textSoft');
expect(t.textDisabled, isNotNull, reason: '${t.id}.textDisabled');
// Accent
expect(t.accentLight, isNotNull, reason: '${t.id}.accentLight');
expect(t.accentMid, isNotNull, reason: '${t.id}.accentMid');
expect(t.accentDark, isNotNull, reason: '${t.id}.accentDark');
expect(t.accentGlowColor, isNotNull, reason: '${t.id}.accentGlowColor');
// Set-point
expect(t.setLight, isNotNull, reason: '${t.id}.setLight');
expect(t.setDark, isNotNull, reason: '${t.id}.setDark');
expect(t.setGlow, isNotNull, reason: '${t.id}.setGlow');
// Semantic
expect(t.okColor, isNotNull, reason: '${t.id}.okColor');
expect(t.warnColor, isNotNull, reason: '${t.id}.warnColor');
expect(t.northColor, isNotNull, reason: '${t.id}.northColor');
// Disengage
expect(t.disengageBackground, isNotNull, reason: '${t.id}.disengageBackground');
expect(t.disengageText, isNotNull, reason: '${t.id}.disengageText');
expect(t.disengageBorder, isNotNull, reason: '${t.id}.disengageBorder');
expect(t.disengageGlow, isNotNull, reason: '${t.id}.disengageGlow');
// Action buttons
expect(t.actionButtonBackground, isNotNull, reason: '${t.id}.actionButtonBackground');
expect(t.actionButtonBorder, isNotNull, reason: '${t.id}.actionButtonBorder');
expect(t.actionButtonText, isNotNull, reason: '${t.id}.actionButtonText');
expect(t.actionButtonGlow, isNotNull, reason: '${t.id}.actionButtonGlow');
});
}
});
group('Glow rules', () {
test('light theme has accentGlowRadius == 0 (no glow in daytime)', () {
expect(ThemeRegistry.byId('light').accentGlowRadius, 0.0);
});
test('dark themes have accentGlowRadius > 0', () {
for (final id in ['cyan', 'wine', 'ochre']) {
expect(
ThemeRegistry.byId(id).accentGlowRadius,
greaterThan(0),
reason: '$id should have glow',
);
}
});
});
group('Background gradient rules', () {
test('light theme has no backgroundGradient (solid colour)', () {
expect(ThemeRegistry.byId('light').backgroundGradient, isNull);
});
test('dark themes have a backgroundGradient', () {
for (final id in ['cyan', 'wine', 'ochre']) {
expect(
ThemeRegistry.byId(id).backgroundGradient,
isNotNull,
reason: '$id should have a radial background gradient',
);
}
});
});
});
}
@@ -0,0 +1,138 @@
// =============================================================================
// rudder_actuator_electric.h -- reversible DC motor actuator (Sprint 4)
// =============================================================================
//
// Drives a reversible DC motor with mechanical end-stops (Lewmar, Simpson
// Lawrence) via H-bridge (DRV8833 or equivalent):
// DO1 (GPIO) -- IN1 (port direction)
// DO2 (GPIO) -- IN2 (starboard direction)
// DO3 (GPIO) -- master power relay (ENA)
// PWM channel -- motor speed (shared LEDC channel 1)
//
// Architecture identical to HydraulicRudderActuator; only the PWM mapping
// differs (electric motors typically need a minimum useful duty to overcome
// static friction — see AR_ACTUATOR_DEADBAND_PCT in firmware_config.h).
// =============================================================================
#pragma once
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <Preferences.h>
#include <driver/ledc.h>
#include "hal/di_do.h"
#include "hal/pinout.h"
#include "hal/rudder_actuator_iface.h"
#include "hal/rudder_sensor.h"
#include "system/ar_log.h"
#ifndef AR_ACTUATOR_DEADBAND_PCT
# define AR_ACTUATOR_DEADBAND_PCT 5.0f
#endif
namespace arautopilot::hal {
class ElectricDcRudderActuator final : public IRudderActuator {
public:
static constexpr const char* NVS_NS = "ar_act";
static constexpr const char* NVS_KEY = "angle_limit";
static constexpr float DEFAULT_LIMIT_DEG = 35.0f;
static constexpr float MIN_LIMIT_DEG = 15.0f;
static constexpr float MAX_LIMIT_DEG = 45.0f;
void init() override {
_prefs.begin(NVS_NS, false);
_limit_deg = _prefs.getFloat(NVS_KEY, DEFAULT_LIMIT_DEG);
_limit_deg = std::clamp(_limit_deg, MIN_LIMIT_DEG, MAX_LIMIT_DEG);
_prefs.end();
pinMode(PIN_DO1, OUTPUT); digitalWrite(PIN_DO1, LOW);
pinMode(PIN_DO2, OUTPUT); digitalWrite(PIN_DO2, LOW);
pinMode(PIN_DO3, OUTPUT); digitalWrite(PIN_DO3, LOW);
ledc_timer_config_t timer{};
timer.speed_mode = LEDC_LOW_SPEED_MODE;
timer.duty_resolution = LEDC_TIMER_8_BIT;
timer.timer_num = LEDC_TIMER_1;
timer.freq_hz = 20000; // 20 kHz — inaudible for DC motor
timer.clk_cfg = LEDC_AUTO_CLK;
ledc_timer_config(&timer);
ledc_channel_config_t ch{};
ch.speed_mode = LEDC_LOW_SPEED_MODE;
ch.channel = LEDC_CHANNEL_1;
ch.timer_sel = LEDC_TIMER_1;
ch.gpio_num = PIN_ACTUATOR_PWM;
ch.duty = 0;
ledc_channel_config(&ch);
AR_LOGI("ElectricActuator", "init OK, limit=%.1f deg", _limit_deg);
}
void power_on() override { digitalWrite(PIN_DO3, HIGH); _powered = true; }
void power_off() override { _stop_outputs(); digitalWrite(PIN_DO3, LOW); _powered = false; }
bool command(float angle_deg) override {
if (!_powered || _at_standby()) return false;
const float clamped = std::clamp(angle_deg, -_limit_deg, _limit_deg);
if (clamped > 0.0f && _limit_stbd()) { _stop_outputs(); return false; }
if (clamped < 0.0f && _limit_port()) { _stop_outputs(); return false; }
const float raw_pct = std::fabsf(clamped) / _limit_deg * 100.0f;
// Apply deadband: below deadband -> 0, otherwise scale to [deadband, 100]
const float db = AR_ACTUATOR_DEADBAND_PCT;
const float duty_pct = (raw_pct < 0.5f) ? 0.0f
: db + raw_pct * (100.0f - db) / 100.0f;
_set_pwm(static_cast<uint8_t>(std::min(duty_pct, 100.0f) * 255.0f / 100.0f));
if (clamped > 0.01f) {
digitalWrite(PIN_DO1, LOW); digitalWrite(PIN_DO2, HIGH);
} else if (clamped < -0.01f) {
digitalWrite(PIN_DO2, LOW); digitalWrite(PIN_DO1, HIGH);
} else {
_stop_outputs();
}
return true;
}
float actual_angle_deg() const override { return rudder_sensor_get_angle_deg(); }
bool is_powered() const override { return _powered; }
bool at_limit() const override { return _limit_port() || _limit_stbd(); }
float angle_limit_deg() const override { return _limit_deg; }
ActuatorType type() const override { return ActuatorType::ELECTRIC_DC; }
void set_angle_limit_deg(float limit_deg) override {
_limit_deg = std::clamp(limit_deg, MIN_LIMIT_DEG, MAX_LIMIT_DEG);
_prefs.begin(NVS_NS, false);
_prefs.putFloat(NVS_KEY, _limit_deg);
_prefs.end();
AR_LOGI("ElectricActuator", "angle_limit updated -> %.1f deg", _limit_deg);
}
private:
Preferences _prefs;
float _limit_deg{DEFAULT_LIMIT_DEG};
bool _powered{false};
static bool _limit_port() { return digitalRead(PIN_DI2) == HIGH; }
static bool _limit_stbd() { return digitalRead(PIN_DI3) == HIGH; }
static bool _at_standby();
void _stop_outputs() {
_set_pwm(0);
digitalWrite(PIN_DO1, LOW);
digitalWrite(PIN_DO2, LOW);
}
static void _set_pwm(uint8_t duty) {
ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_1, duty);
ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_1);
}
};
} // namespace arautopilot::hal
@@ -0,0 +1,52 @@
// =============================================================================
// rudder_actuator_factory.h -- selects concrete actuator at build time
// =============================================================================
//
// The AR_ACTUATOR_TYPE macro is defined in firmware_config.h (generated by
// the .appack compiler). If firmware_config.h is not present (standalone
// build), falls back to ACTUATOR_HYDRAULIC_REVERSIBLE.
//
// Usage in main.cpp:
//
// #include "hal/rudder_actuator_factory.h"
// // ...
// IRudderActuator& actuator = arautopilot::hal::g_rudder_actuator;
// actuator.init();
// =============================================================================
#pragma once
// Pull in generated config if available
#if __has_include("firmware_config.h")
# include "firmware_config.h"
#endif
// Actuator type constants (must match _actuator_type_to_enum in appack.py)
#define ACTUATOR_HYDRAULIC_REVERSIBLE 0
#define ACTUATOR_ELECTRIC_DC 1
#define ACTUATOR_SERVOMOTOR 2
#define ACTUATOR_STERNDRIVE_ANALOG 3
#define ACTUATOR_TEST_RIG_MOTOR 4
#ifndef AR_ACTUATOR_TYPE
# define AR_ACTUATOR_TYPE ACTUATOR_HYDRAULIC_REVERSIBLE
#endif
#if AR_ACTUATOR_TYPE == ACTUATOR_HYDRAULIC_REVERSIBLE
# include "hal/rudder_actuator_hydraulic.h"
namespace arautopilot::hal {
static HydraulicRudderActuator g_rudder_actuator;
}
#elif AR_ACTUATOR_TYPE == ACTUATOR_ELECTRIC_DC
# include "hal/rudder_actuator_electric.h"
namespace arautopilot::hal {
static ElectricDcRudderActuator g_rudder_actuator;
}
#else
// Fallback: hydraulic (compile warning so the integrator notices)
# pragma message("AR_ACTUATOR_TYPE not recognised — defaulting to HydraulicRudderActuator")
# include "hal/rudder_actuator_hydraulic.h"
namespace arautopilot::hal {
static HydraulicRudderActuator g_rudder_actuator;
}
#endif
@@ -0,0 +1,148 @@
// =============================================================================
// rudder_actuator_hydraulic.h -- reversible hydraulic pump actuator (Sprint 4)
// =============================================================================
//
// Drives a reversible hydraulic pump (Hynautic, Hypro, Octopus, Vetus,
// Lecomble & Schmitt) via:
// DO1 (GPIO) -- port direction solenoid
// DO2 (GPIO) -- starboard direction solenoid
// DO3 (GPIO) -- master power relay
// PWM channel -- pump speed proportional to |angle_error|
//
// The software angle limit is loaded from NVS at init() and clamped to
// [15, 45] degrees. It can be updated live via set_angle_limit_deg()
// which also persists the new value to NVS.
// =============================================================================
#pragma once
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <Preferences.h>
#include <driver/ledc.h>
#include "hal/di_do.h"
#include "hal/pinout.h"
#include "hal/rudder_actuator_iface.h"
#include "hal/rudder_sensor.h"
#include "system/ar_log.h"
namespace arautopilot::hal {
class HydraulicRudderActuator final : public IRudderActuator {
public:
// NVS namespace + key for the angle limit
static constexpr const char* NVS_NS = "ar_act";
static constexpr const char* NVS_KEY = "angle_limit";
static constexpr float DEFAULT_LIMIT_DEG = 35.0f;
static constexpr float MIN_LIMIT_DEG = 15.0f;
static constexpr float MAX_LIMIT_DEG = 45.0f;
void init() override {
// Load angle limit from NVS (falls back to firmware_config.h default)
_prefs.begin(NVS_NS, false);
_limit_deg = _prefs.getFloat(NVS_KEY, DEFAULT_LIMIT_DEG);
_limit_deg = std::clamp(_limit_deg, MIN_LIMIT_DEG, MAX_LIMIT_DEG);
_prefs.end();
// Configure direction + power GPIOs
pinMode(PIN_DO1, OUTPUT); digitalWrite(PIN_DO1, LOW); // port
pinMode(PIN_DO2, OUTPUT); digitalWrite(PIN_DO2, LOW); // starboard
pinMode(PIN_DO3, OUTPUT); digitalWrite(PIN_DO3, LOW); // master power
// Configure LEDC PWM for pump speed
ledc_timer_config_t timer{};
timer.speed_mode = LEDC_LOW_SPEED_MODE;
timer.duty_resolution = LEDC_TIMER_8_BIT;
timer.timer_num = LEDC_TIMER_0;
timer.freq_hz = 5000;
timer.clk_cfg = LEDC_AUTO_CLK;
ledc_timer_config(&timer);
ledc_channel_config_t ch{};
ch.speed_mode = LEDC_LOW_SPEED_MODE;
ch.channel = LEDC_CHANNEL_0;
ch.timer_sel = LEDC_TIMER_0;
ch.gpio_num = PIN_ACTUATOR_PWM;
ch.duty = 0;
ledc_channel_config(&ch);
AR_LOGI("HydraulicActuator", "init OK, limit=%.1f deg", _limit_deg);
}
void power_on() override {
digitalWrite(PIN_DO3, HIGH);
_powered = true;
}
void power_off() override {
_stop_outputs();
digitalWrite(PIN_DO3, LOW);
_powered = false;
}
bool command(float angle_deg) override {
if (!_powered) return false;
if (_at_standby()) return false;
// Software angle limit
const float clamped = std::clamp(angle_deg, -_limit_deg, _limit_deg);
// Hard-stop if limit switch asserted in the commanded direction
if (clamped > 0.0f && _limit_stbd()) { _stop_outputs(); return false; }
if (clamped < 0.0f && _limit_port()) { _stop_outputs(); return false; }
const float duty_pct = std::fabsf(clamped) / _limit_deg * 100.0f;
_set_pwm(static_cast<uint8_t>(duty_pct * 255.0f / 100.0f));
if (clamped > 0.01f) {
digitalWrite(PIN_DO1, LOW); digitalWrite(PIN_DO2, HIGH);
} else if (clamped < -0.01f) {
digitalWrite(PIN_DO2, LOW); digitalWrite(PIN_DO1, HIGH);
} else {
_stop_outputs();
}
return true;
}
float actual_angle_deg() const override {
return rudder_sensor_get_angle_deg();
}
bool is_powered() const override { return _powered; }
bool at_limit() const override { return _limit_port() || _limit_stbd(); }
float angle_limit_deg() const override { return _limit_deg; }
ActuatorType type() const override { return ActuatorType::HYDRAULIC_REVERSIBLE; }
void set_angle_limit_deg(float limit_deg) override {
_limit_deg = std::clamp(limit_deg, MIN_LIMIT_DEG, MAX_LIMIT_DEG);
_prefs.begin(NVS_NS, false);
_prefs.putFloat(NVS_KEY, _limit_deg);
_prefs.end();
AR_LOGI("HydraulicActuator", "angle_limit updated -> %.1f deg", _limit_deg);
}
private:
Preferences _prefs;
float _limit_deg{DEFAULT_LIMIT_DEG};
bool _powered{false};
static bool _limit_port() { return digitalRead(PIN_DI2) == HIGH; }
static bool _limit_stbd() { return digitalRead(PIN_DI3) == HIGH; }
static bool _at_standby(); // defined in modes/standby.cpp
void _stop_outputs() {
_set_pwm(0);
digitalWrite(PIN_DO1, LOW);
digitalWrite(PIN_DO2, LOW);
}
static void _set_pwm(uint8_t duty) {
ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, duty);
ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0);
}
};
} // namespace arautopilot::hal
@@ -0,0 +1,84 @@
// =============================================================================
// rudder_actuator_iface.h -- abstract rudder actuator interface (Sprint 4)
// =============================================================================
//
// The PID inner loop knows only about IRudderActuator. The concrete
// implementation (hydraulic, electric-DC, servo, or test-rig motor) is
// selected at build time via the AR_ACTUATOR_TYPE macro produced by the
// .appack firmware_config.h.
//
// Safety invariants (all implementations must honour):
// 1. angle_limit_deg -- software limit loaded from NVS (or firmware_config.h
// default). Commanded angles are clamped before
// reaching the physical output.
// 2. Limit switches -- DI2 (port) / DI3 (starboard) block motion in
// the corresponding direction regardless of command.
// 3. Master power -- DO3 must be HIGH before any motion.
// 4. STANDBY guard -- implementations check current_mode() == STANDBY
// and refuse motion.
// =============================================================================
#pragma once
#include <cstdint>
namespace arautopilot::hal {
// ---------------------------------------------------------------------------
// Actuator type enumeration (mirrors .appack firmware_config.h values)
// ---------------------------------------------------------------------------
enum class ActuatorType : uint8_t {
HYDRAULIC_REVERSIBLE = 0, ///< Reversible hydraulic pump (Hynautic, Hypro …)
ELECTRIC_DC = 1, ///< Reversible DC motor with mechanical end-stops
SERVOMOTOR = 2, ///< Servomotor with built-in position feedback
STERNDRIVE_ANALOG = 3, ///< Analog directional sterndrive
TEST_RIG_MOTOR = 4, ///< HIL test rig: DC motor rotates compass platform
};
// ---------------------------------------------------------------------------
// Abstract interface
// ---------------------------------------------------------------------------
class IRudderActuator {
public:
virtual ~IRudderActuator() = default;
/// One-time hardware initialisation (GPIOs, PWM channels …).
virtual void init() = 0;
/// Engage master power relay. Required before command().
virtual void power_on() = 0;
/// Drop master power relay and stop all outputs immediately.
virtual void power_off() = 0;
/// Command the actuator toward *angle_deg* (positive = starboard).
/// The implementation MUST clamp to [-angle_limit_deg, +angle_limit_deg]
/// before driving any output.
/// Returns true if the command was applied; false if an interlock blocked it.
virtual bool command(float angle_deg) = 0;
/// Estimated actual rudder angle (degrees) from the sensor/feedback.
/// Returns 0.0 if no feedback is available.
virtual float actual_angle_deg() const = 0;
/// True if master power relay is currently energised.
virtual bool is_powered() const = 0;
/// True if either limit switch is asserted.
virtual bool at_limit() const = 0;
/// Current software angle limit (degrees, positive value).
/// Can be updated at runtime via set_angle_limit_deg().
virtual float angle_limit_deg() const = 0;
/// Update the software angle limit. Clamped to [15, 45] degrees.
/// Persisted to NVS by the concrete implementation.
virtual void set_angle_limit_deg(float limit_deg) = 0;
/// Actuator type tag for diagnostics.
virtual ActuatorType type() const = 0;
};
} // namespace arautopilot::hal
+262
View File
@@ -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()
+103
View File
@@ -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()
+426
View File
@@ -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()
+246
View File
@@ -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
+201
View File
@@ -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)
+10
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
# AR Electronics License Server package
+33
View File
@@ -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()
+305
View File
@@ -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"}
+51
View File
@@ -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="")
+5
View File
@@ -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
+69
View File
@@ -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
+13
View File
@@ -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"