206 lines
7.3 KiB
Python
206 lines
7.3 KiB
Python
"""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;实机 GATT 表 2026-07-28 经 bridge 诊断确认)
|
||
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 = "0000331f-0000-1000-8000-00805f9b34fb" # 活命令通道(h=57, notify+write+read)
|
||
RXTX_CHAR_UUID_LEGACY = "00001f1f-0000-1000-8000-00805f9b34fb" # h=53 仅write,本设备不执行命令
|
||
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()
|
||
|
||
|
||
def _ror2(v: int) -> int:
|
||
"""8 位循环右移 2(证据 0x07FC24C8:(t<<6)+(t>>2) & 0xFF = ror2,非左移)。"""
|
||
return ((v << 6) + (v >> 2)) & 0xFF
|
||
|
||
|
||
def keygen_code(mac: bytes) -> bytes:
|
||
"""本地激活码:code[i] = ror2(((MAC[i]<<1)&0xFF)^0xEF) % 101,i=0..5。
|
||
证据:docs/firmware-analysis.md §4.2(keygen @0x07FC24C8,MAC=BD 地址,NVDS 顺序)。
|
||
EF 成败不门禁任何固件命令——激活仅影响屏显激活页。"""
|
||
if len(mac) != 6:
|
||
raise ValueError("MAC 必须 6 字节")
|
||
return bytes(_ror2(((b << 1) & 0xFF) ^ 0xEF) % 101 for b in mac)
|
||
|
||
|
||
def recover_mac_from_device_id(device_id: str) -> bytes:
|
||
"""14hex 设备 ID → 6B MAC(NVDS 字节序)。
|
||
ID = %02X(|MAC5-0x99|,|MAC4-0x99|,|MAC3-0x99|,|MAC2-0x99|,|MAC1-0x99|,|MAC0-0x99|,flags),
|
||
flags bit i = MAC[i] ≥ 0x9A。
|
||
证据:实机 2026-07-28 ID '81233F3C267112' ↔ 广播 18:BC:5A:5D:BF:28(校正
|
||
firmware-analysis §4.2 中前两个参数的顺序,固件 snprintf 实参序为 MAC5,MAC4)。"""
|
||
if len(device_id) != 14:
|
||
raise ValueError("设备 ID 必须 14 个 hex 字符")
|
||
try:
|
||
vals = [int(device_id[i:i + 2], 16) for i in range(0, 14, 2)]
|
||
except ValueError:
|
||
raise ValueError("设备 ID 含非 hex 字符") from None
|
||
*v, flags = vals
|
||
# v 顺序:MAC5,MAC4,MAC3,MAC2,MAC1,MAC0 → 反映射
|
||
order = [5, 4, 3, 2, 1, 0]
|
||
mac = [0] * 6
|
||
for vi, idx in zip(v, order):
|
||
if flags & (1 << idx):
|
||
mac[idx] = 0x99 + vi
|
||
else:
|
||
mac[idx] = 0x99 - vi
|
||
if not 0 <= mac[idx] <= 0xFF:
|
||
raise ValueError("设备 ID 无法反解(掩码与差值矛盾)")
|
||
return bytes(mac)
|