fix(mcp): 异常面适配真实 bleak——重试/错误码映射覆盖异常全家

- C1: _run_with_retry 捕获面 TransportError → (base+local 两个 TransportError,
  BleakError, OSError, asyncio.TimeoutError);真实掉线 bleak 裸抛
  BleakError("Not connected") 不再绕过重连重试
- C2/I1a: _map_transport_error 接受异常全家,类型优先(asyncio.TimeoutError、
  BleakDeviceNotFoundError → CONNECT_TIMEOUT)再按中英文消息映射;_call /
  scan_devices / device_status 的 except 同步扩展,device_status 不再把传输层
  错误兜底成 INTERNAL
- I2: _ensure_connected 的 __aenter__ 半途失败补 __aexit__,防半开连接泄漏
- M1: status() 先算 idle_seconds 再 _touch(),读数不再被自身归零
- docstring 校准:scan_devices 返回形状 data.devices;device_status 注明
  触发连接守候且刷新空闲计时
- 新测试 8 个(先红后绿):raw BleakError 重连成功/耗尽上抛、aenter 清理、
  idle_seconds 语义、local 中文 TransportError 两条映射、raw BleakError 映射、
  device_status 传输错误映射

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
chenweiandClaude committed 2026-07-30 23:55:31 +00:00
1 parent 16dbf494c9
commit 1f78587bbd
4 files changed
+164 -15

No files matched your search

+66 -2
View File
@@ -2,6 +2,7 @@
import asyncio
import pytest
from bleak.exc import BleakError
from ppclock.client import PPClient
from ppclock.device_manager import DeviceManager
@@ -15,6 +16,9 @@ class FakeTransport:
self.exits = 0
self.rxtx_writes = []
self.fail_next_write = False
# fail_next_write 触发时抛出的异常实例(默认 SDK 包装后的 TransportError;
# 真实硬件路径常是 bleak 裸抛的 BleakError,测试可替换)
self.fail_exc = TransportError("connection lost")
async def __aenter__(self):
self.enters += 1
@@ -30,7 +34,7 @@ class FakeTransport:
async def write_rxtx(self, data, response=True):
if self.fail_next_write:
self.fail_next_write = False
raise TransportError("connection lost")
raise self.fail_exc
self.rxtx_writes.append(data)
async def read_rxtx(self):
@@ -139,6 +143,55 @@ class TestReconnect:
await m.run(lambda dev: dev.set_mode("clock1"))
await m.close()
@pytest.mark.asyncio
async def test_reconnect_on_raw_bleak_error(self):
"""真实掉线 bleak 裸抛 BleakError(不经 SDK 包装),也要重连重试。"""
t1, t2 = FakeTransport(), FakeTransport()
t1.fail_next_write = True
t1.fail_exc = BleakError("Not connected")
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_bleak_error(self):
"""重连后仍抛 BleakError → 原样上抛。"""
t1, t2 = FakeTransport(), FakeTransport()
for t in (t1, t2):
t.fail_next_write = True
t.fail_exc = BleakError("Not connected")
m = make_manager([t1, t2])
with pytest.raises(BleakError):
await m.run(lambda dev: dev.set_mode("clock1"))
await m.close()
class TestEnterFailure:
@pytest.mark.asyncio
async def test_aenter_failure_cleans_up(self):
"""__aenter__ 抛异常时必须补 __aexit__(否则真实硬件泄漏半开连接)。"""
class HalfOpenClient:
def __init__(self):
self.exits = 0
async def __aenter__(self):
raise BleakError("connect boom")
async def __aexit__(self, *exc):
self.exits += 1
return False
client = HalfOpenClient()
m = DeviceManager(mac="AA:BB:CC:DD:EE:FF",
client_factory=lambda mac, timeout: client)
with pytest.raises(BleakError):
await m.run(lambda dev: dev.set_mode("clock1"))
assert client.exits == 1
assert not m.connected
await m.close()
class TestStatus:
@pytest.mark.asyncio
@@ -150,5 +203,16 @@ class TestStatus:
assert s["device_id"] == "81233F3C267112"
assert s["connect_timeout"] == 90.0
assert s["idle_timeout"] == 300.0
assert isinstance(s["idle_seconds"], float)
assert s["idle_seconds"] is None # 无先前活动;status 自身不得把读数归零
await m.close()
@pytest.mark.asyncio
async def test_status_idle_seconds_reflects_last_activity(self):
"""status 自身的 _touch 不得把 idle_seconds 归零(先算后摸)。"""
t = FakeTransport()
m = make_manager([t])
await m.run(lambda dev: dev.set_mode("clock1"))
await asyncio.sleep(0.12)
s = await m.status()
assert s["idle_seconds"] >= 0.1
await m.close()