feat(mcp): DeviceManager——按需连接+空闲保持+串行锁+掉线重连(方案 C)
This commit is contained in:
1 parent
6b8436ebd8
commit
b280977869
2 files changed
+267
No files matched your search
@@ -0,0 +1,113 @@
|
|||||||
|
"""设备连接管理器(方案 C:按需连接 + 空闲保持,全操作串行)。
|
||||||
|
|
||||||
|
- 一把 asyncio.Lock 串行所有设备操作(BLE 适配器独占)
|
||||||
|
- 首次操作守候连接(connect_timeout 应覆盖设备 30-60s 广播窗口)
|
||||||
|
- 操作后保持连接;空闲 idle_timeout 秒自动断开让设备睡眠
|
||||||
|
- 操作中 TransportError(连接丢失)→ 重连并重试一次,再失败向上抛
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
from .client import PPClient
|
||||||
|
from .transports.base import TransportError
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceManager:
|
||||||
|
def __init__(self, mac: str | None = None, connect_timeout: float = 90.0,
|
||||||
|
idle_timeout: float = 300.0, client_factory=None):
|
||||||
|
self._mac = mac
|
||||||
|
self._connect_timeout = connect_timeout
|
||||||
|
self._idle_timeout = idle_timeout
|
||||||
|
self._factory = client_factory or (
|
||||||
|
lambda mac, timeout: PPClient.via_local(mac=mac, timeout=timeout))
|
||||||
|
self._client = None
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._last_activity: float | None = None
|
||||||
|
self._idle_task: asyncio.Task | None = None
|
||||||
|
self._connect_count = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connected(self) -> bool:
|
||||||
|
return self._client is not None
|
||||||
|
|
||||||
|
async def run(self, op):
|
||||||
|
"""串行执行 op(client);掉线重连重试一次。"""
|
||||||
|
async with self._lock:
|
||||||
|
try:
|
||||||
|
return await self._run_with_retry(op)
|
||||||
|
finally:
|
||||||
|
self._touch()
|
||||||
|
|
||||||
|
async def status(self) -> dict:
|
||||||
|
async with self._lock:
|
||||||
|
await self._ensure_connected()
|
||||||
|
self._touch()
|
||||||
|
info = {
|
||||||
|
"connected": self.connected,
|
||||||
|
"mac": self._mac,
|
||||||
|
"idle_seconds": None if self._last_activity is None
|
||||||
|
else round(time.monotonic() - self._last_activity, 1),
|
||||||
|
"connect_count": self._connect_count,
|
||||||
|
"connect_timeout": self._connect_timeout,
|
||||||
|
"idle_timeout": self._idle_timeout,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
info["device_id"] = await self._client.get_device_id()
|
||||||
|
except Exception: # noqa: BLE001 - 状态查询尽力而为
|
||||||
|
pass
|
||||||
|
return info
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._idle_task is not None:
|
||||||
|
self._idle_task.cancel()
|
||||||
|
async with self._lock:
|
||||||
|
await self._drop()
|
||||||
|
|
||||||
|
# ---------- 内部 ----------
|
||||||
|
|
||||||
|
async def _run_with_retry(self, op):
|
||||||
|
await self._ensure_connected()
|
||||||
|
try:
|
||||||
|
return await op(self._client)
|
||||||
|
except TransportError:
|
||||||
|
await self._drop()
|
||||||
|
await self._ensure_connected()
|
||||||
|
return await op(self._client)
|
||||||
|
|
||||||
|
async def _ensure_connected(self):
|
||||||
|
if self._client is not None:
|
||||||
|
return
|
||||||
|
client = self._factory(self._mac, self._connect_timeout)
|
||||||
|
await client.__aenter__()
|
||||||
|
self._client = client
|
||||||
|
self._connect_count += 1
|
||||||
|
if self._mac is None: # 自动扫描后记住实际地址
|
||||||
|
self._mac = getattr(client._t, "address", None)
|
||||||
|
|
||||||
|
async def _drop(self):
|
||||||
|
client, self._client = self._client, None
|
||||||
|
if client is not None:
|
||||||
|
try:
|
||||||
|
await client.__aexit__(None, None, None)
|
||||||
|
except Exception: # noqa: BLE001 - 断开失败不影响状态
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _touch(self):
|
||||||
|
self._last_activity = time.monotonic()
|
||||||
|
if self._idle_task is not None:
|
||||||
|
self._idle_task.cancel()
|
||||||
|
self._idle_task = None
|
||||||
|
if self.connected:
|
||||||
|
self._idle_task = asyncio.create_task(self._idle_watch())
|
||||||
|
|
||||||
|
async def _idle_watch(self):
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(self._idle_timeout)
|
||||||
|
async with self._lock:
|
||||||
|
if (self._last_activity is not None
|
||||||
|
and time.monotonic() - self._last_activity >= self._idle_timeout):
|
||||||
|
await self._drop()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""DeviceManager 测试:按需连接/串行/空闲超时/掉线重连。"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ppclock.client import PPClient
|
||||||
|
from ppclock.device_manager import DeviceManager
|
||||||
|
from ppclock.transports.base import TransportError
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTransport:
|
||||||
|
def __init__(self):
|
||||||
|
self.address = "AA:BB:CC:DD:EE:FF"
|
||||||
|
self.enters = 0
|
||||||
|
self.exits = 0
|
||||||
|
self.rxtx_writes = []
|
||||||
|
self.fail_next_write = False
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
self.enters += 1
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc):
|
||||||
|
self.exits += 1
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def write_epd(self, data, response=True):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def write_rxtx(self, data, response=True):
|
||||||
|
if self.fail_next_write:
|
||||||
|
self.fail_next_write = False
|
||||||
|
raise TransportError("connection lost")
|
||||||
|
self.rxtx_writes.append(data)
|
||||||
|
|
||||||
|
async def read_rxtx(self):
|
||||||
|
return b"\x00"
|
||||||
|
|
||||||
|
async def request_device_id(self, timeout=8.0):
|
||||||
|
return "81233F3C267112"
|
||||||
|
|
||||||
|
async def run_ota(self, image, on_progress=None):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def delay(self, seconds):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def make_manager(transports, **kw):
|
||||||
|
"""client_factory 依次弹出预置 transport 包成 PPClient。"""
|
||||||
|
def factory(mac, timeout):
|
||||||
|
return PPClient(transports.pop(0))
|
||||||
|
kw.setdefault("client_factory", factory)
|
||||||
|
return DeviceManager(**kw)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConnect:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connect_on_demand_and_reuse(self):
|
||||||
|
t = FakeTransport()
|
||||||
|
m = make_manager([t])
|
||||||
|
assert not m.connected
|
||||||
|
await m.run(lambda dev: dev.set_mode("clock1"))
|
||||||
|
await m.run(lambda dev: dev.set_mode("clock2"))
|
||||||
|
assert t.enters == 1 # 第二次复用连接
|
||||||
|
assert m.connected
|
||||||
|
await m.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remembers_address(self):
|
||||||
|
t = FakeTransport()
|
||||||
|
m = make_manager([t], mac=None)
|
||||||
|
await m.run(lambda dev: dev.set_mode("clock1"))
|
||||||
|
assert (await m.status())["mac"] == "AA:BB:CC:DD:EE:FF"
|
||||||
|
await m.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSerialization:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ops_are_serialized(self):
|
||||||
|
t = FakeTransport()
|
||||||
|
m = make_manager([t])
|
||||||
|
concurrent = 0
|
||||||
|
peak = 0
|
||||||
|
|
||||||
|
async def op(dev):
|
||||||
|
nonlocal concurrent, peak
|
||||||
|
concurrent += 1
|
||||||
|
peak = max(peak, concurrent)
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
concurrent -= 1
|
||||||
|
return 1
|
||||||
|
|
||||||
|
await asyncio.gather(*(m.run(op) for _ in range(5)))
|
||||||
|
assert peak == 1
|
||||||
|
await m.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestIdleTimeout:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_idle_disconnect(self):
|
||||||
|
t = FakeTransport()
|
||||||
|
m = make_manager([t], idle_timeout=0.05)
|
||||||
|
await m.run(lambda dev: dev.set_mode("clock1"))
|
||||||
|
assert m.connected
|
||||||
|
await asyncio.sleep(0.15)
|
||||||
|
assert not m.connected
|
||||||
|
assert t.exits == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_activity_resets_idle(self):
|
||||||
|
t = FakeTransport()
|
||||||
|
m = make_manager([t], idle_timeout=0.1)
|
||||||
|
await m.run(lambda dev: dev.set_mode("clock1"))
|
||||||
|
await asyncio.sleep(0.06)
|
||||||
|
await m.run(lambda dev: dev.set_mode("clock2"))
|
||||||
|
await asyncio.sleep(0.06)
|
||||||
|
assert m.connected # 第二次活动续期
|
||||||
|
await m.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestReconnect:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reconnect_once_on_transport_error(self):
|
||||||
|
t1, t2 = FakeTransport(), FakeTransport()
|
||||||
|
t1.fail_next_write = True
|
||||||
|
m = make_manager([t1, t2])
|
||||||
|
await m.run(lambda dev: dev.set_mode("clock1"))
|
||||||
|
assert t1.enters == 1 and t2.enters == 1 # 掉线重连一次并成功
|
||||||
|
await m.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_exhaustion_raises(self):
|
||||||
|
t1, t2 = FakeTransport(), FakeTransport()
|
||||||
|
t1.fail_next_write = True
|
||||||
|
t2.fail_next_write = True
|
||||||
|
m = make_manager([t1, t2])
|
||||||
|
with pytest.raises(TransportError):
|
||||||
|
await m.run(lambda dev: dev.set_mode("clock1"))
|
||||||
|
await m.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatus:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_status_fields(self):
|
||||||
|
t = FakeTransport()
|
||||||
|
m = make_manager([t], connect_timeout=90.0, idle_timeout=300.0)
|
||||||
|
s = await m.status()
|
||||||
|
assert s["connected"] is True # status 自身触发连接
|
||||||
|
assert s["device_id"] == "81233F3C267112"
|
||||||
|
assert s["connect_timeout"] == 90.0
|
||||||
|
assert s["idle_timeout"] == 300.0
|
||||||
|
assert isinstance(s["idle_seconds"], float)
|
||||||
|
await m.close()
|
||||||
Reference in new issue
Block a user