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>
This commit is contained in:
michael
2026-03-07 10:44:12 -08:00
parent f437e4e281
commit 5cd728c298
11 changed files with 1459 additions and 1099 deletions

166
tools/provision_eh.py Normal file
View File

@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""
Configure NTAG5Link energy harvesting for 1.8V automatic VOUT.
Writes EH_CONFIG to persistent EEPROM (block 0x3D) so the NTAG5
automatically outputs 1.8V when an NFC field is present — powering
the SAMD21 MCU without any firmware intervention.
Also sets ED_CONFIG (FD pin) for NFC-to-I2C SRAM pass-through so
the MCU can detect SRAM writes from the phone.
Usage:
# Read current EH config:
python provision_eh.py --read
# Write 1.8V / 6.5mA EH config:
python provision_eh.py --write
# Write with custom current limit:
python provision_eh.py --write --current 4.0
# Also configure CONFIG_0 and CONFIG_1 for xblink:
python provision_eh.py --write --full
"""
import argparse
import sys
import os
# Add ntag5sensor to path
ntag5sensor_path = os.path.join(os.path.dirname(__file__), "..", "..", "ntag5sensor")
sys.path.insert(0, ntag5sensor_path)
from reader.acr1552 import ACR1552
from vicinity.iso15693 import ISO15693
from vicinity.ntag5link import (
Ntag5Link,
NXP_EH_CONFIG_EH_VOUT_V_SEL_1_8,
NXP_EH_CONFIG_EH_VOUT_V_SEL_2_4,
NXP_EH_CONFIG_EH_VOUT_V_SEL_3_0,
NXP_EH_CONFIG_EH_VOUT_I_SEL_0_4,
NXP_EH_CONFIG_EH_VOUT_I_SEL_0_6,
NXP_EH_CONFIG_EH_VOUT_I_SEL_1_4,
NXP_EH_CONFIG_EH_VOUT_I_SEL_2_7,
NXP_EH_CONFIG_EH_VOUT_I_SEL_4_0,
NXP_EH_CONFIG_EH_VOUT_I_SEL_6_5,
NXP_EH_CONFIG_EH_VOUT_I_SEL_9_0,
NXP_EH_CONFIG_EH_VOUT_I_SEL_12_5,
NXP_ED_CONFIG_NFC_TO_I2C_PASS_THROUGH,
NXP_CONFIG_0_EH_MODE_LOW_FIELD_STRENGTH,
NXP_CONFIG_1_ARBITER_MODE_SRAM_PASSTHROUGH,
NXP_CONFIG_1_USE_CASE_CONF_I2C_SLAVE,
)
# Current limit lookup: string -> constant
CURRENT_MAP = {
"0.4": NXP_EH_CONFIG_EH_VOUT_I_SEL_0_4,
"0.6": NXP_EH_CONFIG_EH_VOUT_I_SEL_0_6,
"1.4": NXP_EH_CONFIG_EH_VOUT_I_SEL_1_4,
"2.7": NXP_EH_CONFIG_EH_VOUT_I_SEL_2_7,
"4.0": NXP_EH_CONFIG_EH_VOUT_I_SEL_4_0,
"6.5": NXP_EH_CONFIG_EH_VOUT_I_SEL_6_5,
"9.0": NXP_EH_CONFIG_EH_VOUT_I_SEL_9_0,
"12.5": NXP_EH_CONFIG_EH_VOUT_I_SEL_12_5,
}
def read_config(chip):
"""Read and display current EH and general config."""
print("=== NTAG5 Configuration ===\n")
info = chip.get_system_info()
print(f"UID: {info['uid'].hex()}")
config = chip.get_config_info()
print(f"\nCONFIG_0:")
print(f" EH mode: {config.get('energy_harvesting_mode', '?')}")
print(f" SRAM copy: {config.get('sram_copy_enabled', '?')}")
print(f" Auto standby: {config.get('auto_standby_mode', '?')}")
print(f"\nCONFIG_1:")
print(f" SRAM enable: {config.get('sram_enabled', '?')}")
print(f" Arbiter mode: {config.get('arbiter_mode', '?')}")
print(f" Use case: {config.get('use_case', '?')}")
print(f" EH arbiter: {config.get('eh_arbiter_mode_enabled', '?')}")
eh = chip.get_eh_ed_config_info()
print(f"\nEH_CONFIG (block 0x3D):")
print(f" EH enable: {eh.get('eh_enable', '?')}")
print(f" VOUT voltage: {eh.get('eh_vout_v_sel', '?')}V")
print(f" VOUT current: {eh.get('eh_vout_i_sel', '?')}mA")
print(f" Power check disabled: {eh.get('disable_power_check', '?')}")
print(f" ED/FD config: {eh.get('ed_config', '?')}")
def write_eh(chip, current_sel, full_config=False):
"""Write EH config for 1.8V automatic come-up."""
print("Writing EH config: 1.8V, current limit = "
f"{[k for k,v in CURRENT_MAP.items() if v == current_sel][0]}mA")
print(f" ED/FD pin: NFC-to-I2C pass-through (SRAM write detect)")
chip.write_eh_ed_config(
enable=True,
disable_power_check=False,
current=current_sel,
voltage=NXP_EH_CONFIG_EH_VOUT_V_SEL_1_8,
ed_config=NXP_ED_CONFIG_NFC_TO_I2C_PASS_THROUGH,
)
print(" EH_CONFIG written.")
if full_config:
print("\nWriting CONFIG_0: EH mode = low field strength")
chip.write_config0(
eh_mode=NXP_CONFIG_0_EH_MODE_LOW_FIELD_STRENGTH,
)
print(" CONFIG_0 written.")
print("Writing CONFIG_1: SRAM enable, arbiter=passthrough, use_case=I2C slave")
chip.write_config1(
sram_enable=True,
arbiter_mode=NXP_CONFIG_1_ARBITER_MODE_SRAM_PASSTHROUGH,
use_case=NXP_CONFIG_1_USE_CASE_CONF_I2C_SLAVE,
)
print(" CONFIG_1 written.")
# Verify
print("\n--- Verify ---")
read_config(chip)
def main():
parser = argparse.ArgumentParser(
description="Configure NTAG5Link energy harvesting for 1.8V")
parser.add_argument("--read", action="store_true",
help="Read current config (no writes)")
parser.add_argument("--write", action="store_true",
help="Write EH config for 1.8V automatic VOUT")
parser.add_argument("--current", default="6.5",
choices=list(CURRENT_MAP.keys()),
help="VOUT current limit in mA (default: 6.5)")
parser.add_argument("--full", action="store_true",
help="Also write CONFIG_0 and CONFIG_1 for xblink")
args = parser.parse_args()
if not args.read and not args.write:
parser.print_help()
sys.exit(1)
reader = ACR1552()
reader.connect()
iso = ISO15693(reader)
chip = Ntag5Link(iso)
if args.read:
read_config(chip)
if args.write:
current_sel = CURRENT_MAP[args.current]
write_eh(chip, current_sel, full_config=args.full)
reader.disconnect()
if __name__ == "__main__":
main()

View File

@@ -1,9 +1,10 @@
#!/usr/bin/env python3
"""
XBLK pattern library serializer for xblink.
XBLK v2 pattern library serializer for xblink.
Converts pattern definitions to the XBLK binary format and writes them
to NTAG5 EEPROM blocks 256+ (upper 1K) via ntag5sensor ISO15693 commands.
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:
@@ -22,27 +23,23 @@ import struct
import sys
import os
# XBLK format constants (must match src/pattern/mod.rs)
# XBLK v2 format constants (must match src/pattern/mod.rs)
XBLK_MAGIC = b"XBLK"
XBLK_VERSION = 0x01
XBLK_VERSION = 0x02
HEADER_SIZE = 16
PATTERN_ENTRY_SIZE = 112
MAX_PATTERNS = 9
MAX_COMMANDS_PER_ENGINE = 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
# LED_MAP encoding helpers
LED_MAP_LOOKUP = {
"direct": 0b00, "i2c": 0b00,
"engine1": 0b01, "e1": 0b01,
"engine2": 0b10, "e2": 0b10,
"engine3": 0b11, "e3": 0b11,
# Waveform IDs
WAVEFORM_LOOKUP = {
"sine": 0, "triangle": 1, "square": 2, "heartbeat": 3,
}
LED_MODE_LOOKUP = {"rgbw": 0x00, "mono3": 0x01}
def crc16(data: bytes) -> int:
"""CRC-16/CCITT-FALSE."""
@@ -58,215 +55,131 @@ def crc16(data: bytes) -> int:
return crc
def encode_led_map(mapping: dict) -> int:
"""Encode {"b": "engine1", "g": "engine1", ...} to LP5562 LED_MAP register byte."""
b = LED_MAP_LOOKUP.get(mapping.get("b", "direct"), 0)
g = LED_MAP_LOOKUP.get(mapping.get("g", "direct"), 0)
r = LED_MAP_LOOKUP.get(mapping.get("r", "direct"), 0)
w = LED_MAP_LOOKUP.get(mapping.get("w", "direct"), 0)
return b | (g << 2) | (r << 4) | (w << 6)
def encode_pattern(pat: dict) -> bytes:
"""Encode a single pattern dict to PATTERN_ENTRY_SIZE bytes."""
engines = pat.get("engines", [[], [], []])
while len(engines) < 3:
engines.append([])
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)
engine_count = sum(1 for e in engines if len(e) > 0)
led_map_reg = encode_led_map(pat.get("led_map", {}))
direct_pwm = pat.get("direct_pwm", [0, 0, 0, 0])
while len(direct_pwm) < 4:
direct_pwm.append(0)
# 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] = engine_count
buf[1] = led_map_reg
buf[2:6] = bytes(direct_pwm[:4])
for eng_idx, cmds in enumerate(engines[:3]):
if len(cmds) > MAX_COMMANDS_PER_ENGINE:
raise ValueError(f"Engine {eng_idx+1} has {len(cmds)} commands (max {MAX_COMMANDS_PER_ENGINE})")
base = 6 + eng_idx * 34 # 2 bytes count + 32 bytes commands
struct.pack_into(">H", buf, base, len(cmds))
for i, cmd in enumerate(cmds):
struct.pack_into(">H", buf, base + 2 + i * 2, cmd & 0xFFFF)
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 library (header + patterns) to 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})")
current = config.get("current", 20)
mode = LED_MODE_LOOKUP.get(config.get("mode", "rgbw"), 0x00)
budget = config.get("budget", 50)
active = config.get("active", 0) % len(patterns)
playlist = config.get("playlist", [])
has_playlist = len(playlist) > 0
# Build header (without CRC)
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] = len(patterns)
header[6] = active
header[7] = current & 0xFF
header[8] = mode
# bytes 9-13 reserved
# bytes 14-15 CRC (filled below)
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)
# Compute CRC over header (bytes 0-13) + all pattern data
crc = crc16(bytes(header[:14]) + pat_data)
struct.pack_into(">H", header, 14, crc)
# Build playlist data
playlist_data = bytes(playlist + [0] * (MAX_PLAYLIST - len(playlist)))
return bytes(header) + pat_data
result = bytes(header) + pat_data
if has_playlist:
result += playlist_data[:MAX_PLAYLIST]
return result
# ---------------------------------------------------------------------------
# Built-in patterns (matching src/pattern/mod.rs)
# ---------------------------------------------------------------------------
# LP5562 EngineCommand helpers (matching lp5562.rs encoding)
def ramp_wait(prescale_slow: bool, step_time: int, up: bool, increment: int) -> int:
prescale_bit = 0x4000 if prescale_slow else 0
sign_bit = 0 if up else 0x0080
return prescale_bit | ((step_time & 0x3F) << 8) | sign_bit | (increment & 0x7F)
def wait(prescale_slow: bool, step_time: int) -> int:
prescale_bit = 0x4000 if prescale_slow else 0
return prescale_bit | ((step_time & 0x3F) << 8)
def set_pwm(value: int) -> int:
return 0x4000 | value # Actually: 0x40xx format
# Wait, let me check the actual encoding...
def branch(step: int, loop_count: int) -> int:
return 0xA000 | ((loop_count & 0x3F) << 7) | (step & 0x7F)
def trigger(wait_mask: int, send_mask: int) -> int:
return 0xE000 | ((wait_mask & 0x07) << 7) | (send_mask & 0x07)
def builtin_patterns() -> dict:
"""Return the 5 built-in patterns as a config dict."""
# Breathe: 1 engine, all RGB channels
breathe_cmds = [
ramp_wait(True, 1, True, 127), # 0→128
ramp_wait(True, 1, True, 127), # 128→255
ramp_wait(True, 1, False, 127), # 255→127
ramp_wait(True, 1, False, 127), # 127→0
wait(True, 48), # pause
branch(0, 0), # loop
]
# Heartbeat: 1 engine, double-pulse
heartbeat_cmds = [
0x40FF, # set_pwm(255)
wait(False, 20), # hold ~10ms
0x4000, # set_pwm(0)
wait(False, 40), # gap ~20ms
0x40FF, # set_pwm(255)
wait(False, 20), # hold ~10ms
0x4000, # set_pwm(0)
wait(True, 63), # rest ~1.0s
wait(True, 32), # rest ~0.5s
branch(0, 0),
]
# Slow pulse: 1 engine, very gentle
slow_pulse_cmds = [
ramp_wait(True, 4, True, 127), # 0→128
ramp_wait(True, 4, True, 127), # 128→255
wait(True, 32), # hold
ramp_wait(True, 4, False, 127), # 255→127
ramp_wait(True, 4, False, 127), # 127→0
wait(True, 63), # pause
branch(0, 0),
]
# RGB cycle: 3 engines with trigger sync
rgb_e1 = [
ramp_wait(True, 1, True, 127),
ramp_wait(True, 1, True, 127),
trigger(0, 0b010), # send to E2
ramp_wait(True, 1, False, 127),
ramp_wait(True, 1, False, 127),
wait(True, 63),
branch(0, 0),
]
rgb_e2 = [
trigger(0b001, 0), # wait for E1
ramp_wait(True, 1, True, 127),
ramp_wait(True, 1, True, 127),
trigger(0, 0b100), # send to E3
ramp_wait(True, 1, False, 127),
ramp_wait(True, 1, False, 127),
wait(True, 32),
branch(0, 0),
]
rgb_e3 = [
trigger(0b010, 0), # wait for E2
ramp_wait(True, 1, True, 127),
ramp_wait(True, 1, True, 127),
ramp_wait(True, 1, False, 127),
ramp_wait(True, 1, False, 127),
wait(True, 32),
branch(0, 0),
]
# Color wash: 3 engines, free-running with different periods
wash_e1 = [
ramp_wait(True, 1, True, 127),
ramp_wait(True, 1, True, 127),
ramp_wait(True, 1, False, 127),
ramp_wait(True, 1, False, 127),
branch(0, 0),
]
wash_e2 = [
ramp_wait(True, 1, True, 127),
ramp_wait(True, 1, True, 127),
ramp_wait(True, 1, False, 127),
ramp_wait(True, 1, False, 127),
wait(True, 32),
branch(0, 0),
]
wash_e3 = [
ramp_wait(True, 1, True, 127),
ramp_wait(True, 1, True, 127),
ramp_wait(True, 1, False, 127),
ramp_wait(True, 1, False, 127),
wait(True, 63),
branch(0, 0),
]
single_rgb_map = {"b": "engine1", "g": "engine1", "r": "engine1", "w": "direct"}
triple_map = {"b": "engine1", "g": "engine2", "r": "engine3", "w": "direct"}
return {
"current": 20,
"mode": "rgbw",
"budget": 50,
"active": 0,
"patterns": [
{"name": "breathe", "led_map": single_rgb_map, "engines": [breathe_cmds, [], []]},
{"name": "heartbeat", "led_map": single_rgb_map, "engines": [heartbeat_cmds, [], []]},
{"name": "slow_pulse", "led_map": single_rgb_map, "engines": [slow_pulse_cmds, [], []]},
{"name": "rgb_cycle", "led_map": triple_map, "engines": [rgb_e1, rgb_e2, rgb_e3]},
{"name": "color_wash", "led_map": triple_map, "engines": [wash_e1, wash_e2, wash_e3]},
{
"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."""
# Add ntag5sensor to path
ntag5sensor_path = os.path.join(os.path.dirname(__file__), "..", "..", "ntag5sensor")
sys.path.insert(0, ntag5sensor_path)
@@ -278,20 +191,16 @@ def write_to_ntag5(data: bytes):
print(f"Writing {len(data)} bytes to EEPROM blocks {LIBRARY_BASE_BLOCK}-{LIBRARY_BASE_BLOCK + len(data)//4 - 1}")
# Write in 4-byte blocks
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))
# Use ISO15693 WRITE SINGLE BLOCK (unaddressed)
# Block address needs protocol extension for blocks > 255
flags = ISO_FLAG_DATA_RATE | 0x08 # data rate + protocol extension
flags = ISO_FLAG_DATA_RATE | 0x08
cmd = bytes([flags, 0x21]) + struct.pack("<H", block) + chunk
reader.transmit_iso15693(cmd, True)
# EEPROM write cycle delay
import time
time.sleep(0.006)
@@ -302,11 +211,10 @@ def write_to_ntag5(data: bytes):
def main():
parser = argparse.ArgumentParser(description="XBLK pattern library serializer")
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("--builtin", action="store_true", help="Use built-in patterns (default if no --json)")
parser.add_argument("--dump", action="store_true", help="Hex dump the binary")
args = parser.parse_args()