""" AidsMonitoring — Slave relay service ===================================== When server_role == "SLAVE", this service maintains a persistent WebSocket connection to the master server and forwards all AIS/ATON/aid_position/alert events upstream. The relay is fully non-blocking: - Events are enqueued from broadcast() via send() (never blocks) - A single asyncio task drains the queue and writes to the master WS - If the master is unreachable, a ring-buffer keeps the last max_queue messages; older events are silently dropped - On reconnect the slave sends a hello so the master can log it Usage (from main.py): relay = SlaveRelay(master_url="ws://10.0.0.1:8000/ws/slave", slave_name="Puerto Barranquilla") await relay.start() ... relay.send({"type": "vessel", "mmsi": "012345678", ...}) ... await relay.stop() """ from __future__ import annotations import asyncio import json import logging from collections import deque log = logging.getLogger("aids.slave_relay") class SlaveRelay: """Non-blocking WebSocket relay: slave → master.""" def __init__( self, master_url: str, slave_name: str, max_queue: int = 500, reconnect_delay: float = 5.0, ): """ Parameters ---------- master_url WebSocket URL of master's slave endpoint. e.g. "ws://192.168.1.10:8000/ws/slave" slave_name Human-readable identifier sent in hello message. e.g. "Puerto Barranquilla" max_queue Ring-buffer capacity. Oldest events dropped when full. reconnect_delay Seconds to wait before reconnecting after a disconnect. """ self.master_url = master_url self.slave_name = slave_name self._max_queue = max_queue self._reconnect_delay = reconnect_delay self._queue: deque = deque(maxlen=max_queue) self._task: asyncio.Task | None = None self._running = False self._connected = False self._connect_count = 0 # total successful connections (for logging) # ── Public API ────────────────────────────────────────────────────────── async def start(self): """Start the background relay task.""" if self._task and not self._task.done(): return # already running self._running = True self._task = asyncio.create_task(self._run(), name="slave_relay") log.info(f"[slave] Relay iniciado → {self.master_url} (nombre='{self.slave_name}')") async def stop(self): """Cancel the relay task and wait for it to finish.""" self._running = False if self._task and not self._task.done(): self._task.cancel() try: await self._task except asyncio.CancelledError: pass self._task = None self._connected = False log.info("[slave] Relay detenido.") def send(self, msg: dict): """ Enqueue an event for forwarding. Non-blocking — safe to call from broadcast() in the main event loop. Drops oldest if queue is full. """ if not self._running: return # Tag every message with slave origin so the master knows the source tagged = {**msg, "_slave": self.slave_name} self._queue.append(tagged) # ── Status ─────────────────────────────────────────────────────────────── @property def connected(self) -> bool: return self._connected @property def queue_len(self) -> int: return len(self._queue) def status(self) -> dict: return { "running": self._running, "connected": self._connected, "master_url": self.master_url, "slave_name": self.slave_name, "queue_len": len(self._queue), "connect_count": self._connect_count, } # ── Background task ────────────────────────────────────────────────────── async def _run(self): import websockets while self._running: try: log.info(f"[slave] Conectando a maestro {self.master_url} …") async with websockets.connect( self.master_url, ping_interval=20, ping_timeout=10, close_timeout=5, open_timeout=10, ) as ws: self._connected = True self._connect_count += 1 log.info( f"[slave] Conectado al maestro " f"(conexión #{self._connect_count}, " f"{len(self._queue)} msgs en cola)" ) # ── Announce this slave ─────────────────────────────── await ws.send(json.dumps({ "type": "slave_hello", "slave_name": self.slave_name, "version": "1.0", })) # ── Drain queue continuously ────────────────────────── while self._running: if self._queue: msg = self._queue.popleft() try: await ws.send(json.dumps(msg)) except Exception as send_err: # Re-queue the failed message (prepend) self._queue.appendleft(msg) log.warning( f"[slave] Error al enviar: {send_err}" ) break # force reconnect else: # Nothing to send — yield to event loop await asyncio.sleep(0.02) # 20 ms idle except asyncio.CancelledError: break except Exception as conn_err: log.warning( f"[slave] Desconectado ({conn_err}). " f"Reintentando en {self._reconnect_delay} s …" ) finally: self._connected = False if self._running: await asyncio.sleep(self._reconnect_delay)