Files
qianbian/src/ppclock/mcp_tools.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

199 lines
8.7 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.
"""MCP 工具层:13 个高层工具,统一输出契约(与 CLI --json 同构)。
成功 {"ok": true, "tool": name, "data": {...}}
失败 {"ok": false, "tool": name, "error": {"code", "message", "hint"?}}
"""
from __future__ import annotations
import asyncio
import base64
import binascii
import datetime
import io
import os
from PIL import Image
from bleak.exc import BleakDeviceNotFoundError
from .client import PPClient
from .device_manager import TRANSPORT_ERRORS
_TIMEOUT_HINT = "设备长睡眠、广播窗口 30-60s;调大 --connect-timeout 或稍后重试"
_NOTFOUND_HINT = "未发现 NRF- 前缀设备;确认设备在位且未处于深睡"
def decode_image_source(source: str) -> Image.Image:
"""source = 服务器本地路径,或 base64(可带 data URI 前缀)。"""
if os.path.exists(source):
try:
return Image.open(source)
except OSError: # 文件存在但不是可识别图片(含截断)
raise ValueError("source 不是可识别的图片") from None
data = source
if data.startswith("data:"):
if "," not in data:
raise ValueError("data URI 缺少 ',' 分隔")
data = data.split(",", 1)[1]
try:
raw = base64.b64decode(data, validate=True)
except (binascii.Error, ValueError):
raise ValueError("source 既不是存在的文件路径,也不是合法 base64") from None
try:
return Image.open(io.BytesIO(raw))
except OSError: # 合法 base64 但不是可识别图片(PIL.UnidentifiedImageError 属 OSError)
raise ValueError("source 不是可识别的图片") from None
def _err(tool: str, code: str, message: str, hint: str | None = None) -> dict:
error = {"code": code, "message": message}
if hint:
error["hint"] = hint
return {"ok": False, "tool": tool, "error": error}
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 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)
return _err(tool, "BLE_ERROR", msg)
def register_tools(mcp, manager) -> None:
"""把 13 个工具注册到 FastMCP 实例;manager 为 DeviceManager(测试可注入 fake)。"""
async def _call(tool: str, op) -> dict:
try:
data = await manager.run(op)
return {"ok": True, "tool": tool, "data": data if data is not None else {}}
except TRANSPORT_ERRORS as exc:
return _map_transport_error(tool, exc)
except ValueError as exc:
return _err(tool, "INVALID_PARAM", str(exc))
except Exception as exc: # noqa: BLE001 - 工具层兜底,错误须回 agent 而非炸会话
return _err(tool, "INTERNAL", f"{type(exc).__name__}: {exc}")
@mcp.tool()
async def scan_devices(timeout: float = 5.0) -> dict:
"""扫描附近 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 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/空闲秒数。
注意:会触发连接守候(最长 --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}")
@mcp.tool()
async def set_time(tz: float = 8.0) -> dict:
"""对时;tz 为时区偏移(默认 8=北京时间)。"""
return await _call("set_time", lambda dev: dev.set_time(tz=tz))
@mcp.tool()
async def set_mode(mode: str) -> dict:
"""切换显示模式:clock1-3/calendar1-3/image0-3/tricolor/mono。"""
return await _call("set_mode", lambda dev: dev.set_mode(mode))
@mcp.tool()
async def toggle(name: str) -> dict:
"""单字节切换:invert/font/rotate180/hour_format/clock_color。"""
return await _call("toggle", lambda dev: dev.toggle(name))
@mcp.tool()
async def upload_image(source: str, slot: int = 0, algo: str = "atkinson",
mono: bool = False,
threshold: float | None = None,
diffusion: float | None = None,
brightness: float | None = None,
contrast: float | None = None,
saturation: float | None = None,
rotate: float | None = None) -> dict:
"""传图到指定槽位。source=服务器路径或 base64;algo∈
none/floydsteinberg/atkinson/bayer/stucki/jarvis。"""
try:
adjust = {k: v for k, v in {
"threshold": threshold, "diffusion": diffusion,
"brightness": brightness, "contrast": contrast,
"saturation": saturation, "rotate": rotate}.items() if v is not None}
img = decode_image_source(source)
except ValueError as exc:
return _err("upload_image", "INVALID_PARAM", str(exc))
return await _call("upload_image", lambda dev: dev.upload_image(
img, slot=slot, algo=algo, mono=mono, **adjust))
@mcp.tool()
async def render_template(name: str, payload: dict | None = None,
slot: int = 0, algo: str = "atkinson") -> dict:
"""本地渲染模板并上传:schedule/businesscard/memo/course/qrcode/custom。"""
return await _call("render_template", lambda dev: dev.render_template(
name, payload, slot=slot, algo=algo))
@mcp.tool()
async def countdown(date: str, mode: str = "clock",
prefix: str | None = None) -> dict:
"""倒计时;date 为 ISO YYYY-MM-DD;mode=clock/calendar。"""
try:
d = datetime.date.fromisoformat(date)
except ValueError as exc:
return _err("countdown", "INVALID_PARAM", str(exc))
return await _call("countdown", lambda dev: dev.countdown(
d, mode=mode, prefix=prefix))
@mcp.tool()
async def countdown_off() -> dict:
"""关闭倒计时。"""
return await _call("countdown_off", lambda dev: dev.countdown_off())
@mcp.tool()
async def calendar_text(text: str) -> dict:
"""设置日历中文文字并切到日历模式一。"""
return await _call("calendar_text", lambda dev: dev.calendar_text(text))
@mcp.tool()
async def set_sleep(on: bool, start_h: int, end_h: int) -> dict:
"""设置休眠时段(0-23 时)。"""
return await _call("set_sleep", lambda dev: dev.sleep(on, start_h, end_h))
@mcp.tool()
async def clear_screen() -> dict:
"""刷屏(EPD 清屏)。"""
return await _call("clear_screen", lambda dev: dev.clear())
@mcp.tool()
async def raw_send(data_hex: str, channel: str = "alt") -> dict:
"""逃生舱:直发十六进制字节串;channel=rxtx/epd/alt。"""
try:
data = bytes.fromhex(data_hex)
except ValueError as exc:
return _err("raw_send", "INVALID_PARAM", str(exc))
return await _call("raw_send", lambda dev: dev.raw(data, channel))