#!/usr/bin/env python3 """ XBLK v2 pattern library serializer for xblink. Converts pattern definitions to the XBLK v2 binary format (GPIO-direct PWM, 16-byte pattern entries) and writes them to NTAG5 EEPROM blocks 256+ (upper 1K) via ntag5sensor ISO15693 commands. Usage: # Serialize built-in patterns to binary file: python xblk_serialize.py --output patterns.bin # Write to NTAG5 via PCSC reader: python xblk_serialize.py --write # Load from JSON file: python xblk_serialize.py --json patterns.json --write """ import argparse import json import struct import sys import os # XBLK v2 format constants (must match src/pattern/mod.rs) XBLK_MAGIC = b"XBLK" XBLK_VERSION = 0x02 HEADER_SIZE = 16 PATTERN_ENTRY_SIZE = 16 MAX_PATTERNS = 61 MAX_PLAYLIST = 32 NUM_LEDS = 6 # EEPROM block offset for pattern library (upper 1K) LIBRARY_BASE_BLOCK = 256 # Waveform IDs WAVEFORM_LOOKUP = { "sine": 0, "triangle": 1, "square": 2, "heartbeat": 3, } def crc16(data: bytes) -> int: """CRC-16/CCITT-FALSE.""" crc = 0xFFFF for b in data: crc ^= b << 8 for _ in range(8): if crc & 0x8000: crc = (crc << 1) ^ 0x1021 else: crc <<= 1 crc &= 0xFFFF return crc def encode_pattern(pat: dict) -> bytes: """Encode a single pattern dict to PATTERN_ENTRY_SIZE bytes.""" waveform = WAVEFORM_LOOKUP.get(pat.get("waveform", "sine"), 0) cycle_len = pat.get("cycle_len", 125) phase = pat.get("phase", [0] * NUM_LEDS) envelope = pat.get("envelope", [255] * NUM_LEDS) repeat_count = pat.get("repeat_count", 0xFF) # Pad/truncate to NUM_LEDS phase = (phase + [0] * NUM_LEDS)[:NUM_LEDS] envelope = (envelope + [255] * NUM_LEDS)[:NUM_LEDS] buf = bytearray(PATTERN_ENTRY_SIZE) buf[0] = waveform & 0xFF buf[1] = cycle_len & 0xFF buf[2:8] = bytes(phase) buf[8:14] = bytes(envelope) buf[14] = repeat_count & 0xFF buf[15] = 0 # reserved return bytes(buf) def encode_library(config: dict) -> bytes: """Encode a full XBLK v2 library (header + patterns + playlist) to bytes.""" patterns = config.get("patterns", []) if len(patterns) == 0: raise ValueError("No patterns defined") if len(patterns) > MAX_PATTERNS: raise ValueError(f"Too many patterns: {len(patterns)} (max {MAX_PATTERNS})") budget = config.get("budget", 50) active = config.get("active", 0) % len(patterns) playlist = config.get("playlist", []) has_playlist = len(playlist) > 0 if len(playlist) > MAX_PLAYLIST: raise ValueError(f"Playlist too long: {len(playlist)} (max {MAX_PLAYLIST})") # Build header header = bytearray(HEADER_SIZE) header[0:4] = XBLK_MAGIC header[4] = XBLK_VERSION header[5] = 0x01 if has_playlist else 0x00 # flags header[6] = len(patterns) header[7] = active header[8] = budget & 0xFF header[9] = len(playlist) & 0xFF # bytes 10-13 reserved # Compute CRC over header bytes 0-13 crc = crc16(bytes(header[:14])) struct.pack_into(">H", header, 14, crc) # Build pattern data pat_data = b"" for pat in patterns: pat_data += encode_pattern(pat) # Build playlist data playlist_data = bytes(playlist + [0] * (MAX_PLAYLIST - len(playlist))) result = bytes(header) + pat_data if has_playlist: result += playlist_data[:MAX_PLAYLIST] return result # --------------------------------------------------------------------------- # Built-in patterns (matching src/pattern/mod.rs) # --------------------------------------------------------------------------- def builtin_patterns() -> dict: """Return the 5 built-in patterns as a config dict.""" return { "budget": 50, "active": 0, "patterns": [ { "name": "breathe", "waveform": "sine", "cycle_len": 125, # 2.5s "phase": [0, 0, 0, 0, 0, 0], "envelope": [255, 255, 255, 255, 255, 255], "repeat_count": 0xFF, }, { "name": "heartbeat", "waveform": "heartbeat", "cycle_len": 80, # 1.6s "phase": [0, 0, 0, 0, 0, 0], "envelope": [255, 255, 255, 255, 255, 255], "repeat_count": 0xFF, }, { "name": "wave_chase", "waveform": "sine", "cycle_len": 100, # 2s "phase": [0, 43, 85, 128, 170, 213], "envelope": [255, 255, 255, 255, 255, 255], "repeat_count": 0xFF, }, { "name": "slow_pulse", "waveform": "triangle", "cycle_len": 250, # 5s "phase": [0, 0, 0, 0, 0, 0], "envelope": [200, 200, 200, 200, 200, 200], "repeat_count": 0xFF, }, { "name": "alternating_blink", "waveform": "square", "cycle_len": 50, # 1s "phase": [0, 128, 0, 128, 0, 128], "envelope": [255, 255, 255, 255, 255, 255], "repeat_count": 0xFF, }, ], } def write_to_ntag5(data: bytes): """Write binary data to NTAG5 EEPROM blocks 256+ via ntag5sensor.""" ntag5sensor_path = os.path.join(os.path.dirname(__file__), "..", "..", "ntag5sensor") sys.path.insert(0, ntag5sensor_path) from vicinity.iso15693 import ISO15693, ISO_FLAG_DATA_RATE from vicinity.ntag5link import Ntag5Link reader = ISO15693() chip = Ntag5Link(reader) print(f"Writing {len(data)} bytes to EEPROM blocks {LIBRARY_BASE_BLOCK}-{LIBRARY_BASE_BLOCK + len(data)//4 - 1}") for i in range(0, len(data), 4): block = LIBRARY_BASE_BLOCK + i // 4 chunk = data[i:i+4] if len(chunk) < 4: chunk = chunk + b'\x00' * (4 - len(chunk)) flags = ISO_FLAG_DATA_RATE | 0x08 cmd = bytes([flags, 0x21]) + struct.pack("H', data, 14)[0]:04X}") if args.dump: for i in range(0, len(data), 16): hex_str = " ".join(f"{b:02X}" for b in data[i:i+16]) ascii_str = "".join(chr(b) if 32 <= b < 127 else "." for b in data[i:i+16]) print(f" {i:04X}: {hex_str:<48s} {ascii_str}") if args.output: with open(args.output, "wb") as f: f.write(data) print(f"Written to {args.output}") if args.write: write_to_ntag5(data) if __name__ == "__main__": main()