"""Stateless encoders/decoders for the Shining Mask BLE protocol. This module contains *zero* I/O and *zero* BLE dependencies, so it can be unit tested without hardware (and without ``bleak`` installed). Every ``cmd_*`` helper returns a ready-to-write, AES-encrypted 16-byte block for the command characteristic. Upload data packets (sent unencrypted to the upload characteristic) are produced by :func:`upload_packet`. Command frame layout (before encryption), padded with 0x00 to 16 bytes:: [ len ][ ASCII command name ][ args ... ][ 0x00 padding ... ] ^-- len = len(name) + len(args) Notable divergences from mask-go (the field-tested Go reference), all chosen to match the *real app traffic* documented in ble-protocol.md: * ``IMAG`` / ``ANIM`` use ``len = 5`` (name 4 + arg 1). mask-go hard-codes 6, which also works only because the extra byte lands in zero padding. * Background color command is ``BC`` (as captured from the app), not mask-go's ``BG`` typo. * Notification tokens are matched by *prefix*. (The doc *labels* the upload acks ``DATOK``/``REOKOK``, but its own hex decodes to ``DATSOK``/``REOKOK``, matching mask-go's observed ``DATSOK``/``REOK``. We match the real wire tokens ``DATSOK`` and ``REOK``.) """ from __future__ import annotations from typing import Optional import crypto from constants import FRAME_SIZE, UPLOAD_MAX_DATA, UploadKind # --------------------------------------------------------------------------- # Command frame construction # --------------------------------------------------------------------------- def build_frame(name: str, args: bytes = b"") -> bytes: """Build a single *unencrypted* 16-byte command frame. ``len`` byte = number of (command name + argument) bytes, per the protocol spec. The remainder is zero padding (the mask uses AES-ECB, so padding is just zeros -- there is no IV/nonce). """ name_b = name.encode("ascii") args = bytes(args) body = bytes([len(name_b) + len(args)]) + name_b + args if len(body) > FRAME_SIZE: raise ValueError( f"command {name!r} with {len(args)} arg bytes exceeds {FRAME_SIZE}-byte frame" ) return body.ljust(FRAME_SIZE, b"\x00") def encode_command(name: str, args: bytes = b"") -> bytes: """Build and AES-encrypt a command frame for the command characteristic.""" return crypto.encrypt(build_frame(name, args)) def _u8(value: int, what: str) -> int: if not 0 <= value <= 255: raise ValueError(f"{what} must be 0..255, got {value}") return value # --------------------------------------------------------------------------- # Control commands (all return encrypted 16-byte blocks) # --------------------------------------------------------------------------- def cmd_brightness(level: int) -> bytes: """LIGHT: set brightness 0..255 (the doc recommends keeping it <= ~100).""" return encode_command("LIGHT", bytes([_u8(level, "brightness")])) def cmd_image(image_id: int) -> bytes: """IMAG: show a built-in image by id.""" return encode_command("IMAG", bytes([_u8(image_id, "image id")])) def cmd_animation(anim_id: int) -> bytes: """ANIM: play a built-in animation by id.""" return encode_command("ANIM", bytes([_u8(anim_id, "animation id")])) def cmd_play_diy(image_id: int) -> bytes: """PLAY: show a single uploaded DIY image (count=1).""" return encode_command("PLAY", bytes([1, _u8(image_id, "DIY image id")])) def cmd_play_diy_sequence(image_ids: "list[int]") -> bytes: """PLAY: play several uploaded DIY images in order (max 10).""" if not 1 <= len(image_ids) <= 10: raise ValueError("PLAY accepts between 1 and 10 DIY image ids") ids = bytes(_u8(i, "DIY image id") for i in image_ids) return encode_command("PLAY", bytes([len(image_ids)]) + ids) def cmd_delete_diy(image_ids: "list[int]") -> bytes: """DELE: delete the given DIY images from the mask (max 10).""" if not 1 <= len(image_ids) <= 10: raise ValueError("DELE accepts between 1 and 10 DIY image ids") ids = bytes(_u8(i, "DIY image id") for i in image_ids) return encode_command("DELE", bytes([len(image_ids)]) + ids) def cmd_check_diy_count() -> bytes: """CHEC: ask how many DIY images are stored (answer arrives on notify).""" return encode_command("CHEC") def cmd_text_mode(mode: int) -> bytes: """MODE: scroll/animation mode for displayed text (see constants.TextMode).""" return encode_command("MODE", bytes([_u8(mode, "text mode")])) def cmd_text_speed(speed: int) -> bytes: """SPEED: text scroll speed 0..255.""" return encode_command("SPEED", bytes([_u8(speed, "text speed")])) def cmd_text_color_effect(mode: int, enable: bool = True) -> bytes: """M: special text color effect / background image (see ble-protocol.md).""" return encode_command("M", bytes([1 if enable else 0, _u8(mode, "color effect")])) def cmd_text_fg_color(r: int, g: int, b: int, enable: bool = True) -> bytes: """FC: foreground (text) color in RGB.""" return encode_command( "FC", bytes([1 if enable else 0, _u8(r, "r"), _u8(g, "g"), _u8(b, "b")]) ) def cmd_text_bg_color(r: int, g: int, b: int, enable: bool = True) -> bytes: """BC: background color in RGB (set to black to disable M's background images).""" return encode_command( "BC", bytes([1 if enable else 0, _u8(r, "r"), _u8(g, "g"), _u8(b, "b")]) ) # --------------------------------------------------------------------------- # Upload commands # --------------------------------------------------------------------------- def cmd_dats(total_len: int, second_field: int, kind: int) -> bytes: """DATS: announce an upload. For a text *bitmap* (kind=BITMAP), ``second_field`` is the bitmap length in bytes (so the mask knows where the appended color array starts). For a full-color *image* (kind=IMAGE), it is the destination image index. Both 16-bit fields are big-endian. """ if not 0 <= total_len <= 0xFFFF: raise ValueError("total_len out of 16-bit range") if not 0 <= second_field <= 0xFFFF: raise ValueError("second_field out of 16-bit range") args = total_len.to_bytes(2, "big") + second_field.to_bytes(2, "big") + bytes([_u8(kind, "kind")]) return encode_command("DATS", args) def cmd_datcp() -> bytes: """DATCP: finish/commit an upload. (mask-go omits the optional unix time.)""" return encode_command("DATCP") def upload_packet(packet_index: int, chunk: bytes, pad_to: "Optional[int]" = None) -> bytes: """Build one *unencrypted* upload data packet for the upload characteristic. Layout: ``[ data_len+1 ][ packet_index ][ chunk... ]`` where ``data_len`` is ``len(chunk)``. The leading length byte counts the packet-index byte plus the data bytes. ``packet_index`` starts at 0 and increments per packet. For *bitmap* (text) uploads packets are NOT padded -- this matches mask-go, and the length byte already delimits the real content. For *image* uploads ble-protocol.md specifies fixed 100-byte packets, so pass ``pad_to=100``. """ if not 0 <= packet_index <= 255: raise ValueError("packet_index must fit in one byte") if len(chunk) > UPLOAD_MAX_DATA: raise ValueError(f"chunk too large: {len(chunk)} > {UPLOAD_MAX_DATA}") packet = bytes([len(chunk) + 1, packet_index]) + bytes(chunk) if pad_to is not None and len(packet) < pad_to: packet = packet.ljust(pad_to, b"\x00") return packet def iter_upload_packets(buffer: bytes, pad_to: "Optional[int]" = None): """Yield successive :func:`upload_packet` blocks covering ``buffer``.""" index = 0 for start in range(0, len(buffer), UPLOAD_MAX_DATA): yield upload_packet(index, buffer[start:start + UPLOAD_MAX_DATA], pad_to=pad_to) index += 1 # --------------------------------------------------------------------------- # Audio visualization (optional / experimental -- per ble-protocol.md) # --------------------------------------------------------------------------- def cmd_audio_frame(mode: int, nibbles: "list[int]") -> bytes: """Build an encrypted audio-visualization frame for the audio characteristic. ``mode`` selects the visual style (0..4). ``nibbles`` is up to 28 row intensities (0x0..0xf), packed two per byte into 14 bytes. Frame layout: ``[ 0x0f ][ mode ][ 14 packed bytes ]`` = 16 bytes, then AES-encrypted. """ if len(nibbles) > 28: raise ValueError("at most 28 nibbles (14 bytes) of visualization data") rows = list(nibbles) + [0] * (28 - len(nibbles)) packed = bytes((rows[i] & 0x0F) << 4 | (rows[i + 1] & 0x0F) for i in range(0, 28, 2)) frame = bytes([0x0F, _u8(mode, "audio mode")]) + packed return crypto.encrypt(frame) # --------------------------------------------------------------------------- # Notification parsing # --------------------------------------------------------------------------- def parse_notification(data: bytes) -> str: """Decrypt a notify-characteristic payload and return its ASCII token. The first decrypted byte is the token length; the token follows. Returns an empty string if the payload can't be interpreted. """ if not data or len(data) % FRAME_SIZE != 0: return "" buf = crypto.decrypt(bytes(data)) n = buf[0] if n == 0 or n > len(buf) - 1: return "" return buf[1:1 + n].decode("ascii", "ignore") # Upload-handshake response tokens, matched by prefix. These are the real wire # tokens (the doc's "DATOK" label is a typo -- its own hex decodes to "DATSOK"). RESP_DATS_OK = "DATSOK" # ack of DATS (start of upload) RESP_REC_OK = "REOK" # ack of an upload packet (doc spells the full token "REOKOK") RESP_DATCP_OK = "DATCPOK" # ack of DATCP -> upload complete RESP_PLAY_OK = "PLAYOK" RESP_DELE_OK = "DELEOK" def matches(token: str, expected: str) -> bool: """Prefix match for notification tokens (tolerant of spelling variants).""" return token.startswith(expected)