#!/usr/bin/env python3 # Standalone async TCP server (stdlib only). Multiple simultaneous clients. # No watchdog: runs on a full host (e.g. Raspberry Pi); ESP32 clients may use WDT. # For RTT latency, clients may send lines like ``rtt 12345`` (ticks); they are echoed back. # # Run from anywhere (default: all IPv4 interfaces, port 9000): # python3 async_tcp_server.py # python3 async_tcp_server.py --port 9000 # Localhost only: # python3 async_tcp_server.py --host 127.0.0.1 # # Or from this directory: # chmod +x async_tcp_server.py && ./async_tcp_server.py import argparse import asyncio import time class _ClientRegistry: """Track writers and broadcast newline-terminated lines to all clients.""" def __init__(self) -> None: self._writers: set[asyncio.StreamWriter] = set() def add(self, writer: asyncio.StreamWriter) -> None: self._writers.add(writer) def remove(self, writer: asyncio.StreamWriter) -> None: self._writers.discard(writer) def count(self) -> int: return len(self._writers) async def broadcast_line(self, line: str) -> None: data = (line.rstrip("\r\n") + "\n").encode("utf-8") for writer in list(self._writers): try: writer.write(data) await writer.drain() except Exception as e: print(f"[tcp] broadcast failed, dropping client: {e}") self._writers.discard(writer) try: writer.close() await writer.wait_closed() except Exception: pass async def _periodic_broadcast( registry: _ClientRegistry, interval_sec: float, message: str, ) -> None: while True: await asyncio.sleep(interval_sec) if registry.count() == 0: continue line = message.format(t=time.time()) print(f"[tcp] broadcast to {registry.count()} client(s): {line!r}") await registry.broadcast_line(line) async def _handle_client( reader: asyncio.StreamReader, writer: asyncio.StreamWriter, registry: _ClientRegistry, ) -> None: peer = writer.get_extra_info("peername") print(f"[tcp] connected: {peer}") registry.add(writer) try: while not reader.at_eof(): data = await reader.readline() if not data: break message = data.decode("utf-8", errors="replace").rstrip("\r\n") # Echo newline-delimited lines (simple test harness behaviour). # Clients may send ``rtt `` for round-trip timing; echo unchanged. t0 = time.perf_counter() writer.write((message + "\n").encode("utf-8")) await writer.drain() if message.startswith("rtt "): server_ms = (time.perf_counter() - t0) * 1000.0 print( f"[tcp] echoed rtt from {peer} " f"(host write+drain ~{server_ms:.2f} ms)" ) finally: registry.remove(writer) writer.close() await writer.wait_closed() print(f"[tcp] disconnected: {peer}") def _make_client_handler(registry: _ClientRegistry): async def _handler( reader: asyncio.StreamReader, writer: asyncio.StreamWriter, ) -> None: await _handle_client(reader, writer, registry) return _handler async def _run( host: str, port: int, broadcast_interval: float | None, broadcast_message: str, ) -> None: registry = _ClientRegistry() handler = _make_client_handler(registry) server = await asyncio.start_server(handler, host, port) print(f"[tcp] listening on {host}:{port} (Ctrl+C to stop)") if broadcast_interval is not None and broadcast_interval > 0: print( f"[tcp] periodic broadcast every {broadcast_interval}s " f"(use {{t}} in --message for unix time)" ) async with server: tasks = [] if broadcast_interval is not None and broadcast_interval > 0: tasks.append( asyncio.create_task( _periodic_broadcast(registry, broadcast_interval, broadcast_message), name="broadcast", ) ) try: if tasks: await asyncio.gather(server.serve_forever(), *tasks) else: await server.serve_forever() finally: for t in tasks: t.cancel() for t in tasks: try: await t except asyncio.CancelledError: pass def main() -> None: parser = argparse.ArgumentParser( description="Standalone asyncio TCP server (multiple connections).", ) parser.add_argument( "--host", default="0.0.0.0", help="bind address (default: all IPv4 interfaces)", ) parser.add_argument("--port", type=int, default=9000, help="bind port") parser.add_argument( "--interval", type=float, default=5.0, metavar="SEC", help="seconds between broadcast lines to all clients (default: 5)", ) parser.add_argument( "--message", default="ping {t:.0f}", help='broadcast line (newline added); use "{t}" for time.time() (default: %(default)s)', ) parser.add_argument( "--no-broadcast", action="store_true", help="disable periodic broadcast (echo-only)", ) args = parser.parse_args() interval = None if args.no_broadcast else args.interval try: asyncio.run(_run(args.host, args.port, interval, args.message)) except KeyboardInterrupt: print("\n[tcp] stopped") if __name__ == "__main__": main()