feat(ppclock): 命令层/传输层/CLI/本地模板渲染,90 测试全绿
This commit is contained in:
1 parent
6f5b0c60be
commit
2b01e5f589
60 files changed
+16869
No files matched your search
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user