"""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,本设备不执行命令 FIRMWARE_REVISION_UUID = "00002a26-0000-1000-8000-00805f9b34fb" DEVICE_NAME_PREFIX = "NRF-" BLOCK_PAYLOAD = 240 # my_step=480 hex 字符 = 240 数据字节 COMMIT = 0xE2 def select_rxtx_uuid(firmware_revision: str | None) -> str: """按固件家族选择控制特征。 PP 4.2 实机的 1F1F 虽可写但不执行控制命令,活通道是 331F; HM42_AIO 实机则与 Web-BLE / 厂商 App 一致,仅执行 1F1F。 未知或读不到 DIS 版本时保留既有 331F 行为,避免回归已验证的 PP 设备。 """ revision = (firmware_revision or "").strip().upper() if revision.startswith("HM42_AIO"): return RXTX_CHAR_UUID_LEGACY return RXTX_CHAR_UUID def requires_complete_image_planes(firmware_revision: str | None) -> bool: """Whether a nominally mono full-screen upload still needs both planes. The HM42 Web-BLE implementation always sends the 400x300 black/white plane followed by a red plane before opcode 01. Omitting the second plane leaves HM42 rendering stale/uninitialised red data (observed as a red noisy screen). """ revision = (firmware_revision or "").strip().upper() return revision.startswith("HM42_AIO") def uses_hm42_image_session(firmware_revision: str | None) -> bool: """Whether image transfer needs the newer HM42 EPD session framing. The vendor Android provider ``轻舞/时光`` (``d8.e``) wraps both image planes with ``8001, 0000, 020000`` and closes with ``0101`` followed by RXTX ``e2`` for a normal (non-calendar) image. The older Web-BLE path has no such preamble and ends with the legacy one-byte EPD frame. """ revision = (firmware_revision or "").strip().upper() return revision.startswith("HM42_AIO") def hm42_image_begin_frames() -> tuple[bytes, ...]: """Vendor-app EPD image-session preamble for HM42_AIO firmware.""" return (bytes.fromhex("8001"), bytes.fromhex("0000"), bytes.fromhex("020000")) def hm42_image_finish_frame() -> bytes: """Vendor-app EPD image-session terminator for HM42_AIO firmware.""" return bytes.fromhex("0101") 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)