Files
qianbian/src/ppclock/device_manager.py
T
chenweiandClaude 1f78587bbd 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>
2026-07-30 23:55:31 +00:00

137 lines
5.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""设备连接管理器(方案 C:按需连接 + 空闲保持,全操作串行)。
- 一把 asyncio.Lock 串行所有设备操作(BLE 适配器独占)
- 首次操作守候连接(connect_timeout 应覆盖设备 30-60s 广播窗口)
- 操作后保持连接;空闲 idle_timeout 秒自动断开让设备睡眠
- 操作中连接丢失(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:
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()
# 先算 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": idle,
"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 TRANSPORT_ERRORS:
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)
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: # 自动扫描后记住实际地址
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