feat: Compass initial commit

This commit is contained in:
2026-07-03 12:23:41 -04:00
commit 72dcfeb315
26 changed files with 2551 additions and 0 deletions
+42
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+81
View File
@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="174.418mm"
height="66.771004mm"
viewBox="0 0 174.418 66.771004"
version="1.1"
id="svg1"
inkscape:version="1.4.3 (0d15f75, 2025-12-25)"
sodipodi:docname="Perfil Carguero ocre.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="1.0167041"
inkscape:cx="397.85421"
inkscape:cy="92.947399"
inkscape:window-width="1920"
inkscape:window-height="1009"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Capa 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-55.567674,-48.336721)">
<path
style="fill:#806600;stroke:#000000;stroke-width:0.264583"
d="m 61.446301,94.557256 c 0.0015,2.82065 -0.07796,2.720713 0.14574,7.102834 4.269258,0.30696 5.359228,0.53387 9.716856,0.54355 2.851676,0.006 5.313513,7.97248 5.03607,9.88966 l 134.235153,0.0674 c 3.59216,-0.069 16.88755,1.57273 12.82835,-5.64002 -0.83843,-1.4898 -3.22393,2.1554 -5.35978,1.85356 -1.65443,-4.99324 5.86656,-7.85234 7.66461,-14.105577 L 95.897085,94.614399 95.609107,74.980755 91.705563,69.645911 67.243347,69.385674 66.904958,94.613629 Z"
id="path1"
sodipodi:nodetypes="ccsccscccccccc" />
<path
style="fill:#806600;stroke:#000000;stroke-width:0.271241"
d="M 103.05694,94.585381 V 89.5246 h 32.1325 l 0.13009,5.060781 z"
id="path2" />
<path
style="fill:#806600;stroke:#000000;stroke-width:0.264583"
d="m 142.16771,94.458708 -0.069,-5.175417 35.7909,-0.207018 0.023,5.313431 z"
id="path3" />
<path
style="fill:#806600;stroke:#000000;stroke-width:0.264583"
d="m 186.35428,94.274695 0.046,-5.290428 23.18587,0.138012 0.092,5.198419 z"
id="path4" />
<path
style="fill:#806600;stroke:#000000;stroke-width:0.264583"
d="m 220.37403,94.228689 0.0461,-16.613205 h 0.19518 l 0.24173,16.705214 z"
id="path5" />
<path
style="fill:#806600;stroke:#000000;stroke-width:0.264583"
d="m 79.050616,69.501692 2.599211,0.046 -0.846449,-19.777498 h -0.390353 z"
id="path6" />
<path
style="fill:#806600;stroke:#000000;stroke-width:0.264583"
d="m 80.982773,53.101367 h 2.36919 l -0.046,0.184015 -2.300184,0.552045 z"
id="path7" />
<path
style="fill:#806600;stroke:#000000;stroke-width:0.266721"
d="m 61.480566,94.545248 -1.885345,-4.763729 2.925536,4.763729 z"
id="path8" />
<path
style="fill:#806600;stroke:#000000;stroke-width:0.25345"
d="m 70.185521,102.16932 -0.0052,2.03881 0.05484,2.84738 0.09994,4.85186 -7.300804,-0.0323 -0.09508,-9.56221 3.751845,0.21094 2.466239,0.26072 -0.03002,-0.54996 z"
id="path9"
sodipodi:nodetypes="cccccccccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

+90
View File
@@ -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 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

+46
View File
@@ -0,0 +1,46 @@
{
"_comment": "AR Electronics — Paleta oficial de marca. Usar en todas las apps.",
"_version": "1.0.0",
"background": {
"primary": "#0D1B2A",
"secondary": "#1A2744",
"card": "#162035",
"surface": "#1E2D47"
},
"accent": {
"blue_electric": "#2563EB",
"blue_neon": "#4A9FE8",
"blue_dark": "#1A47A8",
"blue_glow": "#60B8FF"
},
"text": {
"primary": "#E2E8F0",
"secondary": "#A8B5C4",
"muted": "#6B7A8D",
"on_accent": "#FFFFFF"
},
"status": {
"ok": "#22C55E",
"warning": "#F59E0B",
"alarm": "#EF4444",
"info": "#4A9FE8"
},
"metallic": {
"silver_light": "#C8D2DC",
"silver_mid": "#A8B5C4",
"silver_dark": "#6B7A8D"
},
"flutter": {
"_comment": "Valores listos para copiar en ThemeData de Flutter",
"primaryColor": "0xFF2563EB",
"scaffoldBackground": "0xFF0D1B2A",
"cardColor": "0xFF162035",
"accentColor": "0xFF4A9FE8"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+15
View File
@@ -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'
View File
+161
View File
@@ -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)
}
+45
View File
@@ -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()]
+66
View File
@@ -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
)
)
+66
View File
@@ -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 "$@"
+76
View File
@@ -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()
+2
View File
@@ -0,0 +1,2 @@
PyQt5>=5.15.0
pyserial>=3.5
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File
+375
View File
@@ -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.')
View File
+276
View File
@@ -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}")
+949
View File
@@ -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)
+195
View File
@@ -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
+66
View File
@@ -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