Files
venus/axiom-nmea/raymarine_nmea/nmea/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

382 lines
13 KiB
Python

"""
NMEA TCP Server.
Provides a TCP server that broadcasts NMEA 0183 sentences to connected clients.
This is useful for feeding navigation apps, charting software, and SignalK.
"""
import asyncio
import logging
import socket
import threading
from typing import List, Optional, Callable, Set
from ..data.store import SensorData
from .generator import NMEAGenerator
logger = logging.getLogger(__name__)
class NMEATcpServer:
"""TCP server that broadcasts NMEA sentences to connected clients.
This server accepts TCP connections and broadcasts NMEA 0183 sentences
to all connected clients. It uses asyncio internally for robust handling
of multiple concurrent clients - a slow client won't block others.
The server publishes ALL available NMEA sentences, which is more data
than what Venus OS can display via D-Bus. This includes:
- GPS: GGA, GLL, RMC
- Navigation: HDG, HDT, VTG, VHW
- Wind: MWV (apparent & true), MWD
- Depth: DPT, DBT
- Temperature: MTW, MTA
- Transducers: XDR (tanks, batteries, pressure)
Example:
from raymarine_nmea import SensorData, NMEAGenerator
from raymarine_nmea.nmea import NMEATcpServer
sensor_data = SensorData()
server = NMEATcpServer(sensor_data, port=10110)
server.start()
# Later, in your update loop:
server.broadcast()
# When done:
server.stop()
Thread Safety:
This class is thread-safe. The broadcast() method can be called
from any thread, and client connections are managed safely via
asyncio running in a background thread.
"""
# Default NMEA TCP port (standard)
DEFAULT_PORT = 10110
def __init__(
self,
sensor_data: SensorData,
port: int = DEFAULT_PORT,
generator: Optional[NMEAGenerator] = None,
on_client_connect: Optional[Callable[[str, int], None]] = None,
on_client_disconnect: Optional[Callable[[str, int], None]] = None,
):
"""Initialize the NMEA TCP server.
Args:
sensor_data: SensorData instance to read values from
port: TCP port to listen on (default: 10110)
generator: NMEAGenerator instance (creates default if None)
on_client_connect: Callback when client connects (addr, port)
on_client_disconnect: Callback when client disconnects (addr, port)
"""
self._sensor_data = sensor_data
self._port = port
self._generator = generator or NMEAGenerator()
self._on_client_connect = on_client_connect
self._on_client_disconnect = on_client_disconnect
self._running = False
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._thread: Optional[threading.Thread] = None
self._server: Optional[asyncio.Server] = None
# Client tracking (accessed from asyncio thread)
self._clients: Set[asyncio.StreamWriter] = set()
self._client_addrs: dict = {} # writer -> (addr, port)
# Thread-safe counter for client count
self._client_count = 0
self._client_count_lock = threading.Lock()
@property
def port(self) -> int:
"""Get the TCP port."""
return self._port
@property
def client_count(self) -> int:
"""Get the number of connected clients."""
with self._client_count_lock:
return self._client_count
@property
def is_running(self) -> bool:
"""Check if server is running."""
return self._running
def start(self) -> bool:
"""Start the TCP server.
Returns:
True if server started successfully, False otherwise
"""
if self._running:
logger.warning("NMEATcpServer already running")
return True
# Create and start the asyncio event loop in a background thread
self._running = True
# Use an event to wait for server to be ready
ready_event = threading.Event()
startup_error = [None] # Use list to allow modification in nested function
def run_loop():
try:
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
# Start the server
self._loop.run_until_complete(self._start_server(ready_event, startup_error))
# Run until stopped
if self._running and not startup_error[0]:
self._loop.run_forever()
except Exception as e:
startup_error[0] = e
ready_event.set()
finally:
# Cleanup
if self._loop:
try:
self._loop.run_until_complete(self._cleanup())
except Exception:
pass
self._loop.close()
self._loop = None
self._thread = threading.Thread(
target=run_loop,
daemon=True,
name="NMEATcpServer-AsyncIO"
)
self._thread.start()
# Wait for server to be ready (with timeout)
if not ready_event.wait(timeout=5.0):
logger.error("Timeout waiting for NMEA TCP server to start")
self._running = False
return False
if startup_error[0]:
logger.error(f"Failed to start NMEA TCP server: {startup_error[0]}")
self._running = False
return False
logger.info(f"NMEA TCP server listening on port {self._port}")
return True
async def _start_server(self, ready_event: threading.Event, startup_error: list) -> None:
"""Start the asyncio TCP server."""
try:
self._server = await asyncio.start_server(
self._handle_client,
'',
self._port,
reuse_address=True,
)
ready_event.set()
except OSError as e:
startup_error[0] = e
ready_event.set()
async def _handle_client(
self,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter
) -> None:
"""Handle a client connection."""
addr = writer.get_extra_info('peername')
addr_tuple = (addr[0], addr[1]) if addr else ('unknown', 0)
# Configure socket for optimal NMEA streaming
sock = writer.get_extra_info('socket')
if sock:
self._configure_socket(sock)
# Track client
self._clients.add(writer)
self._client_addrs[writer] = addr_tuple
with self._client_count_lock:
self._client_count += 1
logger.info(f"NMEA TCP client connected: {addr_tuple[0]}:{addr_tuple[1]}")
# Callback
if self._on_client_connect:
try:
self._on_client_connect(addr_tuple[0], addr_tuple[1])
except Exception as e:
logger.debug(f"Client connect callback error: {e}")
try:
# Keep connection alive until client disconnects or server stops
while self._running:
try:
# Check if client is still connected
data = await asyncio.wait_for(reader.read(1), timeout=5.0)
if not data:
# Client disconnected cleanly
break
except asyncio.TimeoutError:
# No data, but connection still alive
continue
except (ConnectionResetError, BrokenPipeError):
break
except Exception as e:
logger.debug(f"Client {addr_tuple[0]}:{addr_tuple[1]} error: {e}")
finally:
await self._remove_client(writer)
def _configure_socket(self, sock: socket.socket) -> None:
"""Configure client socket for optimal NMEA streaming."""
# Enable TCP keepalive to detect dead connections
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
# Platform-specific keepalive settings (Linux)
if hasattr(socket, 'TCP_KEEPIDLE'):
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 10)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 5)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3)
# Disable Nagle's algorithm for lower latency
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
async def _remove_client(self, writer: asyncio.StreamWriter) -> None:
"""Remove a client from tracking."""
if writer not in self._clients:
return
self._clients.discard(writer)
addr = self._client_addrs.pop(writer, None)
with self._client_count_lock:
self._client_count = max(0, self._client_count - 1)
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
if addr:
logger.info(f"NMEA TCP client disconnected: {addr[0]}:{addr[1]}")
# Callback
if self._on_client_disconnect:
try:
self._on_client_disconnect(addr[0], addr[1])
except Exception as e:
logger.debug(f"Client disconnect callback error: {e}")
async def _cleanup(self) -> None:
"""Clean up server resources."""
# Close server
if self._server:
self._server.close()
await self._server.wait_closed()
self._server = None
# Close all clients
for writer in list(self._clients):
await self._remove_client(writer)
def stop(self) -> None:
"""Stop the TCP server and disconnect all clients."""
if not self._running:
return
self._running = False
# Stop the asyncio event loop
if self._loop and self._loop.is_running():
self._loop.call_soon_threadsafe(self._loop.stop)
# Wait for thread to finish
if self._thread and self._thread.is_alive():
self._thread.join(timeout=5.0)
self._thread = None
logger.info("NMEA TCP server stopped")
def broadcast(self) -> int:
"""Generate and broadcast NMEA sentences to all connected clients.
This method generates all NMEA sentences from the current sensor data
and sends them to all connected clients. Each client is sent data
independently, so a slow client won't block others.
Returns:
Number of sentences broadcast (0 if no clients or no data)
"""
if not self._running or not self._loop:
return 0
# Check if we have clients
if self.client_count == 0:
return 0
# Generate NMEA sentences
sentences = self._generator.generate_all(self._sensor_data)
if not sentences:
return 0
# Encode data
data = ''.join(sentences).encode('ascii')
# Schedule broadcast on the asyncio event loop
try:
asyncio.run_coroutine_threadsafe(
self._broadcast_async(data),
self._loop
)
except RuntimeError:
# Loop not running
return 0
return len(sentences)
async def _broadcast_async(self, data: bytes) -> None:
"""Broadcast data to all clients asynchronously."""
if not self._clients:
return
# Send to all clients concurrently with timeout
async def send_to_client(writer: asyncio.StreamWriter) -> bool:
"""Send data to a single client. Returns False if client is dead."""
try:
writer.write(data)
# Use wait_for to timeout slow clients
await asyncio.wait_for(writer.drain(), timeout=2.0)
return True
except (asyncio.TimeoutError, ConnectionResetError, BrokenPipeError, OSError):
return False
except Exception as e:
logger.debug(f"Send error: {e}")
return False
# Create tasks for all clients
clients = list(self._clients)
results = await asyncio.gather(
*[send_to_client(writer) for writer in clients],
return_exceptions=True
)
# Remove dead clients
for writer, success in zip(clients, results):
if success is False or isinstance(success, Exception):
await self._remove_client(writer)
def get_client_addresses(self) -> List[tuple]:
"""Get list of connected client addresses.
Returns:
List of (host, port) tuples for all connected clients
"""
return list(self._client_addrs.values())