From 1f78587bbd8101e2ff0dc081017248e1b5d926ca Mon Sep 17 00:00:00 2001 From: chenwei Date: Thu, 30 Jul 2026 23:55:31 +0000 Subject: [PATCH] =?UTF-8?q?fix(mcp):=20=E5=BC=82=E5=B8=B8=E9=9D=A2?= =?UTF-8?q?=E9=80=82=E9=85=8D=E7=9C=9F=E5=AE=9E=20bleak=E2=80=94=E2=80=94?= =?UTF-8?q?=E9=87=8D=E8=AF=95/=E9=94=99=E8=AF=AF=E7=A0=81=E6=98=A0?= =?UTF-8?q?=E5=B0=84=E8=A6=86=E7=9B=96=E5=BC=82=E5=B8=B8=E5=85=A8=E5=AE=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/ppclock/device_manager.py | 33 ++++++++++++++--- src/ppclock/mcp_tools.py | 35 ++++++++++++++---- tests/test_device_manager.py | 68 +++++++++++++++++++++++++++++++++-- tests/test_mcp_tools.py | 43 +++++++++++++++++++++- 4 files changed, 164 insertions(+), 15 deletions(-) diff --git a/src/ppclock/device_manager.py b/src/ppclock/device_manager.py index a161270..de75603 100644 --- a/src/ppclock/device_manager.py +++ b/src/ppclock/device_manager.py @@ -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: # 自动扫描后记住实际地址 diff --git a/src/ppclock/mcp_tools.py b/src/ppclock/mcp_tools.py index 4a27490..046cd97 100644 --- a/src/ppclock/mcp_tools.py +++ b/src/ppclock/mcp_tools.py @@ -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}") diff --git a/tests/test_device_manager.py b/tests/test_device_manager.py index 66fbc2a..2391552 100644 --- a/tests/test_device_manager.py +++ b/tests/test_device_manager.py @@ -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() diff --git a/tests/test_mcp_tools.py b/tests/test_mcp_tools.py index 110cf0d..df274b2 100644 --- a/tests/test_mcp_tools.py +++ b/tests/test_mcp_tools.py @@ -4,6 +4,7 @@ import io import json import pytest +from bleak.exc import BleakError from mcp.server.fastmcp import FastMCP from mcp.shared.memory import create_connected_server_and_client_session from PIL import Image @@ -11,6 +12,7 @@ from PIL import Image from ppclock.client import PPClient from ppclock.mcp_tools import decode_image_source, register_tools from ppclock.transports.base import TransportError +from ppclock.transports.local import TransportError as LocalTransportError class FakeTransport: @@ -47,9 +49,10 @@ class FakeTransport: class FakeManager: """模拟 DeviceManager:直接对 fake client 执行 op,或抛预置错误。""" - def __init__(self, error=None): + def __init__(self, error=None, status_error=None): self.client = PPClient(FakeTransport()) self.error = error + self.status_error = status_error self.ops = 0 async def run(self, op): @@ -59,6 +62,8 @@ class FakeManager: return await op(self.client) 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", "idle_seconds": 0.0, "connect_count": 1, "connect_timeout": 90.0, "idle_timeout": 300.0, @@ -144,6 +149,42 @@ class TestTools: assert r["error"]["code"] == "CONNECT_TIMEOUT" 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 async def test_device_status(self): r = await call(make_session_coro(FakeManager()), "device_status", {})