- 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>
199 lines
8.7 KiB
Python
199 lines
8.7 KiB
Python
"""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))
|