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
382 lines
13 KiB
Python
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())
|