Compare commits

...

20 Commits

Author SHA1 Message Date
alro65 efddda100e feat: AR-Autopilot initial commit 2026-07-03 12:21:26 -04:00
alro65 46dc0423a0 fix(display-manager): lazy launch by default + minimize unassigned apps
- autolaunch default: True → False (on-demand only, saves GPU on startup)
- add ProcessManager.minimize() to minimize a window to taskbar
- add win32_utils.minimize_window() (SW_MINIMIZE via user32)
- DisplayManager._minimize_unassigned(): after each app switch, minimize
  every running app not currently assigned to any screen, freeing iGPU
  resources (critical on J6412 UHD 600 with limited EUs)

Background: J6412 Intel UHD 600 has only 16 EUs @ 800 MHz. Running
AR-ECDIS (MapLibre GL) and GPS (OpenLayers) simultaneously consumes ~60%
iGPU. By minimizing inactive apps Windows suspends their GPU presentation
chain, dropping idle GPU load near zero.

AR_electronics — AR-Autopilot Project
2026-05-24 21:53:00 -04:00
alro65 42b2eec2e1 feat: AR Display Manager — multi-monitor floating app switcher
Adds the AR Display Manager daemon (Task #9):

- display_manager/ package with 8 modules:
  - app_registry.py   : static metadata for the 4 bridge apps
  - config.py         : JSON-persisted config + per-screen layout store
  - win32_utils.py    : ctypes wrappers (EnumWindows, SetWindowPos, ShowWindow)
  - process_manager.py: launch + track app processes, HWND lookup
  - floating_button.py: always-on-top 52×52 px AR button per monitor
                        with draggable placement + custom-painted popup
  - display_manager.py: orchestrator (QScreens → buttons → app placement
                        + system tray with Settings/Launch/Quit)
  - settings_dialog.py: modal dialog for exe paths, button position,
                        and Windows autostart toggle
  - autostart.py      : HKCU Run registry read/write helpers
- display_manager_main.py at repo root: launcher script
- Studio Overview tab: "Lanzar Display Manager" button
- pyproject.toml: display-manager optional dependency group (PySide6)

Layout persistence: ~/.ar-autopilot/display_manager/layout.json
Config:            ~/.ar-autopilot/display_manager/config.json
Supports up to 4 monitors (2 HDMI native + 2 USB DisplayLink).
Double-click tray icon to toggle button visibility.

AR_electronics — AR-Autopilot Project
2026-05-24 21:48:27 -04:00
alro65 b68cd64cf1 fix(studio): Qt6 compat + Ctrl+C signal handler
- flash_console: setTextFormat(Qt.TextFormat.RichText) — int overload removed in Qt6
- installer_widget: same fix + add Qt to QtCore import
- project_editor/installer_widget: Session.has() → Session.can()
- app.py: signal.signal(SIGINT, SIG_DFL) so Ctrl+C kills the process from terminal

AR_electronics — AR-Autopilot Project
2026-05-24 11:45:17 -04:00
alro65 3c4a0b530e Merge branch 'claude/elated-sammet-86a4ab' 2026-05-24 11:39:49 -04:00
alro65 5238bd31f0 feat(studio): AR Electronics branding + Telemetría + Instalar J6412 tabs
ar_style.py — global QSS dark theme + QPalette matching the Flutter brand
  palette (navy #0D1B2A, electric blue #2563EB, glow #60B8FF). Single call:
  apply_ar_style(app).

app.py — applies AR style and window icon on startup.

main_window.py — complete rewrite of the layout:
  - Sidebar: AR logo (PNG), user/role display, capabilities list, version stamp
  - 5 tabs: Overview ·  Flash ESP32 · 📋 Proyecto · 📡 Telemetría · 💾 Instalar J6412
  - Overview tab: rich-text guide with icons for each tab's purpose

telemetry_widget.py — live $PARP STATUS chart tab:
  - QSerialPort RX-only connection to AR-Concentrador (port selector + Refresh)
  - Python $PARP XOR-checksum parser (mirrors Dart ParpCodec)
  - _RollingChart: pure QPainter scrolling time-series, 60 s window, no
    external charting library
  - Heading + Setpoint on one chart; Rudder on a second chart
  - Live value strip shows Rumbo / Setpoint / Timón + mode label

installer_widget.py — J6412 USB image builder tab:
  - Vessel name + serial number (auto-generate or paste)
  - Optional CSV log path for CRM
  - App checkboxes (AR-ECDIS / AR-Autopilot / skip Flutter build)
  - Worker thread runs installer/build_usb.py with streamed log output
  - "Abrir dist/" button when build succeeds
  - RBAC gated: Engineer or Super Admin only

pyproject.toml — adds [installer] and [license-server] optional dep groups

AR_electronics — AR-Autopilot Project
2026-05-24 11:22:38 -04:00
alro65 de25dcee57 feat(installer): J6412 USB installer + AR Electronics license activation server
installer/:
  - build_usb.py: dev tool — builds Flutter + AR-ECDIS, assembles USB pendrive
    image with serial.key, autorun.inf, and START_INSTALLER.bat
  - serial_generator.py: generates AR-XXXX-XXXX-XXXX serial numbers (48-bit
    entropy), logs to CSV for CRM integration
  - src/license.py: hardware fingerprint (Windows MachineGuid + primary MAC),
    serial validation, online activation POST, local cache with 30-day offline
    grace period
  - src/sysconfig.py: HKCU autostart registry entries, .lnk shortcuts (desktop
    + Start Menu via WScript.Shell), firewall rules (netsh), COM port detection
  - src/install.py: tkinter installer GUI — 9 sequential steps with per-step
    progress indicators, threaded execution, error dialogs, and silent mode

license_server/ (FastAPI service — deploy to arelectronics.com VPS):
  - POST /api/v1/activate: first activation accepted; same-HW re-activation
    refreshes heartbeat; different-HW rejected with 409
  - GET  /api/v1/validate/{serial}: heartbeat endpoint to refresh offline cache
  - Admin endpoints (X-Admin-Key): issue, list, revoke licenses
  - SQLAlchemy models: License (serial registry) + Activation (per-machine rows)
  - SQLite default, PostgreSQL-ready via DATABASE_URL env var

AR_electronics — AR-Autopilot Project
2026-05-24 01:36:24 -04:00
alro65 abe9b764c7 feat(display): Sprint 7 — USB serial link to AR-Concentrador via NMEA $PARP
- Add flutter_libserialport dependency (pubspec.yaml)
- New ParpCodec: XOR-checksum NMEA parser + command builders for all PARP sentences
- New ConcentradorService: manages two independent COM ports (RX-OUT broadcast,
  TX-IN commands) at 115200/8N1; auto-fires onConnectionChanged on link drop
- AutopilotState: dual-mode operation (demo timer OR live serial); connectToSerial /
  disconnectSerial; command methods (engage/disengage/adjustSetpoint) forward to
  ConcentradorService when connected; falls back to demo on disconnect
- New PortSettingsScreen (/settings/ports): RX+TX dropdowns populated from
  SerialPort.availablePorts, persisted in SharedPreferences; Connect/Disconnect
  buttons with error display and snackbar feedback
- main.dart: auto-connect to saved ports on startup (silent fail → demo mode);
  registers /settings/ports route
- CockpitScreen: gear icon replaced with PopupMenuButton (Puertos COM / Apariencia)

AR_electronics — AR-Autopilot Project
2026-05-24 01:28:04 -04:00
alro65 c946d2df6d feat(display): Sprint 4 — pantalla principal del autopilot
Archivos nuevos:
  display/lib/data/autopilot_state.dart
    - ChangeNotifier con todos los datos del autopilot
    - Simulación de velero en mar (heading drift / P-controller)
    - API pública estable: engage(), disengage(), adjustSetpoint(), selectMode()
    - Sprint 7: los internos se reemplazan por Modbus RTU, la API no cambia

  display/lib/screens/cockpit/cockpit_screen.dart
    - Pantalla principal: TopBar, ModeSelector, CompassRose, DataStrip,
      HeadingAdjustBar, RudderIndicator, ENGAGE/DISENGAGE
    - Logo con triple-tap para ciclar temas (StateWidget con Timer)
    - Indicador DEMO visible cuando isConnected == false
    - Engranaje → AppearanceSettingsScreen

  display/lib/widgets/themed/engage_button.dart
    - Botón verde con glow; dimmed cuando ya está engaged

  display/lib/widgets/themed/heading_adjust_bar.dart
    - Botones << < [SET 048.0°] > >>
    - Deshabilitado cuando mode == STANDBY

  display/lib/widgets/themed/status_chip.dart
    - Indicador de punto + label para NMEA / GPS (ok / warn / off)

Modificado:
  display/lib/main.dart
    - MultiProvider: agrega AutopilotState al árbol de providers
    - Ruta inicial: CockpitScreen.routeName ('/') en lugar de Appearance

AR_electronics — AR-Autopilot Project
2026-05-23 11:53:50 -04:00
alro65 d4d12caac7 fix(.gitignore): excluir display/lib/ del ignore global de lib/
El .gitignore raíz ignoraba lib/ globalmente (convención Python/Node).
display/lib/ es código fuente Flutter — debe estar trackeado.

AR_electronics — AR-Autopilot Project
2026-05-23 11:34:00 -04:00
alro65 080e47efc0 feat(firmware): BNO085 node, concentrador y listener PGN 127237
AR_electronics — AR-Autopilot Project

Tarea #3 — firmware/ar_bno085_node_v1/
  Nodo compacto ESP32+CAN lee heading y yaw rate del BNO085 via I2C
  y los publica en el backbone NMEA 2000 como PGN 127250+127251 a 10 Hz.

Tarea #4 — firmware/ar_concentrador_v1/
  Gateway NMEA2000<->NMEA0183 con 4 puertos OUT (UART1 TX broadcast)
  y 4 puertos IN (UART2 RX comandos). Parsea sentencias $PARP, gestiona
  autoridad de mando entre estaciones con timeout 10s y override del puente.
  Reenvía comandos autopilot como PGN 127237 al backbone.

Tarea #5 — nmea2000_consumer (ar_autopilot_v1)
  Listener PGN 127237 entrante desde concentrador:
  HeadingControlSnapshot, HandleHeadingControl() con filtro source address,
  nmea2000_htc() publico, ventana stale 3s para comandos externos.
2026-05-23 11:00:22 -04:00
alro65 4cc5b19f0c docs: spec protocolo NMEA 0183/2000 del concentrador USB
Define el protocolo completo de la tarjeta concentrador NMEA2000-USB:
- Sentencias NMEA 0183 estandar emitidas por el ESP32 hacia los puertos OUT
- Sentencias propietarias $PARP para comandos del autopilot
- Protocolo de transferencia de mando entre estaciones (puente, cockpit, flybridge)
- Mapeo completo NMEA 0183 <-> NMEA 2000 PGNs
- Seguridad: validacion de checksum, permisos por estacion, alarmas

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 01:07:06 -04:00
alro65 ad4c4581b6 fix(spice): corregir errores LTspice en buck chain y output stage
1_buck_chain.cir:
- RL1 creaba nodo flotante V5V_AFTER_L sin retorno
- Reemplazado por parametro Rser=0.051 en L1 (soporte nativo LTspice)

2_output_stage.cir:
- Gcoll con VALUE={} no es sintaxis valida para G-source en LTspice
  → convertido a B-source: Bcoll GATE_Q1 EMITTER I={MAX(0, 1.0*I(Dled))}
- .net V(out1) VLOAD → directiva invalida en este contexto, eliminada
- .meas V(out1) → nodo inexistente, corregido a V(drain_q1)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 00:30:24 -04:00
alro65 78a58d1b53 fix: remove invalid .connect directive in output_stage.cir — use shared node name GATE_Q1 2026-05-22 23:27:21 -04:00
alro65 b82ed400bc feat: BNO085 IMU integration — SPICE + simulator yaw rate feed-forward
SPICE (6_bno085_imu.cir):
  - BNO085 power supply with 10uF + 100nF decoupling on VDD
  - Power-on reset RC circuit (R=10K, C=1uF, tau=10ms → deasserts at ~12ms)
  - I2C Fast Mode 400kHz bus: 4.7K pull-ups, 50pF bus capacitance model
  - Full I2C transaction: START + address 0x4A + R/W + BNO085 ACK + STOP
  - INT pin (open-drain, 10K pull-up, 100Hz interrupt simulation)
  - .meas directives: reset timing, SCL rise time, VDD stability

Simulator (esp32_sim.py):
  - SimSnapshot.bno085_yaw_rate_dps field added
  - _bno085_enabled / _bno085_noise_std_dps / _bno085_yaw_rate_dps state
  - enable_bno085(noise_std_dps=0.02) public method
  - disable_bno085() public method
  - _run_physics: samples gyro at 50Hz with Gaussian noise model
  - _run_outer_loop: uses BNO085 yaw rate for rot_ff_term when enabled
    (replaces NMEA-derived ROT — lower latency ~4ms vs ~100-200ms)

Usage:
  sim.enable_bno085()          # activate gyro feed-forward
  sim.enable_bno085(noise_std_dps=0.014)  # with BNO085 spec noise

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:46:16 -04:00
alro65 3b36f178aa fix: update analog input SPICE to reflect corrected 100K+27K resistor values
All analog ports (IN-BAT, IN-WATER, IN-OILP, IN-RPM) now use R_high=100K,
R_low=27K — unified design confirmed in schematic. Updated .param values,
voltage divider netlist, verification comments, and design notes.

Vout @ 14.4V (alternator) = 3.06V — just at the ADC limit, correct.
Vout @ 15.5V = 3.30V — absolute maximum safe input voltage.
Filter fc = 747 Hz with 100K||27K and 10nF.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 10:19:24 -04:00
alro65 210c44341f feat: LTspice RS-485 / NMEA 0183 interface simulation (SN65HVD1781)
Simulates the full RS-485 half-duplex channel on the ESP32+CAN+RS485 board:
  - SN65HVD1781 behavioral driver/receiver (3.3V native, 32 unit loads)
  - Half-duplex direction control via DE/RE GPIO4
  - 560Ω bias resistors keeping bus HIGH during idle (NMEA 0183 failsafe)
  - 120Ω termination at both ends of a 10m T-line model (Zo=120, Td=50ns)
  - Transmits ASCII 'G' (0x47) at 4800 bps — one complete NMEA character
  - Second node as passive 12kΩ unit-load receiver
  - .meas directives verify Vdiff > ±200mV (RS-485 spec) in both polarities

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 10:13:16 -04:00
alro65 fa8a65f687 feat: LTspice SPICE netlists for all hardware subcircuits
Four .cir behavioral simulations ready to open in LTspice via File→Open:
  1_buck_chain.cir      — dual MP2338 12V→5V→3.3V, verifies Rfb math + soft-start
  2_output_stage.cir    — PC817 + IRLML6344 isolated output (inverted logic confirmed)
  3_analog_input.cir    — analog input conditioning; flags ADC overvoltage on IN-BAT/WATER/OILP
  4_nmea2000_can.cir    — MCP2562T CAN transceiver, two-node NMEA2000 bus, T-line model

CRITICAL finding in 3_analog_input.cir: R_high=10K + R_low=15K gives 4.3V
at ESP32 ADC when measuring a 12V battery — exceeds 3.3V limit. Fix: use
R_high=100K (same as IN-RPM, which is correctly designed at 3.06V @ 14.4V).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 09:59:50 -04:00
alro65 c432fc3725 feat: sea-state model + 7 bad-weather / stress TCs (14/14 pass)
esp32_sim.py — Sea state engine
  • _BEAUFORT_TABLE (B0–B8): wave torque, swell, noise, wind-bias, period
  • set_sea_state(beaufort, seed): injects external_yaw_torque into
    VesselHeadingSimulator each tick (sine wave + swell + white noise +
    weather-helm drift).  B4≈±3° heading osc, B6 disengages correctly.
  • tune_response(rudder_kp, counter_rudder, max_rudder_deg): runtime
    gain adjustment — simulates the classic Robertson "RUDDER" /
    "COUNTER RUDDER" / working-rudder-limit knobs.
  • _compute_wave_torque(): composed 4-component disturbance model.

sim_protocol.py — 7 new demanding test cases
  TC-08  Beaufort 4, 5 min     max dev 10.4°, RMS 5.6°, no disengage
  TC-09  Beaufort 6 operational limit — SEVERE alarm fires at 35.8 s
         (correct safety behaviour; documents factory-gain limit)
  TC-10  Low speed 2 kn, +10°  τ_outer≈113 s, settles 0.83° < 3°
  TC-11  180° reversal          no SEVERE (tracking_settled guard), 1.18°
  TC-12  Rapid setpoint stress  5 changes in 100 s, final error 1.17°
  TC-13  Gain boost in B5       1.70× RMS improvement (12.3°→7.2°),
         demonstrates RUDDER+COUNTER RUDDER tuning effect
  TC-14  B7 spike (30 s)       SEVERE fires correctly, re-engages,
         recovers to 0.0° within 90 s of calm return

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:26:37 -04:00
alro65 2b574b57f6 feat: Python-native ESP32 simulator + 7-TC HIL protocol (all pass)
tools/esp32_sim.py  – Full software ESP32 simulator with cascade PID
(outer 10 Hz / inner 50 Hz), vessel yaw physics, Modbus register banks,
mode state-machine (STANDBY/HEADING_HOLD/DODGE), alarm engine
(HEADING_LOST, OFF_COURSE with _tracking_settled guard).

Sim-specific parameter tuning vs. firmware defaults:
  • outer: kd=0, ki=0, aw_gain=0, deadband=0  → pure P+ROT-FF, τ≈24 s
  • inner: kp=20, deadband=0, min_useful=0     → τ_cl=1 s, no bang-bang
  • vessel: rudder_response_gain=0.004         → 30 m yacht dynamics

tools/sim_protocol.py – 7 automated test cases (TC-01…TC-07) with
heading-trace charts and HTML report. All 7 PASS:
  TC-02 settle 49.8 s, error 0.488°   (crit <60 s, <1°)
  TC-03 settle 134 s, error 0.985°    (crit <180 s, <2°)
  TC-04 settle 56.5 s, error 0.570°   (crit <90 s, <1°)
  TC-07 dodge 1.73°, return 0.527°    (crit <2°, <1°)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 04:18:01 -04:00
63 changed files with 10983 additions and 58 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/
+18
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
@@ -78,9 +80,25 @@ def run(argv: list[str] | None = None) -> int:
from arautopilot.studio.login_window import LoginDialog
from arautopilot.studio.main_window import StudioMainWindow
import signal # noqa: PLC0415
signal.signal(signal.SIGINT, signal.SIG_DFL) # Ctrl+C kills the process
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)
+5 -5
View File
@@ -330,9 +330,9 @@ class ProjectEditorWidget(QWidget):
# ------------------------------------------------------------------
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)
can_commission = self._session.can(Capability.EDIT_COMMISSIONING)
can_gains = self._session.can(Capability.EDIT_BASE_GAINS)
can_compile = self._session.can(Capability.EDIT_COMMISSIONING)
for w in [
self._max_rudder_deg,
@@ -381,7 +381,7 @@ class ProjectEditorWidget(QWidget):
self._sens_diverge_alarm,
self._sens_diverge_failover,
]:
w.setEnabled(enabled and self._session.has(Capability.EDIT_COMMISSIONING))
w.setEnabled(enabled and self._session.can(Capability.EDIT_COMMISSIONING))
def _on_new(self) -> None:
self._project = None
@@ -425,7 +425,7 @@ class ProjectEditorWidget(QWidget):
QMessageBox.critical(self, "Save error", str(exc))
def _on_compile(self) -> None:
if not self._session.has(Capability.EDIT_COMMISSIONING):
if not self._session.can(Capability.EDIT_COMMISSIONING):
QMessageBox.warning(self, "Access denied", "Engineer or Super Admin required.")
return
+2 -2
View File
@@ -31,7 +31,7 @@ import sys
from pathlib import Path
from typing import Optional
from PySide6.QtCore import QObject, QThread, Signal
from PySide6.QtCore import QObject, Qt, QThread, Signal
from PySide6.QtWidgets import (
QComboBox,
QDialog,
@@ -148,7 +148,7 @@ class FlashConsoleWidget(QWidget):
f"Logged in as <b>{self._session.user.display_name}</b> "
f"({self._session.role.value})."
)
header.setTextFormat(0x1) # PlainText would lose <br/>; RichText = 1
header.setTextFormat(Qt.TextFormat.RichText)
header.setWordWrap(True)
outer.addWidget(header)
+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, Qt, QThread, Signal
from PySide6.QtWidgets import (
QCheckBox,
QFileDialog,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPlainTextEdit,
QPushButton,
QVBoxLayout,
QWidget,
)
from arautopilot.core.rbac import Capability
from arautopilot.studio.session import Session
REPO_ROOT = Path(__file__).resolve().parents[2]
INSTALLER_DIR = REPO_ROOT / "installer"
DIST_DIR = INSTALLER_DIR / "dist"
BUILD_SCRIPT = INSTALLER_DIR / "build_usb.py"
# ---------------------------------------------------------------------------
# Serial generator (inline — no dependency on installer/serial_generator.py)
# ---------------------------------------------------------------------------
def _generate_serial() -> str:
raw = secrets.token_hex(6).upper()
return f"AR-{raw[0:4]}-{raw[4:8]}-{raw[8:12]}"
# ---------------------------------------------------------------------------
# Worker thread
# ---------------------------------------------------------------------------
class _BuildWorker(QObject):
line = Signal(str)
finished = Signal(int) # exit code
error = Signal(str)
def __init__(self, argv: list[str]) -> None:
super().__init__()
self._argv = argv
self._proc: subprocess.Popen | None = None
self._cancelled = False
def run(self) -> None:
try:
env = os.environ.copy()
env["PYTHONIOENCODING"] = "utf-8"
self._proc = subprocess.Popen(
self._argv,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
env=env,
)
assert self._proc.stdout
for raw in self._proc.stdout:
if self._cancelled:
break
self.line.emit(raw.rstrip("\n"))
code = self._proc.wait()
self.finished.emit(code)
except FileNotFoundError as exc:
self.error.emit(f"No se encontró el ejecutable: {exc}")
except Exception as exc: # noqa: BLE001
self.error.emit(str(exc))
def cancel(self) -> None:
self._cancelled = True
if self._proc:
try:
self._proc.terminate()
except Exception:
pass
# ---------------------------------------------------------------------------
# Widget
# ---------------------------------------------------------------------------
class InstallerWidget(QWidget):
"""'Instalar J6412' tab content."""
def __init__(self, session: Session, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._session = session
self._thread: QThread | None = None
self._worker: _BuildWorker | None = None
self._build_ui()
# ── UI ───────────────────────────────────────────────────────────────────
def _build_ui(self) -> None:
root = QVBoxLayout(self)
root.setContentsMargins(12, 12, 12, 12)
root.setSpacing(12)
# Header
hdr = QLabel(
"<b style='color:#4A9FE8;font-size:14px;letter-spacing:1px'>"
"INSTALAR EN J6412</b><br/>"
"<span style='color:#8899AA;font-size:11px'>"
"Genera un pendrive USB con las apps AR Electronics y la licencia del buque.</span>"
)
hdr.setTextFormat(Qt.TextFormat.RichText) # RichText
hdr.setWordWrap(True)
root.addWidget(hdr)
# ── Config group ─────────────────────────────────────────────────────
cfg = QGroupBox("Configuración del paquete")
form = QVBoxLayout(cfg)
# Vessel name
vessel_row = QHBoxLayout()
vessel_row.addWidget(QLabel("Nombre del buque:"))
self._vessel_edit = QLineEdit()
self._vessel_edit.setPlaceholderText("e.g. M/Y PACIFICO")
vessel_row.addWidget(self._vessel_edit, 1)
form.addLayout(vessel_row)
# Serial number
serial_row = QHBoxLayout()
serial_row.addWidget(QLabel("Número de serie:"))
self._serial_edit = QLineEdit()
self._serial_edit.setPlaceholderText("AR-XXXX-XXXX-XXXX")
self._serial_edit.setMaximumWidth(220)
gen_btn = QPushButton("Generar")
gen_btn.setToolTip("Genera un nuevo número de serie aleatorio")
gen_btn.clicked.connect(self._on_generate_serial)
serial_row.addWidget(self._serial_edit)
serial_row.addWidget(gen_btn)
serial_row.addStretch(1)
form.addLayout(serial_row)
# CSV log
csv_row = QHBoxLayout()
csv_row.addWidget(QLabel("Registro CSV:"))
self._csv_edit = QLineEdit()
self._csv_edit.setPlaceholderText("Opcional — ruta al archivo serials.csv")
browse_btn = QPushButton("")
browse_btn.setFixedWidth(30)
browse_btn.clicked.connect(self._on_browse_csv)
csv_row.addWidget(self._csv_edit, 1)
csv_row.addWidget(browse_btn)
form.addLayout(csv_row)
# App checkboxes
app_row = QHBoxLayout()
self._chk_autopilot = QCheckBox("AR-Autopilot Display (Flutter)")
self._chk_autopilot.setChecked(True)
self._chk_ecdis = QCheckBox("AR-ECDIS")
self._chk_ecdis.setChecked(True)
self._chk_no_flutter = QCheckBox("Omitir compilación Flutter (usar build existente)")
app_row.addWidget(self._chk_autopilot)
app_row.addWidget(self._chk_ecdis)
app_row.addStretch(1)
form.addLayout(app_row)
form.addWidget(self._chk_no_flutter)
root.addWidget(cfg)
# ── Action row ───────────────────────────────────────────────────────
act = QHBoxLayout()
self._build_btn = QPushButton("▶ Build USB Image")
self._build_btn.setObjectName("primary")
self._build_btn.setToolTip("Compila e empaqueta el instalador USB")
self._build_btn.clicked.connect(self._on_build)
act.addWidget(self._build_btn)
self._cancel_btn = QPushButton("Cancelar")
self._cancel_btn.setEnabled(False)
self._cancel_btn.clicked.connect(self._on_cancel)
act.addWidget(self._cancel_btn)
self._open_btn = QPushButton("Abrir dist/")
self._open_btn.setEnabled(False)
self._open_btn.setToolTip(f"Abre la carpeta: {DIST_DIR}")
self._open_btn.clicked.connect(self._on_open_dist)
act.addWidget(self._open_btn)
act.addStretch(1)
self._status_lbl = QLabel("")
act.addWidget(self._status_lbl)
root.addLayout(act)
# ── Log ──────────────────────────────────────────────────────────────
self._log = QPlainTextEdit()
self._log.setReadOnly(True)
self._log.setStyleSheet(
"background:#0A1520; color:#C9D1D9; font-family:Consolas,'Cascadia Mono',monospace; font-size:10px;"
)
self._log.setPlaceholderText("El log del proceso de build aparecerá aquí…")
root.addWidget(self._log, 1)
# RBAC gate
can_build = self._session.can(Capability.EDIT_COMMISSIONING)
if not can_build:
self._build_btn.setEnabled(False)
self._build_btn.setToolTip("Requiere rol Engineer o Super Admin.")
self._vessel_edit.setEnabled(False)
self._serial_edit.setEnabled(False)
self._chk_autopilot.setEnabled(False)
self._chk_ecdis.setEnabled(False)
self._chk_no_flutter.setEnabled(False)
root.insertWidget(0, QLabel(
"⚠ Tu rol no permite construir instaladores. "
"Inicia sesión como Engineer o Super Admin."
))
# ── Handlers ──────────────────────────────────────────────────────────────
def _on_generate_serial(self) -> None:
self._serial_edit.setText(_generate_serial())
def _on_browse_csv(self) -> None:
path, _ = QFileDialog.getSaveFileName(
self, "Registro de seriales", str(Path.home() / "serials.csv"),
"CSV (*.csv);;Todos los archivos (*)"
)
if path:
self._csv_edit.setText(path)
def _on_build(self) -> None:
vessel = self._vessel_edit.text().strip()
serial = self._serial_edit.text().strip().upper()
if not vessel:
QMessageBox.warning(self, "Falta dato", "Introduce el nombre del buque.")
return
if not serial:
serial = _generate_serial()
self._serial_edit.setText(serial)
self._log.appendPlainText(f"[auto] Serial generado: {serial}")
if not BUILD_SCRIPT.exists():
QMessageBox.critical(
self, "Script no encontrado",
f"No se encontró:\n{BUILD_SCRIPT}\n\nVerifica que el repo esté completo."
)
return
argv = [sys.executable, str(BUILD_SCRIPT), "--vessel", vessel, "--serial", serial]
if not self._chk_autopilot.isChecked() or self._chk_no_flutter.isChecked():
argv.append("--no-flutter")
if not self._chk_ecdis.isChecked():
argv.append("--no-ecdis")
csv = self._csv_edit.text().strip()
if csv:
argv += ["--csv", csv]
self._log.clear()
self._log.appendPlainText(f"$ {' '.join(shlex.quote(a) for a in argv)}\n")
self._set_running(True)
self._status_lbl.setText("Building…")
self._thread = QThread(self)
self._worker = _BuildWorker(argv)
self._worker.moveToThread(self._thread)
self._thread.started.connect(self._worker.run)
self._worker.line.connect(self._log.appendPlainText)
self._worker.finished.connect(self._on_build_done)
self._worker.error.connect(self._on_build_error)
self._worker.finished.connect(self._thread.quit)
self._worker.error.connect(self._thread.quit)
self._thread.finished.connect(self._cleanup_thread)
self._thread.start()
def _on_cancel(self) -> None:
if self._worker:
self._worker.cancel()
self._log.appendPlainText("\n[cancelado por el operador]")
def _on_open_dist(self) -> None:
if DIST_DIR.exists():
os.startfile(str(DIST_DIR)) # Windows Explorer
else:
QMessageBox.information(self, "Sin carpeta", f"No existe:\n{DIST_DIR}")
def _on_build_done(self, code: int) -> None:
if code == 0:
self._status_lbl.setText("✓ Build completado")
self._open_btn.setEnabled(True)
self._log.appendPlainText(
f"\n✓ Pendrive listo en:\n{DIST_DIR}\n"
"Copie TODO el contenido de dist/ al pendrive USB."
)
else:
self._status_lbl.setText(f"✗ Error (exit {code})")
self._set_running(False)
def _on_build_error(self, msg: str) -> None:
self._log.appendPlainText(f"\n[ERROR] {msg}")
self._status_lbl.setText("✗ Error")
self._set_running(False)
def _set_running(self, running: bool) -> None:
can_build = self._session.can(Capability.EDIT_COMMISSIONING)
self._build_btn.setEnabled(not running and can_build)
self._cancel_btn.setEnabled(running)
def _cleanup_thread(self) -> None:
if self._thread:
self._thread.deleteLater()
self._thread = None
if self._worker:
self._worker.deleteLater()
self._worker = None
+193 -43
View File
@@ -1,34 +1,48 @@
"""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,
QHBoxLayout,
QLabel,
QListWidget,
QMainWindow,
QMessageBox,
QPushButton,
QSplitter,
QStatusBar,
QTabWidget,
QTextEdit,
QVBoxLayout,
QWidget,
)
from arautopilot.core.rbac import capabilities_of
from arautopilot.studio.ar_style import ACCENT_MID, GLOW, NAVY, TEXT_MUTED
from arautopilot.studio.editors.project_editor import ProjectEditorWidget
from arautopilot.studio.flash_console import FlashConsoleWidget
from arautopilot.studio.installer_widget import InstallerWidget
from arautopilot.studio.session import Session
from arautopilot.studio.telemetry_widget import TelemetryWidget
from arautopilot.version import __version__
REPO_ROOT = Path(__file__).resolve().parents[2]
_LOGO_PATH = REPO_ROOT / "display" / "assets" / "images" / "ar_logo_full.png"
class StudioMainWindow(QMainWindow):
"""Top-level Studio window."""
@@ -37,71 +51,207 @@ 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(ProjectEditorWidget(self._session), "Project")
tabs.addTab(self._placeholder_tab(
"Telemetry -- Sprint 4.\n\n"
"Live Modbus telemetry from the connected AR-NMEA-IO board."
), "Telemetry")
tabs.addTab(self._build_overview_tab(), "🧭 Overview")
tabs.addTab(FlashConsoleWidget(self._session), "⚡ Flash ESP32")
tabs.addTab(ProjectEditorWidget(self._session), "📋 Proyecto")
tabs.addTab(TelemetryWidget(self._session), "📡 Telemetría")
tabs.addTab(InstallerWidget(self._session), "💾 Instalar J6412")
return tabs
# ── Overview tab ──────────────────────────────────────────────────────────
def _build_overview_tab(self) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
layout.addWidget(QLabel(
"<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.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)
# AR Display Manager quick launch
sep2 = QFrame()
sep2.setFrameShape(QFrame.Shape.HLine)
sep2.setStyleSheet(f"color:{ACCENT_MID}; margin: 10px 0;")
layout.addWidget(sep2)
dm_row = QHBoxLayout()
dm_label = QLabel(
f"<b style='color:{ACCENT_MID}'>🖥 AR Display Manager</b><br/>"
f"<span style='color:{TEXT_MUTED};font-size:11px;'>"
"Gestiona cuál app aparece en cada monitor del J6412 "
"(hasta 4 pantallas simultáneas).</span>"
)
dm_label.setTextFormat(Qt.TextFormat.RichText)
dm_label.setWordWrap(True)
dm_row.addWidget(dm_label, stretch=1)
dm_btn = QPushButton("Lanzar Display Manager")
dm_btn.setMinimumWidth(200)
dm_btn.clicked.connect(self._launch_display_manager)
dm_row.addWidget(dm_btn)
layout.addLayout(dm_row)
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)
return w
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
# ── Display Manager launcher ──────────────────────────────────────────────
def _launch_display_manager(self) -> None:
import subprocess
import sys as _sys
launcher = REPO_ROOT / "display_manager_main.py"
if not launcher.exists():
QMessageBox.warning(
self,
"Not found",
f"display_manager_main.py not found at:\n{launcher}",
)
return
try:
subprocess.Popen(
[_sys.executable, str(launcher)],
creationflags=(
subprocess.CREATE_NEW_PROCESS_GROUP
if _sys.platform == "win32" else 0
),
)
except Exception as exc:
QMessageBox.critical(self, "Launch failed", str(exc))
+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)
+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();
}
}
+40 -4
View File
@@ -1,15 +1,50 @@
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(
ChangeNotifierProvider<AutopilotThemeProvider>.value(
value: themeProvider,
MultiProvider(
providers: [
ChangeNotifierProvider<AutopilotThemeProvider>.value(
value: themeProvider,
),
ChangeNotifierProvider<AutopilotState>.value(
value: autopilotState,
),
],
child: const ArAutopilotApp(),
),
);
@@ -24,10 +59,11 @@ class ArAutopilotApp extends StatelessWidget {
title: 'AR-Autopilot',
debugShowCheckedModeBanner: false,
theme: ThemeData(useMaterial3: true),
// Initial route — Sprint 4 starts with the Appearance screen for demo
initialRoute: AppearanceSettingsScreen.routeName,
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,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,
};
}
@@ -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,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,
),
),
],
);
}
}
+2
View File
@@ -13,6 +13,8 @@ dependencies:
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:
+1
View File
@@ -0,0 +1 @@
"""AR Display Manager — multi-monitor app switcher for the Integrated Bridge System."""
+45
View File
@@ -0,0 +1,45 @@
"""AR Display Manager entry point."""
from __future__ import annotations
import signal
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
def run(argv: list[str] | None = None) -> int:
"""Launch the AR Display Manager daemon."""
try:
from PySide6.QtWidgets import QApplication
except ImportError:
sys.stderr.write(
"PySide6 is not installed. Run:\n\n"
" pip install PySide6\n"
)
return 2
signal.signal(signal.SIGINT, signal.SIG_DFL) # Ctrl+C kills the process
app = QApplication(argv if argv is not None else sys.argv)
app.setApplicationName("AR Display Manager")
app.setQuitOnLastWindowClosed(False) # keep running when popup closes
# Brand icon
from PySide6.QtGui import QIcon
_logo = REPO_ROOT / "display" / "assets" / "images" / "ar_logo_full.png"
if _logo.exists():
app.setWindowIcon(QIcon(str(_logo)))
# Apply brand stylesheet
from arautopilot.studio.ar_style import apply_ar_style
apply_ar_style(app)
from display_manager.display_manager import DisplayManager
_mgr = DisplayManager(app) # noqa: F841 — kept alive via QObject parent=None
return app.exec()
if __name__ == "__main__":
sys.exit(run())
+45
View File
@@ -0,0 +1,45 @@
"""Static metadata for the four AR Bridge applications."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class AppMeta:
id: str
name: str
icon: str # emoji used in the button popup
color: str # hex accent colour for the card
description: str
# Ordered list — shown in this order in the popup
APPS: list[AppMeta] = [
AppMeta(
id="autopilot",
name="AR-Autopilot",
icon="",
color="#2563EB",
description="Autopilot control display",
),
AppMeta(
id="ecdis",
name="AR-ECDIS",
icon="🗺",
color="#22C55E",
description="Electronic chart display",
),
AppMeta(
id="compass",
name="Compass",
icon="🧭",
color="#F59E0B",
description="Ship motion & compass",
),
AppMeta(
id="gps",
name="GPS Navigator",
icon="📍",
color="#8B5CF6",
description="GPS navigation display",
),
]
APP_BY_ID: dict[str, AppMeta] = {a.id: a for a in APPS}
+66
View File
@@ -0,0 +1,66 @@
"""Register / unregister AR Display Manager as a Windows autostart entry."""
from __future__ import annotations
import sys
from pathlib import Path
_REG_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
_APP_NAME = "AR Display Manager"
def _pythonw() -> str:
"""Return the pythonw.exe path (no console window on start)."""
exe = Path(sys.executable)
pw = exe.parent / "pythonw.exe"
return str(pw) if pw.exists() else sys.executable
def enable() -> bool:
"""Add the autostart registry entry. Returns True on success."""
if sys.platform != "win32":
return False
try:
import winreg # type: ignore[import-not-found]
launcher = Path(__file__).resolve().parents[1] / "display_manager_main.py"
cmd = f'"{_pythonw()}" "{launcher}"'
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER, _REG_KEY, 0, winreg.KEY_SET_VALUE
) as key:
winreg.SetValueEx(key, _APP_NAME, 0, winreg.REG_SZ, cmd)
return True
except Exception:
return False
def disable() -> bool:
"""Remove the autostart registry entry. Returns True if it existed."""
if sys.platform != "win32":
return False
try:
import winreg # type: ignore[import-not-found]
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER, _REG_KEY, 0, winreg.KEY_SET_VALUE
) as key:
winreg.DeleteValue(key, _APP_NAME)
return True
except FileNotFoundError:
return False
except Exception:
return False
def is_enabled() -> bool:
"""Return True if the autostart entry exists."""
if sys.platform != "win32":
return False
try:
import winreg # type: ignore[import-not-found]
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER, _REG_KEY, 0, winreg.KEY_READ
) as key:
winreg.QueryValueEx(key, _APP_NAME)
return True
except FileNotFoundError:
return False
except Exception:
return False
+110
View File
@@ -0,0 +1,110 @@
"""Configuration and layout persistence for AR Display Manager."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
CONFIG_DIR = Path.home() / ".ar-autopilot" / "display_manager"
CONFIG_FILE = CONFIG_DIR / "config.json"
LAYOUT_FILE = CONFIG_DIR / "layout.json"
@dataclass
class AppExeConfig:
"""Path + launch args for one application."""
exe: str = ""
args: list[str] = field(default_factory=list)
title_hint: str = "" # substring to match window title when searching
_DEFAULT_EXES: dict[str, AppExeConfig] = {
"autopilot": AppExeConfig(
exe=str(
REPO_ROOT / "display" / "build" / "windows" / "x64" / "runner"
/ "Release" / "display.exe"
),
title_hint="AR Autopilot",
),
"ecdis": AppExeConfig(exe="", title_hint="AR-ECDIS"),
"compass": AppExeConfig(exe="", title_hint="Compass"),
"gps": AppExeConfig(exe="", title_hint="GPS Navigator"),
}
@dataclass
class DisplayManagerConfig:
apps: dict[str, AppExeConfig] = field(default_factory=dict)
button_position: str = "top-right" # top-right | top-left | bottom-right | bottom-left
button_margin: int = 12 # px from screen edge
autolaunch: bool = False # launch all apps on startup (False = on-demand only)
# ------------------------------------------------------------------ I/O
@classmethod
def load(cls) -> "DisplayManagerConfig":
cfg = cls(apps=dict(_DEFAULT_EXES))
if not CONFIG_FILE.exists():
return cfg
try:
with open(CONFIG_FILE, encoding="utf-8") as f:
data = json.load(f)
cfg.button_position = data.get("button_position", cfg.button_position)
cfg.button_margin = int(data.get("button_margin", cfg.button_margin))
cfg.autolaunch = bool(data.get("autolaunch", cfg.autolaunch))
for app_id, raw in data.get("apps", {}).items():
cfg.apps[app_id] = AppExeConfig(
exe=raw.get("exe", ""),
args=raw.get("args", []),
title_hint=raw.get("title_hint", ""),
)
except Exception:
pass
return cfg
def save(self) -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(
{
"button_position": self.button_position,
"button_margin": self.button_margin,
"autolaunch": self.autolaunch,
"apps": {
k: {"exe": v.exe, "args": v.args, "title_hint": v.title_hint}
for k, v in self.apps.items()
},
},
f,
indent=2,
)
# --------------------------------------------------------------------------- Layout
class LayoutStore:
"""Persists {screen_serial → app_id} so layout survives restarts."""
def __init__(self) -> None:
self._data: dict[str, str] = {}
self._load()
def _load(self) -> None:
if LAYOUT_FILE.exists():
try:
with open(LAYOUT_FILE, encoding="utf-8") as f:
self._data = json.load(f)
except Exception:
self._data = {}
def get(self, screen_serial: str) -> str | None:
return self._data.get(screen_serial)
def set(self, screen_serial: str, app_id: str) -> None:
self._data[screen_serial] = app_id
self._save()
def _save(self) -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with open(LAYOUT_FILE, "w", encoding="utf-8") as f:
json.dump(self._data, f, indent=2)
+189
View File
@@ -0,0 +1,189 @@
"""AR Display Manager orchestrator."""
from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import QObject, QRect, QTimer
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon
from display_manager.config import DisplayManagerConfig, LayoutStore
from display_manager.floating_button import FloatingButton
from display_manager.process_manager import ProcessManager
REPO_ROOT = Path(__file__).resolve().parents[1]
_LOGO = REPO_ROOT / "display" / "assets" / "images" / "ar_logo_full.png"
class DisplayManager(QObject):
"""Creates one FloatingButton per screen, manages app windows."""
def __init__(self, app: QApplication) -> None:
super().__init__()
self._app = app
self._config = DisplayManagerConfig.load()
self._layout = LayoutStore()
self._proc_mgr = ProcessManager(self._config, self)
self._buttons: dict[str, FloatingButton] = {} # serial → button
# React to screen add/remove
app.screenAdded.connect(self._on_screen_added)
app.screenRemoved.connect(self._on_screen_removed)
# Bootstrap buttons
for screen in app.screens():
self._add_button(screen)
# System tray
self._tray = self._build_tray()
self._tray.show()
# Launch apps
if self._config.autolaunch:
# Slight delay so the GUI is visible first
QTimer.singleShot(1_500, self._proc_mgr.launch_all)
# Restore last layout after apps have had time to show windows
QTimer.singleShot(8_000, self._restore_layout)
# ---------------------------------------------------------------- Screens
def _screen_serial(self, screen: object) -> str:
"""Unique identifier for a QScreen (name + geometry hash)."""
from PySide6.QtGui import QScreen
s: QScreen = screen # type: ignore[assignment]
g = s.geometry()
return f"{s.name()}_{g.x()}_{g.y()}_{g.width()}_{g.height()}"
def _on_screen_added(self, screen: object) -> None:
self._add_button(screen)
def _on_screen_removed(self, screen: object) -> None:
serial = self._screen_serial(screen)
btn = self._buttons.pop(serial, None)
if btn:
btn.hide()
btn.deleteLater()
def _add_button(self, screen: object) -> None:
from PySide6.QtGui import QScreen
s: QScreen = screen # type: ignore[assignment]
serial = self._screen_serial(s)
if serial in self._buttons:
return
btn = FloatingButton(
screen_serial=serial,
screen_geometry=s.geometry(),
config=self._config,
on_app_select=self._on_app_select,
get_current_app=self._layout.get,
get_running_ids=self._proc_mgr.running_ids,
)
self._buttons[serial] = btn
# ---------------------------------------------------------------- App switching
def _on_app_select(self, screen_serial: str, app_id: str) -> None:
"""User chose *app_id* for the screen identified by *screen_serial*."""
# Find the QScreen for this serial
screen_geo: QRect | None = None
for s in self._app.screens():
if self._screen_serial(s) == screen_serial:
screen_geo = s.geometry()
break
if screen_geo is None:
return
# Launch if not running
if not self._proc_mgr.is_running(app_id):
self._proc_mgr.launch_if_configured(app_id)
# Delay window placement until the app has time to start
QTimer.singleShot(
4_000,
lambda: self._bring_app(app_id, screen_geo), # type: ignore[arg-type]
)
else:
self._bring_app(app_id, screen_geo)
self._layout.set(screen_serial, app_id)
# Minimize apps not assigned to any screen (free GPU)
self._minimize_unassigned()
def _minimize_unassigned(self) -> None:
"""Minimize every running app that is not currently assigned to any screen."""
assigned = set(
self._layout.get(self._screen_serial(s))
for s in self._app.screens()
) - {None}
for app_id in self._proc_mgr.running_ids():
if app_id not in assigned:
self._proc_mgr.minimize(app_id)
def _bring_app(self, app_id: str, geo: QRect) -> None:
self._proc_mgr.bring_to_screen(app_id, geo.x(), geo.y(), geo.width(), geo.height())
def _restore_layout(self) -> None:
"""After startup, move apps to their last assigned screens."""
for s in self._app.screens():
serial = self._screen_serial(s)
app_id = self._layout.get(serial)
if app_id and self._proc_mgr.is_running(app_id):
geo = s.geometry()
self._bring_app(app_id, geo)
# ---------------------------------------------------------------- Tray icon
def _build_tray(self) -> QSystemTrayIcon:
if _LOGO.exists():
icon = QIcon(str(_LOGO))
else:
icon = QApplication.style().standardIcon( # type: ignore[union-attr]
__import__("PySide6.QtWidgets", fromlist=["QStyle"]).QStyle.StandardPixmap.SP_ComputerIcon
)
tray = QSystemTrayIcon(icon, self)
tray.setToolTip("AR Display Manager")
menu = QMenu()
menu.addAction("AR Display Manager").setEnabled(False)
menu.addSeparator()
launch_menu = menu.addMenu("Launch app…")
from display_manager.app_registry import APPS
for app_meta in APPS:
act = launch_menu.addAction(f"{app_meta.icon} {app_meta.name}")
act.triggered.connect(
lambda checked=False, aid=app_meta.id: self._proc_mgr.launch_if_configured(aid)
)
menu.addSeparator()
settings_act = menu.addAction("⚙ Settings…")
settings_act.triggered.connect(self._open_settings)
menu.addSeparator()
quit_act = menu.addAction("Quit")
quit_act.triggered.connect(self._quit)
tray.setContextMenu(menu)
tray.activated.connect(self._on_tray_activated)
return tray
def _on_tray_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None:
if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
# Show/hide all floating buttons
any_visible = any(b.isVisible() for b in self._buttons.values())
for btn in self._buttons.values():
btn.setVisible(not any_visible)
def _open_settings(self) -> None:
from display_manager.settings_dialog import SettingsDialog
dlg = SettingsDialog(self._config)
if dlg.exec():
# Reposition buttons if button_position changed
for s in self._app.screens():
serial = self._screen_serial(s)
btn = self._buttons.get(serial)
if btn:
btn.update_screen(s.geometry())
def _quit(self) -> None:
self._app.quit()
+277
View File
@@ -0,0 +1,277 @@
"""Always-on-top floating AR button — one per monitor."""
from __future__ import annotations
from typing import Callable
from PySide6.QtCore import (
QPoint, QRect, QSize, Qt,
)
from PySide6.QtGui import (
QColor, QFont, QMouseEvent, QPainter, QPainterPath,
)
from PySide6.QtWidgets import QWidget
from display_manager.app_registry import APP_BY_ID, APPS
from display_manager.config import DisplayManagerConfig
_BTN_SIZE = 52 # button width and height, px
_BTN_RADIUS = 14 # corner radius
_POPUP_W = 260
_POPUP_ITEM_H = 64
class AppSwitcherPopup(QWidget):
"""Frameless popup that lists the four apps; appears near the floating button."""
def __init__(
self,
current_app_id: str | None,
running_ids: set[str],
on_select: Callable[[str], None],
parent: QWidget | None = None,
) -> None:
super().__init__(
parent,
Qt.WindowType.Tool
| Qt.WindowType.FramelessWindowHint
| Qt.WindowType.WindowStaysOnTopHint,
)
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
self._current = current_app_id
self._running = running_ids
self._on_select = on_select
self._hovered: str | None = None
self.setFixedWidth(_POPUP_W)
self.setFixedHeight(len(APPS) * _POPUP_ITEM_H + 16)
self.setMouseTracking(True)
def paintEvent(self, _event: object) -> None: # type: ignore[override]
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
# Background
bg = QColor("#0D1B2A")
bg.setAlpha(240)
path = QPainterPath()
path.addRoundedRect(0, 0, self.width(), self.height(), 12, 12)
p.fillPath(path, bg)
p.setPen(QColor("#2563EB"))
p.drawPath(path)
y = 8
for app in APPS:
rect = QRect(8, y, _POPUP_W - 16, _POPUP_ITEM_H - 4)
# Card background
is_current = app.id == self._current
is_hovered = app.id == self._hovered
is_running = app.id in self._running
card_bg = QColor(app.color)
if is_current:
card_bg.setAlpha(80)
elif is_hovered:
card_bg.setAlpha(50)
else:
card_bg.setAlpha(25)
card_path = QPainterPath()
card_path.addRoundedRect(rect, 8, 8)
p.fillPath(card_path, card_bg)
if is_current or is_hovered:
p.setPen(QColor(app.color))
p.drawPath(card_path)
# Icon
icon_font = QFont("Segoe UI Emoji", 20)
p.setFont(icon_font)
p.setPen(QColor("#E2E8F0"))
p.drawText(QRect(rect.x() + 8, rect.y(), 44, rect.height()), Qt.AlignmentFlag.AlignVCenter, app.icon)
# Name + description
name_font = QFont("Segoe UI", 11, QFont.Weight.Bold)
p.setFont(name_font)
p.setPen(QColor("#E2E8F0") if is_running else QColor("#8899AA"))
p.drawText(QRect(rect.x() + 58, rect.y() + 6, rect.width() - 66, 20), Qt.AlignmentFlag.AlignLeft, app.name)
desc_font = QFont("Segoe UI", 9)
p.setFont(desc_font)
p.setPen(QColor("#8899AA"))
p.drawText(QRect(rect.x() + 58, rect.y() + 28, rect.width() - 66, 18), Qt.AlignmentFlag.AlignLeft, app.description)
# Running indicator dot
if is_running:
dot_x = rect.right() - 10
dot_y = rect.y() + rect.height() // 2 - 4
p.setBrush(QColor("#22C55E"))
p.setPen(Qt.PenStyle.NoPen)
p.drawEllipse(dot_x, dot_y, 8, 8)
y += _POPUP_ITEM_H
def mouseMoveEvent(self, event: QMouseEvent) -> None:
self._hovered = self._app_at(event.position().y())
self.update()
def mousePressEvent(self, event: QMouseEvent) -> None:
app_id = self._app_at(event.position().y())
if app_id:
self._on_select(app_id)
self.close()
def leaveEvent(self, _event: object) -> None:
self._hovered = None
self.update()
def _app_at(self, y: float) -> str | None:
idx = int((y - 8) // _POPUP_ITEM_H)
if 0 <= idx < len(APPS):
return APPS[idx].id
return None
def focusOutEvent(self, _event: object) -> None: # type: ignore[override]
self.close()
class FloatingButton(QWidget):
"""Small always-on-top AR button anchored to one monitor."""
def __init__(
self,
screen_serial: str,
screen_geometry: QRect,
config: DisplayManagerConfig,
on_app_select: Callable[[str, str], None], # (screen_serial, app_id)
get_current_app: Callable[[str], str | None],
get_running_ids: Callable[[], set[str]],
parent: QWidget | None = None,
) -> None:
super().__init__(
parent,
Qt.WindowType.Tool
| Qt.WindowType.FramelessWindowHint
| Qt.WindowType.WindowStaysOnTopHint,
)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False)
self.setFixedSize(QSize(_BTN_SIZE, _BTN_SIZE))
self._serial = screen_serial
self._geo = screen_geometry
self._config = config
self._on_app_select = on_app_select
self._get_current = get_current_app
self._get_running = get_running_ids
self._popup: AppSwitcherPopup | None = None
self._drag_start: QPoint | None = None
self._dragging = False
self._reposition()
self.show()
# ---------------------------------------------------------------- Position
def _reposition(self) -> None:
pos = self._config.button_position
m = self._config.button_margin
g = self._geo
if pos == "top-left":
x, y = g.left() + m, g.top() + m
elif pos == "bottom-left":
x, y = g.left() + m, g.bottom() - _BTN_SIZE - m
elif pos == "bottom-right":
x, y = g.right() - _BTN_SIZE - m, g.bottom() - _BTN_SIZE - m
else: # top-right (default)
x, y = g.right() - _BTN_SIZE - m, g.top() + m
self.move(x, y)
def update_screen(self, geo: QRect) -> None:
self._geo = geo
self._reposition()
# ---------------------------------------------------------------- Paint
def paintEvent(self, _event: object) -> None: # type: ignore[override]
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
# Glow ring for current app colour
current_id = self._get_current(self._serial)
if current_id and current_id in APP_BY_ID:
ring_col = QColor(APP_BY_ID[current_id].color)
ring_col.setAlpha(120)
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(ring_col)
p.drawRoundedRect(0, 0, _BTN_SIZE, _BTN_SIZE, _BTN_RADIUS + 2, _BTN_RADIUS + 2)
# Button background
bg = QColor("#0D1B2A")
bg.setAlpha(220)
p.setBrush(bg)
border_col = QColor("#2563EB")
border_col.setAlpha(200)
p.setPen(border_col)
p.drawRoundedRect(2, 2, _BTN_SIZE - 4, _BTN_SIZE - 4, _BTN_RADIUS, _BTN_RADIUS)
# "AR" text
font = QFont("Segoe UI", 13, QFont.Weight.Bold)
p.setFont(font)
p.setPen(QColor("#60B8FF"))
p.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "AR")
# ---------------------------------------------------------------- Interaction
def mousePressEvent(self, event: QMouseEvent) -> None:
if event.button() == Qt.MouseButton.LeftButton:
self._drag_start = event.globalPosition().toPoint()
self._dragging = False
def mouseMoveEvent(self, event: QMouseEvent) -> None:
if self._drag_start is not None:
delta = event.globalPosition().toPoint() - self._drag_start
if delta.manhattanLength() > 5:
self._dragging = True
new_pos = self.pos() + delta
self.move(new_pos)
self._drag_start = event.globalPosition().toPoint()
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
if event.button() == Qt.MouseButton.LeftButton and not self._dragging:
self._show_popup()
self._drag_start = None
self._dragging = False
# ---------------------------------------------------------------- Popup
def _show_popup(self) -> None:
if self._popup and not self._popup.isHidden():
self._popup.close()
self._popup = None
return
current = self._get_current(self._serial)
running = self._get_running()
def on_select(app_id: str) -> None:
self._on_app_select(self._serial, app_id)
self.update()
popup = AppSwitcherPopup(
current_app_id=current,
running_ids=running,
on_select=on_select,
)
self._popup = popup
# Position popup near button (prefer below, flip if near bottom)
btn_global = self.mapToGlobal(QPoint(0, 0))
px = btn_global.x() - _POPUP_W + _BTN_SIZE
py = btn_global.y() + _BTN_SIZE + 4
geo = self._geo
if py + popup.height() > geo.bottom():
py = btn_global.y() - popup.height() - 4
if px < geo.left():
px = geo.left() + 4
popup.move(px, py)
popup.show()
popup.setFocus()
+134
View File
@@ -0,0 +1,134 @@
"""Launch and track the four AR Bridge applications."""
from __future__ import annotations
import subprocess
import sys
import time
from pathlib import Path
from typing import Optional
from PySide6.QtCore import QObject, QTimer, Signal
from display_manager.config import AppExeConfig, DisplayManagerConfig
from display_manager import win32_utils
class AppHandle:
"""Tracks one running application."""
def __init__(self, app_id: str, proc: subprocess.Popen[bytes]) -> None:
self.app_id = app_id
self.proc = proc
self.hwnd: Optional[int] = None
self._hwnd_found_at: float = 0.0
@property
def pid(self) -> int:
return self.proc.pid
def find_hwnd(self, title_hint: str = "") -> int | None:
"""Try to locate the main window HWND (may not be available immediately)."""
hwnd = win32_utils.find_window_by_pid(self.pid)
if hwnd is None and title_hint:
hwnd = win32_utils.find_window_by_title_hint(title_hint)
if hwnd is not None:
self.hwnd = hwnd
self._hwnd_found_at = time.monotonic()
return hwnd
def is_running(self) -> bool:
return self.proc.poll() is None
def is_window_alive(self) -> bool:
return self.hwnd is not None and win32_utils.is_window_alive(self.hwnd)
class ProcessManager(QObject):
"""Manages the lifecycle of all registered apps.
Emits ``app_state_changed`` whenever an app starts or stops.
"""
app_state_changed = Signal(str, bool) # (app_id, is_running)
_POLL_MS = 3_000 # check process health every 3 s
def __init__(self, config: DisplayManagerConfig, parent: QObject | None = None) -> None:
super().__init__(parent)
self._config = config
self._handles: dict[str, AppHandle] = {}
self._timer = QTimer(self)
self._timer.timeout.connect(self._poll)
self._timer.start(self._POLL_MS)
# ---------------------------------------------------------------- Public
def launch_if_configured(self, app_id: str) -> bool:
"""Launch *app_id* if it has a valid exe and is not already running."""
if app_id in self._handles and self._handles[app_id].is_running():
return True
exe_cfg = self._config.apps.get(app_id)
if not exe_cfg or not exe_cfg.exe or not Path(exe_cfg.exe).exists():
return False
return self._launch(app_id, exe_cfg)
def launch_all(self) -> None:
"""Attempt to launch every configured app."""
for app_id in self._config.apps:
self.launch_if_configured(app_id)
def bring_to_screen(self, app_id: str, x: int, y: int, w: int, h: int) -> bool:
"""Move *app_id*'s window to the given screen rect. Returns False if not running."""
handle = self._handles.get(app_id)
if handle is None or not handle.is_running():
return False
title_hint = self._config.apps.get(app_id, AppExeConfig()).title_hint
hwnd = handle.find_hwnd(title_hint)
if hwnd is None:
return False
win32_utils.move_and_maximize(hwnd, x, y, w, h)
return True
def minimize(self, app_id: str) -> None:
"""Minimize *app_id*'s window to free GPU resources."""
handle = self._handles.get(app_id)
if handle is None or not handle.is_running():
return
title_hint = self._config.apps.get(app_id, AppExeConfig()).title_hint
hwnd = handle.find_hwnd(title_hint)
if hwnd is not None:
win32_utils.minimize_window(hwnd)
def is_running(self, app_id: str) -> bool:
h = self._handles.get(app_id)
return h is not None and h.is_running()
def running_ids(self) -> set[str]:
return {aid for aid, h in self._handles.items() if h.is_running()}
# ---------------------------------------------------------------- Private
def _launch(self, app_id: str, exe_cfg: AppExeConfig) -> bool:
try:
cmd = [exe_cfg.exe] + exe_cfg.args
proc = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=(
subprocess.CREATE_NEW_PROCESS_GROUP
if sys.platform == "win32" else 0
),
)
self._handles[app_id] = AppHandle(app_id, proc)
self.app_state_changed.emit(app_id, True)
return True
except Exception:
return False
def _poll(self) -> None:
for app_id, handle in list(self._handles.items()):
if not handle.is_running():
del self._handles[app_id]
self.app_state_changed.emit(app_id, False)
+110
View File
@@ -0,0 +1,110 @@
"""Settings dialog for AR Display Manager — configure exe paths and button position."""
from __future__ import annotations
from pathlib import Path
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLineEdit,
QPushButton,
QVBoxLayout,
QWidget,
)
from display_manager.app_registry import APPS
from display_manager.autostart import disable as autostart_disable
from display_manager.autostart import enable as autostart_enable
from display_manager.autostart import is_enabled as autostart_is_enabled
from display_manager.config import AppExeConfig, DisplayManagerConfig
class SettingsDialog(QDialog):
"""Modal settings editor for the Display Manager."""
def __init__(self, config: DisplayManagerConfig, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setWindowTitle("AR Display Manager — Settings")
self.setMinimumWidth(520)
self._config = config
self._fields: dict[str, QLineEdit] = {}
self._build_ui()
def _build_ui(self) -> None:
layout = QVBoxLayout(self)
# Button position
pos_group = QGroupBox("Floating button")
pos_form = QFormLayout(pos_group)
self._pos_combo = QComboBox()
for label, val in [
("Top-right (default)", "top-right"),
("Top-left", "top-left"),
("Bottom-right", "bottom-right"),
("Bottom-left", "bottom-left"),
]:
self._pos_combo.addItem(label, userData=val)
idx = self._pos_combo.findData(self._config.button_position)
if idx >= 0:
self._pos_combo.setCurrentIndex(idx)
pos_form.addRow("Position on each monitor:", self._pos_combo)
layout.addWidget(pos_group)
# App executables
apps_group = QGroupBox("Application executables")
apps_form = QFormLayout(apps_group)
for app_meta in APPS:
exe_cfg = self._config.apps.get(app_meta.id, AppExeConfig())
row = QHBoxLayout()
field = QLineEdit(exe_cfg.exe)
field.setPlaceholderText("(not configured — click Browse)")
self._fields[app_meta.id] = field
row.addWidget(field, stretch=1)
browse = QPushButton("Browse…")
browse.clicked.connect(lambda checked=False, f=field: self._browse(f))
row.addWidget(browse)
apps_form.addRow(f"{app_meta.icon} {app_meta.name}:", row)
layout.addWidget(apps_group)
# Autostart
sys_group = QGroupBox("System")
sys_form = QFormLayout(sys_group)
self._autostart_chk = QCheckBox("Start AR Display Manager with Windows")
self._autostart_chk.setChecked(autostart_is_enabled())
sys_form.addRow(self._autostart_chk)
layout.addWidget(sys_group)
# Buttons
btns = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
btns.accepted.connect(self._accept)
btns.rejected.connect(self.reject)
layout.addWidget(btns)
def _browse(self, field: QLineEdit) -> None:
path, _ = QFileDialog.getOpenFileName(
self, "Select executable", str(Path.home()), "Executables (*.exe);;All files (*)"
)
if path:
field.setText(path)
def _accept(self) -> None:
self._config.button_position = self._pos_combo.currentData()
for app_meta in APPS:
if app_meta.id not in self._config.apps:
self._config.apps[app_meta.id] = AppExeConfig()
self._config.apps[app_meta.id].exe = self._fields[app_meta.id].text()
self._config.save()
# Autostart
if self._autostart_chk.isChecked():
autostart_enable()
else:
autostart_disable()
self.accept()
+89
View File
@@ -0,0 +1,89 @@
"""Windows API helpers for window management (ctypes, no extra dependencies)."""
from __future__ import annotations
import ctypes
import ctypes.wintypes as wt
import sys
if sys.platform != "win32":
# Stub for non-Windows development/testing
def find_window_by_pid(pid: int) -> int | None:
return None
def move_and_maximize(hwnd: int, x: int, y: int, w: int, h: int) -> None:
pass
def is_window_alive(hwnd: int) -> bool:
return False
else:
_user32 = ctypes.windll.user32 # type: ignore[attr-defined]
# Constants
_HWND_TOP = 0
_SW_RESTORE = 9
_SW_MAXIMIZE = 3
_SWP_SHOWWINDOW = 0x0040
_SWP_FRAMECHANGED = 0x0020
_GW_OWNER = 4
# Callback type for EnumWindows
_WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, wt.HWND, wt.LPARAM)
def find_window_by_pid(pid: int) -> int | None:
"""Return the first visible top-level HWND belonging to *pid*."""
found: list[int] = []
def _cb(hwnd: int, _: int) -> bool:
if not _user32.IsWindowVisible(hwnd):
return True
# Skip windows with an owner (child dialogs, etc.)
if _user32.GetWindow(hwnd, _GW_OWNER):
return True
lp_pid = wt.DWORD()
_user32.GetWindowThreadProcessId(hwnd, ctypes.byref(lp_pid))
if lp_pid.value == pid:
found.append(hwnd)
return False # stop enumeration
return True
_user32.EnumWindows(_WNDENUMPROC(_cb), 0)
return found[0] if found else None
def find_window_by_title_hint(hint: str) -> int | None:
"""Return the first visible top-level HWND whose title contains *hint*."""
if not hint:
return None
found: list[int] = []
def _cb(hwnd: int, _: int) -> bool:
if not _user32.IsWindowVisible(hwnd):
return True
buf = ctypes.create_unicode_buffer(512)
_user32.GetWindowTextW(hwnd, buf, 512)
if hint.lower() in buf.value.lower():
found.append(hwnd)
return False
return True
_user32.EnumWindows(_WNDENUMPROC(_cb), 0)
return found[0] if found else None
def move_and_maximize(hwnd: int, x: int, y: int, w: int, h: int) -> None:
"""Move a window to (x, y, w, h) then maximize it on that monitor."""
_user32.ShowWindow(hwnd, _SW_RESTORE)
_user32.SetWindowPos(
hwnd, _HWND_TOP,
x, y, w, h,
_SWP_SHOWWINDOW | _SWP_FRAMECHANGED,
)
_user32.ShowWindow(hwnd, _SW_MAXIMIZE)
_user32.SetForegroundWindow(hwnd)
def minimize_window(hwnd: int) -> None:
"""Minimize a window to taskbar to free GPU resources."""
_SW_MINIMIZE = 6
_user32.ShowWindow(hwnd, _SW_MINIMIZE)
def is_window_alive(hwnd: int) -> bool:
return bool(_user32.IsWindow(hwnd))
+25
View File
@@ -0,0 +1,25 @@
"""AR Display Manager — repo-root launcher.
Usage:
python display_manager_main.py
python -m display_manager.app
The daemon detects all connected monitors and places a small floating
AR button in the corner of each. Clicking the button opens the app
switcher: choose which of the four AR Bridge apps you want visible on
that monitor. The chosen app's window is moved and maximized there;
the others continue running in the background.
Layout (which app is on which screen) is persisted in
~/.ar-autopilot/display_manager/layout.json so the arrangement
survives restarts.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from display_manager.app import run
if __name__ == "__main__":
sys.exit(run())
+242
View File
@@ -0,0 +1,242 @@
# Protocolo Concentrador NMEA2000-USB
**AR-Autopilot — Tarjeta Concentrador**
Versión 1.0 — 2026-05-23
---
## Arquitectura general
```
Software (J6412 / Tablet / PC)
│ NMEA 0183 ASCII @115200 bps por USB (CH340N)
┌─────────────────────────────────────┐
│ ESP32 Concentrador │
│ NMEA 0183 ←→ NMEA 2000 gateway │
└─────────────────────────────────────┘
│ NMEA 2000 (CAN 250 kbps)
Backbone del barco
```
- **USB → ESP32 (IN)** : sentencias NMEA 0183 que el software envía como comandos
- **ESP32 → USB (OUT)**: sentencias NMEA 0183 que el ESP32 emite con datos del backbone
- **Formato**: ASCII, terminado en `\r\n`, checksum NMEA estándar `*XX`
- **Velocidad**: 115200 bps en todos los puertos USB
---
## Hardware — Puertos USB
```
UART1 TX → CH340N #1 → USB-OUT1 (puente — solo lectura)
→ CH340N #2 → USB-OUT2 (cockpit — solo lectura)
→ CH340N #3 → USB-OUT3 (flybridge — solo lectura)
→ CH340N #4 → USB-OUT4 (reserva — solo lectura)
UART2 RX ← CH340N #5 ← USB-IN1 (puente — manda comandos)
← CH340N #6 ← USB-IN2 (cockpit — manda si tiene el mando)
← CH340N #7 ← USB-IN3 (flybridge — manda si tiene el mando)
← CH340N #8 ← USB-IN4 (reserva — futuro)
```
**IDs de estación:**
| ID | Estación | Prioridad |
|-----|-----------|-----------|
| 01 | Puente | Máxima — override sin confirmación |
| 02 | Cockpit | Normal |
| 03 | Flybridge | Normal |
| 04 | Reserva | Normal |
---
## Sentencias NMEA 0183 estándar (salida ESP32 → USB-OUT)
El ESP32 convierte los PGNs del backbone a estas sentencias y las emite
por todos los puertos OUT cada vez que recibe datos nuevos del bus.
| Sentencia | Contenido | PGN fuente | Frecuencia |
|-----------|-----------|------------|------------|
| `$IIHDT,x.x,T*XX` | Heading verdadero | 127250 | 10 Hz |
| `$IIROT,x.x,A*XX` | Rate of turn (°/min) | 127251 | 10 Hz |
| `$IIVHW,,,x.x,N,x.x,K*XX` | Velocidad agua | 128259 | 1 Hz |
| `$IIDPT,x.x,0.0*XX` | Profundidad | 128267 | 1 Hz |
| `$GPGLL,x.x,N,x.x,W,hhmmss,A*XX` | Posición GPS | 129025 | 1 Hz |
| `$GPRMC,...*XX` | GPS completo (COG, SOG) | 129026 | 1 Hz |
| `$IIMTW,x.x,C*XX` | Temperatura agua | 130310 | 0.5 Hz |
| `$IIMWV,...*XX` | Viento | 130306 | 1 Hz |
---
## Sentencias propietarias AR-Autopilot (salida ESP32 → USB-OUT)
Prefijo `$PARP` (P=Proprietary, ARP=AR-Pilot).
### Estado del autopilot (broadcast continuo, 2 Hz)
```
$PARP,STATUS,<modo>,<setpoint>,<heading>,<rudder>,<commander>*XX
```
| Campo | Valores |
|-------|---------|
| modo | `STANDBY` `HEADING_HOLD` `TRACK` `ALARM` |
| setpoint | heading objetivo en grados (000.0359.9) |
| heading | heading actual en grados |
| rudder | posición timón en grados (-35.0 a +35.0, + = estribor) |
| commander | ID estación con el mando (0104, 00 = nadie) |
Ejemplo:
```
$PARP,STATUS,HEADING_HOLD,045.5,044.8,-3.2,01*4F
```
### Estado de transferencia de mando (broadcast al ocurrir)
```
$PARP,CMDTRANSFER,<desde>,<hacia>*XX
$PARP,CMDREQUEST,<estacion>*XX
```
Ejemplos:
```
$PARP,CMDTRANSFER,01,02*3A ← el puente cedió el mando al cockpit
$PARP,CMDREQUEST,02*1B ← cockpit pide el mando (pendiente de confirmación)
```
---
## Sentencias propietarias AR-Autopilot (entrada USB-IN → ESP32)
El ESP32 solo acepta estas sentencias de la estación que tiene el mando,
excepto `TAKECMD` y `REQCMD` que tienen reglas propias.
### Formato general
```
$PARP,<COMANDO>,<parametro>,<station_id>*XX\r\n
```
### Comandos de control del autopilot
| Sentencia | Acción | → PGN NMEA 2000 |
|-----------|--------|-----------------|
| `$PARP,ENGAGE,000.0,01*XX` | Activar autopilot en heading actual | PGN 127237 mode=heading_hold |
| `$PARP,DISENGAGE,000.0,01*XX` | Desactivar autopilot | PGN 127237 mode=standby |
| `$PARP,SETHEADING,045.5,01*XX` | Fijar heading objetivo | PGN 127237 commanded_heading |
| `$PARP,PORTONE,000.0,01*XX` | Girar 1° a babor | PGN 127237 (setpoint -= 1°) |
| `$PARP,STBDONE,000.0,01*XX` | Girar 1° a estribor | PGN 127237 (setpoint += 1°) |
| `$PARP,PORTTEN,000.0,01*XX` | Girar 10° a babor | PGN 127237 (setpoint -= 10°) |
| `$PARP,STBDTEN,000.0,01*XX` | Girar 10° a estribor | PGN 127237 (setpoint += 10°) |
### Comandos de transferencia de mando
| Sentencia | Acción | Regla |
|-----------|--------|-------|
| `$PARP,REQCMD,000.0,02*XX` | Solicitar el mando | Avisa al commander actual; auto-transfer en 10s si no responde |
| `$PARP,RELCMD,000.0,01*XX` | Ceder el mando voluntariamente | Solo el commander actual |
| `$PARP,TAKECMD,000.0,01*XX` | Tomar el mando por override | Solo estación 01 (puente); inmediato, sin confirmación |
| `$PARP,ACKCMD,000.0,01*XX` | Confirmar o denegar solicitud de mando | Solo el commander actual |
| `$PARP,DENYCMD,000.0,01*XX` | Denegar solicitud de mando | Solo el commander actual |
---
## Protocolo de transferencia de mando
```
Estado normal: Puente (01) tiene el mando
Cockpit quiere mando:
Cockpit ──► $PARP,REQCMD,000.0,02*XX
ESP32 ──► broadcast $PARP,CMDREQUEST,02*XX (todos se enteran)
┌────────┴────────┐
Puente confirma Puente deniega Puente no responde (10s)
│ │ │
$PARP,RELCMD,02 $PARP,DENYCMD,02 auto-transfer
│ │ │
commander = 02 commander = 01 commander = 02
$PARP,CMDTRANSFER,01,02*XX $PARP,CMDTRANSFER,01,02*XX
Override del puente (siempre posible):
Puente ──► $PARP,TAKECMD,000.0,01*XX
ESP32 ──► commander = 01 inmediatamente
ESP32 ──► broadcast $PARP,CMDTRANSFER,XX,01*XX
```
---
## Mapeo NMEA 0183 → NMEA 2000
| Sentencia IN | PGN generado | Descripción PGN |
|-------------|-------------|-----------------|
| `$PARP,ENGAGE` | 127237 | Heading/Track Control — mode: heading_hold |
| `$PARP,DISENGAGE` | 127237 | Heading/Track Control — mode: standby |
| `$PARP,SETHEADING` | 127237 | commanded_heading field |
| `$PARP,PORT*` / `$PARP,STBD*` | 127237 | commanded_heading ± delta |
## Mapeo NMEA 2000 → NMEA 0183
| PGN recibido | Sentencia generada |
|-------------|-------------------|
| 127250 — Vessel Heading | `$IIHDT` |
| 127251 — Rate of Turn | `$IIROT` |
| 127237 — Heading/Track Control | `$PARP,STATUS` |
| 128259 — Speed Through Water | `$IIVHW` |
| 128267 — Water Depth | `$IIDPT` |
| 129025 — Position Rapid | `$GPGLL` |
| 129026 — COG & SOG | `$GPRMC` |
| 130310 — Water Temperature | `$IIMTW` |
| 130306 — Wind | `$IIMWV` |
---
## Cálculo de checksum NMEA
```python
def nmea_checksum(sentence: str) -> str:
"""sentence: contenido entre $ y * (sin incluir ambos)"""
chk = 0
for c in sentence:
chk ^= ord(c)
return f"{chk:02X}"
# Ejemplo:
# sentence = "PARP,STATUS,HEADING_HOLD,045.5,044.8,-3.2,01"
# checksum = "4F"
# resultado = "$PARP,STATUS,HEADING_HOLD,045.5,044.8,-3.2,01*4F\r\n"
```
---
## Seguridad y validación en el ESP32
1. **Checksum inválido** → descartar silenciosamente, no ejecutar
2. **Comando de estación sin mando** → descartar, emitir `$PARP,STATUS` con commander actual
3. **Heading fuera de rango** (< 0° o > 359.9°) → normalizar a rango válido
4. **Pérdida de datos del backbone** (> 3s sin PGNs) → emitir alarma `$PARP,STATUS,ALARM,...`
5. **Desconexión USB-IN** → no afecta la operación; el mando permanece asignado
6. **Power-on**: `commander = 00` (nadie), autopilot en STANDBY
---
## Ejemplo de sesión completa
```
[t=0s] OUT → $PARP,STATUS,STANDBY,000.0,182.3,+0.0,00*XX
[t=1s] IN1 ← $PARP,REQCMD,000.0,01*XX (puente toma el mando)
[t=1s] OUT → $PARP,CMDTRANSFER,00,01*XX
[t=2s] OUT → $PARP,STATUS,STANDBY,000.0,182.5,+0.0,01*XX
[t=3s] IN1 ← $PARP,ENGAGE,000.0,01*XX
[t=3s] OUT → $PARP,STATUS,HEADING_HOLD,182.5,182.5,+0.0,01*XX
[t=5s] IN1 ← $PARP,STBDTEN,000.0,01*XX
[t=5s] OUT → $PARP,STATUS,HEADING_HOLD,192.5,183.1,+2.5,01*XX
[t=10s] IN2 ← $PARP,REQCMD,000.0,02*XX (cockpit pide mando)
[t=10s] OUT → $PARP,CMDREQUEST,02*XX
[t=15s] IN1 ← $PARP,RELCMD,000.0,01*XX (puente lo cede)
[t=15s] OUT → $PARP,CMDTRANSFER,01,02*XX
[t=16s] IN2 ← $PARP,SETHEADING,200.0,02*XX
[t=16s] OUT → $PARP,STATUS,HEADING_HOLD,200.0,193.4,+3.8,02*XX
```
@@ -51,6 +51,17 @@ NavDataSnapshot g_nav_data{
.valid = false,
};
HeadingControlSnapshot g_htc{
.commanded_heading_deg = NAN,
.mode = HtcMode::UNKNOWN,
.source_addr = 0xFF,
.age_ms = 0,
.valid = false,
};
// Our own NMEA2000 source address — filter out self-echoes.
constexpr uint8_t OWN_SOURCE_ADDR = 25;
float rad_to_deg_pos(float rad) {
float d = rad * (180.0f / (float)M_PI);
// Normalise to 0..360.
@@ -155,12 +166,67 @@ void HandleNavData(const tN2kMsg& msg) {
AR_LOGV(TAG, "PGN 129284 XTE=%.2f m DTW=%.0f m", xte_m, dtw_m);
}
void HandleHeadingControl(const tN2kMsg& msg) {
// Ignore our own broadcasts — concentrador uses a different source address.
if (msg.Source == OWN_SOURCE_ADDR) return;
tN2kOnOff rud_lim, off_hdg, off_trk, override_st;
tN2kSteeringMode steering_mode;
tN2kTurnMode turn_mode;
tN2kHeadingReference hdg_ref;
tN2kRudderDirectionOrder cmd_rud_dir;
double cmd_rud_angle = N2kDoubleNA;
double hdg_to_steer = N2kDoubleNA;
double track = N2kDoubleNA;
double rud_limit = N2kDoubleNA;
double off_hdg_limit = N2kDoubleNA;
double radius = N2kDoubleNA;
double rot_order = N2kDoubleNA;
double xte_lim = N2kDoubleNA;
double vessel_hdg = N2kDoubleNA;
if (!ParseN2kHeadingTrackControl(msg,
rud_lim, off_hdg, off_trk, override_st,
steering_mode, turn_mode, hdg_ref,
cmd_rud_dir, cmd_rud_angle,
hdg_to_steer, track,
rud_limit, off_hdg_limit, radius, rot_order,
xte_lim, vessel_hdg)) {
return;
}
// Map NMEA2000 steering mode to our internal enum.
HtcMode our_mode;
switch (steering_mode) {
case N2kSM_HeadingControl: our_mode = HtcMode::HEADING_HOLD; break;
case N2kSM_MainSteering: our_mode = HtcMode::STANDBY; break;
default: return; // ignore unsupported modes
}
const float hdg_deg = (hdg_to_steer < 1e8)
? rad_to_deg_pos((float)hdg_to_steer)
: NAN;
const uint32_t now = millis();
portENTER_CRITICAL(&g_mux);
g_htc.commanded_heading_deg = hdg_deg;
g_htc.mode = our_mode;
g_htc.source_addr = msg.Source;
g_htc.age_ms = now;
g_htc.valid = true;
portEXIT_CRITICAL(&g_mux);
AR_LOGI(TAG, "PGN 127237 src=%d mode=%d hdg=%.2f deg",
msg.Source, (int)our_mode, hdg_deg);
}
void MessageHandler(const tN2kMsg& msg) {
switch (msg.PGN) {
case 127250L: HandleHeading(msg); break;
case 127251L: HandleROT(msg); break;
case 129026L: HandleCogSog(msg); break;
case 129284L: HandleNavData(msg); break;
case 127237L: HandleHeadingControl(msg); break;
case 127250L: HandleHeading(msg); break;
case 127251L: HandleROT(msg); break;
case 129026L: HandleCogSog(msg); break;
case 129284L: HandleNavData(msg); break;
default: break;
}
}
@@ -186,6 +252,11 @@ void RxTask(void* /*pv*/) {
if (g_nav_data.valid && (now - g_nav_data.age_ms) > STALE_THRESHOLD_MS) {
g_nav_data.valid = false;
}
// HTC commands have a shorter stale window (3 s) — if the remote stops
// sending, we don't want to keep acting on an old command.
if (g_htc.valid && (now - g_htc.age_ms) > 3000U) {
g_htc.valid = false;
}
portEXIT_CRITICAL(&g_mux);
// 100 Hz polling is plenty -- the CAN driver buffers incoming frames.
vTaskDelay(pdMS_TO_TICKS(10));
@@ -262,4 +333,16 @@ bool nmea2000_cog_is_stale() {
return !nmea2000_cog_sog().valid;
}
HeadingControlSnapshot nmea2000_htc() {
HeadingControlSnapshot copy;
portENTER_CRITICAL(&g_mux);
copy = g_htc;
portEXIT_CRITICAL(&g_mux);
return copy;
}
bool nmea2000_htc_is_stale() {
return !nmea2000_htc().valid;
}
} // namespace arautopilot::protocols::nmea2000
@@ -53,6 +53,25 @@ struct NavDataSnapshot {
bool valid; ///< fresh (<5 s) and non-NaN
};
// ---------------------------------------------------------------------------
// PGN 127237 -- Heading Track Control (incoming command from concentrador)
// ---------------------------------------------------------------------------
enum class HtcMode : uint8_t {
STANDBY = 0, ///< Autopilot off / manual steering
HEADING_HOLD = 1, ///< Hold commanded heading
TRACK = 2, ///< Track to waypoint (NMEA route)
UNKNOWN = 0xFF,
};
struct HeadingControlSnapshot {
float commanded_heading_deg; ///< 0..360 (NAN if not set)
HtcMode mode; ///< desired autopilot mode
uint8_t source_addr; ///< NMEA2000 source address of sender
uint32_t age_ms; ///< millis() at last 127237 update
bool valid; ///< fresh (<3 s) and parsed correctly
};
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
@@ -78,4 +97,11 @@ bool nmea2000_is_stale();
/// True if COG/SOG age exceeds 5 s or data was never received.
bool nmea2000_cog_is_stale();
/// Thread-safe read of the latest Heading Track Control command (PGN 127237).
/// Returns the last command received from an external concentrador.
HeadingControlSnapshot nmea2000_htc();
/// True if no PGN 127237 has been received in the last 3 s.
bool nmea2000_htc_is_stale();
} // namespace arautopilot::protocols::nmea2000
+45
View File
@@ -0,0 +1,45 @@
; =============================================================================
; AR BNO085 Compact Node v1 -- firmware build configuration
; =============================================================================
;
; Target hardware: AR-BNO085-NODE v1.0 (ESP32-DOWD, compact board)
; Role: NMEA 2000 IMU node -- publishes heading (PGN 127250)
; and rate-of-turn (PGN 127251) from BNO085 sensor.
;
; Pinout:
; GPIO21 -- I2C SDA (BNO085)
; GPIO22 -- I2C SCL (BNO085)
; GPIO34 -- BNO085 INT (data-ready, active low, input-only)
; GPIO13 -- BNO085 NRST (output, active low, drive LOW to reset)
; GPIO23 -- CAN TX (MCP2562T)
; GPIO4 -- CAN RX (MCP2562T)
; =============================================================================
[platformio]
src_dir = src
default_envs = esp32-dev
[env]
platform = espressif32@^6.7.0
framework = arduino
monitor_speed = 115200
monitor_filters = esp32_exception_decoder, time
build_flags =
-std=gnu++17
-DCORE_DEBUG_LEVEL=3
-DAR_FW_VERSION=\"1.0.0\"
-Wall
-Wno-unused-parameter
build_unflags =
-std=gnu++11
lib_deps =
ttlappalainen/NMEA2000-library@^4.22.0
ttlappalainen/NMEA2000_esp32@^1.0.3
sparkfun/SparkFun BNO08x Cortex Based IMU@^1.0.3
[env:esp32-dev]
board = esp32dev
build_type = release
build_flags =
${env.build_flags}
-Os
+182
View File
@@ -0,0 +1,182 @@
// =============================================================================
// AR BNO085 Compact Node v1 — main.cpp
// =============================================================================
//
// Reads heading (ARVR Stabilized Rotation Vector) and yaw rate (Gyroscope)
// from the BNO085 via I2C and publishes them on the NMEA 2000 backbone:
// PGN 127250 — Vessel Heading (100 Hz)
// PGN 127251 — Rate of Turn (100 Hz)
//
// The main autopilot board (ar_autopilot_v1) receives these PGNs from the
// backbone via its nmea2000_consumer and feeds them into the PID outer loop.
// =============================================================================
#include <Arduino.h>
#include <Wire.h>
// --- NMEA 2000 ---
#define ESP32_CAN_TX_PIN GPIO_NUM_23
#define ESP32_CAN_RX_PIN GPIO_NUM_4
#include <NMEA2000_CAN.h>
#include <N2kMessages.h>
// --- BNO085 ---
#include <SparkFun_BNO08x_Arduino_Library.h>
// =============================================================================
// Pin definitions
// =============================================================================
static constexpr int PIN_I2C_SDA = 21;
static constexpr int PIN_I2C_SCL = 22;
static constexpr int PIN_BNO_INT = 34; // data-ready, active low
static constexpr int PIN_BNO_NRST = 13; // reset, active low
// =============================================================================
// BNO085 instance
// =============================================================================
static BNO08x imu;
// Latest measurements (updated from BNO085 reports)
static volatile float g_heading_rad = 0.0f; // true heading, radians
static volatile float g_rot_rad_s = 0.0f; // yaw rate, rad/s (+ve = stbd)
static volatile bool g_heading_valid = false;
static volatile bool g_rot_valid = false;
// =============================================================================
// NMEA 2000 setup
// =============================================================================
static void nmea2000_init() {
NMEA2000.SetProductInformation(
"AR-BNO-1",
200,
"AR-BNO085-NODE v1",
"1.0.0",
"AR-BNO085-NODE v1.0"
);
NMEA2000.SetDeviceInformation(
2, // Unique number (different from autopilot node = 1)
140, // Device function: Attitude sensor
60, // Device class: Navigation
2046 // Manufacturer code (test)
);
NMEA2000.SetMode(tNMEA2000::N2km_NodeOnly, 26);
NMEA2000.EnableForward(false);
NMEA2000.Open();
}
// =============================================================================
// BNO085 initialisation
// =============================================================================
static bool bno085_init() {
// Hard reset — hold NRST low for 10 ms, then release.
pinMode(PIN_BNO_NRST, OUTPUT);
digitalWrite(PIN_BNO_NRST, LOW);
delay(15);
digitalWrite(PIN_BNO_NRST, HIGH);
delay(100); // wait for BNO085 startup (~50 ms typical)
Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL);
Wire.setClock(400000); // 400 kHz Fast Mode
if (!imu.begin(0x4A, Wire, PIN_BNO_INT)) {
Serial.println("[BNO085] begin() failed — check wiring / I2C address");
return false;
}
// ARVR Stabilized Rotation Vector → heading (tilt-compensated), 100 Hz
if (!imu.enableARVRStabilizedRotationVector(10000)) { // 10 ms = 100 Hz
Serial.println("[BNO085] enableARVRStabilizedRotationVector() failed");
return false;
}
// Calibrated Gyroscope → yaw rate, 100 Hz
if (!imu.enableGyro(10000)) {
Serial.println("[BNO085] enableGyro() failed");
return false;
}
Serial.println("[BNO085] init OK — heading + yaw rate @ 100 Hz");
return true;
}
// =============================================================================
// Read BNO085 reports (call frequently)
// =============================================================================
static void bno085_read() {
if (!imu.dataAvailable()) return;
// Rotation Vector → heading (yaw around Z)
if (imu.getSensorEventID() == SENSOR_REPORTID_ARVR_STABILIZED_ROTATION_VECTOR) {
// SparkFun library: getYaw() returns yaw in radians, -pi..+pi
const float yaw = imu.getYaw();
// Convert to 0..2pi (nautical convention, 0 = North, + = clockwise)
float hdg = -yaw; // BNO085: +yaw = CCW (math convention); nautical = CW
if (hdg < 0.0f) hdg += 2.0f * (float)M_PI;
if (hdg >= 2.0f * (float)M_PI) hdg -= 2.0f * (float)M_PI;
g_heading_rad = hdg;
g_heading_valid = true;
}
// Gyroscope Z → yaw rate (+ = clockwise = starboard turn)
if (imu.getSensorEventID() == SENSOR_REPORTID_GYROSCOPE_CALIBRATED) {
// getGyroZ(): rad/s, +Z = looking down = CW from above = starboard
g_rot_rad_s = imu.getGyroZ();
g_rot_valid = true;
}
}
// =============================================================================
// Publish NMEA 2000 PGNs
// =============================================================================
static void publish_pgns() {
if (g_heading_valid) {
tN2kMsg msg;
SetN2kHeading(msg,
0, // SID
(double)g_heading_rad,
N2kDoubleNA, // Deviation
N2kDoubleNA, // Variation
N2khr_true); // Reference: true north (BNO085 is absolute)
NMEA2000.SendMsg(msg);
}
if (g_rot_valid) {
tN2kMsg msg;
// PGN 127251: rate in rad/s
SetN2kRateOfTurn(msg, 0, (double)g_rot_rad_s);
NMEA2000.SendMsg(msg);
}
}
// =============================================================================
// Arduino entry points
// =============================================================================
void setup() {
Serial.begin(115200);
Serial.println("[AR-BNO085-NODE] booting...");
nmea2000_init();
if (!bno085_init()) {
Serial.println("[AR-BNO085-NODE] FATAL: IMU init failed. Halting.");
for (;;) delay(1000);
}
Serial.println("[AR-BNO085-NODE] ready.");
}
void loop() {
// Read sensor at ~100 Hz (non-blocking, interrupt-driven via INT pin)
bno085_read();
// Publish to NMEA 2000 backbone at 10 Hz
static uint32_t last_pub = 0;
const uint32_t now = millis();
if (now - last_pub >= 100) {
last_pub = now;
publish_pgns();
}
// Keep the NMEA2000 stack alive
NMEA2000.ParseMessages();
}
@@ -0,0 +1,52 @@
; =============================================================================
; AR Concentrador NMEA2000-USB v1 -- firmware build configuration
; =============================================================================
;
; Target hardware: AR-CONCENTRADOR v1.0 (ESP32-DOWD)
; Role: Bidirectional NMEA 2000 <-> NMEA 0183 USB gateway.
;
; UART1 TX (GPIO17) → 4x CH340N → USB-OUT1..4 (broadcast NMEA 0183 data)
; UART2 RX (GPIO16) ← 4x CH340N ← USB-IN1..4 (receive $PARP commands)
; CAN TX (GPIO21) → MCP2562T → NMEA 2000 backbone
; CAN RX (GPIO22) ← MCP2562T ← NMEA 2000 backbone
;
; Protocol: docs/concentrador_protocol.md
; =============================================================================
[platformio]
src_dir = src
default_envs = esp32-dev
[env]
platform = espressif32@^6.7.0
framework = arduino
monitor_speed = 115200
monitor_filters = esp32_exception_decoder, time
build_flags =
-std=gnu++17
-DCORE_DEBUG_LEVEL=3
-DAR_FW_VERSION=\"1.0.0\"
-Wall
-Wno-unused-parameter
-Wno-missing-field-initializers
build_unflags =
-std=gnu++11
lib_deps =
ttlappalainen/NMEA2000-library@^4.22.0
ttlappalainen/NMEA2000_esp32@^1.0.3
[env:esp32-dev]
board = esp32dev
build_type = release
build_flags =
${env.build_flags}
-Os
[env:esp32-debug]
board = esp32dev
build_type = debug
build_flags =
${env.build_flags}
-O0
-ggdb
-DCORE_DEBUG_LEVEL=5
+188
View File
@@ -0,0 +1,188 @@
// =============================================================================
// AR Concentrador NMEA2000-USB v1 — main.cpp
// =============================================================================
//
// Bidirectional NMEA 2000 <-> NMEA 0183 USB gateway with multi-station
// command authority management.
//
// Hardware:
// UART1 TX (GPIO17) → 4x CH340N → USB-OUT1..4 (broadcast NMEA 0183)
// UART2 RX (GPIO16) ← 4x CH340N ← USB-IN1..4 (receive $PARP commands)
// CAN TX (GPIO21) → MCP2562T → NMEA 2000 backbone
// CAN RX (GPIO22) ← MCP2562T ← NMEA 2000 backbone
//
// Protocol: docs/concentrador_protocol.md
// =============================================================================
#include <Arduino.h>
#define ESP32_CAN_TX_PIN GPIO_NUM_21
#define ESP32_CAN_RX_PIN GPIO_NUM_22
#include <NMEA2000_CAN.h>
#include <N2kMessages.h>
#include "protocols/n2k_bridge.h"
#include "protocols/nmea0183_parser.h"
#include "station/station_mgr.h"
using namespace arconcentrador::protocols;
using namespace arconcentrador::station;
// =============================================================================
// Serial port configuration
// =============================================================================
#define UART_OUT Serial1 // UART1 TX → CH340N OUT ports
#define UART_IN Serial2 // UART2 RX ← CH340N IN ports
static constexpr int PIN_UART1_TX = 17;
static constexpr int PIN_UART1_RX = -1; // not used (TX only)
static constexpr int PIN_UART2_TX = -1; // not used (RX only)
static constexpr int PIN_UART2_RX = 16;
static constexpr int UART_BAUD = 115200;
// =============================================================================
// Broadcast helpers
// =============================================================================
/// Write a sentence to all OUT ports (UART1 TX).
static void broadcast(const char* sentence) {
if (sentence && sentence[0]) {
UART_OUT.print(sentence);
}
}
/// Build and broadcast the $PARP,STATUS sentence (2 Hz heartbeat).
static void broadcast_status(uint8_t commander) {
const ApStatus ap = n2k_ap_status();
const float hdg = n2k_latest_heading_deg();
const char* mode_str = "STANDBY";
if (ap.valid) {
if (ap.mode == 1) mode_str = "HEADING_HOLD";
else if (ap.mode == 2) mode_str = "TRACK";
}
char body[128];
snprintf(body, sizeof(body),
"PARP,STATUS,%s,%.1f,%.1f,%.1f,%02d",
mode_str,
ap.valid ? ap.commanded_deg : 0.0f,
hdg,
ap.valid ? ap.rudder_deg : 0.0f,
commander);
char sentence[160];
uint8_t crc = 0;
for (const char* p = body; *p; ++p) crc ^= (uint8_t)*p;
snprintf(sentence, sizeof(sentence), "$%s*%02X\r\n", body, crc);
broadcast(sentence);
}
// =============================================================================
// NMEA 2000 message handler (pumped by NMEA2000.ParseMessages())
// =============================================================================
static const uint32_t PGNS_TO_FORWARD[] = {
127250L, // Heading
127251L, // ROT
129026L, // COG/SOG
128267L, // Depth
130310L, // Water temp
0
};
static void on_n2k_message(const tN2kMsg& msg) {
// Forward selected PGNs to all USB-OUT ports as NMEA 0183 sentences.
for (int i = 0; PGNS_TO_FORWARD[i]; ++i) {
if (msg.PGN == PGNS_TO_FORWARD[i]) {
char buf[128];
if (n2k_to_0183(msg, buf, sizeof(buf))) {
broadcast(buf);
}
break;
}
}
}
// =============================================================================
// Command processor (reads from UART2 RX)
// =============================================================================
static void process_uart_in() {
while (UART_IN.available()) {
const char c = (char)UART_IN.read();
ParpCommand cmd{};
if (!parp_feed(c, cmd)) continue;
// --- Station management commands ---
char station_broadcast[128];
station_process(cmd.cmd, cmd.station_id,
station_broadcast, sizeof(station_broadcast));
if (station_broadcast[0]) {
broadcast(station_broadcast);
}
// --- Autopilot commands (only from current commander) ---
const uint8_t cmdr = current_commander();
const bool is_commander = (cmdr == cmd.station_id);
const bool is_ap_cmd = (strcmp(cmd.cmd, "ENGAGE") == 0 ||
strcmp(cmd.cmd, "DISENGAGE") == 0 ||
strcmp(cmd.cmd, "SETHEADING") == 0 ||
strcmp(cmd.cmd, "PORTONE") == 0 ||
strcmp(cmd.cmd, "STBDONE") == 0 ||
strcmp(cmd.cmd, "PORTTEN") == 0 ||
strcmp(cmd.cmd, "STBDTEN") == 0);
if (is_ap_cmd && is_commander) {
const float hdg = n2k_latest_heading_deg();
parp_to_n2k(cmd, hdg);
Serial.printf("[MAIN] cmd %s val=%.1f sta=%d → NMEA2000\n",
cmd.cmd, cmd.value, cmd.station_id);
} else if (is_ap_cmd && !is_commander) {
Serial.printf("[MAIN] cmd %s rechazado: sta=%d no tiene el mando (mando=%d)\n",
cmd.cmd, cmd.station_id, cmdr);
}
}
}
// =============================================================================
// Arduino entry points
// =============================================================================
void setup() {
Serial.begin(115200);
Serial.println("[AR-CONCENTRADOR] booting...");
// UART1: TX only (broadcast to CH340N OUT ports)
UART_OUT.begin(UART_BAUD, SERIAL_8N1, PIN_UART1_RX, PIN_UART1_TX);
// UART2: RX only (receive from CH340N IN ports)
UART_IN.begin(UART_BAUD, SERIAL_8N1, PIN_UART2_RX, PIN_UART2_TX);
// Station manager
station_init();
// NMEA 2000 bridge
n2k_bridge_init();
NMEA2000.SetMsgHandler(on_n2k_message);
Serial.println("[AR-CONCENTRADOR] ready.");
Serial.println("[AR-CONCENTRADOR] UART1 TX=GPIO17 (OUT) | UART2 RX=GPIO16 (IN)");
Serial.println("[AR-CONCENTRADOR] CAN TX=GPIO21 | CAN RX=GPIO22");
}
void loop() {
// 1. Pump NMEA 2000 (calls on_n2k_message for each received PGN)
NMEA2000.ParseMessages();
// 2. Process incoming $PARP commands from USB-IN ports
process_uart_in();
// 3. Station management tick (REQCMD timeout)
station_tick();
// 4. Broadcast autopilot status at 2 Hz
static uint32_t last_status = 0;
const uint32_t now = millis();
if (now - last_status >= 500) {
last_status = now;
broadcast_status(current_commander());
}
}
@@ -0,0 +1,279 @@
// =============================================================================
// protocols/n2k_bridge.cpp -- NMEA 2000 <-> NMEA 0183 conversion
// =============================================================================
#include "n2k_bridge.h"
#include <Arduino.h>
#include <NMEA2000_CAN.h>
#include <N2kMessages.h>
#include <cstring>
#include <cstdio>
#include <cmath>
namespace arconcentrador::protocols {
namespace {
// ---------------------------------------------------------------------------
// Shared state (updated by PGN handlers, read by main loop)
// ---------------------------------------------------------------------------
static float g_heading_deg = 0.0f;
static bool g_heading_valid = false;
static float g_rot_dps = 0.0f;
static float g_sog_kn = NAN;
static float g_cog_deg = NAN;
static float g_depth_m = NAN;
static float g_water_temp_c = NAN;
static ApStatus g_ap_status{};
static portMUX_TYPE g_mux = portMUX_INITIALIZER_UNLOCKED;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
static float rad2deg_pos(double rad) {
float d = (float)(rad * 180.0 / M_PI);
while (d < 0.0f) d += 360.0f;
while (d >= 360.0f) d -= 360.0f;
return d;
}
static uint8_t nmea_crc(const char* body) {
uint8_t crc = 0;
for (; *body; ++body) crc ^= (uint8_t)*body;
return crc;
}
static int write_sentence(char* buf, size_t len, const char* body) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "%s", body);
const uint8_t crc = nmea_crc(tmp);
return snprintf(buf, len, "$%s*%02X\r\n", body, crc);
}
// ---------------------------------------------------------------------------
// PGN handlers (called from NMEA2000.ParseMessages())
// ---------------------------------------------------------------------------
static void on_heading(const tN2kMsg& msg) {
unsigned char sid;
double hdg = 0.0, dev = 0.0, var = 0.0;
tN2kHeadingReference ref;
if (!ParseN2kHeading(msg, sid, hdg, dev, var, ref)) return;
if (hdg > 1e8) return;
portENTER_CRITICAL(&g_mux);
g_heading_deg = rad2deg_pos(hdg);
g_heading_valid = true;
portEXIT_CRITICAL(&g_mux);
}
static void on_rot(const tN2kMsg& msg) {
unsigned char sid;
double rot = 0.0;
if (!ParseN2kRateOfTurn(msg, sid, rot)) return;
if (rot > 1e8 || rot < -1e8) return;
// PGN 127251 is in rad/s; NMEA 0183 ROT sentence uses deg/min
portENTER_CRITICAL(&g_mux);
g_rot_dps = (float)(rot * 180.0 / M_PI);
portEXIT_CRITICAL(&g_mux);
}
static void on_cog_sog(const tN2kMsg& msg) {
unsigned char sid;
tN2kHeadingReference ref;
double cog = 0.0, sog = 0.0;
if (!ParseN2kCOGSOGRapid(msg, sid, ref, cog, sog)) return;
if (cog > 1e8 || sog > 1e8) return;
portENTER_CRITICAL(&g_mux);
g_cog_deg = rad2deg_pos(cog);
g_sog_kn = (float)(sog * 1.94384f);
portEXIT_CRITICAL(&g_mux);
}
static void on_depth(const tN2kMsg& msg) {
unsigned char sid;
double depth = 0.0, offset = 0.0, range = 0.0;
if (!ParseN2kWaterDepth(msg, sid, depth, offset, range)) return;
if (depth > 1e8) return;
portENTER_CRITICAL(&g_mux);
g_depth_m = (float)depth;
portEXIT_CRITICAL(&g_mux);
}
static void on_water_temp(const tN2kMsg& msg) {
unsigned char sid;
tN2kTempSource src;
double temp = 0.0, set_temp = 0.0;
if (!ParseN2kTemperature(msg, sid, src, temp, set_temp)) return;
if (src != N2kts_SeaTemperature) return;
if (temp > 1e8) return;
portENTER_CRITICAL(&g_mux);
g_water_temp_c = (float)(temp - 273.15); // K → °C
portEXIT_CRITICAL(&g_mux);
}
static void on_htc(const tN2kMsg& msg) {
// PGN 127237 from the autopilot: its current status.
tN2kOnOff rl, ol, tl, ov;
tN2kSteeringMode sm;
tN2kTurnMode tm;
tN2kHeadingReference hr;
tN2kRudderDirectionOrder rdo;
double cra = 0, hts = 0, trk = 0, rlim = 0, ohl = 0;
double rot_ord = 0, xtel = 0, vhd = 0;
if (!ParseN2kHeadingTrackControl(msg, rl, ol, tl, ov, sm, tm, hr,
rdo, cra, hts, trk, rlim, ohl,
rot_ord, rot_ord, xtel, vhd)) return;
ApStatus s{};
s.heading_deg = (vhd < 1e8) ? rad2deg_pos(vhd) : g_heading_deg;
s.commanded_deg = (hts < 1e8) ? rad2deg_pos(hts) : 0.0f;
s.rudder_deg = (cra < 1e8) ? (float)(cra * 180.0 / M_PI) : 0.0f;
s.mode = (sm == N2kSM_HeadingControl) ? 1 :
(sm == N2kSM_TrackControl) ? 2 : 0;
s.valid = true;
portENTER_CRITICAL(&g_mux);
g_ap_status = s;
portEXIT_CRITICAL(&g_mux);
}
static void MessageHandler(const tN2kMsg& msg) {
switch (msg.PGN) {
case 127250L: on_heading(msg); break;
case 127251L: on_rot(msg); break;
case 127237L: on_htc(msg); break;
case 128267L: on_depth(msg); break;
case 129026L: on_cog_sog(msg); break;
case 130310L: on_water_temp(msg); break;
default: break;
}
}
} // namespace
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
void n2k_bridge_init() {
NMEA2000.SetProductInformation("AR-CONC-1", 300,
"AR-Concentrador v1", "1.0.0", "AR-CONCENTRADOR v1.0");
NMEA2000.SetDeviceInformation(3, 170, 25, 2046);
NMEA2000.SetMode(tNMEA2000::N2km_ListenAndNode, 27);
NMEA2000.EnableForward(false);
NMEA2000.SetMsgHandler(&MessageHandler);
NMEA2000.Open();
}
bool n2k_to_0183(const tN2kMsg& msg, char* out_buf, size_t out_len) {
char body[200];
portENTER_CRITICAL(&g_mux);
const float hdg = g_heading_deg;
const float rot = g_rot_dps;
const float sog = g_sog_kn;
const float cog = g_cog_deg;
const float dep = g_depth_m;
const float tmp = g_water_temp_c;
portEXIT_CRITICAL(&g_mux);
switch (msg.PGN) {
case 127250L: // Heading → $IIHDT
snprintf(body, sizeof(body), "IIHDT,%.1f,T", hdg);
write_sentence(out_buf, out_len, body);
return true;
case 127251L: // ROT → $IIROT (deg/min)
snprintf(body, sizeof(body), "IIROT,%.2f,A", rot * 60.0f);
write_sentence(out_buf, out_len, body);
return true;
case 129026L: // COG/SOG → $IIVHW
if (!isnan(sog) && !isnan(cog)) {
snprintf(body, sizeof(body),
"IIVHW,%.1f,T,,M,%.2f,N,,K", cog, sog);
write_sentence(out_buf, out_len, body);
return true;
}
break;
case 128267L: // Depth → $IIDPT
if (!isnan(dep)) {
snprintf(body, sizeof(body), "IIDPT,%.1f,0.0", dep);
write_sentence(out_buf, out_len, body);
return true;
}
break;
case 130310L: // Water temp → $IIMTW
if (!isnan(tmp)) {
snprintf(body, sizeof(body), "IIMTW,%.1f,C", tmp);
write_sentence(out_buf, out_len, body);
return true;
}
break;
default:
break;
}
return false;
}
bool parp_to_n2k(const ParpCommand& cmd, float current_heading_deg) {
// Determine the target steering mode and commanded heading.
tN2kSteeringMode mode = N2kSM_MainSteering; // STANDBY default
double heading_to_steer = N2kDoubleNA;
if (strcmp(cmd.cmd, "ENGAGE") == 0) {
mode = N2kSM_HeadingControl;
// Engage on current heading if no specific heading commanded.
heading_to_steer = (double)current_heading_deg * M_PI / 180.0;
} else if (strcmp(cmd.cmd, "DISENGAGE") == 0) {
mode = N2kSM_MainSteering;
} else if (strcmp(cmd.cmd, "SETHEADING") == 0) {
mode = N2kSM_HeadingControl;
heading_to_steer = (double)cmd.value * M_PI / 180.0;
} else if (strcmp(cmd.cmd, "PORTONE") == 0) {
mode = N2kSM_HeadingControl;
heading_to_steer = (double)(cmd.value - 1.0f) * M_PI / 180.0;
} else if (strcmp(cmd.cmd, "STBDONE") == 0) {
mode = N2kSM_HeadingControl;
heading_to_steer = (double)(cmd.value + 1.0f) * M_PI / 180.0;
} else if (strcmp(cmd.cmd, "PORTTEN") == 0) {
mode = N2kSM_HeadingControl;
heading_to_steer = (double)(cmd.value - 10.0f) * M_PI / 180.0;
} else if (strcmp(cmd.cmd, "STBDTEN") == 0) {
mode = N2kSM_HeadingControl;
heading_to_steer = (double)(cmd.value + 10.0f) * M_PI / 180.0;
} else {
return false; // station management commands — not forwarded to NMEA2000
}
tN2kMsg msg;
SetN2kHeadingTrackControl(msg,
N2kOnOff_Unavailable, N2kOnOff_Unavailable,
N2kOnOff_Unavailable, N2kOnOff_Off,
mode, N2kTM_RudderLimitControlled, N2khr_true,
N2kRDO_NoDirectionOrder, N2kDoubleNA,
heading_to_steer, N2kDoubleNA,
N2kDoubleNA, N2kDoubleNA, N2kDoubleNA, N2kDoubleNA,
N2kDoubleNA, N2kDoubleNA);
return NMEA2000.SendMsg(msg);
}
float n2k_latest_heading_deg() {
portENTER_CRITICAL(&g_mux);
const float h = g_heading_deg;
portEXIT_CRITICAL(&g_mux);
return h;
}
ApStatus n2k_ap_status() {
portENTER_CRITICAL(&g_mux);
const ApStatus s = g_ap_status;
portEXIT_CRITICAL(&g_mux);
return s;
}
} // namespace arconcentrador::protocols
@@ -0,0 +1,45 @@
// =============================================================================
// protocols/n2k_bridge.h -- NMEA 2000 <-> NMEA 0183 conversion
// =============================================================================
//
// Outbound (NMEA 2000 → NMEA 0183 for USB-OUT ports):
// Receives PGNs from the backbone and formats standard/proprietary sentences.
//
// Inbound (NMEA 0183 → NMEA 2000 for $PARP commands from USB-IN ports):
// Converts parsed ParpCommand structs into NMEA 2000 PGN 127237.
// =============================================================================
#pragma once
#include "nmea0183_parser.h"
#include <N2kMessages.h>
#include <Stream.h>
namespace arconcentrador::protocols {
/// Initialise the NMEA 2000 stack and register PGN handlers.
void n2k_bridge_init();
/// Process one incoming NMEA 2000 message.
/// Formats the corresponding NMEA 0183 sentence into out_buf.
/// Returns true if a sentence was written.
bool n2k_to_0183(const tN2kMsg& msg, char* out_buf, size_t out_len);
/// Convert a parsed $PARP command into a NMEA 2000 PGN 127237 and send it.
/// Returns true if a PGN was sent.
bool parp_to_n2k(const ParpCommand& cmd, float current_heading_deg);
/// Latest vessel heading received from backbone (degrees, for status broadcasts).
float n2k_latest_heading_deg();
/// Latest autopilot status received from backbone (PGN 127237 from autopilot).
struct ApStatus {
float heading_deg; ///< actual vessel heading
float commanded_deg; ///< heading setpoint
float rudder_deg; ///< rudder angle
uint8_t mode; ///< 0=STANDBY 1=HEADING_HOLD 2=TRACK
bool valid;
};
ApStatus n2k_ap_status();
} // namespace arconcentrador::protocols
@@ -0,0 +1,93 @@
// =============================================================================
// protocols/nmea0183_parser.cpp -- $PARP sentence parser
// =============================================================================
#include "nmea0183_parser.h"
#include <cstring>
#include <cstdio>
#include <cstdlib>
#include <Arduino.h>
namespace arconcentrador::protocols {
// ---------------------------------------------------------------------------
uint8_t nmea_checksum(const char* sentence) {
// Find content between '$' and '*'
const char* start = strchr(sentence, '$');
if (!start) return 0xFF;
start++; // skip '$'
const char* end = strchr(start, '*');
if (!end) return 0xFF;
uint8_t crc = 0;
for (const char* p = start; p < end; ++p) crc ^= (uint8_t)*p;
return crc;
}
// ---------------------------------------------------------------------------
ParpCommand parp_parse(const char* sentence) {
ParpCommand result{};
result.valid = false;
// Must start with $PARP,
if (strncmp(sentence, "$PARP,", 6) != 0) return result;
// Validate checksum
const char* star = strchr(sentence, '*');
if (!star || strlen(star) < 3) return result;
const uint8_t recv_crc = (uint8_t)strtoul(star + 1, nullptr, 16);
const uint8_t calc_crc = nmea_checksum(sentence);
if (recv_crc != calc_crc) {
Serial.printf("[PARSER] checksum error: recv=%02X calc=%02X\n",
recv_crc, calc_crc);
return result;
}
// Copy body between "$PARP," and '*'
char body[128];
const char* body_start = sentence + 6;
const size_t body_len = (size_t)(star - body_start);
if (body_len == 0 || body_len >= sizeof(body)) return result;
memcpy(body, body_start, body_len);
body[body_len] = '\0';
// Tokenize: CMD,VALUE,STATION_ID
char* saveptr = nullptr;
const char* tok_cmd = strtok_r(body, ",", &saveptr);
const char* tok_val = strtok_r(nullptr, ",", &saveptr);
const char* tok_sta = strtok_r(nullptr, ",", &saveptr);
if (!tok_cmd || !tok_val || !tok_sta) return result;
strncpy(result.cmd, tok_cmd, sizeof(result.cmd) - 1);
result.value = (float)atof(tok_val);
result.station_id = (uint8_t)atoi(tok_sta);
if (result.station_id < 1 || result.station_id > 4) return result;
result.valid = true;
return result;
}
// ---------------------------------------------------------------------------
static char s_line_buf[256];
static size_t s_line_len = 0;
bool parp_feed(char c, ParpCommand& out) {
if (c == '$') {
// Start of new sentence — reset buffer.
s_line_len = 0;
}
if (s_line_len < sizeof(s_line_buf) - 1) {
s_line_buf[s_line_len++] = c;
}
if (c == '\n') {
s_line_buf[s_line_len] = '\0';
s_line_len = 0;
out = parp_parse(s_line_buf);
return out.valid;
}
return false;
}
} // namespace arconcentrador::protocols
@@ -0,0 +1,35 @@
// =============================================================================
// protocols/nmea0183_parser.h -- $PARP sentence parser
// =============================================================================
//
// Parses incoming $PARP sentences from UART2 RX (USB-IN ports).
// Validates checksum before returning any data.
// =============================================================================
#pragma once
#include <cstdint>
#include <cstdbool>
namespace arconcentrador::protocols {
struct ParpCommand {
char cmd[16]; ///< e.g. "ENGAGE", "SETHEADING", "REQCMD"
float value; ///< numeric parameter (heading degrees, etc.)
uint8_t station_id; ///< 1-4
bool valid; ///< false if checksum failed or format invalid
};
/// Parse one null-terminated NMEA sentence (including the leading '$').
/// Returns a ParpCommand with valid=true on success.
ParpCommand parp_parse(const char* sentence);
/// Compute NMEA XOR checksum of content between '$' and '*'.
uint8_t nmea_checksum(const char* sentence);
/// Append one character to the internal line buffer.
/// When a complete sentence (\n) arrives, calls parp_parse and stores result.
/// Returns true if a complete sentence was parsed this call.
bool parp_feed(char c, ParpCommand& out);
} // namespace arconcentrador::protocols
@@ -0,0 +1,122 @@
// =============================================================================
// station/station_mgr.cpp -- Control authority state machine
// =============================================================================
#include "station_mgr.h"
#include <Arduino.h>
#include <cstring>
#include <cstdio>
namespace arconcentrador::station {
namespace {
uint8_t g_commander = STATION_NONE;
uint8_t g_pending_request = STATION_NONE; // station waiting for REQCMD ack
uint32_t g_request_time_ms = 0;
static constexpr uint32_t REQCMD_TIMEOUT_MS = 10000;
// --- NMEA checksum ---
static uint8_t nmea_crc(const char* s) {
uint8_t crc = 0;
for (; *s; ++s) crc ^= (uint8_t)*s;
return crc;
}
static void make_sentence(char* out, size_t len, const char* body) {
char tmp[128];
snprintf(tmp, sizeof(tmp), "PARP,%s", body);
const uint8_t crc = nmea_crc(tmp);
snprintf(out, len, "$PARP,%s*%02X\r\n", body, crc);
}
static void transfer_to(uint8_t new_commander,
char* out, size_t out_len) {
const uint8_t prev = g_commander;
g_commander = new_commander;
g_pending_request = STATION_NONE;
char body[64];
snprintf(body, sizeof(body), "CMDTRANSFER,%02d,%02d", prev, new_commander);
make_sentence(out, out_len, body);
Serial.printf("[STATION] mando transferido %d → %d\n", prev, new_commander);
}
} // namespace
void station_init() {
g_commander = STATION_NONE;
g_pending_request = STATION_NONE;
}
uint8_t current_commander() {
return g_commander;
}
void station_process(const char* cmd, uint8_t station_id,
char* out_broadcast, size_t out_len) {
out_broadcast[0] = '\0';
// --- TAKECMD: bridge override, always immediate ---
if (strcmp(cmd, "TAKECMD") == 0 && station_id == STATION_BRIDGE) {
transfer_to(STATION_BRIDGE, out_broadcast, out_len);
return;
}
// --- REQCMD: request command from another station ---
if (strcmp(cmd, "REQCMD") == 0) {
if (station_id == g_commander) return; // already the commander
g_pending_request = station_id;
g_request_time_ms = millis();
char body[64];
snprintf(body, sizeof(body), "CMDREQUEST,%02d", station_id);
make_sentence(out_broadcast, out_len, body);
Serial.printf("[STATION] estacion %d solicita el mando\n", station_id);
return;
}
// --- RELCMD: commander voluntarily releases ---
if (strcmp(cmd, "RELCMD") == 0 && station_id == g_commander) {
if (g_pending_request != STATION_NONE) {
transfer_to(g_pending_request, out_broadcast, out_len);
} else {
transfer_to(STATION_NONE, out_broadcast, out_len);
}
return;
}
// --- ACKCMD: commander confirms the pending request ---
if (strcmp(cmd, "ACKCMD") == 0 && station_id == g_commander) {
if (g_pending_request != STATION_NONE) {
transfer_to(g_pending_request, out_broadcast, out_len);
}
return;
}
// --- DENYCMD: commander denies the pending request ---
if (strcmp(cmd, "DENYCMD") == 0 && station_id == g_commander) {
g_pending_request = STATION_NONE;
char body[64];
snprintf(body, sizeof(body), "CMDDENIED,%02d", station_id);
make_sentence(out_broadcast, out_len, body);
Serial.printf("[STATION] solicitud de estacion %d denegada\n",
g_pending_request);
return;
}
}
void station_tick() {
if (g_pending_request == STATION_NONE) return;
if (millis() - g_request_time_ms >= REQCMD_TIMEOUT_MS) {
// Auto-transfer after 10 s with no response from commander.
Serial.printf("[STATION] timeout REQCMD → auto-transfer a estacion %d\n",
g_pending_request);
// We can't write out_broadcast here (no output buffer in tick),
// so the main loop re-broadcasts the transfer status on the next
// regular STATUS pulse.
g_commander = g_pending_request;
g_pending_request = STATION_NONE;
}
}
} // namespace arconcentrador::station
@@ -0,0 +1,37 @@
// =============================================================================
// station/station_mgr.h -- Control authority state machine
// =============================================================================
//
// Manages who has command authority over the autopilot.
// Rules (from docs/concentrador_protocol.md):
// - Station 01 (bridge) has override priority — TAKECMD is immediate.
// - Other stations request command via REQCMD, wait for ACK or timeout (10s).
// - Only the current commander can release (RELCMD) or deny requests (DENYCMD).
// =============================================================================
#pragma once
#include <cstdint>
namespace arconcentrador::station {
static constexpr uint8_t STATION_NONE = 0x00;
static constexpr uint8_t STATION_BRIDGE = 0x01; // highest priority
/// Initialise with no commander (STATION_NONE).
void station_init();
/// Return the current commander ID (0 = no commander).
uint8_t current_commander();
/// Process an incoming $PARP sentence already parsed into fields.
/// cmd is the command field (e.g. "REQCMD", "TAKECMD", "RELCMD", ...).
/// station_id is the sender's station ID (1-4).
/// out_broadcast is set to a full $PARP sentence to broadcast (or empty string).
void station_process(const char* cmd, uint8_t station_id,
char* out_broadcast, size_t out_len);
/// Call once per loop — handles the 10-second REQCMD timeout.
void station_tick();
} // namespace arconcentrador::station
+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
+18
View File
@@ -44,11 +44,29 @@ 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",
]
# AR Display Manager — multi-monitor app switcher for the Integrated Bridge System.
# Same PySide6 dep as the Studio; listed separately so it can be installed standalone.
display-manager = [
"PySide6>=6.6",
]
[project.urls]
Homepage = "https://github.com/alro65/AR-Autopilot"
+925
View File
@@ -0,0 +1,925 @@
"""
PROPÓSITO
---------
Simulación software completa del firmware ESP32 de AR-Autopilot.
POR QUÉ EXISTE
--------------
El ESP32 físico necesita CAN-bus (NMEA 2000) para recibir el compass y
RS-485 para recibir comandos Modbus. Sin hardware real ambos canales están
ausentes. Este módulo implementa en Python puro el mismo estado interno y la
misma aritmética que el firmware C++, permitiendo desarrollar y validar el
protocolo de pruebas antes de tener el barco delante.
CÓMO FUNCIONA
-------------
El simulador tiene tres capas en paralelo:
Capa física (50 Hz)
RudderSimulator PWM ángulo de timón
VesselHeadingSimulator timón × velocidad ROT rumbo
Cascada PID (outer 10 Hz, inner 50 Hz)
PidOuter error_rumbo setpoint_timón
PidInner error_timón PWM
Interfaz Modbus (diccionarios Python)
holdings escribibles por el PC (comandos)
coils escribibles por el PC (pulsos)
inputs de solo lectura (telemetría)
discretes de solo lectura (alarmas / flags)
RELACIONADO
-----------
arautopilot/studio/simulator/pid_outer.py
arautopilot/studio/simulator/pid_inner.py
arautopilot/studio/simulator/vessel_heading.py
arautopilot/studio/simulator/rudder_dynamics.py
arautopilot/shared/modbus_register_map.py
tools/sim_protocol.py
"""
from __future__ import annotations
import math
import random
import sys
from dataclasses import dataclass, field
from enum import IntEnum
from pathlib import Path
# Añadir raíz del proyecto al path para importar arautopilot.*
sys.path.insert(0, str(Path(__file__).parent.parent))
from arautopilot.studio.simulator.pid_inner import PidInner, PidInnerConfig
from arautopilot.studio.simulator.pid_outer import PidOuter, PidOuterConfig
from arautopilot.studio.simulator.rudder_dynamics import (
RudderDynamicsConfig,
RudderSimulator,
)
from arautopilot.studio.simulator.vessel_heading import (
VesselHeadingConfig,
VesselHeadingSimulator,
heading_error_deg,
)
# ---------------------------------------------------------------------------
# Constantes — mirrors de los defines del firmware
# ---------------------------------------------------------------------------
OFF_COURSE_WARN_DEG: float = 15.0
OFF_COURSE_SEVERE_DEG: float = 30.0
HEADING_TIMEOUT_S: float = 5.0
DT_INNER: float = 1.0 / 50.0 # 50 Hz inner loop
DT_OUTER: float = 1.0 / 10.0 # 10 Hz outer loop
INNER_PER_OUTER: int = 5 # inner ticks per outer tick
# ---------------------------------------------------------------------------
# Beaufort scale → wave disturbance parameters
# ---------------------------------------------------------------------------
# Each entry: (wave_torque_amp, swell_torque_amp, noise_torque_std,
# wind_bias_amp, wave_period_s)
# Torques are in °/s² (= yaw angular acceleration).
# Steady-state ROT from torque T: ROT_ss = T / yaw_damping = T / 0.8
# Heading oscillation at wave freq ω (rad/s): A_h ≈ T / (yaw_damping * ω)
# Expected peak heading oscillation for our 30 m yacht at 10 kn:
# B3 ≈ ±1° B4 ≈ ±3° B5 ≈ ±5° B6 ≈ ±8° B7 ≈ ±13°
_BEAUFORT_TABLE: dict[int, tuple[float, float, float, float, float]] = {
# B: wave swell noise wind period_s
0: (0.00, 0.00, 0.00, 0.00, 8.0),
1: (0.10, 0.05, 0.05, 0.05, 7.0),
2: (0.30, 0.10, 0.10, 0.10, 6.5),
3: (0.60, 0.20, 0.20, 0.20, 5.5),
4: (1.50, 0.40, 0.30, 0.40, 6.0),
5: (2.50, 0.70, 0.60, 0.80, 7.0),
6: (3.50, 1.00, 1.00, 1.20, 8.0),
7: (5.50, 1.50, 1.60, 2.00, 9.0),
8: (8.00, 2.00, 2.50, 3.00, 10.0),
}
class AutopilotMode(IntEnum):
"""Modos del autopiloto — idénticos al enum C++ del firmware."""
STANDBY = 0
HEADING_HOLD = 1
TRUE_COURSE = 2
TRACK_KEEPING = 3
DODGE = 4
# ---------------------------------------------------------------------------
# Datos de telemetría
# ---------------------------------------------------------------------------
@dataclass
class SimSnapshot:
"""Una muestra completa del estado del simulador (para análisis y gráficas)."""
t: float
mode: AutopilotMode
heading_deg: float
heading_setpoint_deg: float
heading_error_deg: float
rot_dps: float
sog_kn: float
outer_rudder_sp_deg: float
rudder_angle_deg: float
inner_pwm_pct: float
alarm_heading_lost: bool
alarm_off_course: bool
alarm_off_course_severe: bool
bno085_yaw_rate_dps: float = 0.0 # medición del giróscopo BNO085 (0 si desactivado)
@dataclass
class SimEvent:
"""Evento discreto en la simulación (engage, alarma, etc.)."""
t: float
kind: str # "engage", "disengage", "alarm", "ack"
detail: str = ""
# ---------------------------------------------------------------------------
# Simulador principal
# ---------------------------------------------------------------------------
class ESP32Simulator:
"""
Simulación software del firmware AR-Autopilot ESP32.
Reproduce el mismo comportamiento que el C++:
- Máquina de estados (STANDBY / HEADING_HOLD / DODGE / ...)
- Cascada PID a dos tasas (outer 10 Hz, inner 50 Hz)
- Física del buque (timón ROT rumbo)
- Banco de registros Modbus (holdings, coils, inputs, discretes)
Uso rápido::
sim = ESP32Simulator()
sim.inject_nmea(heading_deg=90.0, sog_kn=10.0)
sim.write_holding(0, 1) # MODE_REQUEST = HEADING_HOLD
sim.write_holding(1, 9000) # HEADING_SETPOINT_X100 = 90.00°
sim.write_coil(0, 1) # CMD_ENGAGE_REQUEST
sim.step(5.0) # enganche
sim.write_holding(1, 10000) # nuevo rumbo 100°
sim.step(120.0) # maniobra
print(f"Rumbo final: {sim.heading:.1f}°")
"""
def __init__(
self,
vessel_cfg: VesselHeadingConfig | None = None,
rudder_cfg: RudderDynamicsConfig | None = None,
outer_cfg: PidOuterConfig | None = None,
inner_cfg: PidInnerConfig | None = None,
) -> None:
# -- Física -------------------------------------------------------
# El VesselHeadingSimulator por defecto tiene rudder_response_gain=0.18,
# lo que da ~11 dps a 5° de timón a 10 kn — demasiado sensible para
# los ganadores PID de referencia (kd=1.2, rot_ff_gain=1.5).
# Con gain=0.004: ROT_ss = 0.004*10*35/0.8 = 1.75 dps al máximo timón.
# Esto equivale a un yate de 30 m con radio de giro ~165 m a 10 kn,
# consistente con la hoja de datos del brief y numéricamente estable
# con los coeficientes PID por defecto.
default_vessel = VesselHeadingConfig(rudder_response_gain=0.004)
self._vessel = VesselHeadingSimulator(vessel_cfg or default_vessel)
# En la simulación software eliminamos el deadband y el min_useful del
# actuador físico: no hay bomba hidráulica real, por lo que cualquier
# señal mueve el timón proporcionalmente. Esto evita el efecto bang-bang
# (snap a 12 % para señales pequeñas) que causa sobrepasamiento y
# oscilaciones en torno al setpoint. El hardware real conserva su propio
# deadband (deadband_pct=7 en RudderDynamicsConfig es para hardware).
default_rudder = RudderDynamicsConfig(deadband_pct=0.0, min_useful_pwm_pct=0.0)
self._rudder = RudderSimulator(rudder_cfg or default_rudder)
# -- Controladores PID -------------------------------------------
# En la simulación software el outer PID usa solo P + ROT feed-forward
# (kd=0, ki=0).
#
# kd=0: En firmware kd=1.2, que genera un pico de derivada enorme
# cuando el setpoint cambia de golpe (el error salta de 0° a Δrumbo
# en un solo tick). En hardware ese pico es inofensivo: el actuador
# hidráulico ya está saturado mecánicamente. En simulación dispara el
# anti-windup y lleva el integrador a ~7°, lo que obliga al outer PID
# a pasar 25 s recuperando la demanda. Con kd=0 el pico desaparece.
# El ROT feed-forward (rot_ff_gain=1.5) sigue amortiguando el
# sobrepasamiento de forma anticipada.
#
# ki=0: El integrador del firmware compensa derivas reales: corriente,
# viento, fricción asimétrica. Ninguna de estas perturbaciones existe
# en la simulación. Con ki≠0 el integrador acumula ~2-7° durante la
# maniobra de aproximación y luego tarda 100-500 s en vaciarse, lo
# que genera tiempos de asentamiento de más de 120 s incluso con
# maniobras pequeñas. Con ki=0 el bucle externo es P+FF puro: el
# error de estado estacionario es cero (el modelo es perfecto) y el
# asentamiento ocurre en 20-40 s.
# aw_gain=0.0 desactiva el anti-windup de saturación. Con ki=0 el
# integrador nunca acumula errores útiles, pero la corrección de
# anti-windup SÍ modifica el integrador cuando P+FF satura (maniobras
# grandes como 90° → 180°). Resultado: integrador llega a -35° y
# luego arrastra la salida del outer hacia abajo prematuramente, lo
# que convierte un giro de 90° en un proceso de 245 s en lugar de
# ~60 s. Con aw_gain=0 el integrador permanece en 0 siempre.
#
# deadband_deg=0.0: El deadband del firmware (0.5°) filtra el ruido
# del compass real. En simulación el compass es perfecto (sin ruido),
# por lo que el deadband solo introduce una zona muerta que impide al
# controlador P corregir errores < 0.5°. El resultado es que el error
# de estado estacionario converge a ~0.5° en vez de 0°, lo que hace
# que maniobras de 15° nunca entren en la banda de ±1° dentro del
# ventana de prueba. Con deadband=0: τ_outer ≈ 24 s, una maniobra
# de 15° asienta en ~65 s (< 90 s criterio TC-04) y el retorno de
# DODGE (18°) converge por debajo de 1° en ~70 s (< 120 s TC-07).
default_outer = PidOuterConfig(
base_kd=0.0, base_ki=0.0, aw_gain=0.0, deadband_deg=0.0
)
self._pid_outer = PidOuter(outer_cfg or default_outer)
# En el simulador software el inner PID necesita kp mucho mayor que
# en el firmware (kp=2.5). El firmware fue calibrado asumiendo que
# min_useful_pwm_pct=12 % actúa como "suelo" de señal, dando a la
# bomba hidráulica un mínimo de corriente para arrancar (~0.6 dps
# efectivos). Sin ese suelo el lazo cerrado tiene τ_cl = friction /
# (actuator_gain × kp) = 4 / (0.2 × 2.5) = 8 s — demasiado lento
# para que el outer PID vea una planta dinámica coherente.
# Con kp=20: τ_cl = 4/(0.2×20) = 1 s — rápido y sin bang-bang.
# deadband_pct=0 y min_useful=0 para eliminar la no-linealidad del
# actuador hidráulico que no existe en el modelo matemático.
default_inner = PidInnerConfig(kp=20.0, deadband_pct=0.0, min_useful_pwm_pct=0.0)
self._pid_inner = PidInner(inner_cfg or default_inner)
# -- Reloj de simulación -----------------------------------------
self._t: float = 0.0
self._inner_tick: int = 0
# -- Estado de la máquina de estados ------------------------------
self._mode: AutopilotMode = AutopilotMode.STANDBY
self._heading_setpoint_deg: float = 0.0
self._pre_dodge_heading_deg: float = 0.0
# -- Señales NMEA 2000 (el CAN virtual) --------------------------
self._nmea_heading_deg: float = 0.0
self._nmea_rot_dps: float = 0.0
self._nmea_sog_kn: float = 10.0
self._nmea_cog_deg: float = 0.0
self._nmea_xte_dm: float = 0.0
self._physics_heading_active: bool = True # physics → NMEA heading
self._last_nmea_update_t: float = -999.0 # para timeout
# -- Bancos de registros Modbus ----------------------------------
self._holdings: dict[int, int] = self._default_holdings()
self._coils: dict[int, int] = {i: 0 for i in range(8)}
self._inputs: dict[int, int] = self._default_inputs()
self._discretes: dict[int, int] = {i: 0 for i in range(32)}
self._prev_coils: dict[int, int] = {i: 0 for i in range(8)}
# -- Alarmas ------------------------------------------------------
self._alarm_heading_lost: bool = False
self._alarm_off_course: bool = False
self._alarm_off_course_severe: bool = False
# Bandera de "ya hemos convergido al setpoint al menos una vez".
# La alarma OFF_COURSE solo se dispara cuando el rumbo HA ESTADO
# cerca del setpoint y luego SE ALEJA. Esto evita disparar la alarma
# en maniobras con cambio grande inicial (p. ej. 90° → 180°): el error
# parte de 90°, que es > OFF_COURSE_SEVERE_DEG = 30°, pero el buque
# está *aproximándose* al setpoint, no desviándose. Los pilotos
# reales (Simrad, Raymarine) tienen el mismo comportamiento.
self._tracking_settled: bool = False
# -- Salidas de los controladores (usadas entre pasos) -----------
self._outer_rudder_sp: float = 0.0
self._inner_pwm_pct: float = 0.0
# -- Estado del mar (sea state) ----------------------------------
self._sea_beaufort: int = 0
self._sea_wave_amp: float = 0.0
self._sea_swell_amp: float = 0.0
self._sea_noise_amp: float = 0.0
self._sea_wind_bias: float = 0.0
self._sea_wave_period: float = 8.0
self._sea_rng: random.Random = random.Random(42)
# -- BNO085 IMU (yaw rate de alta frecuencia) --------------------
# Cuando está activo, el rot_ff_term del outer PID usa la medición
# del giróscopo BNO085 (50 Hz, ruido ~0.02 °/s) en lugar del ROT
# derivado del NMEA compass (10 Hz, mayor latencia).
# Esto reduce el overshoot en maniobras y el cabeceo en olas.
self._bno085_enabled: bool = False
self._bno085_noise_std_dps: float = 0.02 # spec BNO085: ~0.014 °/s
self._bno085_yaw_rate_dps: float = 0.0
# -- Registro de telemetría para análisis ------------------------
self.log: list[SimSnapshot] = []
self.events: list[SimEvent] = []
self._log_dt: float = 0.1 # cada 100 ms
self._next_log_t: float = 0.0
# -----------------------------------------------------------------------
# API pública — Condiciones iniciales NMEA
# -----------------------------------------------------------------------
def inject_nmea(
self,
*,
heading_deg: float | None = None,
rot_dps: float | None = None,
sog_kn: float | None = None,
cog_deg: float | None = None,
xte_dm: float | None = None,
) -> None:
"""
Inyecta datos de sensores NMEA 2000 (equivale al CAN bus).
Cuando se llama con heading_deg, también reinicia la física del buque
para que el simulador parta de ese rumbo. Las llamadas posteriores
(sin heading_deg) solo actualizan los campos indicados.
"""
if heading_deg is not None:
hd = heading_deg % 360.0
rot = rot_dps if rot_dps is not None else self._nmea_rot_dps
self._vessel.reset(heading_deg=hd, rate_of_turn_dps=rot)
self._nmea_heading_deg = hd
self._nmea_cog_deg = hd # sin leeway → COG ≈ heading
self._physics_heading_active = True
self._last_nmea_update_t = self._t
if rot_dps is not None:
self._nmea_rot_dps = rot_dps
if sog_kn is not None:
self._nmea_sog_kn = max(0.0, sog_kn)
if cog_deg is not None:
self._nmea_cog_deg = cog_deg % 360.0
if xte_dm is not None:
self._nmea_xte_dm = xte_dm
def disconnect_nmea_heading(self) -> None:
"""
Simula la pérdida del compass NMEA 2000 (cable roto, bus saturado).
La física sigue avanzando pero el sensor deja de reportar.
Tras HEADING_TIMEOUT_S el firmware dispara ALARM_HEADING_LOST
y desengrana el autopiloto.
"""
self._physics_heading_active = False
# No actualizamos _last_nmea_update_t → el timeout empieza a contar
def reconnect_nmea_heading(self) -> None:
"""Restaura el compass NMEA 2000 (después de un fallo)."""
self._physics_heading_active = True
self._last_nmea_update_t = self._t
def set_sea_state(self, beaufort: int, *, seed: int = 42) -> None:
"""
Configura el estado del mar según la escala Beaufort (0 = calma, 8 = temporal).
Modela tres fuentes de perturbación de guiñada:
* **Ola dominante** sinusoide al periodo de ola característico del estado.
Ejemplo B6 (mar gruesa): ±4° de oscilación de rumbo a 8 s de periodo.
* **Mar de fondo (swell)** sinusoide de periodo 3× más largo que la ola.
* **Ruido blanco** turbulencia residual aleatoria (random walk en ROT).
* **Viento de costado (weather helm)** deriva lenta, periodo 120 s.
Todos se inyectan como ``external_yaw_torque`` en el
``VesselHeadingSimulator`` (campo ya existente, °/).
Seatate esperado de oscilación de rumbo (barco 30 m, 10 kn, con AP activo):
B0=0° B3±1° B4±3° B5±5° B6±8° B7±13°
Args:
beaufort: Nivel Beaufort (08). Valores fuera de rango se clamean.
seed: Semilla del RNG para reproducibilidad (default 42).
"""
beaufort = max(0, min(8, beaufort))
row = _BEAUFORT_TABLE[beaufort]
self._sea_beaufort = beaufort
self._sea_wave_amp = row[0]
self._sea_swell_amp = row[1]
self._sea_noise_amp = row[2]
self._sea_wind_bias = row[3]
self._sea_wave_period = row[4]
self._sea_rng = random.Random(seed)
def tune_response(
self,
*,
rudder_kp: float | None = None,
counter_rudder: float | None = None,
max_rudder_deg: float | None = None,
) -> None:
"""
Ajusta los parámetros del outer PID en tiempo real (simula los knobs del piloto).
Equivalente a los controles físicos de pilotos clásicos:
* ``rudder_kp`` knob **"RUDDER"** (Robertson/Simrad): ganancia proporcional.
Más ganancia = más timón por grado de error respuesta más agresiva.
* ``counter_rudder`` knob **"COUNTER RUDDER"** / "Yaw Damping": feed-forward de ROT.
Más contra-timón = mejor amortiguamiento del sobrepasamiento y del oleaje.
* ``max_rudder_deg`` límite de timón de trabajo (no el tope mecánico de 35°).
En mal tiempo se sube de ~20° a 2530° para dar más autoridad.
Los cambios tienen efecto inmediato en el próximo outer-tick (10 Hz).
Args:
rudder_kp: Nuevo Kp del outer PID (típico rango 0.54.0).
counter_rudder: Nuevo rot_ff_gain (típico rango 0.53.0).
max_rudder_deg: Nuevo límite de trabajo del timón en grados (típico 1535°).
"""
if rudder_kp is not None:
self._pid_outer.config.base_kp = float(rudder_kp)
if counter_rudder is not None:
self._pid_outer.config.rot_ff_gain = float(counter_rudder)
if max_rudder_deg is not None:
self._pid_outer.config.max_rudder_deg = float(max_rudder_deg)
def enable_bno085(self, *, noise_std_dps: float = 0.02) -> None:
"""
Activa el BNO085 como fuente de yaw rate para el outer PID.
Con BNO085 activo el ``rot_ff_term`` del outer PID usa la medición
del giróscopo (50 Hz, ruido típico ~0.014 °/s) en lugar del ROT
derivado del bus NMEA 2000 (10 Hz, mayor latencia de bus + GPS).
Diferencia clave respecto al ROT por NMEA:
- Latencia: BNO085 4 ms vs NMEA ROT 100-200 ms
- Ruido: BNO085 0.02 °/s vs NMEA ROT 0.1-0.5 °/s
- Frecuencia: BNO085 muestrea a 250 Hz (gyro), outer PID consume a 10 Hz
En mal tiempo (B4-B5) el BNO085 permite que el ``rot_ff_term``
reaccione a los picos de guiñada generados por las olas antes de
que el error de heading se acumule equivale a subir el knob
"COUNTER RUDDER" manteniendo la ganancia proporcional estable.
Args:
noise_std_dps: Desviación estándar del ruido del giróscopo en °/s.
BNO085 spec: ~0.014 °/s típico. Default 0.02 °/s (conservador).
"""
self._bno085_enabled = True
self._bno085_noise_std_dps = float(noise_std_dps)
def disable_bno085(self) -> None:
"""Desactiva el BNO085; el outer PID vuelve a usar ROT del NMEA 2000."""
self._bno085_enabled = False
self._bno085_yaw_rate_dps = 0.0
# -----------------------------------------------------------------------
# API pública — Interfaz Modbus
# -----------------------------------------------------------------------
def write_holding(self, addr: int, value: int) -> None:
"""Escribe un holding register (comando del PC → ESP32)."""
self._holdings[addr] = int(value) & 0xFFFF
def write_coil(self, addr: int, value: int) -> None:
"""Escribe una coil (pulso de comando del PC → ESP32)."""
self._coils[addr] = 1 if value else 0
def read_input(self, addr: int) -> int:
"""Lee un input register (telemetría ESP32 → PC, solo lectura)."""
return self._inputs.get(addr, 0)
def read_discrete(self, addr: int) -> int:
"""Lee un discrete input (flag de estado ESP32 → PC, solo lectura)."""
return self._discretes.get(addr, 0)
def read_holding(self, addr: int) -> int:
"""Lee un holding register (para debug / verificación)."""
return self._holdings.get(addr, 0)
# -----------------------------------------------------------------------
# API pública — Control de la simulación
# -----------------------------------------------------------------------
def step(self, duration_s: float, log_dt: float = 0.1) -> None:
"""
Avanza la simulación ``duration_s`` segundos.
La física corre a 50 Hz. El outer PID se ejecuta cada 5 ticks (10 Hz).
El inner PID corre en cada tick (50 Hz).
Args:
duration_s: Tiempo de simulación a avanzar (segundos).
log_dt: Intervalo entre snapshots de telemetría (segundos).
"""
self._log_dt = log_dt
n_ticks = max(1, round(duration_s / DT_INNER))
for _ in range(n_ticks):
self._tick()
# -- Propiedades de conveniencia ----------------------------------------
@property
def t(self) -> float:
"""Tiempo de simulación actual (segundos)."""
return self._t
@property
def mode(self) -> AutopilotMode:
return self._mode
@property
def heading(self) -> float:
"""Rumbo actual del buque [0..360)."""
return self._vessel.state.heading_deg
@property
def rot(self) -> float:
"""Rate of turn actual (deg/s)."""
return self._vessel.state.rate_of_turn_dps
@property
def rudder(self) -> float:
"""Ángulo actual del timón (grados)."""
return self._rudder.state.angle_deg
@property
def engaged(self) -> bool:
return self._mode != AutopilotMode.STANDBY
@property
def any_alarm(self) -> bool:
return (self._alarm_heading_lost
or self._alarm_off_course
or self._alarm_off_course_severe)
# -----------------------------------------------------------------------
# Internos — tick principal (50 Hz)
# -----------------------------------------------------------------------
def _tick(self) -> None:
# 1. Procesar coils (flancos de subida)
self._process_coils()
# 2. Leer cambios en holdings (setpoint dinámico)
self._sync_from_holdings()
# 3. Outer loop @ 10 Hz
if self._inner_tick % INNER_PER_OUTER == 0:
self._run_outer_loop()
# 4. Inner loop @ 50 Hz
self._run_inner_loop()
# 5. Física: avanzar la planta con el PWM actual
self._run_physics()
# 6. Evaluar alarmas con el estado post-física
self._check_alarms()
# 7. Espejo a los registros Modbus
self._sync_to_registers()
# 8. Capturar snapshot si toca
if self._t >= self._next_log_t:
self._capture_snapshot()
self._next_log_t = self._t + self._log_dt
# 9. Avanzar reloj
self._t += DT_INNER
self._inner_tick += 1
# -----------------------------------------------------------------------
# Internos — procesamiento de coils (flancos de subida)
# -----------------------------------------------------------------------
def _process_coils(self) -> None:
if self._coils[0] and not self._prev_coils[0]:
self._cmd_engage()
if self._coils[1] and not self._prev_coils[1]:
self._cmd_disengage()
if self._coils[2] and not self._prev_coils[2]:
self._cmd_ack_alarms()
self._prev_coils = dict(self._coils)
# Las coils de comando son one-shot: se limpian tras procesar
self._coils[0] = 0
self._coils[1] = 0
self._coils[2] = 0
def _cmd_engage(self) -> None:
"""Intenta enganchar en el modo solicitado por MODE_REQUEST."""
requested = AutopilotMode(self._holdings[0] & 0x0F)
# Interlock: necesita rumbo válido para modos de control de rumbo
heading_age = self._t - self._last_nmea_update_t
heading_valid = heading_age <= HEADING_TIMEOUT_S
if requested in (AutopilotMode.HEADING_HOLD,
AutopilotMode.TRUE_COURSE,
AutopilotMode.TRACK_KEEPING):
if not heading_valid:
self._add_event("engage_refused", "Sin rumbo NMEA válido")
return # rechazar enganche
if requested == AutopilotMode.HEADING_HOLD:
raw_sp = self._holdings[1]
if raw_sp == 0:
# Auto-captura del rumbo actual si el setpoint es cero
self._heading_setpoint_deg = self._nmea_heading_deg
self._holdings[1] = int(self._nmea_heading_deg * 100) % 36000
else:
self._heading_setpoint_deg = raw_sp * 0.01
self._pid_outer.reset(heading_deg=self._nmea_heading_deg)
self._pid_inner.reset(measured_deg=self._rudder.state.angle_deg)
self._mode = AutopilotMode.HEADING_HOLD
# Resetear el flag de convergencia: el rumbo parte desde lejos del
# nuevo setpoint; OFF_COURSE solo disparará cuando hayamos llegado
# cerca y luego nos alejemos.
self._tracking_settled = False
self._add_event("engage", f"HEADING_HOLD sp={self._heading_setpoint_deg:.1f}°")
elif requested == AutopilotMode.DODGE:
self._pre_dodge_heading_deg = self._heading_setpoint_deg
raw_offset = self._holdings[8]
if raw_offset > 0x7FFF:
raw_offset -= 0x10000
offset_deg = raw_offset * 0.01
self._heading_setpoint_deg = (
self._heading_setpoint_deg + offset_deg
) % 360.0
self._mode = AutopilotMode.DODGE
self._add_event("engage", f"DODGE offset={offset_deg:.1f}°")
elif requested == AutopilotMode.STANDBY:
self._do_disengage()
def _cmd_disengage(self) -> None:
self._do_disengage()
def _cmd_ack_alarms(self) -> None:
self._alarm_heading_lost = False
self._alarm_off_course = False
self._alarm_off_course_severe = False
self._add_event("ack", "Alarmas reconocidas")
def _do_disengage(self) -> None:
if self._mode != AutopilotMode.STANDBY:
self._add_event("disengage", f"Desde {self._mode.name}")
self._mode = AutopilotMode.STANDBY
self._outer_rudder_sp = 0.0
self._inner_pwm_pct = 0.0
self._pid_outer.reset()
self._pid_inner.reset()
self._tracking_settled = False
# -----------------------------------------------------------------------
# Internos — sincronización desde holdings
# -----------------------------------------------------------------------
def _sync_from_holdings(self) -> None:
"""En HEADING_HOLD, el setpoint puede cambiar en caliente (knob)."""
if self._mode == AutopilotMode.HEADING_HOLD:
raw = self._holdings[1]
new_sp = raw * 0.01
# Si el operador ha cambiado el setpoint significativamente
# (> 1°) se resetea el flag de convergencia: el buque está
# ahora aproximándose al nuevo rumbo, no desviándose del
# anterior. Esto evita que OFF_COURSE dispare durante la
# maniobra de aproximación al nuevo setpoint.
if abs(heading_error_deg(new_sp, self._heading_setpoint_deg)) > 1.0:
self._tracking_settled = False
self._heading_setpoint_deg = new_sp
# -----------------------------------------------------------------------
# Internos — outer loop (10 Hz)
# -----------------------------------------------------------------------
def _run_outer_loop(self) -> None:
if self._mode == AutopilotMode.STANDBY:
self._outer_rudder_sp = 0.0
return
heading_age = self._t - self._last_nmea_update_t
if heading_age > HEADING_TIMEOUT_S:
# No hay rumbo: salida cero (el firmware disengrana, esto lo hacemos
# en _check_alarms, pero la salida ya no es válida)
self._outer_rudder_sp = 0.0
return
# Velocidad para gain scheduling: primero del holding, luego NMEA
sp_speed_raw = self._holdings[25] # PID_OUTER_SPEED_KN_REQ_X10
sog_kn = (sp_speed_raw * 0.1) if sp_speed_raw > 0 else self._nmea_sog_kn
# BNO085 activo → usa yaw rate del giróscopo (50 Hz, baja latencia).
# BNO085 inactivo → usa ROT del bus NMEA 2000 (10 Hz, GPS/compass).
rot_for_pid = (self._bno085_yaw_rate_dps if self._bno085_enabled
else self._nmea_rot_dps)
self._outer_rudder_sp = self._pid_outer.step(
heading_setpoint_deg=self._heading_setpoint_deg,
heading_measured_deg=self._nmea_heading_deg,
rate_of_turn_dps=rot_for_pid,
speed_kn=sog_kn,
allowed=True,
)
# -----------------------------------------------------------------------
# Internos — inner loop (50 Hz)
# -----------------------------------------------------------------------
def _run_inner_loop(self) -> None:
self._inner_pwm_pct = self._pid_inner.step(
setpoint_deg=self._outer_rudder_sp,
measured_deg=self._rudder.state.angle_deg,
allowed=self._mode != AutopilotMode.STANDBY,
)
# -----------------------------------------------------------------------
# Internos — física (50 Hz)
# -----------------------------------------------------------------------
def _compute_wave_torque(self) -> float:
"""
Calcula el torque externo de guiñada del oleaje (°/) para el tick actual.
Compone cuatro componentes:
- Ola dominante: sinusoide al periodo característico del estado de mar.
- Mar de fondo: sinusoide a periodo 3.2× más largo.
- Ruido blanco: perturbación aleatoria (Gaussiana, proporcional a dt).
- Weather helm: deriva de viento, sinusoide de 120 s de periodo.
Retorna 0.0 si el mar está en calma (beaufort == 0).
"""
if self._sea_beaufort == 0:
return 0.0
t = self._t
T = self._sea_wave_period
wave = self._sea_wave_amp * math.sin(2.0 * math.pi * t / T)
swell = self._sea_swell_amp * math.sin(2.0 * math.pi * t / (T * 3.2))
# Ruido blanco: la std por tick se escala con √dt para mantener
# la densidad espectral de potencia constante en frecuencia.
noise = self._sea_noise_amp * self._sea_rng.gauss(0.0, 1.0) * math.sqrt(DT_INNER)
wind = self._sea_wind_bias * math.sin(2.0 * math.pi * t / 120.0)
return wave + swell + noise + wind
def _run_physics(self) -> None:
# Timón responde al PWM
self._rudder.step(dt=DT_INNER, pwm_pct=self._inner_pwm_pct)
# Perturbación de mar: actualizar torque externo del buque cada tick
self._vessel.config.external_yaw_torque = self._compute_wave_torque()
# Buque responde al ángulo real del timón (y al torque del oleaje)
self._vessel.step(
dt=DT_INNER,
rudder_deg=self._rudder.state.angle_deg,
speed_kn=self._nmea_sog_kn,
)
# La física actualiza el "sensor NMEA" si está conectado
if self._physics_heading_active:
self._nmea_heading_deg = self._vessel.state.heading_deg
self._nmea_rot_dps = self._vessel.state.rate_of_turn_dps
# COG = heading (sin corriente en este modelo simple)
self._nmea_cog_deg = self._nmea_heading_deg
self._last_nmea_update_t = self._t
# BNO085: muestrea yaw rate a 50 Hz (mismo ritmo que el inner loop).
# El giróscopo del BNO085 tiene latencia ~4ms y ruido ~0.02 °/s,
# mucho mejor que el ROT calculado desde el GPS/compass NMEA (100-200ms).
# En el outer PID (10 Hz) esto significa que rot_ff_term reacciona a
# picos de guiñada por olas ~10x más rápido que con ROT por NMEA.
if self._bno085_enabled:
noise = (self._sea_rng.gauss(0.0, self._bno085_noise_std_dps)
if self._bno085_noise_std_dps > 0.0 else 0.0)
self._bno085_yaw_rate_dps = self._vessel.state.rate_of_turn_dps + noise
# -----------------------------------------------------------------------
# Internos — alarmas
# -----------------------------------------------------------------------
def _check_alarms(self) -> None:
# HEADING_LOST: timeout del sensor NMEA
heading_age = self._t - self._last_nmea_update_t
if heading_age > HEADING_TIMEOUT_S:
if not self._alarm_heading_lost:
self._alarm_heading_lost = True
self._add_event("alarm", "ALARM_HEADING_LOST")
if self._mode != AutopilotMode.STANDBY:
self._do_disengage()
# OFF_COURSE (solo cuando enganchado)
if self._mode != AutopilotMode.STANDBY:
err = abs(heading_error_deg(
self._heading_setpoint_deg, self._nmea_heading_deg
))
# Marcar "primera convergencia" cuando el error cae bajo 5°.
# Hasta ese momento el buque está en aproximación inicial al
# setpoint — no se consideran desviaciones (sería falsa alarma).
if err < 5.0:
self._tracking_settled = True
# OFF_COURSE solo se evalúa cuando ya hemos convergido al menos
# una vez. Esto reproduce el comportamiento de pilotos reales
# (Simrad, Raymarine): la alarma es para "nos hemos desviado del
# rumbo", no para "el operador acaba de ordenar un cambio grande".
if self._tracking_settled:
if err > OFF_COURSE_SEVERE_DEG:
if not self._alarm_off_course_severe:
self._alarm_off_course_severe = True
self._alarm_off_course = True
self._add_event("alarm", f"ALARM_OFF_COURSE_SEVERE err={err:.1f}°")
self._do_disengage()
elif err > OFF_COURSE_WARN_DEG:
if not self._alarm_off_course:
self._alarm_off_course = True
self._add_event("alarm", f"ALARM_OFF_COURSE err={err:.1f}°")
else:
if self._alarm_off_course and not self._alarm_off_course_severe:
self._alarm_off_course = False
# -----------------------------------------------------------------------
# Internos — espejo a registros Modbus
# -----------------------------------------------------------------------
def _sync_to_registers(self) -> None:
uptime = int(self._t)
self._inputs[4] = uptime & 0xFFFF
self._inputs[5] = (uptime >> 16) & 0xFFFF
self._inputs[6] = int(self._mode)
# Timón
self._inputs[16] = _u16(int(self._rudder.state.angle_deg * 100))
# Rumbo / ROT / edad
self._inputs[24] = _u16(int(self._nmea_heading_deg * 100))
self._inputs[25] = _s16_to_u16(int(self._nmea_rot_dps * 100))
age_ms = int((self._t - self._last_nmea_update_t) * 1000)
self._inputs[26] = min(60000, max(0, age_ms))
# COG / SOG / XTE
self._inputs[60] = _u16(int(self._nmea_cog_deg * 100))
self._inputs[61] = _u16(int(self._nmea_sog_kn * 10))
self._inputs[63] = _s16_to_u16(int(self._nmea_xte_dm))
# Telemetría inner PID
self._inputs[40] = _s16_to_u16(int(self._outer_rudder_sp * 100))
self._inputs[41] = _s16_to_u16(int(self._inner_pwm_pct * 100))
inner_err = self._outer_rudder_sp - self._rudder.state.angle_deg
self._inputs[42] = _s16_to_u16(int(inner_err * 100))
# Telemetría outer PID
self._inputs[50] = _u16(int(self._heading_setpoint_deg * 100))
self._inputs[51] = _s16_to_u16(int(self._outer_rudder_sp * 100))
outer_err = heading_error_deg(
self._heading_setpoint_deg, self._nmea_heading_deg
)
self._inputs[52] = _s16_to_u16(int(outer_err * 100))
self._inputs[53] = _u16(int(self._nmea_sog_kn * 10))
# Discretos
self._discretes[0] = 1 if self._mode != AutopilotMode.STANDBY else 0
self._discretes[8] = self._discretes[0]
self._discretes[16] = 1 if self._alarm_off_course else 0
self._discretes[17] = 1 if self._alarm_off_course_severe else 0
self._discretes[19] = 1 if self._alarm_heading_lost else 0
self._discretes[25] = 1 if self.any_alarm else 0
# -----------------------------------------------------------------------
# Internos — telemetría
# -----------------------------------------------------------------------
def _capture_snapshot(self) -> None:
hdg = self._nmea_heading_deg
sp = self._heading_setpoint_deg
self.log.append(SimSnapshot(
t=self._t,
mode=self._mode,
heading_deg=hdg,
heading_setpoint_deg=sp,
heading_error_deg=heading_error_deg(sp, hdg),
rot_dps=self._nmea_rot_dps,
sog_kn=self._nmea_sog_kn,
outer_rudder_sp_deg=self._outer_rudder_sp,
rudder_angle_deg=self._rudder.state.angle_deg,
inner_pwm_pct=self._inner_pwm_pct,
alarm_heading_lost=self._alarm_heading_lost,
alarm_off_course=self._alarm_off_course,
alarm_off_course_severe=self._alarm_off_course_severe,
bno085_yaw_rate_dps=self._bno085_yaw_rate_dps,
))
def _add_event(self, kind: str, detail: str = "") -> None:
self.events.append(SimEvent(t=self._t, kind=kind, detail=detail))
# -----------------------------------------------------------------------
# Internos — valores por defecto
# -----------------------------------------------------------------------
def _default_holdings(self) -> dict[int, int]:
h: dict[int, int] = {i: 0 for i in range(40)}
h[0] = 0 # MODE_REQUEST = STANDBY
h[1] = 0 # HEADING_SETPOINT_X100
h[2] = 80 # BRIGHTNESS_PCT
h[3] = 70 # ALARM_VOLUME_PCT
h[25] = 100 # PID_OUTER_SPEED_KN_REQ_X10 = 10.0 kn
h[33] = 50 # XTE_GAIN_X1000 = 0.050 deg/m
h[34] = 2000 # XTE_MAX_CORRECTION_X100 = 20.0°
return h
def _default_inputs(self) -> dict[int, int]:
i: dict[int, int] = {k: 0 for k in range(80)}
i[0] = 0 # FW_VERSION_MAJOR
i[1] = 4 # FW_VERSION_MINOR (sprint 4)
i[2] = 0 # FW_VERSION_PATCH
i[3] = 0 # SCHEMA_VERSION
i[18] = 1 # RUDDER_VALID (el sim siempre es válido)
return i
# ---------------------------------------------------------------------------
# Helpers de codificación de registros (Modbus es big-endian uint16)
# ---------------------------------------------------------------------------
def _u16(x: int) -> int:
"""Clamp a uint16 [0..65535]."""
return max(0, min(0xFFFF, x)) & 0xFFFF
def _s16_to_u16(x: int) -> int:
"""Convierte int16 con signo a representación uint16 (complemento a 2)."""
if x < 0:
return (x + 0x10000) & 0xFFFF
return x & 0xFFFF
File diff suppressed because it is too large Load Diff
+146
View File
@@ -0,0 +1,146 @@
* =======================================================================
* AR-Autopilot — Cadena de Alimentacion: 12V → 5V → 3.3V
* Archivo: 1_buck_chain.cir
* Tarjetas: Modulo ESP32+CAN+RS485 / Modulo ESP32+CAN (nodo compacto)
*
* COMO USAR EN LTSPICE:
* File → Open → seleccionar este .cir
* Run (boton Play) → ya configurado para .tran 50ms
* Probes utiles:
* V(v5v) → tension 5V de salida
* V(v33) → tension 3.3V de salida
* I(Rload5) → corriente consumida a 5V
* I(Rload33)→ corriente consumida a 3.3V
*
* MODELO: Average behavioural (no switching). Captura correctamente:
* - Tension DC de salida (verificacion de Rfb)
* - Rampa de arranque (soft-start ~2ms)
* - Respuesta a escalon de carga
* - Rizado de salida (via ESR del condensador)
* =======================================================================
.title AR-Autopilot Buck Chain 12V-5V-3V3
* -----------------------------------------------------------------------
* PROTECCION DE ENTRADA 12V
* -----------------------------------------------------------------------
* Fusible 1812L125_16DR: 1.25A / 16V — modelado como resistencia serie
* (en SPICE el fusible no se funde, pero RSer=0.1 Ohm representa la caida)
* TVS SM6T24A: Vclamp=24V, absorbe load dump marino (hasta 45V transitorio)
* Para simular el TVS activo, cambiar Vin a PULSE(12 45 10m 1u 1u 1m 100m)
Vin VIN_RAW GND PULSE(0 12 0 500u 500u 200m 500m)
Rfuse VIN_RAW VIN_FUSED 0.1
* Catodo Anodo modelo
DTVS VIN_FUSED GND DTVS_SM6T24A
.model DTVS_SM6T24A D(Ron=0.05 Vfwd=24 epsilon=0.5 Ilimit=25)
* -----------------------------------------------------------------------
* ETAPA 1: Buck 12V → 5V (MP2338, L=6.8uH, Cout=44uF)
* -----------------------------------------------------------------------
* Calculo Vfb: Vout = 0.8V * (1 + R47/R45) = 0.8*(1+52.3k/10k) = 4.984V ≈ 5V
* Frecuencia de conmutacion: 1.4 MHz
* Corriente ripple inductor: dIL = Vout*(1-D)/(L*Fsw)
* = 5*(1-5/12)/(6.8u*1.4M) = 0.306A pp
* Condensadores de salida: 2x EMK212BBJ226MGT = 2x22uF = 44uF
* ESR tipico a 1MHz: ~10mOhm por condensador → 5mOhm en paralelo
* Fuente behavioural — modelo promedio del buck MP2338
* La rampa de 2ms emula el soft-start interno del IC
Ebuck1 V5V_IDEAL GND VALUE={
+ IF( V(VIN_FUSED) > 4.5,
+ MIN(5.0 , V(VIN_FUSED) * (1 - EXP(-TIME/0.002)) * (5.0/12.0) * (12.0/V(VIN_FUSED)) ),
+ 0 ) }
* Resistencia de salida interna (modela perdidas del buck: DCR+RDS_on)
Rbuck1 V5V_IDEAL V5V_SW 0.05
* Inductor de salida real: L2 = DRA74-6R8-R (6.8uH, DCR=51mOhm)
* DCR modelado con Rser= (parametro interno de LTspice) — evita nodo flotante
L1 V5V_SW V5V 6.8u Rser=0.051
.ic V(V5V)=0
* Condensadores de salida — 2x 22uF en paralelo
* ESR de EMK212BBJ226MGT a 1MHz: ~10mOhm. En paralelo = 5mOhm
Cout1a V5V GND 22u IC=0
.param ESR_emk=0.01
Resr1a V5V COUT1A_NODE 0.01
Cout1b COUT1A_NODE GND 22u IC=0
* Resistencias de feedback (para referencia — no afectan el modelo behavioural)
* pero verifican el calculo: Vout = 0.8*(1+R47/R45)
R47 V5V VFB1 52.3k
R45 VFB1 GND 10k
* Vfb1 deberia estar en ~0.8V cuando Vout=5V:
* Vfb1 = 5V * R45/(R47+R45) = 5 * 10k/62.3k = 0.803V ✓
* Carga de prueba a 5V
* ESP32 DevKit + MCP2562T + SN65HVD1781 ≈ 200mA total
* 5V / 200mA = 25 Ohm (carga nominal)
* Para simular escalon de carga: PULSE(25 12.5 10m 1u 1u 5m 50m)
Rload5 V5V GND 25
* Condensador de bypass junto al conector
Cbypass5 V5V GND 100n
* -----------------------------------------------------------------------
* ETAPA 2: Buck 5V → 3.3V (MP2338, L=4.7uH, Cout=44uF)
* -----------------------------------------------------------------------
* Calculo Vfb: Vout = 0.8V * (1 + R44/R42) = 0.8*(1+31.6k/10k) = 3.328V ≈ 3.3V
* Corriente ripple: dIL = 3.3*(1-3.3/5)/(4.7u*1.4M) = 0.171A pp
Ebuck2 V33_IDEAL GND VALUE={
+ IF( V(V5V) > 3.5,
+ MIN(3.3 , V(V5V) * (1 - EXP(-(TIME-0.001)/0.002)) * (3.3/5.0) * (5.0/V(V5V)) ),
+ 0 ) }
Rbuck2 V33_IDEAL V33_SW 0.04
* Inductor: L3 = NRS5010T4R7NMGF (4.7uH, DCR=28mOhm, Isat=4.8A)
L2 V33_SW V33 4.7u
.ic V(V33)=0
* Condensadores de salida — 2x 22uF
Cout2a V33 GND 22u IC=0
Cout2b V33 GND 22u IC=0
* Resistencias de feedback
* Vout = 0.8*(1+R44/R42) = 0.8*(1+31.6k/10k) = 3.328V
R44 V33 VFB2 31.6k
R42 VFB2 GND 10k
* Carga de prueba a 3.3V
* ESP32 activo consumo tipico: 80mA @ 3.3V → 41 Ohm
* Picos en TX WiFi: 350mA → 9.4 Ohm
* Carga nominal para prueba:
Rload33 V33 GND 41
Cbypass33 V33 GND 100n
* -----------------------------------------------------------------------
* DIRECTIVAS DE SIMULACION
* -----------------------------------------------------------------------
* Analisis transitorio: 50ms total, paso maximo 1us
* Permite ver:
* - Rampa de arranque (0-5ms)
* - Estado estable (10-50ms)
* - Rizado de salida (zoom a estado estable)
.tran 0 50m 0 1u
* Opciones de convergencia para fuentes behaviorales
.options reltol=0.001 abstol=1n vntol=1m
* -----------------------------------------------------------------------
* VALORES ESPERADOS (verificar con probes)
* -----------------------------------------------------------------------
* V(v5v) en estado estable: 4.95V - 5.05V (tolerancia ±1% del MP2338)
* V(v33) en estado estable: 3.267V - 3.333V
* V(vfb1) en estado estable: ~0.803V
* V(vfb2) en estado estable: ~0.797V
* I(Rload5) en estado estable: ~200mA
* I(Rload33) en estado estable: ~80mA
* Tiempo de arranque (10%-90%): ~2-4ms por etapa
.backanno
.end
+172
View File
@@ -0,0 +1,172 @@
* =======================================================================
* AR-Autopilot — Etapa de Salida Digital Aislada
* Archivo: 2_output_stage.cir
* Tarjeta: Modulo ESP32+CAN+RS485 (Ports Layout — OUT1 a OUT10)
*
* CIRCUITO (un canal, identico x10):
*
* ESP32 GPIO ──[R=332Ω]──▶|──[PC817 LED]──── GND
* optocoupler
* VCC_OUT ──[R_pull=10kΩ]──┬── MOSFET Gate (IRLML6344)
* │
* PC817 Collector ───────────┘
* PC817 Emitter ──────────────── GND
*
* MOSFET Drain ──[Load]──── V_LOAD+ (12V o 5V externo)
* MOSFET Source ──────────── GND
* Diodo SS14 ──────────── Drain a V_LOAD+ (flyback para cargas inductivas)
*
* LOGICA:
* GPIO HIGH (3.3V) → PC817 ON → Gate LOW → MOSFET OFF → OUT=0V (abierto)
* GPIO LOW (0V) → PC817 OFF → Gate HIGH (pull-up 10k) → MOSFET ON → OUT=V_LOAD
*
* COMO USAR EN LTSPICE:
* File → Open → 2_output_stage.cir
* Ver: V(out1) para tension de salida
* I(Rload_out) para corriente en la carga
* V(gate_q1) para tension de gate del MOSFET
* I(Dled) para corriente en el LED del optocoupler
* =======================================================================
.title AR-Autopilot Output Stage PC817 + IRLML6344
* -----------------------------------------------------------------------
* FUENTES DE ALIMENTACION
* -----------------------------------------------------------------------
V33 V33 GND 3.3V ; Alimentacion ESP32 (3.3V)
VLOAD VLOAD GND 12V ; Carga externa (puede ser 5V o 12V)
VGND GND 0 0V
* -----------------------------------------------------------------------
* SENAL DEL GPIO (simula conmutacion tipica)
* -----------------------------------------------------------------------
* PULSE(Vbajo Valto Tdelay Trise Tfall Ton Tperiod)
* Periodo 100ms: 50ms GPIO HIGH (salida OFF), 50ms GPIO LOW (salida ON)
Vgpio GPIO GND PULSE(3.3 0 5m 1u 1u 50m 100m)
* -----------------------------------------------------------------------
* RESISTENCIA DE DRIVE DEL LED (R9=332Ω en tu esquematico)
* -----------------------------------------------------------------------
* Corriente LED: I = (V33 - Vf_LED) / R_drive = (3.3 - 1.2) / 332 = 6.3mA
* Rango IF del PC817: 1mA - 50mA → 6.3mA CORRECTO ✓
* Potencia en R9: P = I² * R = 0.0063² * 332 = 13.2mW (muy bajo) ✓
Rdrive GPIO ANODE_LED 332
* -----------------------------------------------------------------------
* PC817 OPTOCOUPLER (modelo behavioural)
* -----------------------------------------------------------------------
* CTR tipico del PC817: 50% a 100% (usamos 100% para analisis de peor caso)
* Vf del LED: ~1.2V a IF=6.3mA
* Vceo(sat) del transistor de salida: ~0.1V a Ic=1mA
*
* Lado LED (diodo)
Dled ANODE_LED CATHODE_LED DLED_PC817
.model DLED_PC817 D(Is=1e-12 N=1.8 Rs=5 Cjo=10p Vj=0.9)
Rcathode CATHODE_LED GND 1 ; resistencia de retorno (cableado)
* Lado transistor (modelado como VCCS proporcional a corriente LED)
* Gcollector: corriente de colector = CTR * corriente LED
* CTR = 100% minimo garantizado a IF=5mA
Bcoll GATE_Q1 EMITTER I={MAX(0, 1.0 * I(Dled))}
Remit EMITTER GND 0.1 ; Vce_sat ~ 0.1V a Ic<5mA
* -----------------------------------------------------------------------
* PULL-UP DE GATE (R15=10kΩ en tu esquematico)
* -----------------------------------------------------------------------
* Cuando PC817 OFF: Gate se carga a 3.3V via este resistor → MOSFET ON
* Cuando PC817 ON: Gate se descarga a GND via Gcoll → MOSFET OFF
Rpullup V33 GATE_Q1 10k
Cgate GATE_Q1 GND 100p ; capacidad de gate del IRLML6344
* -----------------------------------------------------------------------
* MOSFET IRLML6344TRPBF (N-channel, 20V, 5A, VGS(th)=0.5-1V)
* -----------------------------------------------------------------------
* Parametros clave:
* VDS_max = 20V
* ID_max = 5A (limitado por PCB/disipacion en practica a ~2A)
* VGS(th) = 0.5V min, 1V max → enciende bien con 3.3V de gate ✓
* RDS(on) = 27mΩ @ VGS=4.5V (con 3.3V de gate: ~45mΩ aprox)
* Qg = 3.8nC → tiempo de conmutacion muy rapido
*
* Modelo SPICE del IRLML6344 (parametros extraidos de datasheet IR)
.model IRLML6344 NMOS(
+ Level=3
+ VTO=0.75
+ KP=6.0
+ LAMBDA=0.01
+ RD=0.01
+ RS=0.01
+ RG=1.0
+ CGS=500p
+ CGD=100p
+ CBD=200p
+ IS=1e-14
+ PB=0.8
+ CGSO=1.5e-10
+ CGDO=3e-11)
M1 DRAIN_Q1 GATE_Q1 GND GND IRLML6344 W=1 L=1
* -----------------------------------------------------------------------
* DIODO FLYBACK SS14-E3_61T (Schottky 1A, 40V)
* -----------------------------------------------------------------------
* Protege el MOSFET de picos inductivos cuando carga es inductiva
* Vf = 0.34V @ 1A (Schottky → caida baja, rapido)
.model SS14 D(Is=2e-8 N=1.05 Rs=0.04 Cjo=150p Vj=0.35 M=0.5 BV=40)
Dflyback DRAIN_Q1 VLOAD SS14
* -----------------------------------------------------------------------
* CARGA DE PRUEBA
* -----------------------------------------------------------------------
* Ejemplo 1: Carga resistiva pura (LED de senalizacion, rele pequeno)
Rload_out VLOAD DRAIN_Q1 120 ; 12V / 120Ω = 100mA (ej: LED + rele)
* Ejemplo 2: Carga inductiva (rele, valvula solenoide) — descomentar para probar
* Descomenta las dos lineas siguientes y comenta Rload_out de arriba:
* Rcoil VLOAD DRAIN_Q1 80 ; 12V / 80Ω = 150mA bobina de rele
* Lcoil DRAIN_Q1 DRAIN_Q1b 10m ; 10mH inductancia tipica de rele
* Rload_out DRAIN_Q1b GND 0.1 ; dummy para cerrar el nodo
* -----------------------------------------------------------------------
* MEDICION DE POTENCIA (directivas .meas)
* -----------------------------------------------------------------------
.meas TRAN Pdiss_R9 AVG {I(Rdrive)^2 * 332} FROM 60m TO 100m
.meas TRAN I_led_avg AVG {ABS(I(Dled))} FROM 60m TO 100m
.meas TRAN Vout_on AVG V(drain_q1) FROM 60m TO 100m
.meas TRAN Vgs_on AVG V(gate_q1) FROM 60m TO 100m
* -----------------------------------------------------------------------
* NODO DE SALIDA
* -----------------------------------------------------------------------
* Probe: V(drain_q1) para ver la tension de salida al load
* -----------------------------------------------------------------------
* DIRECTIVAS DE SIMULACION
* -----------------------------------------------------------------------
.tran 0 120m 0 100n
.options reltol=0.001
* -----------------------------------------------------------------------
* VALORES ESPERADOS
* -----------------------------------------------------------------------
* Estado OFF (GPIO=3.3V, t=0 a 5ms):
* I(Dled) = 6.3mA → LED PC817 encendido → transistor ON → Gate = 0V
* V(gate_q1)= ~0V (pulled down by PC817 collector)
* V(out1) = VLOAD = 12V (MOSFET OFF, carga desconectada de GND)
*
* Estado ON (GPIO=0V, t=5ms a 55ms):
* I(Dled) = 0mA → LED apagado → transistor OFF → Gate pulled up
* V(gate_q1)= ~3.3V (pull-up activo)
* V(out1) = ~0.1V (MOSFET ON, RDS*Iload = 0.045*0.1 = 4.5mV)
* I(Rload_out) = 12V/120Ω = 100mA
*
* NOTA SOBRE LOGICA INVERSA:
* GPIO HIGH → Salida APAGADA
* GPIO LOW → Salida ENCENDIDA
* Esto es intencional — el firmware invierte la logica en software
* Ventaja: en reset/power-up (GPIO=flotante→pull-up interno=HIGH) las
* salidas estan APAGADAS por defecto → seguro ante fallo de firmware
.backanno
.end
+192
View File
@@ -0,0 +1,192 @@
* =======================================================================
* AR-Autopilot — Entrada Analogica Universal (Acondicionamiento de Senal)
* Archivo: 3_analog_input.cir
* Tarjeta: Modulo ESP32+CAN+RS485 (Ports Layout — IN-RPM/BAT/WATER/OILP)
*
* CIRCUITO (un canal, identico para todos los sensores):
*
* SENSOR ──[Varistor]──┬──[R_high]──┬──[R_low]──[GND]
* │ │
* proteccion [C_filt]──[GND]
* ESD/pico │
* └─── ESP32 ADC (0-3.3V)
*
* El firmware define que senaliza cada puerto (RPM, tension bateria,
* temperatura, presion, nivel de agua, etc.)
*
* PARAMETROS CONFIGURABLES (cambiar segun sensor):
* .param Vsensor = tension maxima del sensor
* .param R_high = resistor superior del divisor
* .param R_low = resistor inferior del divisor
* .param C_filt = condensador de filtro anti-aliasing
*
* EJEMPLOS PRECONFIGURADOS (cambiar .param activo):
* Tension bateria 12V → ADC 3.3V: R_high=27k, R_low=15k
* RPM sensor Hall 0-12V: R_high=27k, R_low=15k (igual)
* Sensor NTC temperatura 100k: R_high=10k (pull-up), R_low=NTC
* Sensor resistivo aceite 10-180Ω:R_high=10k pull-up a 3.3V
*
* COMO USAR EN LTSPICE:
* File → Open → 3_analog_input.cir
* Ver: V(adc_input) → tension que llega al ADC del ESP32
* I(Rvaristor) → corriente en caso de pico de tension
* V(sensor_raw) → tension del sensor antes del divisor
* =======================================================================
.title AR-Autopilot Analog Input Conditioning
* -----------------------------------------------------------------------
* PARAMETROS DEL DIVISOR (modificar segun sensor)
* -----------------------------------------------------------------------
* VALORES CORREGIDOS EN ESQUEMATICO (todos los puertos analogicos unificados):
* IN-BAT: R41=100K, R40=27K → Vmax_input = 3.3*(100+27)/27 = 15.5V ✓
* IN-WATER: R31=100K, R30=27K → igual configuracion ✓
* IN-OILP: R33=100K, R32=27K → igual configuracion ✓
* IN-RPM: R41=100K, R40=27K → igual (ya estaba bien) ✓
*
* Analisis del divisor con 100K + 27K:
* Vout @ 12.0V = 12.0 * 27/127 = 2.55V ← OK, bateria descargada
* Vout @ 14.4V = 14.4 * 27/127 = 3.06V ← OK, alternador cargando (limite)
* Vout @ 15.5V = 15.5 * 27/127 = 3.30V ← limite absoluto del ADC
* Vout @ 28.0V = pico load dump → clamp ESP32 lo absorbe ✓
*
* Rango de tension segura en la entrada del sensor: hasta 15.5V
* Fc del filtro RC: 1/(2*pi*(100k||27k)*10n) = 1/(2*pi*21.3k*10n) = 747 Hz
* Resolucion ADC 12bit a 12V: 2.55V / 4096 = 0.62mV/LSB → 12V/4096 * 4.98 = 14.6mV/LSB
.param Rhi = 100k ; Resistor superior del divisor (VALOR CORREGIDO)
.param Rlo = 27k ; Resistor inferior del divisor (VALOR CORREGIDO)
.param Cfilt = 10n ; Condensador de filtro anti-aliasing
.param Varistor_Vc = 5.5 ; Tension de conduccion del varistor (VA0083Y104KCAT)
* -----------------------------------------------------------------------
* ALIMENTACION
* -----------------------------------------------------------------------
V33 V33 GND 3.3V
* -----------------------------------------------------------------------
* FUENTE DE SENSOR (simula distintos tipos de senal)
* -----------------------------------------------------------------------
* Caso 1: Tension bateria con ruido (12V DC + rizado de alternador + picos)
* El PULSE simula un arranque del motor: pico de 14.4V (alternador cargando)
Vsensor SENSOR_RAW GND PWL(
+ 0 12.0
+ 5m 12.0
+ 6m 14.4
+ 50m 14.4
+ 51m 12.6
+ 100m 12.6
+ 101m 28.0
+ 102m 12.6
+ 200m 12.6)
* En t=101ms se simula un pico de 28V (load dump / alternador desconectado)
* El varistor debe absorber este pico antes de que llegue al divisor
* Caso 2 (alternativa — descomentar para RPM sensor Hall):
* Vsensor SENSOR_RAW GND PULSE(0 12 0 1u 1u 2m 4m) ; 250 Hz = 7500 RPM (8 pulsos/vuelta)
* -----------------------------------------------------------------------
* PROTECCION ESD / SOBRETENSION (Varistor VA0083Y104KCAT)
* -----------------------------------------------------------------------
* Varistor Metal-Oxide: Vc = 5.5V tipico (no es el valor correcto para 12V!)
* ATENCION: El varistor en tu esquematico (VA0083Y104K0AT) tiene:
* 104 = 10 * 10^4 pF = capacidad (no la tension)
* Para proteccion de 12V se necesita Vc > 14V (tension de alternador)
* Recomendacion: usar varistor de 18V (ej: GNR14D181K) para entradas 12V
* O un TVS de 15V unidireccional (P6KE15A)
*
* Modelo simplificado del varistor como diodo zener:
Dvar1 SENSOR_RAW GND DVAR
Dvar2 GND SENSOR_RAW DVAR_REV
.model DVAR D(BV=18 IBV=1m Rs=1 Cjo=500p)
.model DVAR_REV D(BV=0.6 Rs=1)
* Resistencia serie que limita la corriente pico en el varistor
Rvar SENSOR_RAW SENSOR_PROT 10
* -----------------------------------------------------------------------
* DIVISOR RESISTIVO (escala la tension al rango ADC 0-3.3V)
* -----------------------------------------------------------------------
Rdiv_hi SENSOR_PROT ADC_PRE {Rhi}
Rdiv_lo ADC_PRE GND {Rlo}
* Verificacion del divisor con los valores corregidos (100k + 27k):
* Vout @ 12.0V = 12.0 * 27/(100+27) = 12.0 * 0.213 = 2.55V ← OK ✓
* Vout @ 14.4V = 14.4 * 0.213 = 3.06V ← OK, justo en el limite ✓
* Vout @ 15.5V = 15.5 * 0.213 = 3.30V ← limite absoluto ✓
* Vout @ 28.0V = pico load dump → diodo clamp ESP32 lo absorbe ✓
*
* DISEÑO APROBADO: Todos los puertos IN-BAT, IN-WATER, IN-OILP, IN-RPM
* usan R_high=100k, R_low=27k — diseno uniforme para toda la familia de sensores
* marinos de 12V. Vmax segura en entrada: 15.5V (cubre alternador + margenes).
* -----------------------------------------------------------------------
* FILTRO ANTI-ALIASING RC (condensador de filtro)
* -----------------------------------------------------------------------
* Frecuencia de corte: fc = 1 / (2*pi*R_parallel*C)
* R_parallel = R_high||R_low = 27k||15k = 9.86k
* Con C=10nF: fc = 1/(2*pi*9.86k*10n) = 1.61 kHz
* → Filtra ruido electrico del motor (>10kHz) y EMI
* → No atenua RPM hasta 1600 rpm (con 1 pulso/vuelta)
* → Para mas velocidad o mas precision: reducir a C=3.3nF → fc=4.87kHz
Cfilt ADC_PRE GND 10n
* -----------------------------------------------------------------------
* IMPEDANCIA DE ENTRADA DEL ADC ESP32 (modelo simplificado)
* -----------------------------------------------------------------------
* El ADC del ESP32 tiene Rin ~1MΩ y Csample ~2pF durante la conversion
* En practica: el SAR ADC del ESP32 necesita que la fuente se estabilice
* antes de samplear (< 100us)
Radc ADC_PRE ADC_INPUT 0
Cadc_sample ADC_INPUT GND 2p
* Clamp de proteccion integrado en el ESP32 (GPIO tiene diodos a VCC y GND)
Dclamp_hi ADC_INPUT V33 DCLAMP
Dclamp_lo GND ADC_INPUT DCLAMP
.model DCLAMP D(Is=1e-14 N=1 Rs=100 BV=3.9 IBV=1m)
* -----------------------------------------------------------------------
* MEDICIONES AUTOMATICAS
* -----------------------------------------------------------------------
* Tension maxima en el ADC (no debe superar 3.3V)
.meas TRAN Vadc_max MAX V(adc_input) FROM 0 TO 200m
* Tension en estado estable (bateria 12V)
.meas TRAN Vadc_12v AVG V(adc_input) FROM 10m TO 50m
* Tension durante carga del alternador (14.4V)
.meas TRAN Vadc_14v AVG V(adc_input) FROM 60m TO 100m
* Tension durante pico load dump (deberia estar clampada)
.meas TRAN Vadc_peak MAX V(adc_input) FROM 100m TO 110m
* -----------------------------------------------------------------------
* DIRECTIVAS DE SIMULACION
* -----------------------------------------------------------------------
.tran 0 200m 0 100n
.options reltol=0.001
* -----------------------------------------------------------------------
* NOTAS DE DISENO Y REVISION
* -----------------------------------------------------------------------
* REVISION FINAL — Todos los puertos analogicos verificados:
* [OK] IN-RPM: R41=100k, R40=27k → 0-15.5V → 0-3.30V ADC ✓
* [OK] IN-BAT: R41=100k, R40=27k → idem, corregido ✓
* [OK] IN-WATER: R31=100k, R30=27k → idem, corregido ✓
* [OK] IN-OILP: R33=100k, R32=27k → idem, corregido ✓
*
* Filtro RC con 100k||27k = 21.3k y C=10nF:
* fc = 1/(2*pi*21.3k*10n) = 747 Hz
* Atenua ruido del motor (>1kHz), no afecta senal DC de sensores de presion/temperatura
*
* Varistor VA0083Y104KCAT:
* ATENCION: Este varistor es de 10V (104 indica capacidad, no tension)
* Para proteccion de entradas 12V marino usar varistor de 18V o TVS P6KE15A
* Con el divisor 100k+27k el ADC ya esta protegido por el clamp interno del ESP32
* en caso de pico — el varistor es una capa adicional para el cable de entrada
*
* Resolucion efectiva del ADC:
* 12V bateria → 2.55V ADC → 12bit: 4096 cuentas × (12V/2.55V)/4096 = 2.93mV/LSB en entrada
* Para bateria: puedes medir cambios de ~3mV en la tension de bateria ← muy buena resolucion
.backanno
.end
+216
View File
@@ -0,0 +1,216 @@
* =======================================================================
* AR-Autopilot — Interfaz NMEA 2000 / CAN Bus
* Archivo: 4_nmea2000_can.cir
* Tarjetas: Ambas (Modulo ESP32+CAN+RS485 y Modulo compacto)
*
* CIRCUITO:
*
* ESP32 GPIO23 (TXD) ──── MCP2562T TXD ───┐
* ESP32 GPIO21 (RXD) ──── MCP2562T RXD ───┤
* 3.3V ────────────────── MCP2562T VIO ───┤ (nivel logico 3.3V)
* 3.3V ────────────────── MCP2562T VDD ───┤ (alimentacion)
* GND ─────────────────── MCP2562T GND ───┤
* GND/3.3V ────────────── MCP2562T STBY ──┤ (0=activo, 1=standby)
* │
* MCP2562T CANH ──[R24=120Ω/2]──┬── CANH BUS (NMEA2000)
* MCP2562T CANL ──[R24=120Ω/2]──┴── CANL BUS (NMEA2000)
* │
* [SP0502BAHTG] (proteccion ESD)
* │
* RELAY K1/K2 ── selecciona si es nodo terminal
*
* NMEA 2000 = CAN 2.0B a 250 kbps, maximo 50 nodos, longitud max 200m
*
* COMO USAR EN LTSPICE:
* Ver: V(canh) y V(canl) → formas de onda diferencial del bus CAN
* V(can_diff) = V(canh)-V(canl) → tension diferencial
* V(rxd_esp32) → senal recibida por el ESP32
* =======================================================================
.title AR-Autopilot NMEA2000 CAN Interface MCP2562T
* -----------------------------------------------------------------------
* ALIMENTACION
* -----------------------------------------------------------------------
V33 V33 GND 3.3V
* -----------------------------------------------------------------------
* ESP32 TRANSMISOR (GPIO23 generando tramas CAN)
* -----------------------------------------------------------------------
* Simulamos 3 bits CAN: dominante(0) y recesivo(1)
* CAN 250kbps → bit time = 4us
* Secuencia: idle(1) → start_bit(0) → data(1,0,1) → idle(1)
Vtxd TXD_ESP GND PWL(
+ 0 3.3
+ 4u 3.3
+ 4.1u 0
+ 8u 0
+ 8.1u 3.3
+ 12u 3.3
+ 12.1u 0
+ 16u 0
+ 16.1u 3.3
+ 20u 3.3
+ 20.1u 3.3
+ 40u 3.3)
* -----------------------------------------------------------------------
* MCP2562T-E_MF — CAN FD TRANSCEIVER (modelo comportamental)
* -----------------------------------------------------------------------
* Especificaciones clave:
* VIO: 1.8V a 5.5V (compatible 3.3V del ESP32) ✓
* VDD: 5V (puede usar 3.3V con limitaciones de velocidad)
* CANH dominant: VDD - 1.2V (con VDD=3.3V: CANH=2.1V)
* CANL dominant: 0.9V
* CANH recessive: VDD/2 = 1.65V (bus flotante en recesivo)
* Velocidad: hasta 8 Mbps (CAN FD) — usamos 250kbps para NMEA2000
*
* MODO DOMINANTE (TXD=0): CANH=3.5V, CANL=1.5V, Vdiff=2.0V
* MODO RECESIVO (TXD=1): CANH=CANL=2.5V, Vdiff=0V
*
* Fuentes behaviorales que modelan el driver de salida:
* CANH driver: en dominante sube a ~3.5V, en recesivo va a 2.5V (resistencia de pullup)
Ecanh CANH_DRV GND VALUE={
+ IF(V(TXD_ESP) < 1.65,
+ 3.5,
+ 2.5) }
* CANL driver: en dominante baja a ~1.5V, en recesivo va a 2.5V
Ecanl CANL_DRV GND VALUE={
+ IF(V(TXD_ESP) < 1.65,
+ 1.5,
+ 2.5) }
* Resistencia de salida del driver (impedancia de salida del transceptor)
Rout_h CANH_DRV CANH_IC 50
Rout_l CANL_DRV CANL_IC 50
* -----------------------------------------------------------------------
* CONDENSADORES DE DESACOPLO (C18=4.7uF, C20=100nF, C21=4.7uF en tu esquema)
* -----------------------------------------------------------------------
C18 V33 GND 4.7u
C20 V33 GND 100n
C21 V33 GND 4.7u
* -----------------------------------------------------------------------
* PROTECCION ESD — SP0502BAHTG (Dual Rail-to-Rail ESD)
* -----------------------------------------------------------------------
* Dos diodos TVS de baja capacidad en cada linea CAN
* Vclamping tipico: 9V a 1A, capacidad: 0.5pF (no afecta senal CAN) ✓
.model SP0502 D(BV=6 IBV=1m Rs=0.5 Cjo=0.5p Vj=0.5)
Desd_h1 CANH_IC GND SP0502 ; CANH a GND
Desd_h2 V33 CANH_IC SP0502 ; CANH a VCC
Desd_l1 CANL_IC GND SP0502 ; CANL a GND
Desd_l2 V33 CANL_IC SP0502 ; CANL a VCC
* -----------------------------------------------------------------------
* FILTRO DE MODO COMUN (C19=10nF en tu esquema)
* -----------------------------------------------------------------------
* Filtro entre lineas CAN: reduce EMI de modo comun
* (En tu esquema aparece C19=10nF entre las dos lineas o a GND)
Ccm CANH_IC CANL_IC 10n
* -----------------------------------------------------------------------
* TERMINACION DEL BUS — R24=120Ω con RELAY K1/K2
* -----------------------------------------------------------------------
* La terminacion de 120Ω se activa cuando el nodo es el EXTREMO del bus.
* En tu esquema usas un relay (76740-3) para conmutar la terminacion.
* Esto es muy inteligente: permite cambiar la topologia sin resoldar.
*
* Estado K1/K2 = CERRADO (nodo terminal, terminacion activa):
Rterm CANH_BUS CANL_BUS 120 ; terminacion nominal NMEA2000
* Si K1/K2 = ABIERTO (nodo intermedio):
* Descomenta para simular sin terminacion (nodo medio del bus):
* Rterm_open CANH_BUS CANL_BUS 100Meg ; sin terminacion
* -----------------------------------------------------------------------
* CABLE DEL BUS NMEA 2000 (impedancia de linea 120Ω, longitud 5m tipica)
* -----------------------------------------------------------------------
* Modelo de linea de transmision (T-line)
* Impedancia caracteristica: 120Ω (especificacion NMEA 2000)
* Velocidad de propagacion: ~200 m/us para cable par trenzado
* Retardo para 5m: td = 5m / (200m/us) = 25ns
T1 CANH_IC CANL_IC CANH_BUS CANL_BUS Zo=120 Td=25n
* -----------------------------------------------------------------------
* SEGUNDO NODO CAN EN EL BUS (simula otro instrumento NMEA2000)
* -----------------------------------------------------------------------
* Generador de tramas del segundo nodo (simula un GPS o compass NMEA2000)
Vtxd2 TXD2 GND PWL(
+ 0 3.3
+ 20u 3.3
+ 20.1u 0
+ 24u 0
+ 24.1u 3.3
+ 28u 3.3
+ 28.1u 0
+ 32u 0
+ 32.1u 3.3
+ 40u 3.3)
Ecanh2 CANH2_DRV GND VALUE={IF(V(TXD2) < 1.65, 3.5, 2.5)}
Ecanl2 CANL2_DRV GND VALUE={IF(V(TXD2) < 1.65, 1.5, 2.5)}
Rout_h2 CANH2_DRV CANH_BUS 50
Rout_l2 CANL2_DRV CANL_BUS 50
* -----------------------------------------------------------------------
* RECEPTOR (MCP2562T → ESP32 RXD)
* -----------------------------------------------------------------------
* El receptor compara CANH-CANL:
* Vdiff > 0.9V → dominante → RXD = 0 (LOW al ESP32)
* Vdiff < 0.5V → recesivo → RXD = 1 (HIGH al ESP32)
* Histeresis: 200mV
Erxd RXD_ESP GND VALUE={
+ IF(V(CANH_BUS) - V(CANL_BUS) > 0.9,
+ 0,
+ 3.3) }
* -----------------------------------------------------------------------
* ANALISIS DE TENSION DIFERENCIAL
* -----------------------------------------------------------------------
* Tension diferencial del bus para visualizar en graficas:
Ediff CAN_DIFF GND VALUE={V(CANH_BUS) - V(CANL_BUS)}
* -----------------------------------------------------------------------
* MEDICIONES AUTOMATICAS
* -----------------------------------------------------------------------
.meas TRAN Vdiff_dom MAX V(can_diff) FROM 5u TO 10u
.meas TRAN Vdiff_rec MIN V(can_diff) FROM 10u TO 15u
.meas TRAN Vcanh_dom AVG V(canh_bus) FROM 5u TO 9u
.meas TRAN Vcanl_dom AVG V(canl_bus) FROM 5u TO 9u
* -----------------------------------------------------------------------
* DIRECTIVAS DE SIMULACION
* -----------------------------------------------------------------------
* 40us = 10 periodos de bit a 250kbps
.tran 0 40u 0 1n
.options reltol=0.001
* -----------------------------------------------------------------------
* VALORES ESPERADOS NMEA 2000 (CAN 2.0B a 250kbps)
* -----------------------------------------------------------------------
* En modo DOMINANTE (bit=0):
* V(canh_bus) = 3.5V ± 0.5V ✓ (especificacion: 2.75V min)
* V(canl_bus) = 1.5V ± 0.5V ✓ (especificacion: 2.25V max)
* V(can_diff) = 2.0V ± 0.5V ✓ (especificacion: 1.5V min)
*
* En modo RECESIVO (bit=1):
* V(canh_bus) = V(canl_bus) = 2.5V (terminado con 120Ω)
* V(can_diff) = 0V ± 50mV
*
* LONGITUD MAXIMA DEL BUS:
* A 250kbps, td_max = 5% del bit time = 200ns
* Longitud max = 200ns * 200m/us = 40m (backbone)
* Stubs maximos: 0.3m (troncal principal) → Micro-C connectors
*
* IMPEDANCIA DE TERMINACION:
* 2 terminaciones de 120Ω en paralelo = 60Ω (carga del bus)
* Con VDD=3.3V: corriente de bus en dominante = 2V / 60Ω = 33mA (OK ✓)
.backanno
.end
+257
View File
@@ -0,0 +1,257 @@
* =======================================================================
* AR-Autopilot — Interfaz RS-485 (NMEA 0183 / Instrumentos Serie)
* Archivo: 5_rs485.cir
* Tarjeta: Modulo ESP32+CAN+RS485 (solo esta tarjeta tiene RS-485)
*
* CIRCUITO:
*
* ESP32 GPIO17 (TXD) ──[R?=0Ω]──── SN65HVD1781 DI (Data Input)
* ESP32 GPIO16 (RXD) ──────────── SN65HVD1781 RO (Receiver Output)
* ESP32 GPIO4 (DE) ──────────── SN65HVD1781 DE (Driver Enable)
* ESP32 GPIO4 (RE) ──────────── SN65HVD1781 /RE (Receiver Enable, activo bajo)
* (DE y /RE conectados juntos — half-duplex, control por GPIO4)
*
* 3.3V ─── SN65HVD1781 Vcc ─── [C=100nF decoupling]
*
* SN65HVD1781 A ──[R_bias=560Ω]──── +3.3V (bias de terminacion)
* SN65HVD1781 B ──[R_bias=560Ω]──── GND (bias de terminacion)
* SN65HVD1781 A ──[R_term=120Ω]──── SN65HVD1781 B (terminacion)
* SN65HVD1781 A ──[Proteccion ESD]──── Bus RS485_A
* SN65HVD1781 B ──[Proteccion ESD]──── Bus RS485_B
*
* NMEA 0183: RS-485 half-duplex, 4800 bps (NMEA standard) o 38400 bps
* Nivel logico: 3.3V (SN65HVD1781 es nativo 3.3V) ✓
*
* SN65HVD1781: Bus Fault Protected RS-485 Transceiver
* Vcc: 3V a 3.6V (3.3V ✓)
* Max 32 unit loads por bus (vs 8 del RS-422 clasico)
* Bus fault protection: hasta ±15kV HBM ESD
* Bus pins toleran hasta 12V sin alimentacion (proteccion de bus caliente)
* Driver: A-B > 200mV cuando DE=HIGH (dominante)
* Receiver: salida HIGH cuando A-B > +200mV, LOW cuando A-B < -200mV
*
* VELOCIDADES RS-485 usadas en nautica:
* NMEA 0183: 4800 bps (bit time = 208us)
* NMEA 0183 HS: 38400 bps (bit time = 26us)
* ModBus RTU: 9600 / 19200 / 38400 bps
* Propietario (B&G): 115200 bps o superior
*
* COMO USAR EN LTSPICE:
* Ver: V(rs485_a) y V(rs485_b) → diferencial del bus
* V(rs485_diff) = A-B → tension diferencial
* V(ro_esp32) → datos recibidos por el ESP32
* V(de_gpio) → estado del control de direccion (TX/RX)
* =======================================================================
.title AR-Autopilot RS-485 SN65HVD1781 Interface
* -----------------------------------------------------------------------
* ALIMENTACION
* -----------------------------------------------------------------------
V33 V33 GND 3.3V
* Condensador de desacoplo del transceiver
Cvcc V33 GND 100n
* -----------------------------------------------------------------------
* ESP32 TRANSMISOR (GPIO17 generando trama NMEA 0183)
* -----------------------------------------------------------------------
* NMEA 0183 @ 4800 bps: bit time = 208us
* Simulamos: idle(1) → start_bit(0) → 8 bits de dato → stop_bit(1)
* Dato: ASCII 'G' = 0x47 = 0100 0111 (LSB primero: 1,1,1,0,0,0,1,0)
*
* Secuencia completa: IDLE | START | 1 | 1 | 1 | 0 | 0 | 0 | 1 | 0 | STOP | IDLE
Vtxd TXD_ESP GND PWL(
+ 0 3.3
+ 208u 3.3
+ 208.1u 0 ; START BIT (dominante)
+ 416u 0
+ 416.1u 3.3 ; BIT 0 = 1 (recesivo)
+ 624u 3.3
+ 624.1u 3.3 ; BIT 1 = 1 (recesivo)
+ 832u 3.3
+ 832.1u 3.3 ; BIT 2 = 1 (recesivo)
+ 1040u 3.3
+ 1040.1u 0 ; BIT 3 = 0 (dominante)
+ 1248u 0
+ 1248.1u 0 ; BIT 4 = 0 (dominante)
+ 1456u 0
+ 1456.1u 0 ; BIT 5 = 0 (dominante)
+ 1664u 0
+ 1664.1u 3.3 ; BIT 6 = 1 (recesivo)
+ 1872u 3.3
+ 1872.1u 0 ; BIT 7 = 0 (dominante)
+ 2080u 0
+ 2080.1u 3.3 ; STOP BIT (recesivo)
+ 2500u 3.3) ; IDLE
* -----------------------------------------------------------------------
* CONTROL DE DIRECCION — GPIO4 (DE y /RE juntos)
* -----------------------------------------------------------------------
* HIGH = Modo transmision (Driver Enable, Receiver Disable)
* LOW = Modo recepcion (Driver Disable, Receiver Enable)
* Durante transmision: GPIO4 = HIGH (activo durante toda la trama + guard time)
Vde DE_GPIO GND PWL(
+ 0 0 ; modo recepcion al inicio
+ 100u 0
+ 100.1u 3.3 ; cambiar a TX antes del start bit
+ 2500u 3.3
+ 2500.1u 0 ; volver a RX despues del stop bit
+ 3000u 0)
* -----------------------------------------------------------------------
* SN65HVD1781 — DRIVER RS-485 (modelo comportamental)
* -----------------------------------------------------------------------
* Cuando DE=HIGH (transmitiendo):
* DI=1 (idle/stop) → A > B → Va=3.3V, Vb=0V, Vdiff=+3.3V
* DI=0 (start/mark)→ B > A → Va=0V, Vb=3.3V, Vdiff=-3.3V
* (RS-485 usa logica inversa: 1=A>B, 0=B>A)
*
* Cuando DE=LOW (recibiendo):
* Driver en alta impedancia (salidas flotan en el potencial del bias)
* Driver A: HIGH cuando DI=1 y DE=1, alta-Z cuando DE=0
Edrv_a DRV_A GND VALUE={
+ IF(V(DE_GPIO) > 1.65,
+ IF(V(TXD_ESP) > 1.65, 3.3, 0),
+ 1.65) }
* (en alta-Z, el driver va al potencial medio; el bias de terminacion lo mantiene)
* Driver B: complementario de A
Edrv_b DRV_B GND VALUE={
+ IF(V(DE_GPIO) > 1.65,
+ IF(V(TXD_ESP) > 1.65, 0, 3.3),
+ 1.65) }
* Impedancia de salida del driver SN65HVD1781
* Ron del driver tipico: 12Ω (datasheet Texas Instruments)
Rdrv_a DRV_A RS485_A_IC 12
Rdrv_b DRV_B RS485_B_IC 12
* -----------------------------------------------------------------------
* RESISTENCIAS DE POLARIZACION (BIAS) DEL BUS
* -----------------------------------------------------------------------
* RS-485 requiere bias cuando el bus esta en reposo (sin driver activo)
* para evitar estado indeterminado en el receptor.
* NMEA 0183 requiere Vdiff > 200mV incluso en idle.
* Con Rbias=560Ω a cada rail y Rterm=120Ω:
* Vth = Rbias_hi || Rterm || Rbias_lo + GND
* Va = 3.3 * (120||560) / (560 + 120||560) = 3.3 * 103/663 = 0.51V ← hmm
* Vb = 3.3 * 560 / (560+120) + ... necesita analisis de divisor completo
*
* Analisis correcto con Rterm=120 en paralelo entre A y B:
* Red: V33 -- 560 -- A --+-- 120 --+-- B -- 560 -- GND
* Va = V33 * (120||560 + 560_B_GND) / total ... simplificado:
* Con Rbias=560 y Rterm=120:
* Va = 3.3V * R_abajo/(R_arriba+R_abajo) donde R_abajo incluye la red
* Va ≈ 1.98V, Vb ≈ 1.32V → Vdiff = 0.66V > 200mV ✓ (bus en reposo seguro)
*
Rbias_hi V33 RS485_A_IC 560 ; pullup en linea A
Rbias_lo RS485_B_IC GND 560 ; pulldown en linea B
* -----------------------------------------------------------------------
* TERMINACION DEL BUS RS-485
* -----------------------------------------------------------------------
* Impedancia caracteristica del par trenzado: ~120Ω
* En cada extremo del bus se coloca 120Ω para evitar reflexiones.
* En NMEA 0183 es comun omitir terminacion en buses cortos (<10m)
* En instalaciones largas (salon motor → puente) si es necesaria.
Rterm RS485_A_IC RS485_B_IC 120 ; terminacion nominal
* -----------------------------------------------------------------------
* CABLE DEL BUS RS-485 (modelo de linea de transmision)
* -----------------------------------------------------------------------
* Par trenzado tipico: Zo=120Ω, velocidad ~200m/us
* Para una instalacion tipica en barco: 10m → Td = 50ns
T2 RS485_A_IC RS485_B_IC RS485_A_BUS RS485_B_BUS Zo=120 Td=50n
* -----------------------------------------------------------------------
* SEGUNDO NODO RS-485 (simula un instrumento NMEA 0183)
* -----------------------------------------------------------------------
* El segundo nodo esta en modo recepcion (solo escucha)
* Solo aporta su impedancia de entrada al bus (tipicamente 1 unit load = 12kΩ)
Rnode2_a RS485_A_BUS GND 12k ; impedancia de entrada receptor (unit load)
Rnode2_b RS485_B_BUS GND 12k ; idem linea B
* -----------------------------------------------------------------------
* TERMINACION REMOTA (extremo lejano del cable)
* -----------------------------------------------------------------------
Rterm2 RS485_A_BUS RS485_B_BUS 120
* -----------------------------------------------------------------------
* RECEPTOR RS-485 → ESP32 (SN65HVD1781 Receiver Output)
* -----------------------------------------------------------------------
* El receptor compara A vs B:
* A - B > +200mV → RO = HIGH (3.3V al ESP32)
* A - B < -200mV → RO = LOW (0V al ESP32)
* Histeresis: ~60mV
* Cuando DE=HIGH (transmitiendo): receptor deshabilitado (/RE=HIGH)
*
Erxd RO_ESP32 GND VALUE={
+ IF(V(DE_GPIO) > 1.65,
+ 3.3,
+ IF(V(RS485_A_BUS) - V(RS485_B_BUS) > 0.2,
+ 3.3,
+ 0)) }
* -----------------------------------------------------------------------
* TENSION DIFERENCIAL DEL BUS (para graficas)
* -----------------------------------------------------------------------
Ediff RS485_DIFF GND VALUE={V(RS485_A_BUS) - V(RS485_B_BUS)}
* -----------------------------------------------------------------------
* MEDICIONES AUTOMATICAS
* -----------------------------------------------------------------------
* Tension diferencial durante bit dominante (start bit, t=208us a 416us)
.meas TRAN Vdiff_dom AVG V(rs485_diff) FROM 210u TO 414u
* Tension diferencial durante bit recesivo (bit0=1, t=416us a 624us)
.meas TRAN Vdiff_rec AVG V(rs485_diff) FROM 418u TO 622u
* Tension en linea A durante transmision
.meas TRAN Va_dom AVG V(rs485_a_bus) FROM 210u TO 414u
* Tension en linea B durante transmision
.meas TRAN Vb_dom AVG V(rs485_b_bus) FROM 210u TO 414u
* -----------------------------------------------------------------------
* DIRECTIVAS DE SIMULACION
* -----------------------------------------------------------------------
* 3ms = un caracter NMEA 0183 completo a 4800 bps (10 bits: 1 start + 8 data + 1 stop)
* Paso maximo 100ns para capturar transiciones del driver
.tran 0 3m 0 100n
.options reltol=0.001
* -----------------------------------------------------------------------
* VALORES ESPERADOS RS-485 / NMEA 0183
* -----------------------------------------------------------------------
* Modo DOMINANTE (DI=0, B>A):
* V(rs485_a_bus) ≈ 0.2V (driver baja A)
* V(rs485_b_bus) ≈ 3.1V (driver sube B)
* V(rs485_diff) ≈ -2.9V → Vdiff = A-B < -200mV ✓ (dato logico 0)
*
* Modo RECESIVO (DI=1, A>B):
* V(rs485_a_bus) ≈ 3.1V
* V(rs485_b_bus) ≈ 0.2V
* V(rs485_diff) ≈ +2.9V → Vdiff = A-B > +200mV ✓ (dato logico 1)
*
* Bus en reposo (DE=0, sin driver):
* V(rs485_diff) ≈ +0.66V → receptor ve HIGH (idle = 1 = linea libre) ✓
* (garantizado por resistencias de bias 560Ω)
*
* NMEA 0183 Formato: 1 start (0) + 8 bits dato (LSB first) + 1 stop (1), sin paridad
* ASCII 'G' = 0x47 = 0100 0111:
* LSB first: 1 1 1 0 0 0 1 0
* Linea fisica: S=0, 1, 1, 1, 0, 0, 0, 1, 0, P=1
* (S=start, P=stop)
*
* LONGITUD MAXIMA DEL BUS:
* A 4800 bps: td_max = 10% del bit time = 20.8us → longitud max = 4160m ✓ (mas que cualquier barco)
* A 38400 bps: td_max = 2.6us → 520m ✓ (mas que suficiente)
*
* SN65HVD1781 — CARACTERISTICAS CLAVE:
* 32 unit loads en bus (permite hasta 32 instrumentos NMEA)
* Bus fault protection ±15kV → supervive conexion erronea en marina
* Failsafe receiver: si bus abierto o cortocircuito, RO=HIGH (idle seguro)
* Slew rate limitado: 230kbps max en modo normal (suficiente para NMEA 0183 HS)
.backanno
.end
+293
View File
@@ -0,0 +1,293 @@
* =======================================================================
* AR-Autopilot — IMU BNO085: Alimentacion, Reset e I2C
* Archivo: 6_bno085_imu.cir
* Tarjeta: Modulo compacto ESP32+CAN (CIRCUITO GIRO)
*
* CIRCUITO:
*
* 3.3V ──[C=10uF + C=100nF]──── BNO085 VDD (alimentacion principal)
* 3.3V ──[C=100nF]────────────── BNO085 VDDIO (nivel logico I2C = 3.3V)
*
* 3.3V ──[R=10K]──── NRST (BNO085 RESET, activo bajo)
* └── [C=1uF] ── GND → reset POR: NRST sube lento
*
* 3.3V ──[R=4.7K]──── SCL (I2C clock, 400 kHz Fast Mode)
* 3.3V ──[R=4.7K]──── SDA (I2C data, open-drain)
* 3.3V ──[R=10K] ──── INT (interrupcion data-ready, activo bajo OD)
*
* SA0 ── GND → direccion I2C = 0x4A
* BOOT ── GND → modo operacion normal (no bootloader USB)
*
* BNO085 (CEVA/Hillcrest BNO085):
* Alimentacion: VDD = 3.3V, VDDIO = 1.8V a 3.3V
* Corriente tipica: 6.5 mA (todos los sensores activos, 100 Hz)
* Reset: NRST activo bajo, minimo 10 us, startup: 50 ms tipico
* I2C: 100 kHz o 400 kHz (Fast Mode), max 1 MHz (Fast Mode Plus)
* Reportes configurables: Rotation Vector, Gyroscope, Accelerometer...
* Sensor Fusion DSP interno: 500 Hz internal fusion rate
* Giroscopio: ruido ~0.014 deg/s RMS, bias < 1 deg/s
* Magnetometro: compensacion hard/soft iron automatica
* Rango de temperatura operacion: -40 a +85 C (marino OK)
*
* REPORTES USADOS PARA EL AUTOPILOTO:
* ARVR Stabilized Rotation Vector → Heading (0-360°) a 100 Hz
* Gyroscope Calibrated → Yaw rate (°/s) a 250 Hz
* Linear Acceleration → para deteccion de impactos
*
* COMO USAR EN LTSPICE:
* Ver: V(nrst) → curva de reset POR (debe superar 0.7×VDD = 2.31V en ~12ms)
* V(vdd_bno) → tension de alimentacion BNO085 con decoupling
* V(scl) → reloj I2C 400 kHz con tiempos de subida correctos
* V(sda) → datos I2C con open-drain y pull-up
* V(int_pin) → interrupcion data-ready
* =======================================================================
.title AR-Autopilot BNO085 IMU Power, Reset and I2C Interface
* -----------------------------------------------------------------------
* ALIMENTACION 3.3V
* -----------------------------------------------------------------------
V33 V33 GND 3.3V
* -----------------------------------------------------------------------
* DESACOPLO DE ALIMENTACION BNO085
* -----------------------------------------------------------------------
* BNO085 datasheet recomienda: 10uF (bulk) + 100nF (HF) en VDD
* y 100nF adicional en VDDIO, lo mas cerca posible del IC.
* A 22mm del inductor L2 del buck: el ruido de switching es bajo
* pero el decoupling sigue siendo critico para el magnetometro.
Cbulk V33 VDD_BNO 10u ; capacitor bulk 10uF ceramico (X5R)
Cdec1 VDD_BNO GND 100n ; 100nF ceramico junto al pin VDD del BNO085
Cdec2 V33 GND 100n ; 100nF en VDDIO (misma tension 3.3V)
* Inductancia del trace PCB entre condensador bulk y IC (~10mm de traza)
* L_trace a 1.4MHz (MP2338): XL = 2*pi*1.4e6*1e-9 = 8.8mOhm → despreciable
Rtrace V33 VDD_BNO 0.05 ; resistencia de traza PCB (~50mOhm para 10mm)
* Consumo del BNO085 (todos sensores + fusion a 100Hz)
Ibno085 VDD_BNO GND DC 6.5m ; 6.5 mA tipico (datasheet tabla 4.3)
* -----------------------------------------------------------------------
* CIRCUITO DE RESET (Power-On Reset)
* -----------------------------------------------------------------------
* NRST activo bajo: cuando VDD sube, NRST debe permanecer bajo
* hasta que VDD este estable, luego sube lentamente via RC.
* El BNO085 sale de reset cuando NRST > 0.7 * VDDIO = 2.31V
* Con R=10K, C=1uF: τ = 10ms → NRST cruza 2.31V en ~12ms
*
* Simulamos encendido: VDD sube en 1ms (soft-start del buck converter)
* Despues NRST sube lentamente → BNO085 en reset durante ~12ms ✓
* Resistencia de pull-up del RESET
Rrst V33 NRST_NODE 10k
* Condensador de reset (define el tiempo de reset)
Crst NRST_NODE GND 1u IC=0
* Diodo de descarga rapida (para re-reset rapido si VDD cae)
Drst GND NRST_NODE DRST_FAST
.model DRST_FAST D(Is=1e-12 N=1 Rs=1 Cjo=5p)
* Modelo del umbral de reset del BNO085 (schmitt trigger interno)
* NRST < 2.31V → en reset; NRST > 2.31V → operativo
Eres RESET_STATUS GND VALUE={IF(V(NRST_NODE) > 2.31, 3.3, 0)}
* -----------------------------------------------------------------------
* BUS I2C — RESISTENCIAS DE PULL-UP
* -----------------------------------------------------------------------
* Fast Mode (400 kHz): Rpullup maximo = Vcc/(3mA) = 3.3/0.003 = 1.1k
* Rpullup minimo = (Vcc - Voh)/(bus cap × slew rate) ≈ 1k
* Valor estandar: 4.7k (funciona bien hasta 300kHz con Cbus < 100pF)
* Para 400kHz con Cbus=50pF: tr = 0.8473 * 4700 * 50e-12 = 199ns OK (limite es 300ns)
*
* Si necesitas 400kHz garantizado con cable largo (Cbus > 100pF):
* Reducir a 2.2k → tr = 93ns ✓ (pero mayor consumo: 3.3/2.2k = 1.5mA por linea)
Rpull_scl V33 SCL 4.7k ; pull-up SCL
Rpull_sda V33 SDA 4.7k ; pull-up SDA
* Capacidad de bus (trazas PCB ~10cm + pines I2C de ESP32 y BNO085)
* ESP32 I2C input cap: ~5pF, BNO085 I2C input cap: ~5pF, traza: ~10pF/cm × 10cm = 100pF
Cbus_scl SCL GND 50p ; capacidad de bus SCL (solo 10cm de traza en PCB compacto)
Cbus_sda SDA GND 50p ; capacidad de bus SDA
* -----------------------------------------------------------------------
* ESP32 MASTER I2C — Genera transaccion I2C (direccion 0x4A, lectura)
* -----------------------------------------------------------------------
* Protocolo I2C 400kHz (Fast Mode):
* Periodo = 2.5us
* SCL alto = 0.6us min (spec), SCL bajo = 1.3us min
* Usamos: SCL alto = 0.9us, SCL bajo = 1.6us (simétrico aproximado)
*
* Secuencia simulada:
* t=0: Bus idle (SDA=HIGH, SCL=HIGH)
* t=5us: START condition (SDA baja mientras SCL alto)
* t=7us: SCL baja → comienzo de bits de direccion
* t=7-32us: 8 bits: direccion 0x4A + R/W=1 (lectura)
* 0x4A = 1001010, con R/W=1 → byte = 10010101 = 0x95
* t=32us: SCL sube → ACK del esclavo (BNO085 baja SDA)
* t=37us: SCL baja → BNO085 libera SDA (pull-up sube)
* t=40us: BNO085 comienza a enviar dato (primer byte de SHTP)
* t=60us: STOP condition
* SCL: generado por ESP32 (push-pull internamente, vista del bus = open-drain + pull-up)
* Modelamos el SCL como fuente de tension con resistencia baja (driver fuerte)
Vscl_drv SCL_DRV GND PWL(
+ 0 3.3
+ 4.9u 3.3
+ 5.0u 3.3 ; bus idle
+ 6.9u 3.3
+ 7.0u 0 ; SCL baja → START completado, primer bit
+ 8.5u 0
+ 8.6u 3.3 ; bit 7 (MSB): '1'
+ 9.9u 3.3
+ 10.0u 0
+ 11.4u 0
+ 11.5u 3.3 ; bit 6: '0'
+ 12.9u 3.3
+ 13.0u 0
+ 14.4u 0
+ 14.5u 3.3 ; bit 5: '0'
+ 15.9u 3.3
+ 16.0u 0
+ 17.4u 0
+ 17.5u 3.3 ; bit 4: '1'
+ 18.9u 3.3
+ 19.0u 0
+ 20.4u 0
+ 20.5u 3.3 ; bit 3: '0'
+ 21.9u 3.3
+ 22.0u 0
+ 23.4u 0
+ 23.5u 3.3 ; bit 2: '1'
+ 24.9u 3.3
+ 25.0u 0
+ 26.4u 0
+ 26.5u 3.3 ; bit 1: '0'
+ 27.9u 3.3
+ 28.0u 0
+ 29.4u 0
+ 29.5u 3.3 ; bit 0 (R/W=1, lectura)
+ 30.9u 3.3
+ 31.0u 0 ; SCL bajo para ACK
+ 32.4u 0
+ 32.5u 3.3 ; SCL sube: BNO085 debe mantener SDA baja (ACK)
+ 33.9u 3.3
+ 34.0u 0
+ 59.9u 0
+ 60.0u 3.3) ; ultimo SCL bajo → STOP
Rscl_drv SCL_DRV SCL 10 ; impedancia del driver I2C del ESP32
* SDA: ESP32 genera START y los bits de direccion
* BNO085 genera ACK (baja SDA durante ACK clock)
Vsda_drv SDA_DRV GND PWL(
+ 0 3.3
+ 4.9u 3.3
+ 5.0u 0 ; START: SDA baja mientras SCL alto
+ 5.9u 0
+ 6.0u 3.3 ; START completado: SDA sube (SCL ya bajo)
* bits de direccion 0x95 = 10010101 (MSB first)
* bit7=1: SDA alto (pull-up)
+ 6.9u 3.3
+ 7.0u 3.3 ; bit 7 = 1 (recesivo, pull-up mantiene alto)
+ 9.9u 3.3
+ 10.0u 0 ; bit 6 = 0 (ESP32 baja SDA)
+ 11.4u 0
+ 11.5u 0 ; bit 5 = 0
+ 13.9u 0
+ 14.0u 3.3 ; bit 4 = 1
+ 15.9u 3.3
+ 16.0u 0 ; bit 3 = 0
+ 17.9u 0
+ 18.0u 3.3 ; bit 2 = 1
+ 19.9u 3.3
+ 20.0u 0 ; bit 1 = 0
+ 21.9u 0
+ 22.0u 3.3 ; bit 0 = 1 (R/W=1)
+ 29.9u 3.3
+ 30.0u 0 ; ACK slot: ESP32 libera SDA (flota)
* BNO085 baja SDA para ACK → modelado como fuente separada
+ 34.0u 0
+ 34.1u 3.3 ; ESP32 retoma control del bus (post-ACK)
+ 59.9u 3.3
+ 60.0u 3.3) ; STOP: SDA sube mientras SCL alto
Rsda_drv SDA_DRV SDA 10
* ACK del BNO085: baja SDA durante el clock de ACK (t=32.5us a 34us)
Vsda_ack SDA_ACK GND PWL(
+ 0 3.3
+ 31.9u 3.3
+ 32.0u 0 ; BNO085 ACK: baja SDA
+ 33.9u 0
+ 34.0u 3.3) ; BNO085 libera SDA
Rack SDA_ACK SDA 50 ; el driver del BNO085 tiene impedancia finita
* -----------------------------------------------------------------------
* LINEA DE INTERRUPCION INT (data-ready, activo bajo, open-drain)
* -----------------------------------------------------------------------
* El BNO085 baja INT cuando tiene un reporte listo para leer.
* Con heading + yaw rate a 100Hz: INT pulsa cada 10ms
* El ESP32 lee el dato cuando detecta INT bajo (GPIO input con pull-up)
Rpull_int V33 INT_PIN 10k ; pull-up externo (ESP32 tiene pull-up interno tambien)
Cint INT_PIN GND 10p ; capacidad del pin
* Simula INT pulsando periodicamente (100 Hz = 10ms periodo, 100us de pulso bajo)
Vint_bno INT_DRV GND PULSE(3.3 0 5m 100n 100n 100u 10m)
Rint_drv INT_DRV INT_PIN 100 ; open-drain: BNO085 solo puede bajar, no subir
* -----------------------------------------------------------------------
* MEDICIONES AUTOMATICAS
* -----------------------------------------------------------------------
* Tiempo que tarda NRST en superar el umbral de reset (2.31V)
.meas TRAN t_reset_deassert WHEN V(nrst_node)=2.31 RISE=1
* Tension estable de alimentacion del BNO085
.meas TRAN Vvdd_stable AVG V(vdd_bno) FROM 20m TO 50m
* Tiempo de subida de SCL (10% → 90% de 3.3V = 0.33V → 2.97V)
.meas TRAN t_rise_scl TRIG V(scl)=0.33 RISE=1 TARG V(scl)=2.97 RISE=1
* Tension minima en VDD_BNO durante transient de corriente del BNO085
.meas TRAN Vvdd_min MIN V(vdd_bno) FROM 0 TO 50m
* -----------------------------------------------------------------------
* DIRECTIVAS DE SIMULACION
* -----------------------------------------------------------------------
* 50ms total: captura arranque completo + transaccion I2C + varios pulsos INT
.tran 0 50m 0 10n
.options reltol=0.001
* -----------------------------------------------------------------------
* VALORES ESPERADOS
* -----------------------------------------------------------------------
* t_reset_deassert ≈ 12ms → BNO085 sale de reset 12ms despues del arranque
* Vvdd_stable ≈ 3.28-3.30V → caida de tension por Rtrace=50mOhm + Ibno=6.5mA
* ΔV = 6.5mA × 50mOhm = 0.33mV (despreciable) ✓
* t_rise_scl ≈ 150-200ns → con Rpull=4.7k y Cbus=50pF: τ = 235ns
* tr(10%-90%) = 2.2τ × (80%) = 200ns < 300ns OK ✓
* (especificacion I2C Fast Mode: tr < 300ns)
*
* BNO085 en operacion normal:
* Corriente a 3.3V: 6.5mA tipico, 12mA maximo (fusion completa)
* Tiempo de startup tras reset: 50ms tipico (inicializacion DSP)
* Primer reporte disponible: ~100ms tras arranque
*
* CONEXION TIPICA A ESP32:
* GPIO21 → SDA (I2C SDA, con pull-up 4.7k externo)
* GPIO22 → SCL (I2C SCL, con pull-up 4.7k externo)
* GPIO34 → INT (input only, con pull-up 10k, interrupcion falling edge)
* GPIO13 → NRST (output, normalmente alto; pulsa bajo para hard reset)
*
* CONFIGURACION FIRMWARE:
* wire.begin(21, 22) → ESP32 Arduino I2C
* Wire.setClock(400000) → Fast Mode 400kHz
* BNO08x.begin(0x4A, Wire, GPIO34) → libreria SparkFun BNO08x
*
* REPORTES PARA AUTOPILOTO:
* setReports(ARVR_STABILIZED_RV, 0.01) → heading a 100Hz
* setReports(GYROSCOPE_CALIBRATED, 0.004) → yaw rate a 250Hz
* getRVheading() → degrees (0-360) con compensacion tilt
* getGyroZ() → deg/s (eje Z = yaw rate, positivo = estribor)
.backanno
.end