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 适配器独占)
- 首次操作守候连接(connect_timeout 应覆盖设备 30-60s 广播窗口)
- 操作后保持连接;空闲 idle_timeout 秒自动断开让设备睡眠
- 操作中 TransportError(连接丢失)→ 重连并重试一次,再失败向上抛
- 操作中连接丢失(TRANSPORT_ERRORS 全家)→ 重连并重试一次,再失败向上抛
"""
from __future__ import annotations
import asyncio
import time
from bleak.exc import BleakError
from .client import PPClient
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:
@@ -43,12 +55,14 @@ class DeviceManager:
async def status(self) -> dict:
async with self._lock:
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()
info = {
"connected": self.connected,
"mac": self._mac,
"idle_seconds": None if self._last_activity is None
else round(time.monotonic() - self._last_activity, 1),
"idle_seconds": idle,
"connect_count": self._connect_count,
"connect_timeout": self._connect_timeout,
"idle_timeout": self._idle_timeout,
@@ -71,7 +85,7 @@ class DeviceManager:
await self._ensure_connected()
try:
return await op(self._client)
except TransportError:
except TRANSPORT_ERRORS:
await self._drop()
await self._ensure_connected()
return await op(self._client)
@@ -80,7 +94,16 @@ class DeviceManager:
if self._client is not None:
return
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._connect_count += 1
if self._mac is None: # 自动扫描后记住实际地址
+28 -7
View File
@@ -5,6 +5,7 @@
"""
from __future__ import annotations
import asyncio
import base64
import binascii
import datetime
@@ -12,9 +13,10 @@ import io
import os
from PIL import Image
from bleak.exc import BleakDeviceNotFoundError
from .client import PPClient
from .transports.base import TransportError
from .device_manager import TRANSPORT_ERRORS
_TIMEOUT_HINT = "设备长睡眠、广播窗口 30-60s;调大 --connect-timeout 或稍后重试"
_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}
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)
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)
if "not found" in low or "no device" in low:
return _err(tool, "DEVICE_NOT_FOUND", msg, _NOTFOUND_HINT)
@@ -66,7 +81,7 @@ def register_tools(mcp, manager) -> None:
try:
data = await manager.run(op)
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)
except ValueError as exc:
return _err(tool, "INVALID_PARAM", str(exc))
@@ -75,20 +90,26 @@ def register_tools(mcp, manager) -> None:
@mcp.tool()
async def scan_devices(timeout: float = 5.0) -> dict:
"""扫描附近 NRF- 前缀墨水屏设备,返回 [{name,address,rssi}]。"""
"""扫描附近 NRF- 前缀墨水屏设备。返回形状:data.devices = [{name,address,rssi}]。"""
try:
devices = await PPClient.scan_devices("local", timeout=timeout)
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)
except Exception as exc: # noqa: BLE001
return _err("scan_devices", "INTERNAL", f"{type(exc).__name__}: {exc}")
@mcp.tool()
async def device_status() -> dict:
"""查询连接状态/绑定 MAC/设备 ID/空闲秒数。"""
"""查询连接状态/绑定 MAC/设备 ID/空闲秒数。
注意:会触发连接守候(最长 --connect-timeout 秒)并刷新空闲计时——
周期轮询本工具会阻止 idle 自动断开。
"""
try:
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
return _err("device_status", "INTERNAL", f"{type(exc).__name__}: {exc}")