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())