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>
248 lines
7.4 KiB
Python
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()
|