Files
venus/axiom-nmea/nmea-server/server.py
dev 9756538f16 Initial commit: Venus OS boat addons monorepo
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
2026-03-16 17:04:16 +00:00

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()