Files
xblink/tools/xblk_serialize.py
michael 5cd728c298 Pivot from LP5562 to GPIO-direct PWM LEDs, add NTAG5 EH provisioning
LP5562 requires 2.7V min but NFC energy harvesting produces only 1.8V.
New architecture: SAMD21 drives 6 red LEDs directly via TCC0/TCC1
hardware PWM through current-limiting resistors.

Key changes:
- Add TCC PWM driver (src/led/pwm.rs) for 6 GPIO-direct LED channels
- Rewrite pattern engine: software waveform LUTs (sine/triangle/square/
  heartbeat) replace LP5562 hardware execution engines
- Add XBLK v2 EEPROM format with 16-byte pattern entries + playlist
- Add TC4 50Hz ISR for animation, power governor for current budget
- Add NTAG5 EH provisioning: persistent config + session trigger
- Critical finding: SRAM passthrough in persistent EEPROM blocks all
  NFC access when MCU unpowered — CONFIG_1 must be session-only
- Add provision_eh.py PCSC tool for ACR1552 reader
- Update CLAUDE.md and STATUS.md for new architecture

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 10:44:12 -08:00

248 lines
7.4 KiB
Python

#!/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", block) + chunk
reader.transmit_iso15693(cmd, True)
import time
time.sleep(0.006)
if (i // 4) % 10 == 0:
print(f" Block {block}...")
print("Done!")
def main():
parser = argparse.ArgumentParser(description="XBLK v2 pattern library serializer")
parser.add_argument("--json", help="JSON pattern definition file")
parser.add_argument("--output", "-o", help="Output binary file")
parser.add_argument("--write", action="store_true", help="Write to NTAG5 via PCSC")
parser.add_argument("--dump", action="store_true", help="Hex dump the binary")
args = parser.parse_args()
if args.json:
with open(args.json) as f:
config = json.load(f)
else:
config = builtin_patterns()
data = encode_library(config)
print(f"Library: {len(config['patterns'])} patterns, {len(data)} bytes")
print(f"CRC-16: 0x{struct.unpack_from('>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()