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

+28 -5
View File
@@ -3,15 +3,27 @@
- 一把 asyncio.Lock 串行所有设备操作(BLE 适配器独占) - 一把 asyncio.Lock 串行所有设备操作(BLE 适配器独占)
- 首次操作守候连接(connect_timeout 应覆盖设备 30-60s 广播窗口) - 首次操作守候连接(connect_timeout 应覆盖设备 30-60s 广播窗口)
- 操作后保持连接;空闲 idle_timeout 秒自动断开让设备睡眠 - 操作后保持连接;空闲 idle_timeout 秒自动断开让设备睡眠
- 操作中 TransportError(连接丢失)→ 重连并重试一次,再失败向上抛 - 操作中连接丢失(TRANSPORT_ERRORS 全家)→ 重连并重试一次,再失败向上抛
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import time import time
from bleak.exc import BleakError
from .client import PPClient from .client import PPClient
from .transports.base import TransportError from .transports.base import TransportError
from .transports.local import TransportError as LocalTransportError
# 传输层异常全家(掉线/未连上的全部真实形态):
# - transports.base.TransportError:SDK 公开语义异常(bridge/fake 路径)
# - transports.local.TransportError:真实 BLE 路径 connect/scan/ID 应答的中文异常
# (SDK 两个同名类互不继承,历史包袱;MCP 层必须同时覆盖)
# - bleak.exc.BleakError:写/读路径 bleak 裸抛不包装(如 "Not connected")
# - OSError / asyncio.TimeoutError:bleak 后端平台差异形态
TRANSPORT_ERRORS = (TransportError, LocalTransportError, BleakError,
OSError, asyncio.TimeoutError)
class DeviceManager: class DeviceManager:
@@ -43,12 +55,14 @@ class DeviceManager:
async def status(self) -> dict: async def status(self) -> dict:
async with self._lock: async with self._lock:
await self._ensure_connected() await self._ensure_connected()
# 先算 idle_seconds 再 _touch:status 自身也是活动,但不能把读数归零
idle = (None if self._last_activity is None
else round(time.monotonic() - self._last_activity, 1))
self._touch() self._touch()
info = { info = {
"connected": self.connected, "connected": self.connected,
"mac": self._mac, "mac": self._mac,
"idle_seconds": None if self._last_activity is None "idle_seconds": idle,
else round(time.monotonic() - self._last_activity, 1),
"connect_count": self._connect_count, "connect_count": self._connect_count,
"connect_timeout": self._connect_timeout, "connect_timeout": self._connect_timeout,
"idle_timeout": self._idle_timeout, "idle_timeout": self._idle_timeout,
@@ -71,7 +85,7 @@ class DeviceManager:
await self._ensure_connected() await self._ensure_connected()
try: try:
return await op(self._client) return await op(self._client)
except TransportError: except TRANSPORT_ERRORS:
await self._drop() await self._drop()
await self._ensure_connected() await self._ensure_connected()
return await op(self._client) return await op(self._client)
@@ -80,7 +94,16 @@ class DeviceManager:
if self._client is not None: if self._client is not None:
return return
client = self._factory(self._mac, self._connect_timeout) client = self._factory(self._mac, self._connect_timeout)
await client.__aenter__() try:
await client.__aenter__()
except Exception:
# __aenter__ 半途失败(如 connect 后 start_notify 炸)会留下半开连接,
# 补一次 __aexit__ 清理;清理自身失败不影响原异常上抛
try:
await client.__aexit__(None, None, None)
except Exception: # noqa: BLE001
pass
raise
self._client = client self._client = client
self._connect_count += 1 self._connect_count += 1
if self._mac is None: # 自动扫描后记住实际地址 if self._mac is None: # 自动扫描后记住实际地址
+28 -7
View File
@@ -5,6 +5,7 @@
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import base64 import base64
import binascii import binascii
import datetime import datetime
@@ -12,9 +13,10 @@ import io
import os import os
from PIL import Image from PIL import Image
from bleak.exc import BleakDeviceNotFoundError
from .client import PPClient from .client import PPClient
from .transports.base import TransportError from .device_manager import TRANSPORT_ERRORS
_TIMEOUT_HINT = "设备长睡眠、广播窗口 30-60s;调大 --connect-timeout 或稍后重试" _TIMEOUT_HINT = "设备长睡眠、广播窗口 30-60s;调大 --connect-timeout 或稍后重试"
_NOTFOUND_HINT = "未发现 NRF- 前缀设备;确认设备在位且未处于深睡" _NOTFOUND_HINT = "未发现 NRF- 前缀设备;确认设备在位且未处于深睡"
@@ -49,10 +51,23 @@ def _err(tool: str, code: str, message: str, hint: str | None = None) -> dict:
return {"ok": False, "tool": tool, "error": error} return {"ok": False, "tool": tool, "error": error}
def _map_transport_error(tool: str, exc: TransportError) -> dict: def _map_transport_error(tool: str, exc: Exception) -> dict:
"""传输层异常全家(TRANSPORT_ERRORS)→ 统一错误码。
真实硬件路径的异常来源:SDK 语义 TransportError(base/local 两个同名类)、
bleak 裸抛 BleakError、平台层 OSError / asyncio.TimeoutError。
类型判定优先于消息匹配(BleakDeviceNotFoundError 的英文消息含 "not found",
但语义是"已知 MAC 守候窗口内未出现",归 CONNECT_TIMEOUT 而非 DEVICE_NOT_FOUND)。
"""
msg = str(exc) msg = str(exc)
low = msg.lower() low = msg.lower()
if "timeout" in low or "timed out" in low: if isinstance(exc, (asyncio.TimeoutError, BleakDeviceNotFoundError)):
return _err(tool, "CONNECT_TIMEOUT", msg, _TIMEOUT_HINT)
if "超时" in msg or "timeout" in low or "timed out" in low:
return _err(tool, "CONNECT_TIMEOUT", msg, _TIMEOUT_HINT)
if "未找到" in msg:
return _err(tool, "DEVICE_NOT_FOUND", msg, _NOTFOUND_HINT)
if "连接失败" in msg: # 已知 MAC 守候未连上(bleak not found 的包装形态)
return _err(tool, "CONNECT_TIMEOUT", msg, _TIMEOUT_HINT) return _err(tool, "CONNECT_TIMEOUT", msg, _TIMEOUT_HINT)
if "not found" in low or "no device" in low: if "not found" in low or "no device" in low:
return _err(tool, "DEVICE_NOT_FOUND", msg, _NOTFOUND_HINT) return _err(tool, "DEVICE_NOT_FOUND", msg, _NOTFOUND_HINT)
@@ -66,7 +81,7 @@ def register_tools(mcp, manager) -> None:
try: try:
data = await manager.run(op) data = await manager.run(op)
return {"ok": True, "tool": tool, "data": data if data is not None else {}} return {"ok": True, "tool": tool, "data": data if data is not None else {}}
except TransportError as exc: except TRANSPORT_ERRORS as exc:
return _map_transport_error(tool, exc) return _map_transport_error(tool, exc)
except ValueError as exc: except ValueError as exc:
return _err(tool, "INVALID_PARAM", str(exc)) return _err(tool, "INVALID_PARAM", str(exc))
@@ -75,20 +90,26 @@ def register_tools(mcp, manager) -> None:
@mcp.tool() @mcp.tool()
async def scan_devices(timeout: float = 5.0) -> dict: async def scan_devices(timeout: float = 5.0) -> dict:
"""扫描附近 NRF- 前缀墨水屏设备,返回 [{name,address,rssi}]。""" """扫描附近 NRF- 前缀墨水屏设备。返回形状:data.devices = [{name,address,rssi}]。"""
try: try:
devices = await PPClient.scan_devices("local", timeout=timeout) devices = await PPClient.scan_devices("local", timeout=timeout)
return {"ok": True, "tool": "scan_devices", "data": {"devices": devices}} return {"ok": True, "tool": "scan_devices", "data": {"devices": devices}}
except TransportError as exc: except TRANSPORT_ERRORS as exc:
return _map_transport_error("scan_devices", exc) return _map_transport_error("scan_devices", exc)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
return _err("scan_devices", "INTERNAL", f"{type(exc).__name__}: {exc}") return _err("scan_devices", "INTERNAL", f"{type(exc).__name__}: {exc}")
@mcp.tool() @mcp.tool()
async def device_status() -> dict: async def device_status() -> dict:
"""查询连接状态/绑定 MAC/设备 ID/空闲秒数。""" """查询连接状态/绑定 MAC/设备 ID/空闲秒数。
注意:会触发连接守候(最长 --connect-timeout 秒)并刷新空闲计时——
周期轮询本工具会阻止 idle 自动断开。
"""
try: try:
return {"ok": True, "tool": "device_status", "data": await manager.status()} return {"ok": True, "tool": "device_status", "data": await manager.status()}
except TRANSPORT_ERRORS as exc:
return _map_transport_error("device_status", exc)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
return _err("device_status", "INTERNAL", f"{type(exc).__name__}: {exc}") return _err("device_status", "INTERNAL", f"{type(exc).__name__}: {exc}")
+66 -2
View File
@@ -2,6 +2,7 @@
import asyncio import asyncio
import pytest import pytest
from bleak.exc import BleakError
from ppclock.client import PPClient from ppclock.client import PPClient
from ppclock.device_manager import DeviceManager from ppclock.device_manager import DeviceManager
@@ -15,6 +16,9 @@ class FakeTransport:
self.exits = 0 self.exits = 0
self.rxtx_writes = [] self.rxtx_writes = []
self.fail_next_write = False self.fail_next_write = False
# fail_next_write 触发时抛出的异常实例(默认 SDK 包装后的 TransportError;
# 真实硬件路径常是 bleak 裸抛的 BleakError,测试可替换)
self.fail_exc = TransportError("connection lost")
async def __aenter__(self): async def __aenter__(self):
self.enters += 1 self.enters += 1
@@ -30,7 +34,7 @@ class FakeTransport:
async def write_rxtx(self, data, response=True): async def write_rxtx(self, data, response=True):
if self.fail_next_write: if self.fail_next_write:
self.fail_next_write = False self.fail_next_write = False
raise TransportError("connection lost") raise self.fail_exc
self.rxtx_writes.append(data) self.rxtx_writes.append(data)
async def read_rxtx(self): async def read_rxtx(self):
@@ -139,6 +143,55 @@ class TestReconnect:
await m.run(lambda dev: dev.set_mode("clock1")) await m.run(lambda dev: dev.set_mode("clock1"))
await m.close() 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: class TestStatus:
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -150,5 +203,16 @@ class TestStatus:
assert s["device_id"] == "81233F3C267112" assert s["device_id"] == "81233F3C267112"
assert s["connect_timeout"] == 90.0 assert s["connect_timeout"] == 90.0
assert s["idle_timeout"] == 300.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() await m.close()
+42 -1
View File
@@ -4,6 +4,7 @@ import io
import json import json
import pytest import pytest
from bleak.exc import BleakError
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from mcp.shared.memory import create_connected_server_and_client_session from mcp.shared.memory import create_connected_server_and_client_session
from PIL import Image from PIL import Image
@@ -11,6 +12,7 @@ from PIL import Image
from ppclock.client import PPClient from ppclock.client import PPClient
from ppclock.mcp_tools import decode_image_source, register_tools from ppclock.mcp_tools import decode_image_source, register_tools
from ppclock.transports.base import TransportError from ppclock.transports.base import TransportError
from ppclock.transports.local import TransportError as LocalTransportError
class FakeTransport: class FakeTransport:
@@ -47,9 +49,10 @@ class FakeTransport:
class FakeManager: class FakeManager:
"""模拟 DeviceManager:直接对 fake client 执行 op,或抛预置错误。""" """模拟 DeviceManager:直接对 fake client 执行 op,或抛预置错误。"""
def __init__(self, error=None): def __init__(self, error=None, status_error=None):
self.client = PPClient(FakeTransport()) self.client = PPClient(FakeTransport())
self.error = error self.error = error
self.status_error = status_error
self.ops = 0 self.ops = 0
async def run(self, op): async def run(self, op):
@@ -59,6 +62,8 @@ class FakeManager:
return await op(self.client) return await op(self.client)
async def status(self): async def status(self):
if self.status_error is not None:
raise self.status_error
return {"connected": True, "mac": "AA:BB:CC:DD:EE:FF", return {"connected": True, "mac": "AA:BB:CC:DD:EE:FF",
"idle_seconds": 0.0, "connect_count": 1, "idle_seconds": 0.0, "connect_count": 1,
"connect_timeout": 90.0, "idle_timeout": 300.0, "connect_timeout": 90.0, "idle_timeout": 300.0,
@@ -144,6 +149,42 @@ class TestTools:
assert r["error"]["code"] == "CONNECT_TIMEOUT" assert r["error"]["code"] == "CONNECT_TIMEOUT"
assert "hint" in r["error"] assert "hint" in r["error"]
@pytest.mark.asyncio
async def test_local_not_found_mapped(self):
"""真实 BLE 扫描落空:local.TransportError(中文)→ DEVICE_NOT_FOUND。"""
m = FakeManager(error=LocalTransportError("未找到名称前缀 'NRF-' 的设备"))
r = await call(make_session_coro(m), "set_time", {})
assert r["ok"] is False
assert r["error"]["code"] == "DEVICE_NOT_FOUND"
assert "hint" in r["error"]
@pytest.mark.asyncio
async def test_local_connect_failure_mapped(self):
"""已知 MAC 守候未连上(bleak 报 not found 被包装成"连接失败")→ CONNECT_TIMEOUT。"""
m = FakeManager(error=LocalTransportError(
"连接失败: Device with address AA:BB:CC:DD:EE:FF was not found."))
r = await call(make_session_coro(m), "set_time", {})
assert r["ok"] is False
assert r["error"]["code"] == "CONNECT_TIMEOUT"
assert "hint" in r["error"]
@pytest.mark.asyncio
async def test_raw_bleak_error_mapped(self):
"""写路径 bleak 裸抛 BleakError(不包装)→ BLE_ERROR。"""
m = FakeManager(error=BleakError("Not connected"))
r = await call(make_session_coro(m), "set_time", {})
assert r["ok"] is False
assert r["error"]["code"] == "BLE_ERROR"
@pytest.mark.asyncio
async def test_device_status_transport_error_mapped(self):
"""device_status 的传输层错误与其它工具同映射,不兜底 INTERNAL。"""
m = FakeManager(status_error=LocalTransportError("等待设备 ID 应答超时"))
r = await call(make_session_coro(m), "device_status", {})
assert r["ok"] is False
assert r["error"]["code"] == "CONNECT_TIMEOUT"
assert "hint" in r["error"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_device_status(self): async def test_device_status(self):
r = await call(make_session_coro(FakeManager()), "device_status", {}) r = await call(make_session_coro(FakeManager()), "device_status", {})