feat(ppclock): protocol.py 协议编解码 23 测试全绿(BCD语义经JS证据校正)

This commit is contained in:
agent committed 2026-07-24 10:43:33 +00:00
1 parent 190182fb2e
commit 6f5b0c60be
11 files changed
+32843

No files matched your search

View File
Whitespace-only changes.
+13
View File
@@ -0,0 +1,13 @@
13187b10-eba9-a3ba-044e-83d3217d9a38 -> ['Lqbsg/top/ble/BleManagerKt;::<clinit>()V']
4b646063-6264-f3a7-8941-e65356ea82fe -> ['Lqbsg/top/ble/BleManagerKt;::<clinit>()V']
00001f10-0000-1000-8000-00805f9b34fb -> ['Lqbsg/top/ble/SimulatedRawGattHandle;::findFirstWritableCharacteristic()Lp6/g;', 'Lqbsg/top/ble/SimulatedRawGattHandle;::hasService(Ljava/lang/String;)Z', 'Lqbsg/top/ble/BleManagerKt;::<clinit>()V', 'Lqbsg/top/ble/SimulatedRawGattHandle;::hasCharacteristic(Ljava/lang/String; Ljava/lang/String;)Z']
00001f1f-0000-1000-8000-00805f9b34fb -> ['Lqbsg/top/ble/SimulatedRawGattHandle;::findFirstWritableCharacteristic()Lp6/g;', 'Lqbsg/top/ble/BleManagerKt;::<clinit>()V', 'Lqbsg/top/ble/SimulatedRawGattHandle;::hasCharacteristic(Ljava/lang/String; Ljava/lang/String;)Z']
0000fef5-0000-1000-8000-00805f9b34fb -> ['Lqbsg/top/ble/SimulatedRawGattHandle;::hasService(Ljava/lang/String;)Z', 'Lz7/b0;::i(Ljava/lang/String; Lz7/j0; Lz7/o0; I Lkotlinx/coroutines/sync/Semaphore; Lkotlin/coroutines/jvm/internal/ContinuationImpl;)Ljava/lang/Object;', 'Lqbsg/top/ble/SimulatedGattOtaChannel;::hasService(Ljava/lang/String;)Z', 'Lqbsg/top/ble/SimulatedRawGattHandle$writeCharacteristic$1;::invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object;', 'La8/g;::f([B Ljava/lang/String; Lqbsg/top/ble/GattOtaChannel; Lkotlin/coroutines/jvm/internal/ContinuationImpl;)Ljava/lang/Object;', 'Lqbsg/top/ble/SimulatedRawGattHandle;::hasCharacteristic(Ljava/lang/String; Ljava/lang/String;)Z']
00002A24-0000-1000-8000-00805f9b34fb -> ['Lqbsg/top/ble/BleManagerKt;::<clinit>()V']
00002A26-0000-1000-8000-00805f9b34fb -> ['Lqbsg/top/ble/BleManagerKt;::<clinit>()V']
https://qbsg.top/api/activation/activate -> ['La8/f;::l(Ljava/lang/Object;)Ljava/lang/Object;', 'La9/c2;::invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object;']
https://qbsg.top/api/activation/check -> ['La9/c2;::invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object;']
https://qbsg.top/api/check-update -> ['Lb1/m;::invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object;']
https://qbsg.top/api/firmware/catalog -> ['La0/f0;::invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object;', 'Lb9/g0;::invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object;']
SPOTA_PATCH_DATA_UUID_STR -> []
3fd29fea11187d48e527be1cb2a62d9b617d457033f076cb0b1e20bcc60f966b0ddf81dd86533d893cbc9355949b74c9a378a8ab27e2516401da22268d3a7791 -> ['Lz7/b0;::<init>(Lkotlinx/coroutines/CoroutineScope; Lqbsg/top/ble/BleManagerFacade; Lqbsg/top/ble/RawGattConnector; Ls0/u0;)V']
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Linear-sweep Thumb disassembly of DA14585 firmware.
Code region = file[0x40:], mapped base 0x07FC0000.
- 16KB-aligned linear sweep; on decode failure emit .word and resync (+2).
- Function prologue detection: PUSH {...,lr} (encoding 0xB5xx).
Output: analysis/firmware/fw.asm (addr: bytes mnemonic [;;FUNC markers])
"""
from capstone import Cs, CS_ARCH_ARM, CS_MODE_THUMB, CS_MODE_LITTLE_ENDIAN
IMG = "app/firmware-PP_da14585_4.2_CH.img"
OUT = "analysis/firmware/fw.asm"
BASE = 0x07FC0000
HDR = 0x40
def main():
data = open(IMG, "rb").read()[HDR:]
md = Cs(CS_ARCH_ARM, CS_MODE_THUMB | CS_MODE_LITTLE_ENDIAN)
md.detail = False
out = open(OUT, "w")
out.write(f"; DA14585 firmware linear sweep, base 0x{BASE:08x}, {len(data)} bytes\n")
off = 0
n_ins = 0
n_bad = 0
funcs = []
while off < len(data):
chunk = data[off:off+0x2000]
addr = BASE + off
consumed = 0
for ins in md.disasm(chunk, addr):
# skip if disassembler resync'd mid-chunk oddly
if ins.address < addr + consumed:
continue
# fill any gap (undecodable bytes) with .word
gap = ins.address - (addr + consumed)
while gap > 0:
g = addr + consumed
raw = data[off+consumed:off+consumed+2]
if len(raw) == 2:
w = raw[0] | raw[1] << 8
out.write(f"0x{g:08x}: {w:04x} .word 0x{w:04x}\n")
else:
out.write(f"0x{g:08x}: {raw.hex():<9s} .byte {raw.hex()}\n")
consumed += 2
gap -= 2
n_bad += 1
b = ins.bytes
# prologue: PUSH {...,lr} => 0xB5xx ; also push {lr} alone? (B500)
mark = ""
if len(b) == 2 and b[1] == 0xB5:
mark = " ;;FUNC"
funcs.append(ins.address)
out.write(f"0x{ins.address:08x}: {b.hex():<9s} {ins.mnemonic} {ins.op_str}{mark}\n")
consumed = ins.address + ins.size - addr
n_ins += 1
off += consumed if consumed else 2
if consumed == 0:
n_bad += 1
out.write(f"; instructions={n_ins} badwords={n_bad} functions={len(funcs)}\n")
out.close()
print(f"instructions={n_ins} badwords={n_bad} prologues={len(funcs)}")
with open("analysis/firmware/funcs.txt", "w") as f:
for a in funcs:
f.write(f"0x{a:08x}\n")
if __name__ == "__main__":
main()
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Scan firmware for command-dispatch CMP-immediate and branch-switch patterns."""
from capstone import Cs, CS_ARCH_ARM, CS_MODE_THUMB, CS_MODE_LITTLE_ENDIAN
import struct, collections
IMG = "app/firmware-PP_da14585_4.2_CH.img"
BASE = 0x07FC0000
HDR = 0x40
CODE_END = 0x119e4 # code region size
CMDS = {0xdd:"time", 0xe2:"commit", 0x03:"img_blk_full", 0x04:"img_blk_small",
0x01:"refresh", 0xAA:"refresh_small", 0xEF:"activate", 0xE6:"lut_cal",
0xFB:"sleep_time", 0xEA:"slot", 0xFA:"countdown_off", 0xE1:"mode",
0xE3:"invert", 0xE4:"font", 0xE5:"rotate", 0x23:"bw_3color", 0x14:"12_24h",
0x15:"clk_color", 0x99:"cal_text", 0x00:"refresh_step1", 0xE9:"carousel"}
def main():
data = open(IMG, "rb").read()[HDR:HDR+CODE_END]
md = Cs(CS_ARCH_ARM, CS_MODE_THUMB | CS_MODE_LITTLE_ENDIAN)
# collect all instructions via same sweep
insns = []
off = 0
while off < len(data):
chunk = data[off:off+0x2000]
addr = BASE + off
consumed = 0
for ins in md.disasm(chunk, addr):
if ins.address < addr + consumed:
continue
gap = ins.address - (addr + consumed)
if gap:
consumed += (gap // 2) * 2
insns.append(ins)
consumed = ins.address + ins.size - addr
off += consumed if consumed else 2
# CMP Rn, #imm with imm in CMDS
hits = collections.defaultdict(list)
for ins in insns:
if ins.mnemonic == "cmp":
parts = ins.op_str.split(",")
if len(parts) == 2 and parts[1].strip().startswith("#"):
try:
v = int(parts[1].strip()[1:], 0)
except ValueError:
continue
if v in CMDS:
hits[v].append((ins.address, ins.op_str))
for v in sorted(hits):
print(f"--- CMP #0x{v:02x} ({CMDS[v]}): {len(hits[v])} hits ---")
for a, ops in hits[v]:
print(f" 0x{a:08x}: cmp {ops}")
if __name__ == "__main__":
main()
+273
View File
@@ -0,0 +1,273 @@
0x07fc0574
0x07fc05ec
0x07fc0634
0x07fc06ac
0x07fc0734
0x07fc07ea
0x07fc081a
0x07fc082e
0x07fc08bc
0x07fc0a24
0x07fc0b18
0x07fc0b3e
0x07fc0c34
0x07fc0c7a
0x07fc0d9c
0x07fc0dea
0x07fc0e58
0x07fc0f6c
0x07fc11d8
0x07fc1504
0x07fc154a
0x07fc15b8
0x07fc16ea
0x07fc1754
0x07fc17e6
0x07fc1a56
0x07fc1ab8
0x07fc1b0c
0x07fc1b7c
0x07fc1bcc
0x07fc1c54
0x07fc1cc6
0x07fc1d28
0x07fc1e8c
0x07fc1f5c
0x07fc204c
0x07fc207c
0x07fc209e
0x07fc20c4
0x07fc20de
0x07fc2184
0x07fc21ac
0x07fc21c8
0x07fc221c
0x07fc238c
0x07fc23d0
0x07fc2574
0x07fc2628
0x07fc2788
0x07fc27c4
0x07fc2b3c
0x07fc2c48
0x07fc2cac
0x07fc2da4
0x07fc2dfc
0x07fc3098
0x07fc3154
0x07fc3190
0x07fc31b4
0x07fc32bc
0x07fc3344
0x07fc336c
0x07fc33a8
0x07fc33d0
0x07fc3404
0x07fc3440
0x07fc359c
0x07fc3614
0x07fc361e
0x07fc3628
0x07fc3632
0x07fc363c
0x07fc3646
0x07fc3658
0x07fc36c8
0x07fc3710
0x07fc37a8
0x07fc3854
0x07fc3920
0x07fc3a38
0x07fc3c54
0x07fc3d20
0x07fc3e44
0x07fc48f4
0x07fc4988
0x07fc4bf8
0x07fc4c38
0x07fc4cdc
0x07fc4d50
0x07fc4e20
0x07fc4f90
0x07fc50c4
0x07fc5104
0x07fc519c
0x07fc51d6
0x07fc522c
0x07fc52f4
0x07fc5354
0x07fc53e8
0x07fc546c
0x07fc54bc
0x07fc5504
0x07fc554c
0x07fc5598
0x07fc55b8
0x07fc55e0
0x07fc5678
0x07fc56b0
0x07fc570c
0x07fc5744
0x07fc5764
0x07fc5798
0x07fc57ec
0x07fc582c
0x07fc587c
0x07fc58e0
0x07fc5974
0x07fc5a2c
0x07fc5a6c
0x07fc5a8c
0x07fc5ac4
0x07fc5adc
0x07fc5afc
0x07fc5b0c
0x07fc5b7c
0x07fc5bb0
0x07fc5bd0
0x07fc5c4c
0x07fc5ca4
0x07fc5dd4
0x07fc5df0
0x07fc5e0c
0x07fc5ec0
0x07fc5edc
0x07fc5f5c
0x07fc6078
0x07fc60d8
0x07fc6240
0x07fc62a8
0x07fc62f8
0x07fc631c
0x07fc6374
0x07fc641c
0x07fc6434
0x07fc64bc
0x07fc6540
0x07fc6590
0x07fc65d0
0x07fc6634
0x07fc6680
0x07fc66cc
0x07fc66f4
0x07fc67f8
0x07fc6814
0x07fc683c
0x07fc6aac
0x07fc6b24
0x07fc6b9c
0x07fc6c1c
0x07fc6c40
0x07fc6cb8
0x07fc6cda
0x07fc6ce2
0x07fc6cec
0x07fc6e98
0x07fc6fa0
0x07fc6fc8
0x07fc70a0
0x07fc715c
0x07fc71e0
0x07fc7268
0x07fc7290
0x07fc7374
0x07fc7430
0x07fc7528
0x07fc7618
0x07fc7768
0x07fc778c
0x07fc78b0
0x07fc793c
0x07fc79d0
0x07fc79f8
0x07fc7a58
0x07fc7b54
0x07fc7b94
0x07fc7c6c
0x07fc7cc4
0x07fc7d18
0x07fc7d2c
0x07fc7d3c
0x07fc7e1c
0x07fc7ee4
0x07fc8010
0x07fc80c0
0x07fc826c
0x07fc8340
0x07fc852c
0x07fc8598
0x07fc85e8
0x07fc8610
0x07fc8648
0x07fc9784
0x07fc97a0
0x07fc97cc
0x07fc9b24
0x07fc9ba0
0x07fc9bc4
0x07fc9bcc
0x07fc9bd4
0x07fca22c
0x07fca2dc
0x07fca39c
0x07fca3cc
0x07fca41c
0x07fca518
0x07fca588
0x07fca5b4
0x07fca5f8
0x07fca620
0x07fca648
0x07fca6e4
0x07fca718
0x07fca814
0x07fca880
0x07fca974
0x07fcaa68
0x07fcaad8
0x07fcaae8
0x07fcaafc
0x07fcab74
0x07fcaba2
0x07fcabb4
0x07fcabf4
0x07fcac54
0x07fcac68
0x07fcac80
0x07fcac9c
0x07fcacc4
0x07fcad74
0x07fcadac
0x07fcae24
0x07fcae4c
0x07fcae8c
0x07fcaea4
0x07fcaeda
0x07fcaeec
0x07fcaf20
0x07fcb3b8
0x07fcb3f0
0x07fcb6b8
0x07fcb6f8
0x07fcb824
0x07fcb848
0x07fcb86c
0x07fcb8ec
0x07fcb93c
0x07fcb9bc
0x07fcbb10
0x07fcbc64
0x07fcbde4
0x07fcc1c6
0x07fcc238
0x07fcc660
0x07fcc680
0x07fcc770
0x07fced48
0x07fcf5f2
0x07fd00fa
0x07fd06ec
0x07fd0a84
0x07fd0f2c
0x07fd0f46
0x07fd0ffc
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "ppclock"
version = "0.1.0"
description = "4.2寸墨水屏设备离线 CLI 上位机(DA14585 BLE,agent 友好)"
requires-python = ">=3.10"
dependencies = [
"bleak>=0.22",
"pillow>=10",
]
[project.optional-dependencies]
dev = ["pytest>=8"]
[project.scripts]
ppclock = "ppclock.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
View File
Whitespace-only changes.
+164
View File
@@ -0,0 +1,164 @@
"""BLE 协议编解码(纯函数,无 BLE 依赖)。规范:docs/protocol.md;证据:analysis/web/protocol-raw.md。"""
from __future__ import annotations
import datetime
from typing import Iterator
# GATT(docs/protocol.md §1)
EPD_SERVICE_UUID = "13187b10-eba9-a3ba-044e-83d3217d9a38"
EPD_CHAR_UUID = "4b646063-6264-f3a7-8941-e65356ea82fe"
RXTX_SERVICE_UUID = "00001f10-0000-1000-8000-00805f9b34fb"
RXTX_CHAR_UUID = "00001f1f-0000-1000-8000-00805f9b34fb"
DEVICE_NAME_PREFIX = "NRF-"
BLOCK_PAYLOAD = 240 # my_step=480 hex 字符 = 240 数据字节
COMMIT = 0xE2
MODES = {
**{f"image{i}": bytes([0xE1, 0x00, 0x00 + i]) for i in range(4)},
"calendar1": bytes.fromhex("e10103"),
"calendar2": bytes.fromhex("e10104"),
"calendar3": bytes.fromhex("e10105"),
"clock1": bytes.fromhex("e10201"),
"clock2": bytes.fromhex("e10202"),
"clock3": bytes.fromhex("e10203"),
"tricolor": bytes.fromhex("2300"),
"mono": bytes.fromhex("2301"),
}
SIMPLE_COMMANDS = {
"hour_format": bytes([0x14]),
"clock_color": bytes([0x15]),
"invert": bytes([0xE3]),
"font": bytes([0xE4]),
"rotate180": bytes([0xE5]),
"commit": bytes([COMMIT]),
}
def image_blocks(plane: bytes, *, small: bool, red: bool) -> Iterator[bytes]:
"""平面字节流 → 图像块帧迭代器。cod: 03整图/04小图;code: ff=bw 00=红。"""
cod = 0x04 if small else 0x03
code = 0x00 if red else 0xFF
for offset in range(0, len(plane), BLOCK_PAYLOAD):
chunk = plane[offset : offset + BLOCK_PAYLOAD]
yield bytes([cod, code]) + offset.to_bytes(2, "big") + chunk
def end_frame(*, small: bool) -> bytes:
return bytes([0xAA if small else 0x01])
def clear_frames(param: int = 0x00) -> list[bytes]:
return [bytes([0x00, param]), bytes([0x01])]
def build_set_time(dt: datetime.datetime) -> bytes:
"""dd + unix(4B,本地墙钟按UTC计) + year(2B BE) + month + day + week(周一=1)。"""
unix_local = int(dt.replace(tzinfo=datetime.timezone.utc).timestamp())
return (
bytes([0xDD])
+ unix_local.to_bytes(4, "big")
+ dt.year.to_bytes(2, "big")
+ bytes([dt.month, dt.day, dt.isoweekday()])
)
def parking_frame(number: str) -> bytes:
"""停车牌:每位数字双写,'12' → EF 11 22。"""
if not number.isdigit() or not number:
raise ValueError("停车牌号码必须为纯数字")
return bytes([0xEF]) + bytes(int(f"{d}{d}", 16) for d in number)
def rotation_interval_frame(n: int, unit: str) -> bytes:
"""e901 + n + 01(分钟)/00(小时)。"""
if not 0 <= n <= 0xFF:
raise ValueError("轮播间隔超出范围")
if unit not in ("min", "hour"):
raise ValueError("unit 必须是 min 或 hour")
return bytes([0xE9, 0x01, n, 0x01 if unit == "min" else 0x00])
def slot_select_frame(slot: int) -> bytes:
if not 0 <= slot <= 3:
raise ValueError("槽位 0–3")
return bytes([0xEA, slot])
def rotation_count_frame(count: int) -> bytes:
if not 1 <= count <= 4:
raise ValueError("轮播数量 1–4")
return bytes([0xEA, 0x00, 0x00 + (count - 1)])
def _bcd(v: int) -> int:
"""十进制值 → BCD 字节(JS 中 '0'+n 十进制直拼后经 hexToBytes 的语义,
证据 timedjs2.js:242-243、ble.js:4013-4015)。如 12 → 0x12。"""
if not 0 <= v <= 99:
raise ValueError("BCD 值范围 0–99")
return int(f"{v:02d}", 16)
def countdown_date_frame(d: datetime.date, mode: str) -> bytes:
"""ff00(时钟)/ffff(日历) + 年份4位十进制BCD + 月(BCD) + 日(BCD)。"""
if mode == "clock":
head = bytes([0xFF, 0x00])
elif mode == "calendar":
head = bytes([0xFF, 0xFF])
else:
raise ValueError("mode 必须是 clock 或 calendar")
y = f"{d.year:04d}"
return head + bytes([int(y[:2], 16), int(y[2:], 16), _bcd(d.month), _bcd(d.day)])
def countdown_off_frames() -> list[bytes]:
return [bytes([0xFA]), bytes([COMMIT])]
def activation_frame(code_hex: str) -> bytes:
return bytes([0xEF]) + bytes.fromhex(code_hex.upper())
def request_id_frame() -> bytes:
return bytes([0xEF, 0xEF])
def lut_frame(value: int) -> bytes:
"""E6: 01–0f 设红,10–f0 设黑;0 无效。"""
if not 0x01 <= value <= 0xF0:
raise ValueError("LUT 值范围 0x01–0xF0")
return bytes([0xE6, value])
def sleep_frame(on: bool, start_h: int, end_h: int) -> bytes:
"""FB01/FB00 + 开始小时(BCD) + 结束小时(BCD)(证据 ble.js:4013-4015 十进制直拼)。"""
for h in (start_h, end_h):
if not 0 <= h <= 23:
raise ValueError("小时范围 0–23")
return bytes([0xFB, 0x01 if on else 0x00, _bcd(start_h), _bcd(end_h)])
def mode_frame(mode: str) -> bytes:
try:
return MODES[mode]
except KeyError:
raise ValueError(f"未知模式 {mode!r},可选:{sorted(MODES)}") from None
def simple_command(name: str) -> bytes:
try:
return SIMPLE_COMMANDS[name]
except KeyError:
raise ValueError(f"未知命令 {name!r},可选:{sorted(SIMPLE_COMMANDS)}") from None
def wifi_frame(ssid: str, password: str, city: str) -> bytes:
return f"WIFI_SSID:{ssid};WIFI_PASS:{password};CITY:{city}".encode("utf-8")
def parse_device_id(data: bytes) -> str:
"""14 字节设备 ID notify 应答 → 字符串(可打印即 ASCII,否则大写 hex)。"""
if all(0x20 <= b < 0x7F for b in data):
return data.decode("ascii")
return data.hex().upper()
View File
Whitespace-only changes.
+144
View File
@@ -0,0 +1,144 @@
"""协议编解码测试 — 测试向量来自 docs/protocol.md §8(证据:analysis/web/protocol-raw.md)。"""
import datetime
import pytest
from ppclock import protocol as P
class TestImageBlocks:
def test_first_block_header_bw_full(self):
"""400x300 bw 平面首块 → 03 ff 0000 + 240B(向量#2)"""
plane = bytes(15000)
blocks = list(P.image_blocks(plane, small=False, red=False))
assert blocks[0][:4] == bytes([0x03, 0xFF, 0x00, 0x00])
assert len(blocks[0]) == 244
def test_block_size_and_count(self):
"""15000B / 240B = 62.5 → 63 块;末块 120B+4"""
blocks = list(P.image_blocks(bytes(15000), small=False, red=False))
assert len(blocks) == 63
assert len(blocks[-1]) == 4 + 120
def test_offsets_big_endian_sequential(self):
blocks = list(P.image_blocks(bytes(15000), small=False, red=False))
assert blocks[1][2:4] == (240).to_bytes(2, "big")
assert blocks[2][2:4] == (480).to_bytes(2, "big")
def test_red_plane_code_00(self):
blocks = list(P.image_blocks(bytes(15000), small=False, red=True))
assert blocks[0][1] == 0x00
def test_small_image_cod_04(self):
"""3500B/9000B → cod=04(向量#3)"""
assert list(P.image_blocks(bytes(3500), small=True, red=False))[0][0] == 0x04
assert list(P.image_blocks(bytes(9000), small=True, red=False))[0][0] == 0x04
def test_full_image_cod_03(self):
assert list(P.image_blocks(bytes(15000), small=False, red=False))[0][0] == 0x03
def test_end_frames(self):
assert P.end_frame(small=False) == bytes([0x01])
assert P.end_frame(small=True) == bytes([0xAA])
class TestSetTime:
def test_layout(self):
"""dd + unix(4B BE) + year(2B BE) + month + day + week(向量#1)
2026-07-24 15:30:00 本地(UTC+8) 周五 → unix 字段=本地墙钟按UTC计"""
dt = datetime.datetime(2026, 7, 24, 15, 30, 0)
frame = P.build_set_time(dt)
assert frame[0] == 0xDD
assert len(frame) == 10
expected_unix = int(
dt.replace(tzinfo=datetime.timezone.utc).timestamp()
) # 本地墙钟按 UTC 计
assert frame[1:5] == expected_unix.to_bytes(4, "big")
assert frame[5:7] == (2026).to_bytes(2, "big")
assert frame[7] == 7
assert frame[8] == 24
assert frame[9] == 5 # 周五,周一=1
class TestCommandFrames:
def test_parking(self):
"""停车牌 "12" → EF1122(向量#4)"""
assert P.parking_frame("12") == bytes([0xEF, 0x11, 0x22])
assert P.parking_frame("13800138000") == bytes(
[0xEF] + [int(f"{d}{d}", 16) for d in "13800138000"]
)
def test_rotation_interval(self):
"""轮播 5 分钟 → e9010501;5 小时 → e9010500(向量#5)"""
assert P.rotation_interval_frame(5, "min") == bytes.fromhex("e9010501")
assert P.rotation_interval_frame(5, "hour") == bytes.fromhex("e9010500")
def test_slot_select(self):
"""槽位3(索引2) → EA02(向量#6)"""
assert P.slot_select_frame(2) == bytes([0xEA, 0x02])
with pytest.raises(ValueError):
P.slot_select_frame(4)
def test_rotation_count(self):
"""轮播数量4 → EA0003(向量#6)"""
assert P.rotation_count_frame(4) == bytes.fromhex("EA0003")
assert P.rotation_count_frame(1) == bytes.fromhex("EA0000")
with pytest.raises(ValueError):
P.rotation_count_frame(5)
def test_countdown_date_clock(self):
"""时钟倒计时 2026-12-31 → ff00 + '20''26' BCD + 12 + 31"""
frame = P.countdown_date_frame(datetime.date(2026, 12, 31), "clock")
assert frame == bytes([0xFF, 0x00, 0x20, 0x26, 0x12, 0x31])
def test_countdown_date_calendar(self):
frame = P.countdown_date_frame(datetime.date(2026, 1, 5), "calendar")
assert frame == bytes([0xFF, 0xFF, 0x20, 0x26, 0x01, 0x05])
def test_countdown_off(self):
assert P.countdown_off_frames() == [bytes([0xFA]), bytes([0xE2])]
def test_activation(self):
code = "A1B2" * 8 # 32 hex
assert P.activation_frame(code) == bytes([0xEF]) + bytes.fromhex(code)
def test_request_device_id(self):
"""EF 前缀区分:EFEF=取ID(向量#7)"""
assert P.request_id_frame() == bytes([0xEF, 0xEF])
def test_lut(self):
assert P.lut_frame(0x05) == bytes([0xE6, 0x05])
with pytest.raises(ValueError):
P.lut_frame(0x00)
def test_sleep(self):
assert P.sleep_frame(True, 23, 7) == bytes([0xFB, 0x01, 0x23, 0x07])
assert P.sleep_frame(False, 23, 7) == bytes([0xFB, 0x00, 0x23, 0x07])
def test_modes(self):
assert P.mode_frame("clock1") == bytes.fromhex("e10201")
assert P.mode_frame("clock3") == bytes.fromhex("e10203")
assert P.mode_frame("calendar1") == bytes.fromhex("e10103")
assert P.mode_frame("calendar3") == bytes.fromhex("e10105")
assert P.mode_frame("image0") == bytes.fromhex("e10000")
assert P.mode_frame("image3") == bytes.fromhex("e10003")
assert P.mode_frame("tricolor") == bytes.fromhex("2300")
assert P.mode_frame("mono") == bytes.fromhex("2301")
with pytest.raises(ValueError):
P.mode_frame("nope")
def test_toggles(self):
assert P.simple_command("hour_format") == bytes([0x14])
assert P.simple_command("clock_color") == bytes([0x15])
assert P.simple_command("invert") == bytes([0xE3])
assert P.simple_command("font") == bytes([0xE4])
assert P.simple_command("rotate180") == bytes([0xE5])
assert P.simple_command("commit") == bytes([0xE2])
def test_clear_screen(self):
assert P.clear_frames(0x00) == [bytes([0x00, 0x00]), bytes([0x01])]
def test_wifi(self):
assert P.wifi_frame("MyAP", "pw123", "101010100") == (
b"WIFI_SSID:MyAP;WIFI_PASS:pw123;CITY:101010100"
)