Organizes 11 projects for Cerbo GX/Venus OS into a single repository: - axiom-nmea: Raymarine LightHouse protocol decoder - dbus-generator-ramp: Generator current ramp controller - dbus-lightning: Blitzortung lightning monitor - dbus-meteoblue-forecast: Meteoblue weather forecast - dbus-no-foreign-land: noforeignland.com tracking - dbus-tides: Tide prediction from depth + harmonics - dbus-vrm-history: VRM cloud history proxy - dbus-windy-station: Windy.com weather upload - mfd-custom-app: MFD app deployment package - venus-html5-app: Custom Victron HTML5 app fork - watermaker: Watermaker PLC control UI Adds root README, .gitignore, project template, and per-project .gitignore files. Sensitive config files excluded via .gitignore with .example templates provided. Made-with: Cursor
250 lines
7.9 KiB
Python
250 lines
7.9 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
NMEA Data Server - Docker Daemon
|
|
|
|
Simple TCP/UDP server that broadcasts NMEA sentences from Raymarine data.
|
|
"""
|
|
|
|
import argparse
|
|
import logging
|
|
import os
|
|
import signal
|
|
import socket
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Dict, Optional
|
|
|
|
# Add repo root to path for development (Docker installs via pip)
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from raymarine_nmea import (
|
|
RaymarineDecoder,
|
|
SensorData,
|
|
MulticastListener,
|
|
NMEAGenerator,
|
|
)
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(levelname)s - %(message)s',
|
|
datefmt='%Y-%m-%d %H:%M:%S'
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class NMEAServer:
|
|
"""TCP/UDP server for NMEA broadcast."""
|
|
|
|
def __init__(
|
|
self,
|
|
host: str = '0.0.0.0',
|
|
tcp_port: int = 10110,
|
|
udp_port: Optional[int] = None,
|
|
udp_dest: str = '255.255.255.255',
|
|
interval: float = 1.0
|
|
):
|
|
self.host = host
|
|
self.tcp_port = tcp_port
|
|
self.udp_port = udp_port
|
|
self.udp_dest = udp_dest
|
|
self.interval = interval
|
|
|
|
self.sensor_data = SensorData()
|
|
# Enable all available NMEA sentences
|
|
self.generator = NMEAGenerator(
|
|
enabled_sentences={
|
|
# GPS
|
|
'gga', 'gll', 'rmc',
|
|
# Navigation
|
|
'hdg', 'hdt', 'vtg', 'vhw',
|
|
# Wind
|
|
'mwv_apparent', 'mwv_true', 'mwd',
|
|
# Depth
|
|
'dpt', 'dbt',
|
|
# Temperature
|
|
'mtw', 'mta',
|
|
# Transducers (tanks, batteries, pressure)
|
|
'xdr_tanks', 'xdr_batteries', 'xdr_pressure',
|
|
}
|
|
)
|
|
|
|
self.server_socket: Optional[socket.socket] = None
|
|
self.udp_socket: Optional[socket.socket] = None
|
|
self.clients: Dict[socket.socket, str] = {}
|
|
self.clients_lock = threading.Lock()
|
|
self.running = False
|
|
|
|
def accept_loop(self):
|
|
"""Accept new TCP client connections."""
|
|
while self.running:
|
|
try:
|
|
client, addr = self.server_socket.accept()
|
|
addr_str = f"{addr[0]}:{addr[1]}"
|
|
client.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
|
|
with self.clients_lock:
|
|
self.clients[client] = addr_str
|
|
count = len(self.clients)
|
|
|
|
logger.info(f"TCP client connected: {addr_str} (total: {count})")
|
|
|
|
except socket.timeout:
|
|
continue
|
|
except OSError as e:
|
|
if self.running:
|
|
logger.debug(f"Accept error: {e}")
|
|
|
|
def broadcast(self, data: bytes):
|
|
"""Send data to all TCP clients and UDP."""
|
|
# TCP broadcast
|
|
with self.clients_lock:
|
|
dead = []
|
|
for client, addr in list(self.clients.items()):
|
|
try:
|
|
client.sendall(data)
|
|
except Exception as e:
|
|
logger.info(f"TCP client {addr} error: {e}")
|
|
dead.append(client)
|
|
|
|
for client in dead:
|
|
addr = self.clients.pop(client, "unknown")
|
|
try:
|
|
client.close()
|
|
except Exception:
|
|
pass
|
|
logger.info(f"TCP client {addr} removed (total: {len(self.clients)})")
|
|
|
|
# UDP broadcast
|
|
if self.udp_socket and self.udp_port:
|
|
try:
|
|
self.udp_socket.sendto(data, (self.udp_dest, self.udp_port))
|
|
except Exception as e:
|
|
logger.debug(f"UDP send error: {e}")
|
|
|
|
def run(self, interface_ip: str):
|
|
"""Run the server."""
|
|
self.running = True
|
|
|
|
# Start Raymarine listener
|
|
decoder = RaymarineDecoder()
|
|
listener = MulticastListener(
|
|
decoder=decoder,
|
|
sensor_data=self.sensor_data,
|
|
interface_ip=interface_ip,
|
|
)
|
|
listener.start()
|
|
|
|
# Create TCP server socket
|
|
self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
self.server_socket.settimeout(1.0)
|
|
self.server_socket.bind((self.host, self.tcp_port))
|
|
self.server_socket.listen(5)
|
|
|
|
# Create UDP socket if enabled
|
|
if self.udp_port:
|
|
self.udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
self.udp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
logger.info(f"UDP broadcast enabled: {self.udp_dest}:{self.udp_port}")
|
|
|
|
# Start TCP accept thread
|
|
accept_thread = threading.Thread(target=self.accept_loop, daemon=True)
|
|
accept_thread.start()
|
|
|
|
logger.info(f"TCP server on {self.host}:{self.tcp_port}")
|
|
logger.info(f"Raymarine interface: {interface_ip}")
|
|
|
|
last_log = 0
|
|
|
|
try:
|
|
while self.running:
|
|
sentences = self.generator.generate_all(self.sensor_data)
|
|
if sentences:
|
|
data = ''.join(sentences).encode('ascii')
|
|
self.broadcast(data)
|
|
for s in sentences:
|
|
logger.info(f"TX: {s.strip()}")
|
|
else:
|
|
logger.warning("No NMEA data this cycle")
|
|
|
|
now = time.time()
|
|
if now - last_log >= 30:
|
|
with self.sensor_data._lock:
|
|
lat = self.sensor_data.latitude
|
|
lon = self.sensor_data.longitude
|
|
if lat and lon:
|
|
logger.info(f"GPS: {lat:.6f}, {lon:.6f} | TCP clients: {len(self.clients)}")
|
|
last_log = now
|
|
|
|
time.sleep(self.interval)
|
|
|
|
finally:
|
|
self.running = False
|
|
listener.stop()
|
|
self.server_socket.close()
|
|
if self.udp_socket:
|
|
self.udp_socket.close()
|
|
with self.clients_lock:
|
|
for c in self.clients:
|
|
try:
|
|
c.close()
|
|
except Exception:
|
|
pass
|
|
logger.info("Server stopped")
|
|
|
|
|
|
_server: Optional[NMEAServer] = None
|
|
|
|
|
|
def signal_handler(signum, frame):
|
|
if _server:
|
|
_server.running = False
|
|
|
|
|
|
def main():
|
|
global _server
|
|
|
|
parser = argparse.ArgumentParser(description="NMEA server")
|
|
parser.add_argument('-i', '--interface', default=os.environ.get('RAYMARINE_INTERFACE'))
|
|
parser.add_argument('-H', '--host', default=os.environ.get('NMEA_HOST', '0.0.0.0'))
|
|
parser.add_argument('-p', '--port', type=int, default=int(os.environ.get('NMEA_PORT', '10110')),
|
|
help='TCP port (default: 10110)')
|
|
parser.add_argument('--udp-port', type=int, default=os.environ.get('NMEA_UDP_PORT'),
|
|
help='UDP port for broadcast (optional)')
|
|
parser.add_argument('--udp-dest', default=os.environ.get('NMEA_UDP_DEST', '255.255.255.255'),
|
|
help='UDP destination IP (default: 255.255.255.255 broadcast)')
|
|
parser.add_argument('--interval', type=float, default=float(os.environ.get('UPDATE_INTERVAL', '1.0')))
|
|
parser.add_argument('--log-level', default=os.environ.get('LOG_LEVEL', 'INFO'))
|
|
|
|
args = parser.parse_args()
|
|
|
|
if not args.interface:
|
|
parser.error("Interface IP required (-i or RAYMARINE_INTERFACE)")
|
|
|
|
logging.getLogger().setLevel(getattr(logging, args.log_level))
|
|
|
|
signal.signal(signal.SIGTERM, signal_handler)
|
|
signal.signal(signal.SIGINT, signal_handler)
|
|
|
|
_server = NMEAServer(
|
|
host=args.host,
|
|
tcp_port=args.port,
|
|
udp_port=args.udp_port,
|
|
udp_dest=args.udp_dest,
|
|
interval=args.interval
|
|
)
|
|
|
|
try:
|
|
_server.run(args.interface)
|
|
except KeyboardInterrupt:
|
|
pass
|
|
except Exception as e:
|
|
logger.error(f"Fatal: {e}")
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|