feat: Compass initial commit
@@ -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
|
||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -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 |
@@ -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 |
|
||||
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 93 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.7 MiB |
@@ -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'
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()]
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
@@ -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 "$@"
|
||||
@@ -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()
|
||||
@@ -0,0 +1,2 @@
|
||||
PyQt5>=5.15.0
|
||||
pyserial>=3.5
|
||||
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -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.')
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||