feat(ppclock): 命令层/传输层/CLI/本地模板渲染,90 测试全绿

This commit is contained in:
agent committed 2026-07-24 10:57:25 +00:00
1 parent 6f5b0c60be
commit 2b01e5f589
60 files changed
+16869

No files matched your search

+250
View File
@@ -0,0 +1,250 @@
"""ppclock CLI — 离线、agent 友好(--json 机器可读输出)。"""
from __future__ import annotations
import argparse
import asyncio
import datetime
import json
import sys
from . import commands as C
from . import image_pipeline as IP
from . import protocol as P
EXIT_OK = 0
EXIT_USAGE = 1
EXIT_TRANSPORT = 2
EXIT_ERROR = 3
def _emit(args, cmd, data=None, error=None):
if args.json:
out = {"ok": error is None, "cmd": cmd}
if data is not None:
out["data"] = data
if error is not None:
out["error"] = error
print(json.dumps(out, ensure_ascii=False))
else:
if error is not None:
print(f"错误: {error}", file=sys.stderr)
elif data is not None:
if isinstance(data, (dict, list)):
print(json.dumps(data, ensure_ascii=False, indent=2))
else:
print(data)
else:
print("OK")
def _make_transport(args):
"""工厂函数——测试用 monkeypatch 替换为 FakeTransport。"""
from .transport import BLETransport
return BLETransport(address=args.mac, timeout=args.timeout)
async def _run_with_transport(args, fn):
async with _make_transport(args) as t:
return await fn(t)
def _need_image(args):
from PIL import Image
img = Image.open(args.path)
size = tuple(int(v) for v in args.size.split("x")) if args.size else (400, 300)
bw, red = IP.process_image(
img, size=size, algorithm=args.algo, tricolor=not args.mono,
threshold=args.threshold, diffusion=args.diffusion / 100,
brightness=args.brightness, contrast=args.contrast,
saturation=args.saturation, rotate=args.rotate,
)
small = len(bw) in (3500, 9000)
return bw, red, small
async def _cmd_image(args, t):
bw, red, small = _need_image(args)
await C.upload_image(t, bw, red, small=small, slot=args.slot,
block_delay=args.block_delay)
return {"bytes_bw": len(bw), "bytes_red": len(red) if red else 0,
"small": small, "slot": args.slot}
async def _cmd_template(args, t):
from .templates import render_template
payload = json.loads(args.data) if args.data else {}
img = render_template(args.name, payload)
bw, red = IP.process_image(img, size=(400, 300), algorithm=args.algo,
tricolor=not args.mono)
await C.upload_image(t, bw, red, small=False, slot=args.slot,
block_delay=args.block_delay)
return {"template": args.name, "slot": args.slot}
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="ppclock", description="4.2寸墨水屏离线 CLI 上位机")
p.add_argument("--mac", help="设备 MAC(省略则自动扫描 NRF- 前缀设备)")
p.add_argument("--timeout", type=float, default=10.0)
p.add_argument("--json", action="store_true", help="机器可读 JSON 输出")
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("scan", help="扫描设备")
sp = sub.add_parser("time", help="对时(默认当前本地时间)")
sp.add_argument("datetime", nargs="?", help="ISO8601,如 2026-07-24T15:30:00")
sp = sub.add_parser("mode", help="显示模式")
sp.add_argument("name", choices=sorted(P.MODES))
sp = sub.add_parser("toggle", help="切换类命令")
sp.add_argument("name", choices=["invert", "font", "rotate180", "hour_format", "clock_color"])
sub.add_parser("clear", help="刷屏(EPD 通道)")
sp = sub.add_parser("image", help="传图")
sp.add_argument("path")
sp.add_argument("--slot", type=int, choices=[0, 1, 2, 3])
sp.add_argument("--algo", default="atkinson", choices=IP.ALGORITHMS)
sp.add_argument("--mono", action="store_true", help="黑白模式(不发红平面)")
sp.add_argument("--threshold", type=int, default=125)
sp.add_argument("--diffusion", type=int, default=100, help="0–100")
sp.add_argument("--brightness", type=int, default=0)
sp.add_argument("--contrast", type=int, default=0)
sp.add_argument("--saturation", type=int, default=100)
sp.add_argument("--rotate", type=int, default=0, choices=[0, 90, 180, 270])
sp.add_argument("--size", help="WxH,默认 400x300")
sp.add_argument("--block-delay", type=float, default=0.0)
sp = sub.add_parser("template", help="本地模板渲染并上传")
sp.add_argument("name", choices=["schedule", "businesscard", "memo", "course", "qrcode", "custom"])
sp.add_argument("--data", help="模板参数 JSON")
sp.add_argument("--slot", type=int, choices=[0, 1, 2, 3])
sp.add_argument("--algo", default="atkinson", choices=IP.ALGORITHMS)
sp.add_argument("--mono", action="store_true")
sp.add_argument("--block-delay", type=float, default=0.0)
sp = sub.add_parser("countdown", help="倒计时")
sp.add_argument("date", nargs="?", help="YYYY-MM-DD")
sp.add_argument("--mode", default="clock", choices=["clock", "calendar"])
sp.add_argument("--off", action="store_true")
sp = sub.add_parser("sleep", help="休眠时段")
sp.add_argument("--on", action="store_true")
sp.add_argument("--off", action="store_true")
sp.add_argument("--start", type=int, default=0)
sp.add_argument("--end", type=int, default=0)
sp = sub.add_parser("parking", help="停车牌号码")
sp.add_argument("number")
sp = sub.add_parser("rotation", help="轮播设置")
sp.add_argument("--count", type=int, choices=[1, 2, 3, 4])
sp.add_argument("--interval", type=int)
sp.add_argument("--unit", default="min", choices=["min", "hour"])
sp = sub.add_parser("lut", help="LUT 校准(0x01-0f 红,0x10-f0 黑)")
sp.add_argument("value", type=lambda v: int(v, 0))
sp = sub.add_parser("activate", help="激活")
sp.add_argument("code", nargs="?", help="激活码 hex")
sp.add_argument("--show-id", action="store_true", help="仅读取设备 ID")
sp = sub.add_parser("wifi", help="WiFi 配置")
sp.add_argument("ssid")
sp.add_argument("password")
sp.add_argument("--city", default="")
sp = sub.add_parser("raw", help="高级命令:直发 hex")
sp.add_argument("hex")
sp.add_argument("--channel", default="rxtx", choices=["rxtx", "epd"])
sp.add_argument("--no-response", action="store_true")
return p
async def _dispatch(args) -> dict | None:
if args.cmd == "scan":
from .transport import scan
return {"devices": await scan(timeout=args.timeout)}
async def body(t):
c = args.cmd
if c == "time":
dt = (datetime.datetime.fromisoformat(args.datetime)
if args.datetime else None)
await C.set_time(t, dt)
return {"set": (dt or datetime.datetime.now()).isoformat()}
if c == "mode":
await C.set_mode(t, args.name)
return {"mode": args.name}
if c == "toggle":
await C.toggle(t, args.name)
return {"toggle": args.name}
if c == "clear":
await C.clear(t)
return None
if c == "image":
return await _cmd_image(args, t)
if c == "template":
return await _cmd_template(args, t)
if c == "countdown":
if args.off:
await C.countdown_off(t)
return {"countdown": "off"}
if not args.date:
raise ValueError("缺少日期(YYYY-MM-DD)或 --off")
d = datetime.date.fromisoformat(args.date)
await C.countdown_set(t, d, args.mode)
return {"date": d.isoformat(), "mode": args.mode}
if c == "sleep":
on = args.on or not args.off
await C.sleep(t, on, args.start, args.end)
return {"on": on, "start": args.start, "end": args.end}
if c == "parking":
await C.parking(t, args.number)
return {"number": args.number}
if c == "rotation":
await C.rotation(t, count=args.count, interval=args.interval, unit=args.unit)
return {"count": args.count, "interval": args.interval, "unit": args.unit}
if c == "lut":
await C.lut(t, args.value)
return {"lut": hex(args.value)}
if c == "activate":
if args.show_id:
return {"device_id": await C.get_device_id(t)}
if not args.code:
raise ValueError("缺少激活码或 --show-id")
await C.activate(t, args.code)
return None
if c == "wifi":
await C.wifi(t, args.ssid, args.password, args.city)
return None
if c == "raw":
data = bytes.fromhex(args.hex)
if args.channel == "epd":
await t.write_epd(data, response=not args.no_response)
else:
await t.write_rxtx(data, response=not args.no_response)
return {"sent": len(data)}
raise ValueError(f"未知命令 {c}")
return await _run_with_transport(args, body)
def main(argv=None) -> int:
args = build_parser().parse_args(argv)
try:
data = asyncio.run(_dispatch(args))
except ValueError as e:
_emit(args, args.cmd, error=str(e))
return EXIT_USAGE
except Exception as e:
from .transport import TransportError
code = EXIT_TRANSPORT if isinstance(e, TransportError) else EXIT_ERROR
_emit(args, args.cmd, error=f"{type(e).__name__}: {e}")
return code
_emit(args, args.cmd, data=data)
return EXIT_OK
if __name__ == "__main__":
sys.exit(main())
+106
View File
@@ -0,0 +1,106 @@
"""高层命令:帧序列编排。协议规范 docs/protocol.md。"""
from __future__ import annotations
import datetime
from . import protocol as P
async def set_time(t, dt: datetime.datetime | None = None):
dt = dt or datetime.datetime.now()
await t.write_rxtx(P.build_set_time(dt))
await t.delay(0.3)
await t.write_rxtx(P.simple_command("commit"))
async def set_mode(t, mode: str):
"""模式切换:setri 语义——命令后 300ms 补 e2(ble.js:3956-3962)。"""
await t.write_rxtx(P.mode_frame(mode))
await t.delay(0.3)
await t.write_rxtx(P.simple_command("commit"))
async def toggle(t, name: str):
"""单字节切换类命令(invert/font/rotate180/hour_format/clock_color),补 e2。"""
await t.write_rxtx(P.simple_command(name))
await t.delay(0.3)
await t.write_rxtx(P.simple_command("commit"))
async def clear(t, param: int = 0x00):
for frame in P.clear_frames(param):
await t.write_epd(frame)
async def upload_image(t, bw: bytes, red: bytes | None, *, small: bool,
slot: int | None = None, block_delay: float = 0.0):
"""传图:可选槽位选择 → bw 平面块 → 红平面块 → 300ms → 01/AA。"""
if slot is not None:
await t.write_rxtx(P.slot_select_frame(slot))
await t.delay(0.3)
for frame in P.image_blocks(bw, small=small, red=False):
await t.write_epd(frame)
if block_delay:
await t.delay(block_delay)
if red is not None:
for frame in P.image_blocks(red, small=small, red=True):
await t.write_epd(frame)
if block_delay:
await t.delay(block_delay)
await t.delay(0.3)
await t.write_epd(P.end_frame(small=small))
async def countdown_set(t, d: datetime.date, mode: str):
await t.write_rxtx(P.countdown_date_frame(d, mode))
await t.delay(1.0)
await t.write_rxtx(P.simple_command("commit"))
async def countdown_off(t):
for frame in P.countdown_off_frames():
await t.write_rxtx(frame)
await t.delay(1.0)
async def parking(t, number: str):
await t.write_rxtx(P.parking_frame(number))
await t.delay(0.5)
await t.write_rxtx(P.simple_command("commit"))
async def sleep(t, on: bool, start_h: int, end_h: int):
await t.write_rxtx(P.sleep_frame(on, start_h, end_h))
async def rotation(t, count: int | None = None, interval: int | None = None,
unit: str = "min"):
if count is not None:
await t.write_rxtx(P.rotation_count_frame(count))
if interval is not None:
await t.write_rxtx(P.rotation_interval_frame(interval, unit))
async def activate(t, code_hex: str):
await t.write_rxtx(P.activation_frame(code_hex))
await t.delay(0.4)
await t.write_rxtx(P.simple_command("commit"))
async def get_device_id(t) -> str:
return await t.request_device_id()
async def wifi(t, ssid: str, password: str, city: str = ""):
await t.write_rxtx(P.wifi_frame(ssid, password, city))
async def lut(t, value: int):
await t.write_rxtx(P.lut_frame(value))
async def raw(t, data: bytes, channel: str = "rxtx"):
if channel == "epd":
await t.write_epd(data)
else:
await t.write_rxtx(data)
+164
View File
@@ -0,0 +1,164 @@
"""图像管线:调整 → 抖动量化 → 1bpp 平面打包。
算法与参数证据:analysis/web/timedjs.js(见 docs/protocol.md §5)。
所有实现与 Web 端 JS 语义对齐:同样的灰度系数、误差核、BCD 无 —— 像素级等价为目标。
"""
from __future__ import annotations
from PIL import Image
GRAY = (0.299, 0.587, 0.114)
BAYER4 = (
(15, 135, 45, 165),
(195, 75, 225, 105),
(60, 180, 30, 150),
(240, 120, 210, 90),
)
# 误差扩散核:(dx, dy, 权重分子);除数见 _KERNELS 键名
_KERNELS = {
"floydsteinberg": ([(1, 0, 7), (-1, 1, 3), (0, 1, 5), (1, 1, 1)], 16),
"atkinson": (
[(1, 0, 1), (2, 0, 1), (-1, 1, 1), (0, 1, 1), (1, 1, 1), (0, 2, 1)],
8,
),
"stucki": (
[
(1, 0, 8), (2, 0, 4),
(-2, 1, 2), (-1, 1, 4), (0, 1, 8), (1, 1, 4), (2, 1, 2),
(-2, 2, 1), (-1, 2, 2), (0, 2, 4), (1, 2, 2), (2, 2, 1),
],
42,
),
"jarvis": (
[
(1, 0, 7), (2, 0, 5),
(-2, 1, 3), (-1, 1, 5), (0, 1, 7), (1, 1, 5), (2, 1, 3),
(-2, 2, 1), (-1, 2, 3), (0, 2, 5), (1, 2, 3), (2, 2, 1),
],
48,
),
}
ALGORITHMS = ["none", "floydsteinberg", "atkinson", "bayer", "stucki", "jarvis"]
def _clamp(v: float) -> int:
return 0 if v < 0 else (255 if v > 255 else round(v))
def _gray_of(r: float, g: float, b: float) -> float:
return GRAY[0] * r + GRAY[1] * g + GRAY[2] * b
def adjust_pixels(pixels, brightness=0, contrast=0, saturation=100):
"""JS applyImageAdjustments 语义(timedjs.js:1-41)。
brightness: ±100 → v±255*b/100;contrast: ±100 → v*f+(1-f)*128, f=(c+100)/100;
saturation: 0–100+ → gray+(v-gray)*s/100。"""
out = []
bf = 255 * brightness / 100
cf = (contrast + 100) / 100
ca = (1 - cf) * 128
sf = saturation / 100
for r, g, b in pixels:
if brightness:
r, g, b = r + bf, g + bf, b + bf
if contrast:
r, g, b = r * cf + ca, g * cf + ca, b * cf + ca
if saturation != 100:
gray = _gray_of(r, g, b)
r, g, b = (gray + (v - gray) * sf for v in (r, g, b))
out.append((_clamp(r), _clamp(g), _clamp(b)))
return out
def _is_red(r, g, b, threshold):
return r > g * 1.5 and r > b * 1.5 and r > threshold
def dither_pixels(pixels, width, height, algorithm, threshold=125,
diffusion=1.0, tricolor=False):
"""量化到黑/白(+红)。返回与输入等长的 (r,g,b) 列表。
与 JS 一致:红判定优先且红像素不扩散误差;误差按通道扩散;写入即截断取整。"""
if algorithm not in ALGORITHMS:
raise ValueError(f"未知抖动算法 {algorithm!r},可选:{ALGORITHMS}")
buf = [[float(r), float(g), float(b)] for r, g, b in pixels]
out = [(0, 0, 0)] * (width * height)
def quantize(i, x, y):
r, g, b = buf[i]
if tricolor and _is_red(r, g, b, threshold):
out[i] = (255, 0, 0)
return None # 红像素无误差扩散
gray = _gray_of(r, g, b)
if algorithm == "bayer":
limit = BAYER4[y % 4][x % 4]
else:
limit = threshold
new = 0 if gray < limit else 255
out[i] = (new, new, new)
return (r - new) * diffusion, (g - new) * diffusion, (b - new) * diffusion
if algorithm in ("none", "bayer"):
for y in range(height):
for x in range(width):
quantize(y * width + x, x, y)
return out
kernel, div = _KERNELS[algorithm]
for y in range(height):
for x in range(width):
i = y * width + x
errs = quantize(i, x, y)
if errs is None:
continue
er, eg, eb = errs
for dx, dy, w in kernel:
nx, ny = x + dx, y + dy
if 0 <= nx < width and ny < height:
ni = ny * width + nx
# JS:写入 Uint8ClampedArray 即截断取整(round)
buf[ni][0] = _clamp(buf[ni][0] + er * w / div)
buf[ni][1] = _clamp(buf[ni][1] + eg * w / div)
buf[ni][2] = _clamp(buf[ni][2] + eb * w / div)
return out
def pack_plane(pixels, plane):
"""(r,g,b) 列表 → 1bpp MSB-first 字节流。
bw: R,G,B 全>0 → 1;red: R>0 且 G=0 且 B=0 → 1(timedjs.js:564-584)。"""
if len(pixels) % 8:
raise ValueError("像素数必须是 8 的倍数")
out = bytearray()
acc = 0
nbits = 0
for r, g, b in pixels:
if plane == "bw":
bit = 1 if (r > 0 and g > 0 and b > 0) else 0
elif plane == "red":
bit = 1 if (r > 0 and g == 0 and b == 0) else 0
else:
raise ValueError("plane 必须是 bw 或 red")
acc = (acc << 1) | bit
nbits += 1
if nbits == 8:
out.append(acc)
acc = 0
nbits = 0
return bytes(out)
def process_image(img: Image.Image, *, size=(400, 300), algorithm="atkinson",
tricolor=True, threshold=125, diffusion=1.0,
brightness=0, contrast=0, saturation=100, rotate=0):
"""完整管线:缩放/旋转 → 调整 → 抖动 → 打包。返回 (bw_bytes, red_bytes|None)。"""
img = img.convert("RGB").resize(size, Image.LANCZOS)
if rotate:
img = img.rotate(-rotate, expand=False) # 与 JS 顺时针旋转语义一致
w, h = size
px = list(img.getdata())
px = adjust_pixels(px, brightness, contrast, saturation)
px = dither_pixels(px, w, h, algorithm, threshold, diffusion, tricolor)
bw = pack_plane(px, "bw")
red = pack_plane(px, "red") if tricolor else None
return bw, red
+194
View File
@@ -0,0 +1,194 @@
"""本地模板渲染(替代 Web 端 canvas 渲染,离线可用)。
渲染产物为 400x300 RGB 图,红字用纯红 (255,0,0),经 image_pipeline 三色量化上屏。
模板集与 Web 端对齐:schedule/businesscard/memo/course/qrcode/custom(moban.js)。
"""
from __future__ import annotations
import datetime
from PIL import Image, ImageDraw, ImageFont
W, H = 400, 300
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)
RED = (255, 0, 0)
_FONT_CANDIDATES = [
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
]
_cache: dict[int, ImageFont.FreeTypeFont] = {}
def _font(size: int) -> ImageFont.FreeTypeFont:
if size not in _cache:
for path in _FONT_CANDIDATES:
try:
_cache[size] = ImageFont.truetype(path, size)
break
except OSError:
continue
else:
_cache[size] = ImageFont.load_default()
return _cache[size]
def _canvas() -> tuple[Image.Image, ImageDraw.ImageDraw]:
img = Image.new("RGB", (W, H), WHITE)
return img, ImageDraw.Draw(img)
def _wrap_cjk(text: str, max_units: float) -> list[str]:
"""按显示宽度断行:中文=1 单位,其他=0.5(与 moban.js 自定义文本一致)。"""
lines, cur, width = [], "", 0.0
for ch in text:
u = 1.0 if ord(ch) > 0x2E7F else 0.5
if ch == "\n" or width + u > max_units:
lines.append(cur)
cur, width = "", 0.0
if ch == "\n":
continue
cur += ch
width += u
lines.append(cur)
return lines
def _draw_colored(draw: ImageDraw.ImageDraw, xy, text: str, size: int):
"""绘制含 【…】 红字段落的单行文本。返回结束 x。"""
x, y = xy
segs, cur, red = [], "", False
for ch in text:
if ch == "【":
segs.append((cur, red)); cur, red = "", True
elif ch == "】":
segs.append((cur, red)); cur, red = "", False
else:
cur += ch
segs.append((cur, red))
for seg, is_red in segs:
if not seg:
continue
draw.text((x, y), seg, font=_font(size), fill=RED if is_red else BLACK)
x += draw.textlength(seg, font=_font(size))
return x
def render_schedule(p: dict) -> Image.Image:
"""日程:{"date": "2026-07-24"(可省), "items": [["09:00","晨会【重要】"],...]}"""
img, d = _canvas()
date = p.get("date") or datetime.date.today().isoformat()
d.rectangle([0, 0, W, 46], fill=BLACK)
d.text((16, 10), f"日程 {date}", font=_font(24), fill=WHITE)
y = 58
for time_s, event in p.get("items", [])[:8]:
d.text((16, y), time_s, font=_font(20), fill=BLACK)
_draw_colored(d, (110, y), event, 20)
d.line([(16, y + 30), (W - 16, y + 30)], fill=BLACK, width=1)
y += 30
return img
def render_businesscard(p: dict) -> Image.Image:
"""名片:{"name","title","company","phone","email"}"""
img, d = _canvas()
d.rectangle([0, 0, W, H], outline=BLACK, width=3)
d.text((24, 30), p.get("name", ""), font=_font(40), fill=BLACK)
d.text((24, 86), p.get("title", ""), font=_font(20), fill=RED)
d.text((24, 116), p.get("company", ""), font=_font(20), fill=BLACK)
d.line([(24, 150), (W - 24, 150)], fill=BLACK, width=2)
y = 164
for label, key in (("电话", "phone"), ("邮箱", "email"), ("地址", "addr")):
if p.get(key):
d.text((24, y), f"{label}:{p[key]}", font=_font(18), fill=BLACK)
y += 28
return img
def render_memo(p: dict) -> Image.Image:
"""备忘录:{"title": "...", "lines": ["...", ...]}"""
img, d = _canvas()
d.rectangle([0, 0, W, 44], fill=BLACK)
d.text((16, 8), p.get("title", "备忘录"), font=_font(24), fill=WHITE)
y = 56
for line in p.get("lines", [])[:9]:
d.ellipse([16, y + 8, 24, y + 16], fill=BLACK)
_draw_colored(d, (32, y), line, 18)
y += 27
return img
def render_course(p: dict) -> Image.Image:
"""课程表:{"days": ["周一",...](≤7), "periods": 4(2–9),
"grid": [["数学","语文",...], ...] # grid[period][day]"""
days = p.get("days") or ["周一", "周二", "周三", "周四", "周五"]
periods = int(p.get("periods", 4))
grid = p.get("grid", [])
img, d = _canvas()
cols = len(days) + 1
cw, rh = W // cols, H // (periods + 1)
for i in range(cols + 1):
d.line([(i * cw, 0), (i * cw, H)], fill=BLACK)
for j in range(periods + 2):
d.line([(0, j * rh), (W, j * rh)], fill=BLACK)
for c, day in enumerate(days):
d.text((c * cw + cw // 2, rh // 2), day, font=_font(16),
fill=BLACK, anchor="mm")
for r in range(periods):
d.text((cw // 2, (r + 1) * rh + rh // 2), f"{r + 1}", font=_font(16),
fill=BLACK, anchor="mm")
for c in range(len(days)):
name = grid[r][c] if r < len(grid) and c < len(grid[r]) else ""
if name:
d.text(((c + 1) * cw + cw // 2, (r + 1) * rh + rh // 2),
name, font=_font(14), fill=BLACK, anchor="mm")
return img
def render_qrcode(p: dict) -> Image.Image:
"""二维码:{"content": "...", "caption": "..."}(纠错级 M,与 Web 端一致)"""
import qrcode
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, box_size=8, border=2)
qr.add_data(p.get("content", ""))
qr.make(fit=True)
qimg = qr.make_image(fill_color="black", back_color="white").convert("RGB")
side = min(qimg.width, 240)
qimg = qimg.resize((side, side), Image.NEAREST)
img, d = _canvas()
img.paste(qimg, ((W - side) // 2, (H - side) // 2 - 14))
caption = p.get("caption", "")
if caption:
d.text((W // 2, H - 34), caption, font=_font(20), fill=BLACK, anchor="mm")
return img
def render_custom(p: dict) -> Image.Image:
"""自定义文本:{"text": "支持【红字】与换行", "size": 24(可选)}"""
text = p.get("text", "")
size = int(p.get("size", 24))
img, d = _canvas()
y = 10
for line in _wrap_cjk(text, max_units=(W - 20) / size):
_draw_colored(d, (10, y), line, size)
y += int(size * 1.35)
if y > H - size:
break
return img
RENDERERS = {
"schedule": render_schedule,
"businesscard": render_businesscard,
"memo": render_memo,
"course": render_course,
"qrcode": render_qrcode,
"custom": render_custom,
}
def render_template(name: str, payload: dict) -> Image.Image:
try:
return RENDERERS[name](payload)
except KeyError:
raise ValueError(f"未知模板 {name!r},可选:{sorted(RENDERERS)}") from None
+91
View File
@@ -0,0 +1,91 @@
"""BLE 传输层(bleak 封装)。硬件相关,单测以 FakeTransport 替代。"""
from __future__ import annotations
import asyncio
from bleak import BleakClient, BleakScanner
from bleak.exc import BleakError
from . import protocol as P
class TransportError(Exception):
pass
async def scan(timeout: float = 5.0, name_prefix: str = P.DEVICE_NAME_PREFIX):
"""扫描设备,返回 [{name, address, rssi}]。"""
found = await BleakScanner.discover(timeout=timeout, return_adv=True)
out = []
for addr, (dev, adv) in found.items():
name = dev.name or adv.local_name or ""
if name.startswith(name_prefix):
out.append({"name": name, "address": dev.address, "rssi": adv.rssi})
return out
class BLETransport:
"""与 FakeTransport 同接口:write_epd/write_rxtx/request_device_id/delay。"""
def __init__(self, address: str | None = None, timeout: float = 10.0,
name_prefix: str = P.DEVICE_NAME_PREFIX):
self.address = address
self.timeout = timeout
self.name_prefix = name_prefix
self._client: BleakClient | None = None
self._id_buf = bytearray()
self._id_event = asyncio.Event()
async def __aenter__(self):
await self.connect()
return self
async def __aexit__(self, *exc):
await self.close()
async def connect(self):
if not self.address:
devices = await scan(timeout=self.timeout, name_prefix=self.name_prefix)
if not devices:
raise TransportError(f"未找到名称前缀 {self.name_prefix!r} 的设备")
self.address = devices[0]["address"]
self._client = BleakClient(self.address, timeout=self.timeout)
try:
await self._client.connect()
except BleakError as e:
raise TransportError(f"连接失败: {e}") from e
await self._client.start_notify(P.RXTX_CHAR_UUID, self._on_notify)
async def close(self):
if self._client and self._client.is_connected:
try:
await self._client.stop_notify(P.RXTX_CHAR_UUID)
finally:
await self._client.disconnect()
def _on_notify(self, _sender, data: bytearray):
self._id_buf.extend(data)
if len(self._id_buf) >= 14:
self._id_event.set()
async def write_epd(self, data: bytes, response: bool = True):
await self._client.write_gatt_char(P.EPD_CHAR_UUID, data, response=response)
async def write_rxtx(self, data: bytes, response: bool = True):
await self._client.write_gatt_char(P.RXTX_CHAR_UUID, data, response=response)
async def read_rxtx(self) -> bytes:
return bytes(await self._client.read_gatt_char(P.RXTX_CHAR_UUID))
async def request_device_id(self, timeout: float = 8.0) -> str:
self._id_buf.clear()
self._id_event.clear()
await self.write_rxtx(P.request_id_frame())
try:
await asyncio.wait_for(self._id_event.wait(), timeout)
except asyncio.TimeoutError as e:
raise TransportError("等待设备 ID 应答超时") from e
return P.parse_device_id(bytes(self._id_buf[:14]))
async def delay(self, seconds: float):
await asyncio.sleep(seconds)