commit 72dcfeb315bfbc3dbcd7705459a6d4f3bac523a1 Author: Alvaro Romero Date: Fri Jul 3 12:23:41 2026 -0400 feat: Compass initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb63c7e --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +.venv/ +venv/ +env/ +.env +.env.* +*.egg-info/ +dist/ +build/ + +# Logs +*.log +compass_error.log +compass_sim.log + +# Debug outputs +out.txt +out2.txt +out3.txt +err.txt +err2.txt +err3.txt + +# Cache +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ + +# Database / local data +*.db +*.sqlite3 + +# OS files +.DS_Store +Thumbs.db +*.bak + +# NextCloud sync metadata +.nextcloudsync.log diff --git a/Las tres siluetas barco carguero.jpg b/Las tres siluetas barco carguero.jpg new file mode 100644 index 0000000..e39212d Binary files /dev/null and b/Las tres siluetas barco carguero.jpg differ diff --git a/Las tres siluetas barco offshore.jpg b/Las tres siluetas barco offshore.jpg new file mode 100644 index 0000000..1be8427 Binary files /dev/null and b/Las tres siluetas barco offshore.jpg differ diff --git a/Perfil Carguero ocre.svg b/Perfil Carguero ocre.svg new file mode 100644 index 0000000..74648f0 --- /dev/null +++ b/Perfil Carguero ocre.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 0000000..2a13145 --- /dev/null +++ b/STATUS.md @@ -0,0 +1,90 @@ +# Marine Compass Display — Estado del Proyecto + +## Qué funciona + +### Ventana principal (`ui/main_window.py`) +- Layout 60/40: rosa de los vientos (izq) + panel de info (der) +- Modo fullscreen con F para toggle, Escape para cerrar +- Modo kiosk (`--kiosk`): cursor oculto, screensaver desactivado (Linux/RPi) +- Smoothing de rumbo configurable en `config.py` (`HEADING_SMOOTHING`) + +### Rosa de los vientos (`ui/compass_widget.py`) +- Tarjeta giratoria: el norte siempre apunta al norte real +- Línea lubber fija en rojo (arriba = rumbo del barco) +- Readout digital del rumbo **arriba** de la rosa, justo bajo la línea lubber + - Formato: `045.0°M` o `045.0°T` según modo seleccionado +- Estrella de 16 puntas: N=rojo, cardinales=blanco, intercardinales=oro +- Ring de ticks cada 5°, números cada 10° +- Modo noche / día +- Ball central muestra pitch+roll del buque + +### Panel de información (`ui/info_panel.py`) +- **HDG grande**: muestra °M o °T según el botón HDG seleccionado ← bug corregido hoy +- Filas de datos: HDG(T), ROT, PITCH, ROLL, VAR, HEAVE, YAW RATE +- ROT Arc: semicírculo animado, azul=estribor, rojo=babor, escala ±60°/min +- Siluetas de barco animadas (motor-cruiser 40ft): + - **PITCH** (vista lateral): hull completo, cabina, flybridge, radar arch, línea de boot roja, línea de vida punteada, arco de proa + - **ROLL** (vista de proa): casco V, francobordo con curvatura, raíl de rociada, windshield en V con cristales azules separados, arco radar, luces de navegación rojo/verde + - **YAW** (vista de planta): casco cúbico bezier, cockpit con bancadas, cabina, flybridge, escotillas de cubierta, púlpito de proa, luces de nav +- Botones táctiles: HDG °M/°T, NIGHT, PORTS +- Marcos separados por gaps (para case con compartimentos físicos) +- Responsive: se adapta a cualquier tamaño de pantalla + +### Simulador NMEA (`simulator/nmea_simulator.py`) +- Secuencia realista: INITIAL → TURN_STBD → COMP_STBD → STEADY_STBD → TURN_PORT → COMP_PORT → NORMAL +- Genera: HCHDG (rumbo), IIROT (ROT), IIXDR (pitch, roll, aceleración, giroscopio) +- Servidor TCP en 127.0.0.1:10110 +- Variación magnética: -7.5° (Stuart, FL) +- SOG: 8.5 kn, ROT máx: 60°/min + +### Lanzamiento (`launch.bat`) +- `launch.bat --sim` → usa `python` (con consola), se ven logs del simulador en tiempo real +- `launch.bat` → usa `pythonw` (sin consola), modo kiosk limpio para hardware + +### Parser NMEA (`core/nmea_parser.py`) +- Parsea HCHDG, IIROT, IIXDR +- `NavData.hdg_true_calc` = hdg_mag + variation (calculo automático) + +--- + +## Pendiente / Mejoras posibles + +- [ ] Silueta ROLL (vista de proa): el usuario quiere más detalle y mejores proporciones +- [ ] Test en hardware real con puerto serial +- [ ] Modo RPi: probar con `--kiosk` en Raspberry Pi con pantalla táctil +- [ ] NMEA: agregar soporte para VHW, MWV, GGA si se necesita más datos + +--- + +## Cómo probar + +``` +# Simulador (muestra logs en consola): +launch.bat --sim + +# Con hardware real en COM3: +launch.bat --port COM3 --baud 4800 + +# Puerto configurado en config.py: +launch.bat + +# Python directo (más control): +python main.py --sim +python main.py --port COM3 +``` + +--- + +## Archivos clave + +| Archivo | Función | +|---------|---------| +| `main.py` | Entry point, args, QApplication | +| `config.py` | Puertos serial, smoothing, FPS | +| `ui/main_window.py` | Ventana principal, layout, timer | +| `ui/compass_widget.py` | Rosa de los vientos (QPainter) | +| `ui/info_panel.py` | Panel derecho: datos, siluetas, botones | +| `ui/styles.py` | Colores y helpers de tema | +| `core/nmea_parser.py` | Parser NMEA 0183 | +| `core/serial_reader.py` | Lector serial (QThread) | +| `simulator/nmea_simulator.py` | Simulador de maniobras + TCP server | diff --git a/Tres siluetas barco pasaje.jpg b/Tres siluetas barco pasaje.jpg new file mode 100644 index 0000000..7f0832a Binary files /dev/null and b/Tres siluetas barco pasaje.jpg differ diff --git a/Tres siluetas yates.jpg b/Tres siluetas yates.jpg new file mode 100644 index 0000000..983da85 Binary files /dev/null and b/Tres siluetas yates.jpg differ diff --git a/assets/brand/brand_colors.json b/assets/brand/brand_colors.json new file mode 100644 index 0000000..797895e --- /dev/null +++ b/assets/brand/brand_colors.json @@ -0,0 +1,46 @@ +{ + "_comment": "AR Electronics — Paleta oficial de marca. Usar en todas las apps.", + "_version": "1.0.0", + + "background": { + "primary": "#0D1B2A", + "secondary": "#1A2744", + "card": "#162035", + "surface": "#1E2D47" + }, + + "accent": { + "blue_electric": "#2563EB", + "blue_neon": "#4A9FE8", + "blue_dark": "#1A47A8", + "blue_glow": "#60B8FF" + }, + + "text": { + "primary": "#E2E8F0", + "secondary": "#A8B5C4", + "muted": "#6B7A8D", + "on_accent": "#FFFFFF" + }, + + "status": { + "ok": "#22C55E", + "warning": "#F59E0B", + "alarm": "#EF4444", + "info": "#4A9FE8" + }, + + "metallic": { + "silver_light": "#C8D2DC", + "silver_mid": "#A8B5C4", + "silver_dark": "#6B7A8D" + }, + + "flutter": { + "_comment": "Valores listos para copiar en ThemeData de Flutter", + "primaryColor": "0xFF2563EB", + "scaffoldBackground": "0xFF0D1B2A", + "cardColor": "0xFF162035", + "accentColor": "0xFF4A9FE8" + } +} diff --git a/assets/images/ar_logo_full.png b/assets/images/ar_logo_full.png new file mode 100644 index 0000000..42ad7e9 Binary files /dev/null and b/assets/images/ar_logo_full.png differ diff --git a/config.py b/config.py new file mode 100644 index 0000000..d3b3af7 --- /dev/null +++ b/config.py @@ -0,0 +1,15 @@ +SERIAL_PORTS = [ + {'port': '/dev/ttyUSB0', 'baud': 4800, 'name': 'NMEA Primary'}, + {'port': '/dev/ttyUSB1', 'baud': 38400, 'name': 'NMEA Fast'}, +] + +# Windows example: +# {'port': 'COM3', 'baud': 4800, 'name': 'NMEA Primary'} + +HEADING_SMOOTHING = 0.18 # 0=no smoothing, 1=instant snap +UI_REFRESH_MS = 80 # ~12 fps — smooth animation, low CPU +COMPASS_SPLIT = 0.60 # fraction of width for compass rose + +# Vessel type — controls silhouettes in the attitude panel +# Options: 'motor_cruiser' | 'cargo' | 'offshore' | 'cruise' | 'superyacht' +VESSEL_TYPE = 'cargo' diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/nmea_parser.py b/core/nmea_parser.py new file mode 100644 index 0000000..e606448 --- /dev/null +++ b/core/nmea_parser.py @@ -0,0 +1,161 @@ +""" +NMEA 0183 parser — populates NavData from sentences. +Handles: HDG, HDT, HDM, RMC, VTG, ROT, XDR, MWV, GGA, ZDA +""" +from dataclasses import dataclass +from typing import Optional + + +def _checksum_ok(sentence: str) -> bool: + if '*' not in sentence: + return True + try: + data, cs = sentence.rsplit('*', 1) + val = 0 + for c in data.lstrip('$!'): + val ^= ord(c) + return val == int(cs.strip()[:2], 16) + except Exception: + return False + + +def _f(s: str) -> Optional[float]: + try: + return float(s) if s.strip() else None + except ValueError: + return None + + +@dataclass +class NavData: + # ── Heading ────────────────────────────────────────────────────────────── + hdg_mag: Optional[float] = None # Magnetic heading ° + hdg_true: Optional[float] = None # True heading ° + variation: Optional[float] = None # Mag variation (+ = East) + deviation: Optional[float] = None + + # ── Motion ─────────────────────────────────────────────────────────────── + rot: Optional[float] = None # Rate of turn °/min (+ = stbd) + + # ── Attitude (BNO085) ───────────────────────────────────────────────────── + pitch: Optional[float] = None # ° bow-up positive + roll: Optional[float] = None # ° stbd positive + heel: Optional[float] = None # alias for roll on some talkers + + # ── Acceleration (BNO085 via XDR) ──────────────────────────────────────── + accel_x: Optional[float] = None # m/s² — surge (fore-aft) + accel_y: Optional[float] = None # m/s² — sway (port-stbd) + accel_z: Optional[float] = None # m/s² — heave (up-down) + + # ── Angular velocity / gyro (BNO085 via XDR) ───────────────────────────── + gyro_x: Optional[float] = None # °/s — roll rate + gyro_y: Optional[float] = None # °/s — pitch rate + gyro_z: Optional[float] = None # °/s — yaw rate + + # ── Quaternion (BNO085 via XDR or proprietary) ──────────────────────────── + quat_w: Optional[float] = None + quat_x: Optional[float] = None + quat_y: Optional[float] = None + quat_z: Optional[float] = None + + @property + def hdg_true_calc(self) -> Optional[float]: + if self.hdg_mag is not None and self.variation is not None: + return (self.hdg_mag + self.variation) % 360 + return self.hdg_true + + +def parse(sentence: str, data: NavData) -> bool: + sentence = sentence.strip() + if not (sentence.startswith('$') or sentence.startswith('!')): + return False + if not _checksum_ok(sentence): + return False + + body = sentence[1:sentence.rfind('*')] if '*' in sentence else sentence[1:] + p = body.split(',') + if not p: + return False + + stype = p[0][-3:].upper() + try: + return _PARSERS.get(stype, lambda *_: False)(p, data) + except Exception: + return False + + +# ── Individual sentence parsers ──────────────────────────────────────────── + +def _hdg(p, d): + # $HCHDG,x.x,x.x,a,x.x,a + d.hdg_mag = _f(p[1]) + dev = _f(p[2]) + if dev is not None: + d.deviation = dev if (len(p) > 3 and p[3].upper() != 'W') else -dev + var = _f(p[4]) if len(p) > 4 else None + if var is not None: + d.variation = var if (len(p) > 5 and p[5].upper() != 'W') else -var + return True + +def _hdt(p, d): + if len(p) > 2 and p[2].upper() == 'T': + d.hdg_true = _f(p[1]) + return True + +def _hdm(p, d): + if len(p) > 2 and p[2].upper() == 'M': + d.hdg_mag = _f(p[1]) + return True + +def _rmc(p, d): + # Only used to extract magnetic variation as fallback + if len(p) > 11 and p[10]: + var = _f(p[10]) + if var is not None: + d.variation = var if p[11].upper() != 'W' else -var + return True + +def _rot(p, d): + if len(p) > 2 and p[2].upper() == 'A': + d.rot = _f(p[1]) + return True + +def _xdr(p, d): + """ + XDR carries all BNO085 outputs. + Expected names (configurable in ESP32 firmware): + PITCH, ROLL, HEEL + ACCELX, ACCELY, ACCELZ (or SURGE, SWAY, HEAVE) + GYROX, GYROY, GYROZ + QUATW, QUATX, QUATY, QUATZ + """ + i = 1 + while i + 3 <= len(p) - 1: + name = p[i + 3].upper() + value = _f(p[i + 1]) + if value is not None: + if 'PITCH' in name: d.pitch = value + elif 'ROLL' in name: d.roll = value + elif 'HEEL' in name: d.heel = value + elif name in ('ACCELX', 'SURGE'): d.accel_x = value + elif name in ('ACCELY', 'SWAY'): d.accel_y = value + elif name in ('ACCELZ', 'HEAVE'): d.accel_z = value + elif name in ('GYROX', 'ROLLR'): d.gyro_x = value + elif name in ('GYROY', 'PITCHR'): d.gyro_y = value + elif name in ('GYROZ', 'YAWR'): d.gyro_z = value + elif 'QUATW' in name: d.quat_w = value + elif 'QUATX' in name: d.quat_x = value + elif 'QUATY' in name: d.quat_y = value + elif 'QUATZ' in name: d.quat_z = value + i += 4 + return True + + +_PARSERS = { + 'HDG': _hdg, # Heading + variation + 'HDT': _hdt, # True heading + 'HDM': _hdm, # Magnetic heading + 'ROT': _rot, # Rate of turn + 'XDR': _xdr, # Pitch / Roll transducer + 'RMC': _rmc, # Variation fallback (only var field used) +} diff --git a/core/serial_reader.py b/core/serial_reader.py new file mode 100644 index 0000000..d7a5634 --- /dev/null +++ b/core/serial_reader.py @@ -0,0 +1,45 @@ +""" +Serial port reader — runs in a QThread, emits each NMEA sentence. +""" +import serial +import serial.tools.list_ports +from PyQt5.QtCore import QThread, pyqtSignal + + +class SerialReader(QThread): + sentence = pyqtSignal(str) + connected = pyqtSignal(bool, str) + error = pyqtSignal(str) + + def __init__(self, port: str, baud: int = 4800, parent=None): + super().__init__(parent) + self.port = port + self.baud = baud + self._running = False + + def run(self): + self._running = True + try: + ser = serial.Serial(self.port, self.baud, timeout=1.0) + self.connected.emit(True, self.port) + buf = '' + while self._running: + raw = ser.read(ser.in_waiting or 1) + buf += raw.decode('ascii', errors='ignore') + while '\n' in buf: + line, buf = buf.split('\n', 1) + line = line.strip() + if line.startswith('$') or line.startswith('!'): + self.sentence.emit(line) + ser.close() + except serial.SerialException as e: + self.connected.emit(False, self.port) + self.error.emit(str(e)) + + def stop(self): + self._running = False + self.wait(2000) + + @staticmethod + def available_ports(): + return [p.device for p in serial.tools.list_ports.comports()] diff --git a/launch.bat b/launch.bat new file mode 100644 index 0000000..2845bf0 --- /dev/null +++ b/launch.bat @@ -0,0 +1,66 @@ +@echo off +chcp 65001 >nul +title Marine Compass +cd /d "%~dp0" + +echo ================================================ +echo MARINE COMPASS DISPLAY v1.0 +echo ================================================ +echo launch.bat -- hardware (config.py) +echo launch.bat --sim -- simulador maniobras +echo launch.bat --port COM3 --baud 4800 +echo ================================================ +echo. + +:: Check Python +python --version >nul 2>&1 +if errorlevel 1 ( + echo [ERROR] Python no encontrado. Instala desde python.org + pause + exit /b 1 +) + +:: Dependencies +echo Verificando dependencias... +python -m pip install -r requirements.txt -q --no-warn-script-location +if errorlevel 1 ( + echo [ERROR] Fallo al instalar dependencias. + pause + exit /b 1 +) +echo Dependencias OK. +echo. + +:: Sin argumentos -> simulador (doble-clic en Windows) +:: --sim -> simulador (consola activa, mismo efecto) +:: --port COM3 -> hardware en COM3 (modo kiosk, sin consola) +:: --port COM3 --baud 38400 -> hardware a 38400 bps +if "%1"=="" ( + echo Modo: SIMULADOR -- consola activa, Ctrl+C para salir + echo. + python main.py --sim + if errorlevel 1 ( + echo. + echo [ERROR] El simulador termino con error. Revisa el log arriba. + pause + ) +) else if "%1"=="--sim" ( + echo Modo: SIMULADOR -- consola activa, Ctrl+C para salir + echo. + python main.py --sim + if errorlevel 1 ( + echo. + echo [ERROR] El simulador termino con error. Revisa el log arriba. + pause + ) +) else ( + echo Modo: HARDWARE %* + echo. + pythonw main.py %* > compass_error.log 2>&1 + if errorlevel 1 ( + echo. + echo [ERROR] Revisa compass_error.log + notepad compass_error.log + pause + ) +) diff --git a/launch.sh b/launch.sh new file mode 100644 index 0000000..758d437 --- /dev/null +++ b/launch.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# ================================================================ +# Marine Compass Display — Launcher para Linux / Raspberry Pi +# ================================================================ +# +# Uso: +# ./launch.sh → hardware (lee config.py) +# ./launch.sh --sim → simulador de maniobras +# ./launch.sh --kiosk → kiosk: fullscreen, sin cursor +# ./launch.sh --sim --kiosk +# ./launch.sh --port /dev/ttyUSB0 --baud 4800 +# +# Auto-arranque en RPi — agregar en /etc/rc.local antes de 'exit 0': +# su -c "DISPLAY=:0 /home/pi/compass/launch.sh --kiosk &" pi +# ================================================================ + +set -e +cd "$(dirname "$0")" + +echo "╔══════════════════════════════════════════════╗" +echo "║ MARINE COMPASS DISPLAY v1.0 ║" +echo "╠══════════════════════════════════════════════╣" +echo "║ Uso: ║" +echo "║ ./launch.sh → hardware ║" +echo "║ ./launch.sh --sim → simulador ║" +echo "║ ./launch.sh --kiosk → kiosk RPi ║" +echo "╚══════════════════════════════════════════════╝" +echo + +# Verificar Python 3 +if ! command -v python3 &>/dev/null; then + echo "[ERROR] python3 no encontrado." + echo "Instala: sudo apt install python3 python3-pip" + exit 1 +fi + +# Dependencias +echo "Verificando dependencias..." +pip3 install -r requirements.txt -q +echo "Dependencias OK." +echo + +# Display (necesario en rc.local / cron) +export DISPLAY="${DISPLAY:-:0}" + +# Kiosk: deshabilitar screensaver +for arg in "$@"; do + if [ "$arg" = "--kiosk" ]; then + xset s off 2>/dev/null || true + xset -dpms 2>/dev/null || true + xset s noblank 2>/dev/null || true + command -v unclutter &>/dev/null && unclutter -idle 0 & + break + fi +done + +# Mostrar modo +if [[ " $* " == *" --sim "* ]] || [[ "$1" == "--sim" ]]; then + echo "Modo: SIMULADOR" + echo " INITIAL 60s → TURN STBD → 225° → TURN PORT → 045° → NORMAL" +else + echo "Modo: HARDWARE — leyendo puertos en config.py" +fi +echo + +python3 main.py "$@" diff --git a/main.py b/main.py new file mode 100644 index 0000000..9bef836 --- /dev/null +++ b/main.py @@ -0,0 +1,76 @@ +""" +Marine Compass Display +---------------------- +python main.py # normal run (reads config.py ports) +python main.py --sim # built-in NMEA simulator (no hardware needed) +python main.py --port COM3 # Windows override +python main.py --port /dev/ttyUSB0 --baud 38400 +python main.py --kiosk # RPi fullscreen, cursor hidden, no screensaver +""" +import sys +import argparse +from PyQt5.QtWidgets import QApplication +from PyQt5.QtCore import Qt + + +def main(): + parser = argparse.ArgumentParser(description='Marine Compass Display') + parser.add_argument('--sim', action='store_true', help='Run with NMEA simulator') + parser.add_argument('--port', default=None, help='Serial port override') + parser.add_argument('--baud', type=int, default=4800) + parser.add_argument('--kiosk', action='store_true', help='Kiosk mode (RPi): hide cursor, disable screensaver') + args = parser.parse_args() + + if args.sim: + from simulator.nmea_simulator import start_simulator + start_simulator() + + if args.port: + import config + config.SERIAL_PORTS = [{'port': args.port, 'baud': args.baud, 'name': 'CLI'}] + + if args.kiosk: + _setup_kiosk() + + # Must be set BEFORE QApplication is created + QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) + QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) + + app = QApplication(sys.argv) + + if args.kiosk: + app.setOverrideCursor(Qt.BlankCursor) + + try: + from ui.main_window import MainWindow + win = MainWindow() + win.show() + win.activateWindow() + win.raise_() + sys.exit(app.exec_()) + except Exception as e: + import traceback + from PyQt5.QtWidgets import QMessageBox + QMessageBox.critical(None, 'Error al iniciar', traceback.format_exc()) + sys.exit(1) + + +def _setup_kiosk(): + """Disable screensaver and DPMS on Linux/RPi.""" + import platform + if platform.system() != 'Linux': + return + import subprocess + for cmd in [ + ['xset', 's', 'off'], + ['xset', '-dpms'], + ['xset', 's', 'noblank'], + ]: + try: + subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except FileNotFoundError: + pass + + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b1d57ff --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +PyQt5>=5.15.0 +pyserial>=3.5 diff --git a/silueta carguero pitch.jpg b/silueta carguero pitch.jpg new file mode 100644 index 0000000..97d464d Binary files /dev/null and b/silueta carguero pitch.jpg differ diff --git a/silueta roll barco carguero.jpg b/silueta roll barco carguero.jpg new file mode 100644 index 0000000..e790876 Binary files /dev/null and b/silueta roll barco carguero.jpg differ diff --git a/simulator/__init__.py b/simulator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/simulator/nmea_simulator.py b/simulator/nmea_simulator.py new file mode 100644 index 0000000..f982a4d --- /dev/null +++ b/simulator/nmea_simulator.py @@ -0,0 +1,375 @@ +""" +NMEA 0183 Simulator — Secuencia realista de maniobras de buque +============================================================== + +Secuencia: + 1. INITIAL — Rumbo 045°M estable, 60 segundos + 2. TURN_STBD — Giro por estribor hacia 225° (180° del original) + 3. COMP_STBD — Timonel compensa: ROT baja a 0, buque estabiliza en 225° + 4. STEADY_STBD — Estable en 225°M, 60 segundos + 5. TURN_PORT — Giro por babor de regreso a 045° + 6. COMP_PORT — Timonel compensa: ROT sube desde negativo a 0 + 7. NORMAL — Navegación normal con movimiento suave de olas + +Ejecutar standalone para ver output: + python -m simulator.nmea_simulator +""" + +import sys +import math +import random +import threading +import socket +import time +from enum import Enum, auto +from dataclasses import dataclass, field + +import config + +import logging +import os + +# Log to file — no console output when running under pythonw +_log_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'compass_sim.log') +logging.basicConfig( + filename=_log_path, + filemode='w', + level=logging.INFO, + format='%(asctime)s %(message)s', + datefmt='%H:%M:%S', +) + +def _log(msg: str): + logging.info(msg) + try: + print(msg) # only visible when running from a terminal + except Exception: + pass + + +# ── NMEA helpers ────────────────────────────────────────────────────────────── + +def _cs(s: str) -> str: + v = 0 + for c in s: + v ^= ord(c) + return f'{v:02X}' + +def _sentence(body: str) -> str: + return f'${body}*{_cs(body)}\r\n' + + +# ── Simulation phases ───────────────────────────────────────────────────────── + +class Phase(Enum): + INITIAL = auto() # Estable en rumbo inicial + TURN_STBD = auto() # Girando por estribor + COMP_STBD = auto() # Timonel compensa al llegar a 225° + STEADY_STBD = auto() # Estable en 225° + TURN_PORT = auto() # Girando por babor + COMP_PORT = auto() # Timonel compensa al llegar a 045° + NORMAL = auto() # Navegación normal con olas + +PHASE_NAMES = { + Phase.INITIAL: 'INITIAL — Estable en 045°M', + Phase.TURN_STBD: 'TURN_STBD — Girando ESTRIBOR → 225°', + Phase.COMP_STBD: 'COMP_STBD — Timonel compensa (ROT → 0)', + Phase.STEADY_STBD: 'STEADY_STBD — Estable en 225°M', + Phase.TURN_PORT: 'TURN_PORT — Girando BABOR → 045°', + Phase.COMP_PORT: 'COMP_PORT — Timonel compensa (ROT → 0)', + Phase.NORMAL: 'NORMAL — Navegación suave', +} + + +# ── Configuration ───────────────────────────────────────────────────────────── + +INITIAL_HDG = 45.0 # Rumbo inicial °M +TURN_DEG = 25.0 # Grados de la maniobra (por estribor y luego por babor) +MAX_ROT = 30.0 # ROT máximo °/min (30 = 0.5°/s — maniobra moderada) +ROT_RAMP_SECS = 8.0 # Segundos para alcanzar MAX_ROT desde 0 +COMP_THRESHOLD = 8.0 # Grados antes del rumbo objetivo donde el timonel compensa +STEADY_SECS = 15.0 # Tiempo estable en cada rumbo (segundos) +SOG = 8.5 # Velocidad (knots) +VARIATION = -7.5 # Variación magnética (W = negativo) +LAT = 27.1975 # Stuart, FL +LON = -80.1951 + + +# ── Vessel state ────────────────────────────────────────────────────────────── + +@dataclass +class Vessel: + heading: float = INITIAL_HDG + rot: float = 0.0 # °/min (+ = estribor, - = babor) + rot_target: float = 0.0 # ROT objetivo + pitch: float = 0.0 + roll: float = 0.0 + sog: float = SOG + phase: Phase = Phase.INITIAL + phase_t: float = 0.0 # tiempo en la fase actual (s) + norm_t: float = 0.0 # contador para simulación normal + sim_t: float = 0.0 # tiempo global continuo (s) + _target_hdg: float = 0.0 + + +class VesselSim: + + TICK = 0.25 # segundos por step de simulación + + def __init__(self): + self.v = Vessel() + self.v._target_hdg = (INITIAL_HDG + TURN_DEG) % 360 + self._phase_start_print() + + # ── Main tick ───────────────────────────────────────────────────────────── + + def step(self) -> None: + v = self.v + v.phase_t += self.TICK + v.sim_t += self.TICK + + if v.phase == Phase.INITIAL: self._phase_initial() + elif v.phase == Phase.TURN_STBD: self._phase_turn(+1) + elif v.phase == Phase.COMP_STBD: self._phase_compensate(+1, Phase.STEADY_STBD) + elif v.phase == Phase.STEADY_STBD: self._phase_steady(Phase.TURN_PORT) + elif v.phase == Phase.TURN_PORT: self._phase_turn(-1) + elif v.phase == Phase.COMP_PORT: self._phase_compensate(-1, Phase.NORMAL) + elif v.phase == Phase.NORMAL: self._phase_normal() + + t = v.sim_t + # Pitch: oleaje grueso — swell 8s (ω=0.785) + chop 3s (ω=2.09), máx ±5° + pitch_wave = (3.5 * math.sin(t * 0.785) + + 1.1 * math.sin(t * 2.09 + 1.1) + + 0.3 * math.sin(t * 3.14 + 0.5)) + v.pitch = max(-5.0, min(5.0, pitch_wave + random.uniform(-0.10, 0.10))) + + # Roll: período más corto que pitch (6s, ω=1.047), cabeceo cruzado, escora de maniobra + roll_wave = (3.8 * math.sin(t * 1.047 + 0.75) + + 0.9 * math.sin(t * 2.51 + 2.3)) + roll_turn = -(v.rot / MAX_ROT) * 2.5 + v.roll = max(-5.0, min(5.0, roll_wave + roll_turn + random.uniform(-0.10, 0.10))) + + # Update heading from ROT + v.heading = (v.heading + v.rot / 60.0 * self.TICK) % 360 + + # ── Phases ──────────────────────────────────────────────────────────────── + + def _phase_initial(self): + v = self.v + v.rot = 0.0 + if v.phase_t >= STEADY_SECS: + self._set_phase(Phase.TURN_STBD) + v._target_hdg = (INITIAL_HDG + TURN_DEG) % 360 + + def _phase_turn(self, direction: int): + """direction: +1 = estribor, -1 = babor""" + v = self.v + target = v._target_hdg + + # Angular distance remaining to target + if direction == +1: + remaining = (target - v.heading) % 360 + else: + remaining = (v.heading - target) % 360 + + # Ramp ROT up over ROT_RAMP_SECS + ramp_progress = min(v.phase_t / ROT_RAMP_SECS, 1.0) + v.rot_target = direction * MAX_ROT * ramp_progress + + # Within COMP_THRESHOLD: start reducing ROT (helmsman anticipates) + if remaining < COMP_THRESHOLD: + comp_factor = remaining / COMP_THRESHOLD + v.rot_target *= comp_factor + + # Smooth ROT toward target + v.rot += (v.rot_target - v.rot) * 0.25 + + # Reached target heading? + if remaining < 2.0: + v.heading = target + if direction == +1: + self._set_phase(Phase.COMP_STBD) + else: + self._set_phase(Phase.COMP_PORT) + + def _phase_compensate(self, prev_direction: int, next_phase: Phase): + """Timonel centra el timón: ROT decae a 0.""" + v = self.v + v.rot *= 0.80 # decaimiento exponencial suave + if abs(v.rot) < 0.4: + v.rot = 0.0 + self._set_phase(next_phase) + if next_phase == Phase.TURN_PORT: + v._target_hdg = INITIAL_HDG # girar de vuelta a 045° + + def _phase_steady(self, next_phase: Phase): + v = self.v + v.rot *= 0.90 # cualquier ROT residual se disipa + if v.phase_t >= STEADY_SECS: + self._set_phase(next_phase) + if next_phase == Phase.TURN_PORT: + v._target_hdg = INITIAL_HDG + + def _phase_normal(self): + """Navegación en mar grueso — giñadas amplias y correcciones rápidas.""" + v = self.v + v.norm_t += self.TICK + t = v.norm_t + # Giñada compuesta: deriva principal 12° + componente irregular 5° + hdg_drift = 12.0 * math.sin(t * 0.09) + 5.0 * math.sin(t * 0.23 + 1.7) + target_hdg = (INITIAL_HDG + hdg_drift) % 360 + err = ((target_hdg - v.heading) + 180) % 360 - 180 + v.rot_target = max(-25.0, min(25.0, err * 2.5)) + v.rot += (v.rot_target - v.rot) * 0.20 + + def _add_sea_noise(self): + pass # replaced by sinusoidal model in step() + + # ── Derived data ────────────────────────────────────────────────────────── + + def accel(self): + """Aceleración simulada del BNO085 (m/s²).""" + v = self.v + # Centrípeta en eje Y proporcional al ROT + centripetal_y = (v.rot / 60.0) * math.radians(v.sog * 0.5144) * 0.5 + ax = math.sin(math.radians(v.pitch)) * 9.81 + random.uniform(-0.03, 0.03) + ay = math.sin(math.radians(v.roll)) * 9.81 + centripetal_y + random.uniform(-0.03, 0.03) + az = math.cos(math.radians(v.pitch)) * math.cos(math.radians(v.roll)) * 9.81 + return ax, ay, az + + def gyro(self): + """Velocidad angular del giroscopio (°/s).""" + v = self.v + gx = v.roll * 0.02 + random.uniform(-0.02, 0.02) + gy = v.pitch * 0.02 + random.uniform(-0.02, 0.02) + gz = v.rot / 60.0 + random.uniform(-0.01, 0.01) + return gx, gy, gz + + # ── NMEA sentences ──────────────────────────────────────────────────────── + + def sentences(self) -> list[str]: + self.step() + v = self.v + ax, ay, az = self.accel() + gx, gy, gz = self.gyro() + var_dir = 'W' if VARIATION < 0 else 'E' + + return [ + _sentence(f'HCHDG,{v.heading:.2f},,,{abs(VARIATION):.1f},{var_dir}'), + _sentence(f'IIROT,{v.rot:.2f},A'), + _sentence( + f'IIXDR,' + f'A,{v.pitch:.2f},D,PITCH,' + f'A,{v.roll:.2f},D,ROLL,' + f'A,{ax:.3f},M,ACCELX,' + f'A,{ay:.3f},M,ACCELY,' + f'A,{az:.3f},M,ACCELZ,' + f'A,{gx:.3f},D,GYROX,' + f'A,{gy:.3f},D,GYROY,' + f'A,{gz:.3f},D,GYROZ' + ), + ] + + # ── Phase helpers ───────────────────────────────────────────────────────── + + def _set_phase(self, phase: Phase): + self.v.phase = phase + self.v.phase_t = 0.0 + self._phase_start_print() + + def _phase_start_print(self): + v = self.v + name = PHASE_NAMES.get(v.phase, str(v.phase)) + _log(f'[SIM] -- {name}') + _log(f'[SIM] HDG={v.heading:.1f} ROT={v.rot:.1f}/min ' + f'PITCH={v.pitch:.1f} ROLL={v.roll:.1f}') + + +# ── TCP server ──────────────────────────────────────────────────────────────── + +def _tcp_server(sim: VesselSim): + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind(('127.0.0.1', 10110)) + srv.listen(5) + _log('[SIM] NMEA TCP server en 127.0.0.1:10110 (1 Hz)') + _log('[SIM] Conecta con: python main.py --sim') + + while True: + conn, addr = srv.accept() + _log(f'[SIM] Cliente conectado: {addr}') + try: + while True: + t0 = time.time() + for s in sim.sentences(): + conn.sendall(s.encode('ascii')) + elapsed = time.time() - t0 + time.sleep(max(0, 1.0 - elapsed)) + except Exception: + _log(f'[SIM] Cliente desconectado: {addr}') + conn.close() + + +# ── Entry points ────────────────────────────────────────────────────────────── + +def start_simulator(): + """Called by main.py --sim. Patches SerialReader and starts TCP server.""" + sim = VesselSim() + t = threading.Thread(target=_tcp_server, args=(sim,), daemon=True) + t.start() + time.sleep(0.35) + config.SERIAL_PORTS = [{'port': 'tcp://127.0.0.1:10110', 'baud': 4800, 'name': 'Simulator'}] + _patch_reader() + + +def _patch_reader(): + """Monkey-patch SerialReader to handle tcp:// URI for the simulator.""" + import socket as _socket + from core.serial_reader import SerialReader + + original_run = SerialReader.run + + def patched_run(self): + if not self.port.startswith('tcp://'): + return original_run(self) + self._running = True + host, port = self.port[6:].split(':') + try: + s = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) + s.connect((host, int(port))) + self.connected.emit(True, self.port) + buf = '' + while self._running: + data = s.recv(4096) + if not data: + break + buf += data.decode('ascii', errors='ignore') + while '\n' in buf: + line, buf = buf.split('\n', 1) + line = line.strip() + if line.startswith('$') or line.startswith('!'): + self.sentence.emit(line) + except Exception as e: + self.error.emit(str(e)) + self.connected.emit(False, self.port) + + SerialReader.run = patched_run + + +# ── Standalone ──────────────────────────────────────────────────────────────── + +if __name__ == '__main__': + """Run standalone to see console output without the UI.""" + print('=' * 58) + print(' MARINE COMPASS SIMULATOR - Secuencia de Maniobras') + print('=' * 58) + print('') + sim = VesselSim() + try: + t = threading.Thread(target=_tcp_server, args=(sim,), daemon=True) + t.start() + # Keep alive — TCP server runs in daemon thread + while True: + time.sleep(1) + except KeyboardInterrupt: + print('\n[SIM] Detenido.') diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/compass_widget.py b/ui/compass_widget.py new file mode 100644 index 0000000..64ccbb6 --- /dev/null +++ b/ui/compass_widget.py @@ -0,0 +1,276 @@ +""" +Compass rose widget. +The card rotates; the lubber line (red) is fixed at top. +Current heading always appears at the top of the rose. +""" +import math +from PyQt5.QtWidgets import QWidget +from PyQt5.QtCore import Qt, QPointF, QRectF +from PyQt5.QtGui import (QPainter, QPen, QBrush, QPainterPath, + QRadialGradient, QConicalGradient, QColor, QFont) +import ui.styles as S + + +class CompassWidget(QWidget): + + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumSize(300, 300) + self._hdg = 0.0 + self._roll = 0.0 + self._pitch = 0.0 + self._night = False + self._hdg_mode = 'M' # 'M' = magnetic, 'T' = true + + # ── Public setters ────────────────────────────────────────────────────── + + def set_heading(self, h: float): + self._hdg = h % 360 + self.update() + + def set_attitude(self, pitch: float, roll: float): + self._pitch = pitch + self._roll = roll + self.update() + + def set_hdg_mode(self, mode: str): + self._hdg_mode = mode + self.update() + + def set_night(self, on: bool): + self._night = on + self.update() + + # ── Paint ─────────────────────────────────────────────────────────────── + + def paintEvent(self, event): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + p.setRenderHint(QPainter.TextAntialiasing) + + w, h = self.width(), self.height() + cx, cy = w / 2.0, h / 2.0 + R = min(cx, cy) * 0.94 # outer bezel radius + + C = S.is_night(self._night) + p.fillRect(self.rect(), C['bg']) + + self._bezel(p, cx, cy, R, C) + + # ── Rotating card ────────────────────────────────────────────────── + p.save() + p.translate(cx, cy) + p.rotate(-self._hdg) # card rotates opposite to heading + + self._card_bg(p, R * 0.87, C) + self._tick_ring(p, R * 0.71, R * 0.87, C) + self._star(p, R * 0.50, C) + self._cardinal_labels(p, R * 0.62, C) + + p.restore() + + # ── Fixed elements ───────────────────────────────────────────────── + p.save() + p.translate(cx, cy) + self._lubber_line(p, R, C) + self._center_ball(p, R * 0.10, C) + p.restore() + + self._heading_readout(p, cx, cy, R, C) + + # ── Drawing primitives ────────────────────────────────────────────────── + + def _bezel(self, p, cx, cy, R, C): + p.save() + p.translate(cx, cy) + + g = QRadialGradient(QPointF(0, -R * 0.3), R * 1.2) + g.setColorAt(0.0, S.BEZEL_MID) + g.setColorAt(0.82, S.BEZEL_DARK) + g.setColorAt(0.88, C['gold']) + g.setColorAt(0.93, C['bright']) + g.setColorAt(1.0, C['gold'].darker(160)) + + p.setBrush(QBrush(g)) + p.setPen(Qt.NoPen) + p.drawEllipse(QPointF(0, 0), R * 1.01, R * 1.01) + + p.restore() + + def _card_bg(self, p, r, C): + g = QRadialGradient(QPointF(0, -r * 0.2), r) + g.setColorAt(0.0, QColor('#121A2E')) + g.setColorAt(0.7, QColor('#090D1A')) + g.setColorAt(1.0, QColor('#060810')) + p.setBrush(QBrush(g)) + p.setPen(Qt.NoPen) + p.drawEllipse(QPointF(0, 0), r, r) + + def _tick_ring(self, p, r_in, r_out, C): + span = r_out - r_in + + for deg in range(0, 360, 5): + p.save() + p.rotate(deg) + + if deg % 90 == 0: + length = span * 0.88 + pen = QPen(C['bright'], max(1.8, r_out * 0.006)) + elif deg % 45 == 0: + length = span * 0.72 + pen = QPen(C['gold'], max(1.4, r_out * 0.005)) + elif deg % 10 == 0: + length = span * 0.55 + pen = QPen(C['gold'].darker(130), max(1.1, r_out * 0.004)) + else: + length = span * 0.32 + pen = QPen(S.GOLD_DIM, max(0.8, r_out * 0.003)) + + p.setPen(pen) + p.drawLine(QPointF(0, -r_out), QPointF(0, -r_out + length)) + + # Degree numbers every 10°, skip cardinal positions + if deg % 10 == 0 and deg % 45 != 0: + fs = max(7, int(r_out * 0.052)) + f = QFont('Arial', fs) + p.setFont(f) + p.setPen(QPen(C['dim'])) + text_y = -(r_out - span * 0.98) - fs * 1.6 + rect = QRectF(-20, text_y - 12, 40, 24) + p.drawText(rect, Qt.AlignCenter, str(deg // 10)) + + p.restore() + + def _star(self, p, r, C): + """16-point compass star. N=red, cardinals=white, intercardinals=gold.""" + for i in range(16): + p.save() + p.rotate(i * 22.5) + + cardinal = (i % 4 == 0) + intercardinal = (i % 2 == 0 and not cardinal) + is_north = (i == 0) + + if cardinal: + tip = -r + shld_y = -r * 0.28 + width = r * 0.17 + color = C['red'] if is_north else C['white'] + elif intercardinal: + tip = -r * 0.68 + shld_y = -r * 0.20 + width = r * 0.11 + color = C['gold'] + else: + tip = -r * 0.42 + shld_y = -r * 0.12 + width = r * 0.07 + color = S.GOLD_DIM + + # Main point (upper half — bright) + path = QPainterPath() + path.moveTo(0, tip) + path.lineTo(-width / 2, shld_y) + path.lineTo(0, 0) + path.lineTo( width / 2, shld_y) + path.closeSubpath() + p.setBrush(QBrush(color)) + p.setPen(QPen(color.darker(200), 0.5)) + p.drawPath(path) + + # Lower shadow half + shadow = QPainterPath() + shadow.moveTo(0, 0) + shadow.lineTo(-width / 2, shld_y) + shadow.lineTo(0, abs(tip) * 0.22) + shadow.lineTo( width / 2, shld_y) + shadow.closeSubpath() + p.setBrush(QBrush(color.darker(220))) + p.setPen(Qt.NoPen) + p.drawPath(shadow) + + p.restore() + + # Center ring + cr = r * 0.09 + p.setPen(QPen(C['gold'], max(1.5, r * 0.025))) + p.setBrush(QBrush(S.BEZEL_DARK)) + p.drawEllipse(QPointF(0, 0), cr, cr) + + def _cardinal_labels(self, p, r, C): + entries = [ + ( 0, 'N', C['bright'], True, True), + ( 90, 'E', C['white'], True, False), + (180, 'S', C['white'], True, False), + (270, 'W', C['white'], True, False), + ( 45, 'NE', C['gold'], False, False), + (135, 'SE', C['gold'], False, False), + (225, 'SW', C['gold'], False, False), + (315, 'NW', C['gold'], False, False), + ] + for angle, label, color, big, bold in entries: + p.save() + p.rotate(angle) + fs = max(11, int(r * 0.17)) if big else max(7, int(r * 0.10)) + f = QFont('Arial', fs) + f.setBold(bold) + p.setFont(f) + p.setPen(QPen(color)) + rect = QRectF(-r * 0.20, -r * 1.12, r * 0.40, r * 0.24) + p.drawText(rect, Qt.AlignCenter, label) + p.restore() + + def _lubber_line(self, p, R, C): + """Fixed red lubber line — always at top = ship's heading.""" + pen = QPen(C['red'], max(2.5, R * 0.013)) + pen.setCapStyle(Qt.RoundCap) + p.setPen(pen) + p.drawLine(QPointF(0, -R * 0.97), QPointF(0, -R * 0.70)) + + # Arrow tip + path = QPainterPath() + path.moveTo(0, -R * 0.70) + path.lineTo(-R * 0.028, -R * 0.62) + path.lineTo( R * 0.028, -R * 0.62) + path.closeSubpath() + p.setBrush(QBrush(C['red'])) + p.setPen(Qt.NoPen) + p.drawPath(path) + + def _center_ball(self, p, r, C): + """Ball in center that shows roll/pitch deviation.""" + p.setPen(QPen(C['gold'], max(1.5, r * 0.10))) + p.setBrush(QBrush(QColor('#080C18'))) + p.drawEllipse(QPointF(0, 0), r, r) + + max_d = r * 0.58 + ball_x = math.sin(math.radians(self._roll)) * max_d + ball_y = -math.sin(math.radians(self._pitch)) * max_d + dist = math.hypot(ball_x, ball_y) + if dist > max_d: + ball_x = ball_x / dist * max_d + ball_y = ball_y / dist * max_d + + br = r * 0.38 + p.setPen(Qt.NoPen) + p.setBrush(QBrush(C['gold'])) + p.drawEllipse(QPointF(ball_x, ball_y), br, br) + + def _heading_readout(self, p, cx, cy, R, C): + """Digital heading displayed at the top of the compass, just below the lubber arrow.""" + fs = max(13, int(R * 0.115)) + p.setFont(QFont('Courier New', fs, QFont.Bold)) + + # Semi-transparent backing so text reads cleanly over the rotating card + box_w = R * 0.68 + box_h = R * 0.21 + box_x = cx - box_w / 2 + box_y = cy - R * 0.59 + p.setBrush(QBrush(QColor(6, 9, 20, 200))) + p.setPen(Qt.NoPen) + p.drawRoundedRect(QRectF(box_x, box_y, box_w, box_h), + max(3, box_h * 0.20), max(3, box_h * 0.20)) + + p.setPen(QPen(C['bright'])) + p.drawText(QRectF(box_x, box_y, box_w, box_h), + Qt.AlignCenter, f"{self._hdg:05.1f}°{self._hdg_mode}") diff --git a/ui/info_panel.py b/ui/info_panel.py new file mode 100644 index 0000000..90382c8 --- /dev/null +++ b/ui/info_panel.py @@ -0,0 +1,949 @@ +""" +Right-side information panel — fully responsive. + • Large heading display + • Data fields (HDG T, ROT, PITCH, ROLL, VAR, HEAVE, YAW RATE) + • ROT arc indicator + • Boat attitude silhouettes (pitch / roll / yaw) + • Touch buttons +""" +import math +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, + QLabel, QPushButton, QFrame, QSizePolicy) +from PyQt5.QtCore import Qt, pyqtSignal, QRectF, QPointF +from PyQt5.QtGui import (QFont, QPainter, QPen, QBrush, + QColor, QPainterPath) +import ui.styles as S + + +# ───────────────────────────────────────────────────────────────────────────── +# Info Panel +# ───────────────────────────────────────────────────────────────────────────── + +class InfoPanel(QWidget): + + night_toggled = pyqtSignal(bool) + port_requested = pyqtSignal() + hdg_mode_changed = pyqtSignal(str) # 'M' or 'T' + + def __init__(self, parent=None): + super().__init__(parent) + self._night = False + self._hdg_mode = 'M' + self._build() + + # ── Layout ───────────────────────────────────────────────────────────────── + + def _build(self): + root = QVBoxLayout(self) + root.setContentsMargins(4, 4, 4, 4) + root.setSpacing(4) + self._root_layout = root + + # Large magnetic heading + self.hdg_val = QLabel('---.-°M') + self.hdg_val.setAlignment(Qt.AlignCenter) + self.hdg_val.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Ignored) + root.addWidget(self.hdg_val, stretch=4) + + # Data rows + self._rows = {} + fields = [ + ('HDG (T)', 'hdg_t', '---.-°T'), + ('ROT', 'rot', '--- °/min'), + ('PITCH', 'pitch', '+--.-°'), + ('ROLL', 'roll', '+--.-°'), + ('VAR', 'var', '--.-°-'), + ('HEAVE', 'accel_z', '--.- m/s²'), + ('YAW RATE', 'gyro_z', '--.- °/s'), + ] + rows_wrap = QWidget() + rows_wrap.setObjectName('section_box') + rows_vl = QVBoxLayout(rows_wrap) + rows_vl.setContentsMargins(0, 0, 0, 0) + rows_vl.setSpacing(0) + for label, key, default in fields: + row, val = self._field_row(label, default) + self._rows[key] = val + rows_vl.addWidget(row) + root.addWidget(rows_wrap, stretch=3 * 7) + + # ROT arc + self.rot_arc = RotArc() + self.rot_arc.setObjectName('section_box') + root.addWidget(self.rot_arc, stretch=5) + + # Boat attitude silhouettes + import config + self.boat_att = BoatAttitudeWidget(vessel_type=config.VESSEL_TYPE) + self.boat_att.setObjectName('section_box') + root.addWidget(self.boat_att, stretch=7) + + # Touch buttons + self._btn_container = QWidget() + self._btn_container.setObjectName('section_box') + self._btn_layout = QHBoxLayout(self._btn_container) + self._btn_hdg = self._btn('HDG °M', self._toggle_hdg_mode) + self._btn_night = self._btn('NIGHT', self._toggle_night) + self._btn_port = self._btn('PORTS', self.port_requested.emit) + self._btn_layout.addWidget(self._btn_hdg) + self._btn_layout.addWidget(self._btn_night) + self._btn_layout.addWidget(self._btn_port) + root.addWidget(self._btn_container, stretch=2) + + self._apply_theme() + + def _field_row(self, label_text, default): + row = QWidget() + row.setObjectName('field_row') + hl = QHBoxLayout(row) + hl.setContentsMargins(0, 0, 0, 0) + lbl = QLabel(label_text) + lbl.setObjectName('field_lbl') + lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + val = QLabel(default) + val.setObjectName('field_val') + val.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + hl.addWidget(lbl, stretch=4) + hl.addWidget(val, stretch=6) + return row, val + + def _divider(self): + line = QFrame() + line.setFrameShape(QFrame.HLine) + line.setFixedHeight(1) + line.setStyleSheet(f'background: {S.DIVIDER.name()};') + return line + + def _btn(self, text, callback): + b = QPushButton(text) + b.setObjectName('nav_btn') + b.clicked.connect(callback) + return b + + # ── Theme ────────────────────────────────────────────────────────────────── + + def _apply_theme(self): + n = self._night + bg = S.N_BG.name() if n else S.PANEL_BG.name() + gold = S.N_GOLD.name() if n else S.GOLD.name() + bright = S.N_GOLD.name() if n else S.GOLD_BRIGHT.name() + white = S.N_WHITE.name() if n else S.WHITE.name() + dim = S.N_WHITE.name() if n else S.WHITE_DIM.name() + box_bg = S.BEZEL_DARK.name() + + h = max(self.height(), 480) + w = max(self.width(), 200) + + hdg_fs = max(14, h // 12) + data_fs = max(10, h // 38) + lbl_fs = max(9, h // 48) + btn_fs = max(9, h // 50) + btn_h = max(36, h // 14) + pad_h = max(2, h // 120) + pad_w = max(6, w // 25) + btn_sp = max(3, w // 60) + btn_rad = max(4, h // 100) + gap = max(4, h // 120) # spacing between frames + brad = max(4, h // 140) # border radius for section boxes + + self._root_layout.setContentsMargins(gap, gap, gap, gap) + self._root_layout.setSpacing(gap) + self._btn_layout.setContentsMargins(btn_sp, btn_sp, btn_sp, btn_sp) + self._btn_layout.setSpacing(btn_sp) + + self.setStyleSheet(f""" + QWidget {{ + background: {bg}; + color: {white}; + font-family: 'Courier New'; + }} + QWidget#section_box {{ + background: {box_bg}; + border: 1px solid {S.DIVIDER.name()}; + border-radius: {brad}px; + }} + QWidget#field_row {{ + border-bottom: 1px solid {S.DIVIDER.name()}; + }} + QLabel#field_lbl {{ + color: {dim}; + font-size: {lbl_fs}px; + font-weight: bold; + padding-left: {pad_w}px; + padding-top: {pad_h}px; + padding-bottom: {pad_h}px; + }} + QLabel#field_val {{ + color: {white}; + font-size: {data_fs}px; + padding-right: {pad_w}px; + padding-top: {pad_h}px; + padding-bottom: {pad_h}px; + }} + QPushButton#nav_btn {{ + background: {S.BEZEL_MID.name()}; + color: {gold}; + border: 1px solid {gold}; + border-radius: {btn_rad}px; + font-size: {btn_fs}px; + font-weight: bold; + min-height: {btn_h}px; + }} + QPushButton#nav_btn:pressed {{ + background: {gold}; + color: {bg}; + }} + """) + + f = QFont('Courier New', hdg_fs, QFont.Bold) + self.hdg_val.setFont(f) + self.hdg_val.setStyleSheet(f'color: {bright}; background: transparent;') + + self.rot_arc.set_night(n) + self.boat_att.set_night(n) + + def resizeEvent(self, event): + super().resizeEvent(event) + self._apply_theme() + + # ── Data update ──────────────────────────────────────────────────────────── + + def update_data(self, nav): + if self._hdg_mode == 'T': + hdg = nav.hdg_true_calc + suffix = '°T' + else: + hdg = nav.hdg_mag + suffix = '°M' + self.hdg_val.setText(f'{hdg:05.1f}{suffix}' if hdg is not None else f'---.--{suffix}') + self._set('hdg_t', self._fmt_hdg(nav.hdg_true_calc, '°T')) + self._set('rot', self._fmt_rot(nav.rot)) + self._set('pitch', self._fmt_signed(nav.pitch, '°')) + self._set('roll', self._fmt_signed(nav.roll, '°')) + self._set('var', self._fmt_var(nav.variation)) + self._set('accel_z', self._fmt_accel(nav.accel_z)) + self._set('gyro_z', self._fmt_gyro(nav.gyro_z)) + + rot = nav.rot or 0.0 + pitch = nav.pitch or 0.0 + roll = nav.roll or 0.0 + + self.rot_arc.set_rot(rot) + self.boat_att.set_attitude(pitch, roll, rot) + + def _set(self, key, text): + if key in self._rows: + self._rows[key].setText(text) + + # ── Formatting ───────────────────────────────────────────────────────────── + + @staticmethod + def _fmt_hdg(v, suffix): + return f'{v:05.1f}{suffix}' if v is not None else f'---.--{suffix}' + + @staticmethod + def _fmt_rot(v): + return f'{v:+.1f} °/min' if v is not None else '--- °/min' + + @staticmethod + def _fmt_signed(v, unit): + return f'{v:+.1f}{unit}' if v is not None else f'+--.-{unit}' + + @staticmethod + def _fmt_var(v): + return f'{abs(v):.1f}°{"E" if v >= 0 else "W"}' if v is not None else '--.-°-' + + @staticmethod + def _fmt_accel(v): + return f'{v:+.2f} m/s²' if v is not None else '--.- m/s²' + + @staticmethod + def _fmt_gyro(v): + return f'{v:+.2f} °/s' if v is not None else '--.- °/s' + + # ── Toggles ──────────────────────────────────────────────────────────────── + + def _toggle_hdg_mode(self): + self._hdg_mode = 'T' if self._hdg_mode == 'M' else 'M' + self._btn_hdg.setText(f'HDG °{self._hdg_mode}') + self.hdg_mode_changed.emit(self._hdg_mode) + + def _toggle_night(self): + self._night = not self._night + self._apply_theme() + self.night_toggled.emit(self._night) + + +# ───────────────────────────────────────────────────────────────────────────── +# ROT Arc +# ───────────────────────────────────────────────────────────────────────────── + +class RotArc(QWidget): + """Semicircular rate-of-turn indicator. Fully proportional.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._rot = 0.0 + self._night = False + + def set_rot(self, rot): + self._rot = rot or 0.0 + self.update() + + def set_night(self, on): + self._night = on + self.update() + + def paintEvent(self, event): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + w, h = self.width(), self.height() + if w < 10 or h < 10: + return + + gold = S.N_GOLD if self._night else S.GOLD + white = S.N_WHITE if self._night else S.WHITE_DIM + red = S.N_RED if self._night else S.RED + blue = S.N_BLUE if self._night else S.BLUE + + # Reserve top & bottom padding so text doesn't touch dividers + pad = max(6, h * 0.10) + cx = w / 2.0 + cy = h * 0.60 + r = min(w * 0.40, (h - pad * 2) * 0.68) + lw = max(2.0, r * 0.07) + + # Grey track arc + p.setPen(QPen(S.BEZEL_MID, lw, Qt.SolidLine, Qt.RoundCap)) + p.drawArc(int(cx - r), int(cy - r), int(r * 2), int(r * 2), + 0, 180 * 16) + + # Colored ROT arc (scale: ±60°/min = full 90° sweep) + rot_c = max(-60.0, min(60.0, self._rot)) + if abs(rot_c) > 0.3: + span = -(rot_c / 60.0) * 90.0 + color = blue if rot_c > 0 else red + p.setPen(QPen(color, lw * 1.4, Qt.SolidLine, Qt.RoundCap)) + p.drawArc(int(cx - r), int(cy - r), int(r * 2), int(r * 2), + int(90 * 16), int(-span * 16)) + + # Center tick + p.setPen(QPen(gold, max(1.5, r * 0.04))) + p.drawLine(int(cx), int(cy - r * 1.10), int(cx), int(cy - r * 0.88)) + + # PORT / STBD labels — above the arc ends + side_fs = max(7, int(r * 0.17)) + p.setFont(QFont('Arial', side_fs)) + p.setPen(QPen(white)) + side_h = max(14, int(r * 0.28)) + p.drawText(int(cx - r - r * 0.1), int(cy - r * 0.18), + int(r * 0.9), side_h, Qt.AlignCenter, 'PORT') + p.drawText(int(cx + r * 0.2), int(cy - r * 0.18), + int(r * 0.9), side_h, Qt.AlignCenter, 'STBD') + + # ROT value — with gap below arc + txt_fs = max(8, int(r * 0.22)) + txt_h = max(16, int(r * 0.35)) + p.setFont(QFont('Courier New', txt_fs, QFont.Bold)) + p.setPen(QPen(white)) + p.drawText(0, int(cy + r * 0.18), w, txt_h, + Qt.AlignCenter, f'{self._rot:+.1f} °/min') + + +# ───────────────────────────────────────────────────────────────────────────── +# Boat Attitude Widget +# ───────────────────────────────────────────────────────────────────────────── + +class BoatAttitudeWidget(QWidget): + """ + Three animated boat silhouettes: + PITCH — side view, bow right, rotates fore/aft + ROLL — front view, tilts port/stbd + YAW — top view, rotates with ROT + """ + + def __init__(self, vessel_type: str = 'motor_cruiser', parent=None): + super().__init__(parent) + self._pitch = 0.0 + self._roll = 0.0 + self._rot = 0.0 + self._night = False + self._vessel_type = vessel_type + + def set_attitude(self, pitch: float, roll: float, rot: float = 0.0): + self._pitch = pitch + self._roll = roll + self._rot = rot + self.update() + + def set_night(self, on: bool): + self._night = on + self.update() + + def paintEvent(self, event): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + p.setRenderHint(QPainter.TextAntialiasing) + + w, h = self.width(), self.height() + if w < 30 or h < 20: + return + + pw = w / 3.0 # panel width per silhouette + + self._draw_panel(p, 0, pw, h, self._pitch, + 'PITCH', 'side', f'{self._pitch:+.1f}°') + self._draw_panel(p, pw, pw, h, self._roll, + 'ROLL', 'front', f'{self._roll:+.1f}°') + self._draw_panel(p, pw * 2, pw, h, self._rot, + 'YAW', 'top', f'{self._rot:+.1f}°/m') + + def _draw_panel(self, p, x, pw, h, value, label, view, val_str): + gold = S.N_GOLD if self._night else S.GOLD + white = S.N_WHITE if self._night else S.WHITE + dim = S.N_WHITE if self._night else S.WHITE_DIM + + mg = max(3, int(pw * 0.06)) + lh = max(14, int(h * 0.15)) # label height + vh = max(14, int(h * 0.15)) # value height + boat_h = h - lh - vh + + cx = x + pw / 2.0 + cy = lh + boat_h / 2.0 + r = min(pw * 0.42, boat_h * 0.44) + + # Panel bg + p.setBrush(QBrush(S.BEZEL_DARK)) + p.setPen(QPen(S.DIVIDER, 1)) + p.drawRoundedRect(QRectF(x + mg, mg, pw - mg * 2, h - mg * 2), + max(3, mg * 0.6), max(3, mg * 0.6)) + + # Label + lfs = max(7, int(h * 0.085)) + p.setFont(QFont('Arial', lfs, QFont.Bold)) + p.setPen(QPen(gold)) + p.drawText(QRectF(x, mg, pw, lh), Qt.AlignCenter, label) + + # Waterline (fixed) + p.setPen(QPen(dim.darker(180) if not self._night else dim.darker(120), + max(1, int(r * 0.03)), Qt.DashLine)) + p.drawLine(QPointF(x + mg * 2, cy), QPointF(x + pw - mg * 2, cy)) + + # Rotated boat silhouette + p.save() + p.translate(cx, cy) + + vt = self._vessel_type + if view == 'side': + p.rotate(-value) + if vt == 'cargo': self._boat_side_cargo(p, r, gold) + else: self._boat_side(p, r, gold) + elif view == 'front': + p.rotate(value) + if vt == 'cargo': self._boat_front_cargo(p, r, gold) + else: self._boat_front(p, r, gold) + elif view == 'top': + vis = max(-45.0, min(45.0, value * 0.75)) + p.rotate(vis) + if vt == 'cargo': self._boat_top_cargo(p, r, gold) + else: self._boat_top(p, r, gold) + + p.restore() + + # Value + vfs = max(7, int(h * 0.10)) + p.setFont(QFont('Courier New', vfs, QFont.Bold)) + p.setPen(QPen(white)) + p.drawText(QRectF(x, h - vh - mg, pw, vh), Qt.AlignCenter, val_str) + + # ── Boat shapes ──────────────────────────────────────────────────────────── + + def _boat_side(self, p, r, color): + """Modern superyacht starboard profile — bow right, sleek low lines.""" + lw = max(1.0, r * 0.025) + h_fill = color.darker(148) + h_pen = color.darker(185) + c_fill = color.darker(162) + win_col = QColor(28, 48, 95, 220) + + # ── 1. HULL — sleek, low freeboard ──────────────────────── + hull = QPainterPath() + hull.moveTo(-r*0.88, -r*0.10) # transom top + hull.lineTo(-r*0.92, r*0.14) # transom bottom + hull.quadTo(-r*0.20, r*0.30, r*0.28, r*0.22) + hull.quadTo( r*0.68, r*0.13, r*0.94, -r*0.03) + hull.lineTo( r*0.80, -r*0.28) # bow deck + hull.cubicTo(r*0.52, -r*0.22, r*0.04, -r*0.16, -r*0.30, -r*0.16) + hull.lineTo(-r*0.88, -r*0.10) + hull.closeSubpath() + p.setPen(QPen(h_pen, lw)) + p.setBrush(QBrush(h_fill)) + p.drawPath(hull) + + # ── 2. RED BOOT STRIPE ───────────────────────────────────── + boot = QPainterPath() + boot.moveTo(-r*0.92, r*0.14) + boot.quadTo(-r*0.20, r*0.20, r*0.28, r*0.14) + boot.quadTo( r*0.68, r*0.06, r*0.94, -r*0.03) + boot.lineTo( r*0.90, r*0.02) + boot.quadTo( r*0.66, r*0.06, r*0.24, r*0.10) + boot.quadTo(-r*0.20, r*0.14, -r*0.92, r*0.08) + boot.closeSubpath() + p.setPen(Qt.NoPen) + p.setBrush(QBrush(QColor(172, 22, 22, 210))) + p.drawPath(boot) + + # ── 3. MAIN SUPERSTRUCTURE — long, low, angular ─────────── + sup = QPainterPath() + sup.moveTo(-r*0.76, -r*0.10) + sup.lineTo(-r*0.76, -r*0.42) + sup.lineTo(-r*0.70, -r*0.46) + sup.lineTo( r*0.36, -r*0.46) + sup.lineTo( r*0.44, -r*0.40) + sup.lineTo( r*0.44, -r*0.10) + sup.closeSubpath() + p.setPen(QPen(h_pen, lw * 0.85)) + p.setBrush(QBrush(c_fill)) + p.drawPath(sup) + + # ── 4. LONG WINDOW STRIP ─────────────────────────────────── + p.setPen(QPen(color.darker(122), max(1, r*0.018))) + p.setBrush(QBrush(win_col.lighter(140))) + p.drawRoundedRect(QRectF(-r*0.72, -r*0.43, r*1.06, r*0.22), + r*0.016, r*0.016) + + # ── 5. BRIDGE DECK ───────────────────────────────────────── + br = QPainterPath() + br.moveTo(-r*0.38, -r*0.46) + br.lineTo(-r*0.38, -r*0.68) + br.lineTo(-r*0.32, -r*0.72) + br.lineTo( r*0.26, -r*0.72) + br.lineTo( r*0.32, -r*0.66) + br.lineTo( r*0.32, -r*0.46) + br.closeSubpath() + p.setBrush(QBrush(c_fill.darker(112))) + p.drawPath(br) + p.setBrush(QBrush(win_col.lighter(160))) + p.drawRoundedRect(QRectF(-r*0.32, -r*0.69, r*0.54, r*0.16), + r*0.012, r*0.012) + + # ── 6. MAST & RADAR ──────────────────────────────────────── + p.setPen(QPen(color.darker(128), max(1.5, r*0.028))) + p.drawLine(QPointF(r*0.00, -r*0.72), QPointF(r*0.00, -r*0.95)) + p.setPen(QPen(h_pen, max(1, r*0.015))) + p.setBrush(QBrush(color.darker(160))) + p.drawEllipse(QPointF(r*0.00, -r*0.93), r*0.048, r*0.034) + + # ── 7. SWIM PLATFORM ─────────────────────────────────────── + plat = QPainterPath() + plat.moveTo(-r*0.88, r*0.14) + plat.lineTo(-r*0.98, r*0.14) + plat.lineTo(-r*0.98, r*0.20) + plat.lineTo(-r*0.88, r*0.20) + plat.closeSubpath() + p.setPen(QPen(h_pen, lw * 0.8)) + p.setBrush(QBrush(h_fill.darker(110))) + p.drawPath(plat) + + def _boat_front(self, p, r, color): + """Modern superyacht bow-on — deep-V hull, wide flare, low superstructure.""" + lw = max(1.0, r * 0.025) + h_fill = color.darker(148) + h_pen = color.darker(185) + c_fill = color.darker(162) + win_col = QColor(28, 48, 95, 220) + + # ── HULL below waterline — sharp V ───────────────────────── + hull_v = QPainterPath() + hull_v.moveTo( 0, r*0.50) + hull_v.lineTo(-r*0.52, r*0.06) + hull_v.lineTo( r*0.52, r*0.06) + hull_v.closeSubpath() + p.setPen(QPen(h_pen, lw)) + p.setBrush(QBrush(h_fill.darker(118))) + p.drawPath(hull_v) + + # ── HULL TOPSIDES — wide flare ───────────────────────────── + hull_t = QPainterPath() + hull_t.moveTo(-r*0.52, r*0.06) + hull_t.quadTo(-r*0.84, -r*0.02, -r*0.88, -r*0.16) + hull_t.lineTo(-r*0.62, -r*0.24) + hull_t.lineTo( 0, -r*0.32) + hull_t.lineTo( r*0.62, -r*0.24) + hull_t.lineTo( r*0.88, -r*0.16) + hull_t.quadTo( r*0.84, -r*0.02, r*0.52, r*0.06) + hull_t.closeSubpath() + p.setPen(QPen(h_pen, lw)) + p.setBrush(QBrush(h_fill)) + p.drawPath(hull_t) + + # Boot stripe + p.setPen(QPen(QColor(172, 22, 22, 210), max(2.0, r*0.034))) + p.drawLine(QPointF(-r*0.52, r*0.06), QPointF(r*0.52, r*0.06)) + + # ── WINDSHIELD — wide, angled ────────────────────────────── + ws = QPainterPath() + ws.moveTo(-r*0.26, -r*0.32) + ws.lineTo(-r*0.20, -r*0.58) + ws.lineTo( 0, -r*0.64) + ws.lineTo( r*0.20, -r*0.58) + ws.lineTo( r*0.26, -r*0.32) + ws.closeSubpath() + p.setPen(QPen(h_pen, lw * 0.85)) + p.setBrush(QBrush(c_fill)) + p.drawPath(ws) + + # Glass panels (port + stbd) + for sign in (-1, 1): + pg = QPainterPath() + pg.moveTo(sign * r*0.22, -r*0.34) + pg.lineTo(sign * r*0.16, -r*0.54) + pg.lineTo(sign * r*0.02, -r*0.60) + pg.lineTo(sign * r*0.02, -r*0.34) + pg.closeSubpath() + p.setPen(QPen(color.darker(122), max(1, r*0.016))) + p.setBrush(QBrush(win_col.lighter(145))) + p.drawPath(pg) + + # ── MAST ─────────────────────────────────────────────────── + p.setPen(QPen(color.darker(128), max(1.5, r*0.030))) + p.drawLine(QPointF(0, -r*0.64), QPointF(0, -r*0.92)) + # Radar dome + p.setPen(QPen(h_pen, max(1, r*0.015))) + p.setBrush(QBrush(color.darker(160))) + p.drawEllipse(QPointF(0, -r*0.90), r*0.054, r*0.038) + + # ── NAV LIGHTS ───────────────────────────────────────────── + p.setPen(Qt.NoPen) + p.setBrush(QBrush(QColor(220, 40, 40, 240))) + p.drawEllipse(QPointF(-r*0.90, -r*0.14), r*0.036, r*0.036) + p.setBrush(QBrush(QColor(40, 205, 80, 240))) + p.drawEllipse(QPointF( r*0.90, -r*0.14), r*0.036, r*0.036) + + # Keel stem + p.setPen(QPen(color.darker(120), max(1.0, r*0.030))) + p.drawLine(QPointF(0, r*0.50), QPointF(0, r*0.12)) + + # ── CARGO SHIP ──────────────────────────────────────────────────────────── + + def _boat_side_cargo(self, p, r, color): + """Bulk carrier starboard profile — bow right, bridge + funnel at stern (left). + High freeboard, 4 prominent goalpost cranes, bulbous bow.""" + lw = max(1.0, r * 0.022) + h_fill = color.darker(152) + h_pen = color.darker(190) + s_fill = color.darker(160) + crane_c = color.darker(122) + + DK = -r * 0.14 # deck rail (above center = negative y) + WL = r * 0.24 # waterline + KL = r * 0.44 # keel + DT = DK - r*0.10 # deck top surface + + # ── HULL — very elongated, high freeboard ────────────────── + hull = QPainterPath() + hull.moveTo(-r*0.93, DK) + hull.lineTo(-r*0.93, KL) # stern vertical + hull.lineTo( r*0.74, KL) # flat keel + hull.quadTo( r*0.94, KL, r*0.94, WL) # bow keel curve + hull.lineTo( r*0.94, DT - r*0.06) # bow stem + hull.quadTo( r*0.86, DT - r*0.10, r*0.64, DT - r*0.10) + hull.lineTo(-r*0.86, DT - r*0.10) # flat main deck + hull.lineTo(-r*0.93, DK) + hull.closeSubpath() + p.setPen(QPen(h_pen, lw)) + p.setBrush(QBrush(h_fill)) + p.drawPath(hull) + + # Bulbous bow + p.setPen(QPen(h_pen, lw * 0.70)) + p.setBrush(QBrush(h_fill.darker(114))) + p.drawEllipse(QPointF(r*0.96, WL + r*0.04), r*0.036, r*0.066) + + # Boot stripe — bold red antifouling + p.setPen(QPen(QColor(148, 12, 12, 245), max(2.5, r * 0.042))) + p.drawLine(QPointF(-r*0.93, WL), QPointF(r*0.93, WL)) + + # ── BRIDGE CASTLE at STERN — tall, prominent ─────────────── + # Level 1 — accommodation block (widest) + p.setPen(QPen(h_pen, lw * 0.84)) + p.setBrush(QBrush(s_fill)) + p.drawRect(QRectF(-r*0.93, DT - r*0.52, r*0.44, r*0.42)) + # Level 2 — bridge deck + p.setBrush(QBrush(s_fill.darker(110))) + p.drawRect(QRectF(-r*0.91, DT - r*0.68, r*0.36, r*0.16)) + # Level 3 — wheelhouse + p.setBrush(QBrush(s_fill.darker(122))) + p.drawRect(QRectF(-r*0.88, DT - r*0.82, r*0.26, r*0.14)) + # Bridge windows row + p.setPen(QPen(color.darker(122), max(1, r*0.015))) + p.setBrush(QBrush(QColor(28, 48, 95, 230))) + for wx in (-r*0.84, -r*0.74, -r*0.64): + p.drawRect(QRectF(wx, DT - r*0.79, r*0.08, r*0.10)) + + # Funnel — tall, slim, forward of bridge + p.setPen(QPen(h_pen, lw * 0.78)) + p.setBrush(QBrush(h_fill.darker(124))) + p.drawRect(QRectF(-r*0.60, DT - r*0.80, r*0.12, r*0.70)) + p.setBrush(QBrush(h_fill.darker(132))) + p.drawRect(QRectF(-r*0.62, DT - r*0.88, r*0.16, r*0.10)) # cap + + # ── GOALPOST CRANES — 4 prominent H-frames ───────────────── + ct = DT - r * 0.54 # crossbeam height + hw = r * 0.058 + + for gx in (-r*0.15, r*0.18, r*0.50, r*0.80): + lp, rp = gx - hw, gx + hw + # Vertical legs + p.setPen(QPen(crane_c, max(2.0, r * 0.034))) + p.drawLine(QPointF(lp, DT), QPointF(lp, ct)) + p.drawLine(QPointF(rp, DT), QPointF(rp, ct)) + # Crossbeam + p.setPen(QPen(crane_c, max(1.5, r * 0.025))) + p.drawLine(QPointF(lp - r*0.04, ct), QPointF(rp + r*0.04, ct)) + # Derrick booms angled outward + p.setPen(QPen(crane_c, max(1.0, r * 0.018))) + p.drawLine(QPointF(lp, ct + r*0.10), + QPointF(lp - r*0.20, ct - r*0.18)) + p.drawLine(QPointF(rp, ct + r*0.10), + QPointF(rp + r*0.20, ct - r*0.18)) + + # ── CARGO HATCH COAMINGS — 4 hatches ────────────────────── + p.setPen(QPen(h_pen, max(0.8, r * 0.015))) + p.setBrush(QBrush(h_fill.lighter(118))) + for hx in (-r*0.12, r*0.20, r*0.52, r*0.80): + p.drawRoundedRect(QRectF(hx - r*0.12, DT + r*0.01, r*0.22, r*0.10), + r*0.012, r*0.012) + + # Nav lights + p.setPen(Qt.NoPen) + p.setBrush(QBrush(QColor(220, 40, 40, 232))) + p.drawEllipse(QPointF(-r*0.91, DT - r*0.08), r*0.028, r*0.028) + p.setBrush(QBrush(QColor(40, 205, 80, 232))) + p.drawEllipse(QPointF( r*0.90, DT - r*0.08), r*0.028, r*0.028) + + def _boat_front_cargo(self, p, r, color): + """Bulk carrier bow-on — wide boxy hull, full flare, tall stacked bridge.""" + lw = max(1.0, r * 0.022) + h_fill = color.darker(152) + h_pen = color.darker(190) + s_fill = color.darker(160) + DK = -r * 0.12 # deck + WL = r * 0.10 # waterline + KL = r * 0.56 # keel + HW = r * 0.90 # hull half-width — bulk carriers are WIDE + + # ── HULL below waterline — less V, more full-form ────────── + hull_v = QPainterPath() + hull_v.moveTo( 0, KL) + hull_v.lineTo(-r*0.36, WL + r*0.10) + hull_v.lineTo( r*0.36, WL + r*0.10) + hull_v.closeSubpath() + p.setPen(Qt.NoPen) + p.setBrush(QBrush(h_fill.darker(120))) + p.drawPath(hull_v) + + # ── HULL TOPSIDES — wide, strong flare ───────────────────── + hull = QPainterPath() + hull.moveTo(-r*0.36, WL + r*0.10) + hull.quadTo(-r*0.70, WL + r*0.02, -HW, DK + r*0.08) + hull.lineTo(-HW, DK) + hull.lineTo( HW, DK) + hull.lineTo( HW, DK + r*0.08) + hull.quadTo( r*0.70, WL + r*0.02, r*0.36, WL + r*0.10) + hull.closeSubpath() + p.setPen(QPen(h_pen, lw)) + p.setBrush(QBrush(h_fill)) + p.drawPath(hull) + + # Boot stripe — bold red + p.setPen(QPen(QColor(148, 12, 12, 245), max(2.5, r * 0.042))) + p.drawLine(QPointF(-HW + r*0.06, WL), QPointF(HW - r*0.06, WL)) + + # ── STACKED SUPERSTRUCTURE — 3 levels ────────────────────── + for bw, bt, bh in [ + (r*0.56, DK - r*0.30, r*0.30), # accommodation (widest) + (r*0.42, DK - r*0.52, r*0.22), # bridge deck + (r*0.30, DK - r*0.70, r*0.18), # wheelhouse + ]: + p.setPen(QPen(h_pen, lw * 0.82)) + p.setBrush(QBrush(s_fill)) + p.drawRect(QRectF(-bw, bt, bw * 2, bh)) + + # Bridge windows — prominent row + p.setPen(QPen(color.darker(122), max(1, r * 0.016))) + p.setBrush(QBrush(QColor(28, 48, 95, 232))) + win_y = DK - r*0.67 + for wx in (-r*0.24, -r*0.12, 0, r*0.12, r*0.24): + p.drawRect(QRectF(wx - r*0.045, win_y, r*0.088, r*0.12)) + + # Funnel top (visible above wheelhouse) + p.setPen(QPen(h_pen, lw * 0.7)) + p.setBrush(QBrush(h_fill.darker(124))) + p.drawRect(QRectF(-r*0.10, DK - r*0.88, r*0.20, r*0.18)) + + # ── MAST + YARDARM + RADAR ───────────────────────────────── + p.setPen(QPen(color.darker(128), max(1.5, r * 0.026))) + p.drawLine(QPointF(0, DK - r*0.70), QPointF(0, DK - r*0.96)) + p.setPen(QPen(color.darker(132), max(1.0, r * 0.018))) + p.drawLine(QPointF(-r*0.22, DK - r*0.88), QPointF(r*0.22, DK - r*0.88)) + p.setPen(QPen(h_pen, max(1, r * 0.014))) + p.setBrush(QBrush(color.darker(164))) + p.drawEllipse(QPointF(0, DK - r*0.94), r*0.046, r*0.032) + + # ── NAV LIGHTS ───────────────────────────────────────────── + p.setPen(Qt.NoPen) + p.setBrush(QBrush(QColor(220, 40, 40, 240))) + p.drawEllipse(QPointF(-HW + r*0.02, DK + r*0.02), r*0.036, r*0.036) + p.setBrush(QBrush(QColor(40, 205, 80, 240))) + p.drawEllipse(QPointF( HW - r*0.02, DK + r*0.02), r*0.036, r*0.036) + + # Keel stem + p.setPen(QPen(color.darker(120), max(1.0, r * 0.026))) + p.drawLine(QPointF(0, KL), QPointF(0, WL + r*0.08)) + + def _boat_top_cargo(self, p, r, color): + """Bulk carrier plan view — bow at top, WIDE oval (bulk carriers are wide!), + bridge castle at stern, 4 cargo holds, 2 goalpost crane pairs.""" + lw = max(1.0, r * 0.022) + h_fill = color.darker(152) + h_pen = color.darker(190) + s_fill = color.darker(160) + HW = r * 0.46 # hull half-width — bulk carriers have ~3:1 L/B ratio + + # ── HULL — wide oval with raked bow ──────────────────────── + hull = QPainterPath() + hull.moveTo( 0, -r*0.94) # bow tip + hull.cubicTo(-r*0.20, -r*0.88, -HW, -r*0.70, -HW, -r*0.30) + hull.lineTo(-HW, r*0.50) # port flat side + hull.quadTo(-HW, r*0.76, 0, r*0.86) # stern port + hull.quadTo( HW, r*0.76, HW, r*0.50) # stern stbd + hull.lineTo( HW, -r*0.30) # stbd flat side + hull.cubicTo( HW, -r*0.70, r*0.20, -r*0.88, 0, -r*0.94) + hull.closeSubpath() + p.setPen(QPen(h_pen, lw)) + p.setBrush(QBrush(h_fill)) + p.drawPath(hull) + + # ── BRIDGE CASTLE at STERN (bottom) ──────────────────────── + p.setPen(QPen(h_pen, lw * 0.82)) + p.setBrush(QBrush(s_fill)) + p.drawRoundedRect(QRectF(-r*0.32, r*0.52, r*0.64, r*0.26), + r*0.05, r*0.05) + # Funnel — circle on superstructure + p.setPen(QPen(h_pen, lw * 0.70)) + p.setBrush(QBrush(h_fill.darker(122))) + p.drawEllipse(QPointF(0, r*0.70), r*0.086, r*0.072) + + # ── CARGO HOLDS — 4 wide hatches ─────────────────────────── + p.setPen(QPen(h_pen, max(0.9, r * 0.016))) + p.setBrush(QBrush(h_fill.lighter(116))) + for hy in (-r*0.72, -r*0.36, r*0.00, r*0.36): + p.drawRoundedRect(QRectF(-r*0.38, hy, r*0.76, r*0.28), + r*0.04, r*0.04) + + # ── GOALPOST CRANE PAIRS — 2 sets of double H-frames ─────── + crane_c = color.darker(126) + for cy_c in (-r*0.56, r*0.14): + # Two athwartship beams (top and bottom chord of goalpost) + p.setPen(QPen(crane_c, max(2.5, r * 0.044))) + p.drawLine(QPointF(-r*0.38, cy_c - r*0.07), + QPointF( r*0.38, cy_c - r*0.07)) + p.drawLine(QPointF(-r*0.38, cy_c + r*0.07), + QPointF( r*0.38, cy_c + r*0.07)) + # Centerline spine + p.setPen(QPen(crane_c, max(1.2, r * 0.018))) + p.drawLine(QPointF(0, cy_c - r*0.10), QPointF(0, cy_c + r*0.10)) + + # ── CENTERLINE dashed ─────────────────────────────────────── + p.setPen(QPen(color.darker(148), max(0.8, r * 0.016), Qt.DashLine)) + p.drawLine(QPointF(0, -r*0.88), QPointF(0, r*0.82)) + + # ── BOW DIRECTION ARROW ───────────────────────────────────── + arrow = QPainterPath() + arrow.moveTo( 0, -r*0.94) + arrow.lineTo(-r*0.09, -r*0.78) + arrow.lineTo( r*0.09, -r*0.78) + arrow.closeSubpath() + p.setBrush(QBrush(color)) + p.setPen(Qt.NoPen) + p.drawPath(arrow) + + # ── NAV LIGHTS ───────────────────────────────────────────── + p.setPen(Qt.NoPen) + p.setBrush(QBrush(QColor(220, 40, 40, 240))) + p.drawEllipse(QPointF(-HW + r*0.04, -r*0.48), r*0.040, r*0.040) + p.setBrush(QBrush(QColor(40, 205, 80, 240))) + p.drawEllipse(QPointF( HW - r*0.04, -r*0.48), r*0.040, r*0.040) + + def _boat_top(self, p, r, color): + """Modern superyacht plan view — bow at top, sleek teardrop hull.""" + lw = max(1.0, r * 0.025) + h_fill = color.darker(148) + h_pen = color.darker(185) + c_fill = color.darker(162) + dk_fill = color.darker(155) + + # ── HULL — sharp bow, wide transom ───────────────────────── + hull = QPainterPath() + hull.moveTo( 0, -r*0.94) + hull.cubicTo(-r*0.20, -r*0.86, -r*0.38, -r*0.50, -r*0.38, -r*0.08) + hull.cubicTo(-r*0.38, r*0.22, -r*0.32, r*0.50, -r*0.22, r*0.64) + hull.lineTo(-r*0.22, r*0.76) + hull.lineTo( r*0.22, r*0.76) + hull.lineTo( r*0.22, r*0.64) + hull.cubicTo( r*0.32, r*0.50, r*0.38, r*0.22, r*0.38, -r*0.08) + hull.cubicTo( r*0.38, -r*0.50, r*0.20, -r*0.86, 0, -r*0.94) + hull.closeSubpath() + p.setPen(QPen(h_pen, lw)) + p.setBrush(QBrush(h_fill)) + p.drawPath(hull) + + # ── LONG SUPERSTRUCTURE ──────────────────────────────────── + p.setPen(QPen(h_pen, lw * 0.85)) + p.setBrush(QBrush(c_fill)) + p.drawRoundedRect(QRectF(-r*0.22, -r*0.34, r*0.44, r*0.64), + r*0.06, r*0.06) + + # ── BRIDGE (forward section of superstructure) ───────────── + p.setBrush(QBrush(c_fill.darker(112))) + p.drawRoundedRect(QRectF(-r*0.15, -r*0.30, r*0.30, r*0.22), + r*0.05, r*0.05) + + # ── AFT DECK (open) ──────────────────────────────────────── + p.setBrush(QBrush(dk_fill)) + p.drawRoundedRect(QRectF(-r*0.18, r*0.36, r*0.36, r*0.26), + r*0.04, r*0.04) + + # ── SWIM PLATFORM ────────────────────────────────────────── + p.setBrush(QBrush(h_fill.darker(110))) + p.drawRoundedRect(QRectF(-r*0.16, r*0.64, r*0.32, r*0.12), + r*0.03, r*0.03) + + # ── FOREDECK HATCH ───────────────────────────────────────── + p.setPen(QPen(h_pen, lw * 0.80)) + p.setBrush(QBrush(h_fill.darker(108))) + p.drawRoundedRect(QRectF(-r*0.06, -r*0.60, r*0.12, r*0.10), + r*0.02, r*0.02) + p.drawRoundedRect(QRectF(-r*0.06, -r*0.46, r*0.12, r*0.10), + r*0.02, r*0.02) + + # ── CENTERLINE ───────────────────────────────────────────── + p.setPen(QPen(color.darker(148), max(0.8, r*0.016), Qt.DashLine)) + p.drawLine(QPointF(0, -r*0.88), QPointF(0, r*0.70)) + + # ── BOW DIRECTION ARROW ───────────────────────────────────── + arrow = QPainterPath() + arrow.moveTo( 0, -r*0.94) + arrow.lineTo(-r*0.08, -r*0.78) + arrow.lineTo( r*0.08, -r*0.78) + arrow.closeSubpath() + p.setBrush(QBrush(color)) + p.setPen(Qt.NoPen) + p.drawPath(arrow) + + # ── NAV LIGHTS ───────────────────────────────────────────── + p.setBrush(QBrush(QColor(220, 40, 40, 240))) + p.drawEllipse(QPointF(-r*0.36, -r*0.22), r*0.038, r*0.038) + p.setBrush(QBrush(QColor(40, 205, 80, 240))) + p.drawEllipse(QPointF( r*0.36, -r*0.22), r*0.038, r*0.038) diff --git a/ui/main_window.py b/ui/main_window.py new file mode 100644 index 0000000..28a80f0 --- /dev/null +++ b/ui/main_window.py @@ -0,0 +1,195 @@ +""" +Main window — responsive full-screen layout. +Left: compass rose | Right: info panel +""" +import math +from pathlib import Path +from PyQt5.QtWidgets import (QMainWindow, QWidget, QHBoxLayout, + QDialog, QVBoxLayout, QListWidget, + QPushButton, QLabel, QApplication) +from PyQt5.QtCore import Qt, QTimer +from PyQt5.QtGui import QColor, QIcon + +import config +from core.nmea_parser import NavData, parse +from core.serial_reader import SerialReader +from ui.compass_widget import CompassWidget +from ui.info_panel import InfoPanel +import ui.styles as S + + +class MainWindow(QMainWindow): + + def __init__(self): + super().__init__() + self.setWindowTitle('AR Compass — Marine') + _logo = Path(__file__).parent.parent / 'assets' / 'images' / 'ar_logo_full.png' + if _logo.exists(): + self.setWindowIcon(QIcon(str(_logo))) + self.showFullScreen() + + self._nav = NavData() + self._smooth = 0.0 + self._readers: list[SerialReader] = [] + self._night = False + self._hdg_mode = 'M' # 'M' magnetic, 'T' true + + self._build_ui() + self._start_serial() + self._start_timer() + + # ── UI ────────────────────────────────────────────────────────────────── + + def _build_ui(self): + central = QWidget() + self.setCentralWidget(central) + central.setStyleSheet(f'background: {S.BG.name()};') + + hl = QHBoxLayout(central) + hl.setContentsMargins(0, 0, 0, 0) + hl.setSpacing(0) + + self.compass = CompassWidget() + self.panel = InfoPanel() + + # Divider + div = QWidget() + div.setFixedWidth(2) + div.setStyleSheet(f'background: {S.DIVIDER.name()};') + + # 60 / 40 split + hl.addWidget(self.compass, stretch=60) + hl.addWidget(div) + hl.addWidget(self.panel, stretch=40) + + self.panel.night_toggled.connect(self._on_night) + self.panel.port_requested.connect(self._show_port_dialog) + self.panel.hdg_mode_changed.connect(self._on_hdg_mode) + + # ── Timer — animation & UI refresh ────────────────────────────────────── + + def _start_timer(self): + self._timer = QTimer(self) + self._timer.timeout.connect(self._tick) + self._timer.start(config.UI_REFRESH_MS) + + def _tick(self): + # Select heading source based on mode + if self._hdg_mode == 'T': + hdg = self._nav.hdg_true_calc + else: + hdg = self._nav.hdg_mag + + if hdg is not None: + diff = ((hdg - self._smooth + 180) % 360) - 180 + self._smooth = (self._smooth + diff * config.HEADING_SMOOTHING) % 360 + self.compass.set_heading(self._smooth) + + self.compass.set_attitude( + self._nav.pitch or 0.0, + self._nav.roll or 0.0, + ) + self.panel.update_data(self._nav) + + # ── Serial ─────────────────────────────────────────────────────────────── + + def _start_serial(self): + for cfg in config.SERIAL_PORTS: + r = SerialReader(cfg['port'], cfg['baud'], self) + r.sentence.connect(self._on_sentence) + r.start() + self._readers.append(r) + + def _on_sentence(self, line: str): + parse(line, self._nav) + + # ── Night mode ─────────────────────────────────────────────────────────── + + def _on_hdg_mode(self, mode: str): + self._hdg_mode = mode + self.compass.set_hdg_mode(mode) + + def _on_night(self, on: bool): + self._night = on + self.compass.set_night(on) + bg = S.N_BG.name() if on else S.BG.name() + self.centralWidget().setStyleSheet(f'background: {bg};') + + # ── Port dialog ────────────────────────────────────────────────────────── + + def _show_port_dialog(self): + dlg = PortDialog(self) + if dlg.exec_() == QDialog.Accepted: + port, baud = dlg.selected() + if port: + self._stop_serial() + config.SERIAL_PORTS = [{'port': port, 'baud': baud, 'name': 'Selected'}] + self._start_serial() + + def _stop_serial(self): + for r in self._readers: + r.stop() + self._readers.clear() + + # ── Keyboard shortcuts ─────────────────────────────────────────────────── + + def keyPressEvent(self, event): + if event.key() == Qt.Key_Escape: + self.close() + elif event.key() == Qt.Key_F: + if self.isFullScreen(): + self.showNormal() + else: + self.showFullScreen() + + def closeEvent(self, event): + self._stop_serial() + super().closeEvent(event) + + +class PortDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle('Select Serial Port') + self.setMinimumSize(400, 300) + self.setStyleSheet(f""" + QDialog {{ background: {S.PANEL_BG.name()}; color: {S.WHITE.name()}; }} + QListWidget {{ background: {S.BEZEL_DARK.name()}; color: {S.WHITE.name()}; + border: 1px solid {S.GOLD_DIM.name()}; font-size: 14px; }} + QListWidget::item:selected {{ background: {S.GOLD_DIM.name()}; }} + QPushButton {{ background: {S.BEZEL_MID.name()}; color: {S.GOLD.name()}; + border: 1px solid {S.GOLD.name()}; padding: 8px; border-radius: 4px; }} + """) + + vl = QVBoxLayout(self) + vl.addWidget(QLabel('Available serial ports:')) + + self._list = QListWidget() + for p in SerialReader.available_ports(): + self._list.addItem(p) + vl.addWidget(self._list) + + hl = QHBoxLayout() + ok = QPushButton('Connect 4800 baud') + ok38 = QPushButton('Connect 38400 baud') + cancel = QPushButton('Cancel') + ok.clicked.connect(lambda: self._accept(4800)) + ok38.clicked.connect(lambda: self._accept(38400)) + cancel.clicked.connect(self.reject) + hl.addWidget(ok) + hl.addWidget(ok38) + hl.addWidget(cancel) + vl.addLayout(hl) + + self._port = None + self._baud = 4800 + + def _accept(self, baud): + items = self._list.selectedItems() + if items: + self._port = items[0].text() + self._baud = baud + self.accept() + + def selected(self): + return self._port, self._baud diff --git a/ui/styles.py b/ui/styles.py new file mode 100644 index 0000000..c6986d6 --- /dev/null +++ b/ui/styles.py @@ -0,0 +1,66 @@ +""" +Nautical color theme — day and night modes. +""" +from PyQt5.QtGui import QColor, QFont + +# ── Day palette ──────────────────────────────────────────────────────────── +BG = QColor('#07090F') +PANEL_BG = QColor('#0C1020') +BEZEL_DARK = QColor('#10141E') +BEZEL_MID = QColor('#1A2030') +CARD_BG = QColor('#0A0D18') + +GOLD = QColor('#C9A84C') +GOLD_BRIGHT = QColor('#F0C040') +GOLD_DIM = QColor('#6A5820') + +WHITE = QColor('#E8E8E0') +WHITE_DIM = QColor('#7A8A9A') +CREAM = QColor('#F5F0E0') + +RED = QColor('#CC2222') # north marker + lubber line +BLUE = QColor('#4A9EFF') # COG marker +GREEN = QColor('#44CC88') # GPS valid +ORANGE = QColor('#FF8844') # warning + +DIVIDER = QColor('#1A2030') + +# ── Night palette ────────────────────────────────────────────────────────── +N_BG = QColor('#060708') +N_GOLD = QColor('#6A4A10') +N_WHITE = QColor('#5A6A55') +N_RED = QColor('#661111') +N_BLUE = QColor('#1A3A66') + + +def is_night(night: bool): + return { + 'bg': N_BG if night else BG, + 'gold': N_GOLD if night else GOLD, + 'bright': N_GOLD if night else GOLD_BRIGHT, + 'white': N_WHITE if night else WHITE, + 'dim': N_WHITE if night else WHITE_DIM, + 'red': N_RED if night else RED, + 'blue': N_BLUE if night else BLUE, + } + + +# ── AR Electronics brand palette ──────────────────────────────────────────── +# Additivos — no reemplazan la paleta operacional náutica. +BRAND_NAVY = QColor('#0D1B2A') +BRAND_BLUE_ELECTRIC = QColor('#2563EB') +BRAND_BLUE_NEON = QColor('#4A9FE8') +BRAND_TEXT = QColor('#E2E8F0') +BRAND_SILVER = QColor('#C8D2DC') + + +def mono(size: int, bold: bool = False) -> QFont: + f = QFont('Courier New', size) + f.setBold(bold) + return f + + +def sans(size: int, bold: bool = False) -> QFont: + f = QFont('Arial', size) + f.setBold(bold) + return f