Files
xblink/docs/plans/2026-03-06-gpio-led-pivot.md
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

6.2 KiB

GPIO-Direct LED Pivot

Date: 2026-03-06 Status: Design complete, implementation pending

Why

The LP5562 LED driver IC requires VDD >= 2.7V. Our NTAG5Link NFC energy harvesting antenna produces only 1.8V. No workaround exists -- a boost converter would exceed the power budget. We're replacing the LP5562 with SAMD21E GPIO-direct PWM driving 6 low-Vf red LEDs through current-limiting resistors.

What Changes

  • Remove: LP5562 from BOM, src/led/lp5562.rs driver, LP5562 engine opcode pattern format
  • Add: TCC hardware PWM driver (src/led/pwm.rs), software pattern engine, XBLK v2 EEPROM format
  • Keep: NTAG5Link driver, SRAM mailbox protocol, EEPROM storage, EIC wake, hall sensor design

Hardware

LEDs: 6x Kingbright APTD1608 (0603, red, Vf ~1.7V at 1mA). 0.1V headroom at 1.8V supply. Arranged in a line.

PWM outputs: TCC0 (4 channels) + TCC1 (2 channels) = 6 independent PWM channels.

Pin assignments (SAMD21E, TCC-capable pins):

LED TCC Channel SAMD21E Pin XIAO Pin
0 TCC0 WO[0] PA04 A1
1 TCC0 WO[1] PA05 A2
2 TCC0 WO[2] PA06 A3
3 TCC0 WO[3] PA07 A4*
4 TCC1 WO[0] PA10 D2
5 TCC1 WO[1] PA11 D3

*PA06/PA07 conflict with I2C (A4/A5) on XIAO M0. Dev board testing uses 4 LEDs on non-conflicting pins. Custom PCB (SAMD21E) has no conflict.

Resistors: 10-47 ohm series per LED. At 1.8V supply, Vf=1.7V: I = 0.1V/47ohm = ~2mA (safe). Lower resistance = brighter but more current. Governor handles the budget.

Power Governor

The NTAG5 drops out entirely (hard power loss) when current budget is exceeded. The governor prevents this by running every ISR tick before writing TCC registers:

raw[6] = pattern engine computes brightness 0-255 per LED
total = sum(raw[0..6])
if total > BUDGET:
    scale = BUDGET * 256 / total   // fixed-point
    for i in 0..6:
        raw[i] = raw[i] * scale / 256
write raw values to TCC CC registers

BUDGET: Stored in XBLK v2 header (1 byte, units of ~0.1mA steps). Default ~50 (5mA total LED budget). Configurable via companion app or SRAM mailbox command.

Phase splitting is the key optimization: stagger LED phase offsets so peaks don't align. A 6-LED sine wave with 60-degree phase offsets has roughly constant total brightness, maximizing perceived brightness within the budget.

Software Pattern Engine

Replaces LP5562's hardware execution engines. Runs in TC4 ISR at 50Hz.

PatternState (per-pattern, loaded from EEPROM):

struct PatternState {
    waveform: Waveform,     // sine, triangle, square, heartbeat
    cycle_len: u8,          // ticks per full cycle (1-255 = 20ms-5.1s)
    phase: [u8; 6],         // phase offset per LED (0-255 = 0-360 degrees)
    envelope: [u8; 6],      // max brightness per LED (governor input)
    tick: u16,              // current animation tick (runtime, not stored)
}

Waveform lookup tables (const, in flash):

  • SINE_LUT[256]: 8-bit sine quarter-wave, mirrored at runtime
  • TRIANGLE_LUT[256]: linear ramp up/down
  • HEARTBEAT_LUT[256]: double-pulse cardiac shape

ISR flow (~40us at 8MHz):

  1. Increment tick, wrap at cycle_len * 256
  2. For each LED: phase_pos = (tick + phase[i] * cycle_len) % (cycle_len * 256)
  3. Look up waveform[phase_pos], scale by envelope[i]
  4. Apply governor scaling
  5. Write 6 TCC CC registers

MCU sleep: IDLE mode (not STANDBY) during animation. CPU halts between interrupts, peripherals (TCC, TC4) keep running. ~0.5mA at 1.8V/8MHz. CPU active only ~40us per 20ms tick = 0.2% duty cycle.

XBLK v2 EEPROM Format

Storage region: Last 1KB of NTAG5 EEPROM (blocks 0x100-0x1FF), beyond normal NFC/NDEF access.

Header (16 bytes)

Offset  Size  Field
0x00    4     magic          "XBLK"
0x04    1     version        0x02
0x05    1     flags          bit 0: has_playlist
0x06    1     pattern_count  number of pattern entries
0x07    1     active_index   pattern to load on boot
0x08    1     budget         power governor budget
0x09    1     playlist_count number of playlist entries (0 if no playlist)
0x0A    4     reserved
0x0E    2     crc16          over bytes 0x00-0x0D

Pattern Entry (16 bytes each, immediately after header)

Offset  Size  Field
0x00    1     waveform       (0=sine, 1=triangle, 2=square, 3=heartbeat)
0x01    1     cycle_len      (in 50Hz ticks, 1-255 = 20ms-5.1s)
0x02    6     phase[6]       (0-255 phase offset per LED, maps to 0-360 degrees)
0x08    6     envelope[6]    (max brightness per LED, 0-255)
0x0E    1     repeat_count   (times to play before advancing playlist, 0xFF=forever)
0x0F    1     reserved

Playlist Table (after all pattern entries, if flags bit 0 set)

Each playlist entry is 1 byte = pattern index. Sequence plays in order, loops back to start. Max 32 entries.

Example: patterns [breathe=0, chase=1, flash=2] with playlist [1, 2, 2, 0] plays: chase, flash, flash, breathe, chase, ...

Capacity: 1024B - 16B header - 32B playlist = 976B for patterns. At 16B each: 61 patterns.

Sleep and Power-on Behavior

Three MCU states:

  1. STANDBY (~2uA) -- No animation. Wakes on FD pin (EIC) for SRAM commands or hall sensor tap.
  2. ANIMATING (~0.5mA MCU + LED current) -- IDLE sleep, TC4 ISR at 50Hz updates TCC duty cycles.
  3. COMMAND PROCESSING -- Fully awake, handling SRAM mailbox. Returns to ANIMATING or STANDBY.

Power-on sequence:

  1. NTAG5 EH powers up, VOUT rises, SAMD21 boots
  2. Read EEPROM header -- if valid XBLK v2, load pattern at active_index
  3. If playlist exists, start playlist from entry 0
  4. Configure TC4 (50Hz), TCC0/TCC1 (PWM), start animation
  5. Enter IDLE sleep (animation runs via ISR)
  6. On FD interrupt, wake fully, process SRAM command, resume

Playlist advancement: When repeat_count cycles complete for current pattern, ISR loads next playlist entry. If playlist wraps, loop from start. Hall sensor tap: advance to next playlist entry (or next pattern if no playlist).

Pattern cycling (no playlist): Hall sensor increments active_index, loads next pattern. Wrapping past last pattern goes to STANDBY (LEDs off).