update 0.1.9

This commit is contained in:
controllerzz
2026-06-17 20:44:18 +03:00
parent 32163c0001
commit 99451aceb8
12 changed files with 814 additions and 275 deletions
+115 -48
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import argparse
import asyncio
import contextlib
import json
import logging
from typing import Optional
@@ -10,6 +11,15 @@ import serial_asyncio
log = logging.getLogger("carbus_remote.agent")
RECONNECT_MIN_S = 1.0
RECONNECT_MAX_S = 15.0
CONNECT_TIMEOUT_S = 5.0
AUTH_TIMEOUT_S = 10.0
DATA_HELLO_TIMEOUT_S = 5.0
COM_REOPEN_DELAY_S = 0.25
def parse_hostport(s: str) -> tuple[str, int]:
if ":" not in s:
@@ -38,10 +48,8 @@ async def pipe_bidirectional_streams(
except Exception:
pass
finally:
try:
with contextlib.suppress(Exception):
dst.close()
except Exception:
pass
t1 = asyncio.create_task(pump(a_reader, b_writer), name="pipe_a_to_b")
t2 = asyncio.create_task(pump(b_reader, a_writer), name="pipe_b_to_a")
@@ -49,32 +57,35 @@ async def pipe_bidirectional_streams(
await asyncio.wait({t1, t2}, return_when=asyncio.FIRST_COMPLETED)
for w in (a_writer, b_writer):
try:
with contextlib.suppress(Exception):
w.close()
except Exception:
pass
await asyncio.gather(t1, t2, return_exceptions=True)
for w in (a_writer, b_writer):
try:
with contextlib.suppress(Exception):
await w.wait_closed()
except Exception:
pass
async def open_serial_with_retry(port: str, baudrate: int, *, attempts: int = 3) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
async def open_serial_with_retry(
port: str,
baudrate: int,
*,
attempts: int = 5,
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
last: Optional[BaseException] = None
for i in range(attempts):
try:
return await serial_asyncio.open_serial_connection(url=port, baudrate=baudrate)
except Exception as e:
last = e
await asyncio.sleep(0.25 + 0.25 * i)
await asyncio.sleep(0.25 + 0.35 * i)
assert last is not None
raise last
async def agent_run(
async def agent_run_once(
*,
port: str,
baudrate: int,
@@ -82,11 +93,15 @@ async def agent_run(
serial: str,
password: str,
) -> None:
server_host, server_port = parse_hostport(server)
session_lock = asyncio.Lock()
active_data_task: Optional[asyncio.Task] = None
async def open_data_session(session: str) -> None:
nonlocal active_data_task
async with session_lock:
net_r: Optional[asyncio.StreamReader] = None
net_w: Optional[asyncio.StreamWriter] = None
@@ -98,63 +113,71 @@ async def agent_run(
net_r, net_w = await asyncio.wait_for(
asyncio.open_connection(server_host, server_port),
timeout=5.0,
timeout=CONNECT_TIMEOUT_S,
)
net_w.write((json.dumps({"role": "agent_data", "session": session}) + "\n").encode("utf-8"))
await net_w.drain()
line = await asyncio.wait_for(net_r.readline(), timeout=5.0)
line = await asyncio.wait_for(net_r.readline(), timeout=DATA_HELLO_TIMEOUT_S)
if not line:
raise ConnectionError("relay closed data socket before hello")
resp = json.loads(line.decode("utf-8", errors="ignore") or "{}")
if not resp.get("ok"):
log.error("Data session refused %s: %s", session, resp)
return
await asyncio.sleep(COM_REOPEN_DELAY_S)
log.info("Opening COM %s @ %d for session %s", port, baudrate, session)
dev_r, dev_w = await open_serial_with_retry(port, baudrate, attempts=3)
dev_r, dev_w = await open_serial_with_retry(port, baudrate, attempts=5)
log.info("Session %s accepted. Piping bytes (COM <-> relay).", session)
await pipe_bidirectional_streams(dev_r, dev_w, net_r, net_w)
log.info("Session %s finished.", session)
except asyncio.CancelledError:
log.info("Data session %s cancelled", session)
raise
except Exception:
log.exception("Data session %s crashed", session)
finally:
if net_w is not None:
try:
with contextlib.suppress(Exception):
net_w.close()
with contextlib.suppress(Exception):
await net_w.wait_closed()
except Exception:
pass
if dev_w is not None:
try:
with contextlib.suppress(Exception):
dev_w.close()
with contextlib.suppress(Exception):
await dev_w.wait_closed()
except Exception:
pass
# --- CONTROL CONNECT ---
log.info("Connecting to relay %s:%d (control)", server_host, server_port)
ctrl_reader, ctrl_writer = await asyncio.open_connection(server_host, server_port)
ctrl_writer.write((json.dumps({"role": "agent", "serial": serial, "password": password}) + "\n").encode("utf-8"))
await ctrl_writer.drain()
line = await asyncio.wait_for(ctrl_reader.readline(), timeout=10.0)
resp = json.loads(line.decode("utf-8", errors="ignore") or "{}")
if not resp.get("ok"):
ctrl_writer.close()
try:
await ctrl_writer.wait_closed()
except Exception:
pass
raise RuntimeError(f"relay refused agent: {resp}")
log.info("Agent registered OK. Waiting for sessions... (serial=%s)", serial)
ctrl_reader, ctrl_writer = await asyncio.wait_for(
asyncio.open_connection(server_host, server_port),
timeout=CONNECT_TIMEOUT_S,
)
try:
# AUTH
ctrl_writer.write((json.dumps({"role": "agent", "serial": serial, "password": password}) + "\n").encode("utf-8"))
await ctrl_writer.drain()
line = await asyncio.wait_for(ctrl_reader.readline(), timeout=AUTH_TIMEOUT_S)
if not line:
raise ConnectionError("relay closed control socket during auth")
resp = json.loads(line.decode("utf-8", errors="ignore") or "{}")
if not resp.get("ok"):
raise RuntimeError(f"relay refused agent: {resp}")
log.info("Agent registered OK. Waiting for sessions... (serial=%s)", serial)
# CONTROL LOOP
while True:
line = await ctrl_reader.readline()
if not line:
@@ -168,17 +191,62 @@ async def agent_run(
if msg.get("cmd") == "open_session":
session = str(msg.get("session", "")).strip()
if session:
asyncio.create_task(open_data_session(session), name=f"agent_data_session_{session}")
if not session:
continue
# если уже идёт data-session — игнорируем новый запрос (или можно логировать busy)
if active_data_task is not None and not active_data_task.done():
log.warning("Data session already running; ignoring open_session=%s", session)
continue
active_data_task = asyncio.create_task(
open_data_session(session),
name=f"agent_data_session_{session}",
)
finally:
try:
ctrl_writer.close()
await ctrl_writer.wait_closed()
except Exception:
pass
if active_data_task is not None and not active_data_task.done():
active_data_task.cancel()
with contextlib.suppress(Exception):
await active_data_task
log.info("Agent stopped.")
with contextlib.suppress(Exception):
ctrl_writer.close()
with contextlib.suppress(Exception):
await ctrl_writer.wait_closed()
log.info("Agent control session ended.")
async def agent_supervisor(
*,
port: str,
baudrate: int,
server: str,
serial: str,
password: str,
) -> None:
delay = RECONNECT_MIN_S
while True:
try:
await agent_run_once(
port=port,
baudrate=baudrate,
server=server,
serial=serial,
password=password,
)
delay = RECONNECT_MIN_S
except asyncio.CancelledError:
raise
except Exception as e:
log.exception("Agent crashed: %s", e)
log.info("Reconnect in %.1f sec...", delay)
await asyncio.sleep(delay)
delay = min(RECONNECT_MAX_S, delay * 1.7)
async def main_async(
@@ -188,7 +256,7 @@ async def main_async(
serial: str,
password: str,
) -> None:
await agent_run(
await agent_supervisor(
port=port,
baudrate=baudrate,
server=server,
@@ -224,4 +292,3 @@ def cli() -> None:
if __name__ == "__main__":
cli()