183 lines
5.7 KiB
Python
183 lines
5.7 KiB
Python
#!/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 <ticks>`` 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()
|