Files
xblink/docs/LP5562_ENGINE_REFERENCE.md
michael acb48902be Add pattern library, NTAG5 config check, NDEF writer (M3+M4)
M3: 5 autonomous LP5562 engine patterns (breathe, heartbeat, slow_pulse,
rgb_cycle, color_wash) with dual LED mode support (RGBW/Mono3) and
2mA/ch current for EH-realistic power budget.

M4: NTAG5Link I2C slave driver with session register reads, EEPROM
write-verify, and NDEF Type 5 text record output. Config check validates
CONFIG_0, CONFIG_1, EH_CONFIG against expected values and writes result
as NFC-readable text. Verified on hardware: config check passes, NDEF
readable via ISO15693 from phone.

Key findings from hardware testing:
- SRAM passthrough arbiter blocks NFC reads while I2C bus is active
  (resolves naturally when MCU enters STANDBY — noted in M7 plan)
- NTAG5 must be in I2C slave mode (not master) for MCU communication
- EH config mask set to 0x00 (skip check during external power testing)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:03:31 -08:00

28 KiB
Raw Blame History

LP5562 Engine Programming Reference

Reference for the React Native companion app pattern editor. Covers the LP5562's 16-bit command encoding, timing math, and program construction rules. Everything here is verified against the LP5562 datasheet (SNVS820B, Rev Dec 2016) and tested on hardware with the LP5562EVM.

Hardware Overview

The LP5562 has 3 independent execution engines and 4 LED channels (R, G, B, W). Each engine:

  • Has its own SRAM holding up to 16 commands (16-bit words, 32 bytes)
  • Runs autonomously on the LP5562's internal 32.768 kHz oscillator
  • Can be mapped to drive any combination of LED channels
  • Continues running even when the host MCU is sleeping

The W channel cannot be engine-driven — it is always I2C-direct.

LED Channel Mapping

Each of the 4 channels (B, G, R, W) can be independently assigned to:

Mapping Value Meaning
I2c 0x00 Host sets PWM directly via I2C register write
Engine1 0x01 Engine 1 controls this channel's PWM
Engine2 0x02 Engine 2 controls this channel's PWM
Engine3 0x03 Engine 3 controls this channel's PWM

Written to the LED_MAP register (0x70). Each channel gets 2 bits.

Single-engine patterns (breathe, heartbeat, pulse): map B, G, R all to the same engine — they breathe in unison. W stays I2C-direct.

Multi-engine patterns (rgb_cycle, color_wash): map each color channel to a different engine — each color animates independently.


Command Encoding

All engine commands are 16-bit words stored in SRAM as big-endian (MSB at lower address). The top 1-3 bits determine the command type.

Command Type Identification

Bits 15:13 Type Description
0xx Ramp/Wait Bit 15 = 0. Ramp or wait.
0xx Set PWM Special case: step_time = 0.
000 Go to Start All zeros = reset PC to 0.
101 Branch Jump to step with loop count.
110 End Stop engine, optional interrupt.
111 Trigger Inter-engine synchronization.

1. Ramp/Wait Command

The most important command. Controls smooth PWM transitions and timed waits.

Bit:  15   14      13  12  11  10   9   8     7      6   5   4   3   2   1   0
       0   pre-    ├──── step_time ────┤    sign    ├──── increment ────────────┤
           scale          (6 bits)                          (7 bits)
Field Bits Range Description
(fixed) 15 0 Always 0 for ramp commands
prescale 14 0-1 0 = Fast (0.49ms), 1 = Slow (15.6ms)
step_time 13:8 0-63 Multiplier for prescale period. 0 = Set PWM command.
sign 7 0-1 0 = ramp up (increase PWM), 1 = ramp down (decrease PWM)
increment 6:0 0-127 Number of steps - 1. 0 = pure wait (no PWM change).

Encoding formula:

word = (prescale << 14) | (step_time << 8) | (sign << 7) | increment

Timing Math

Each ramp/wait command executes (increment + 1) steps. Each step takes:

step_duration = step_time × prescale_period

Where:

Prescale Period Source
Fast (0) 0.49 ms 32768 Hz / 16 = 2048 Hz
Slow (1) 15.6 ms 32768 Hz / 512 = 64 Hz

Total command duration:

command_duration = (increment + 1) × step_time × prescale_period

Maximum single-command duration: 128 steps × 63 × 15.6ms = 125.2 seconds.

Minimum step time (fastest visible change): 1 × 0.49ms = 0.49ms.

Critical: The Increment Field

The increment field does NOT mean "PWM units per step." It means "total number of steps minus one."

Each step always changes PWM by exactly 1 unit. The increment field controls how many of those 1-unit steps occur.

increment Actual steps PWM change Notes
0 0 (wait) 0 Pure wait, no PWM change
1 2 2 Almost no visible change
4 5 5 Datasheet example
63 64 64 Quarter of full range
127 128 128 Maximum: half of 0-255

A single ramp command can change PWM by at most 128 units (half the full range). To ramp from 0 to 255, you need two consecutive ramp commands.

When PWM reaches the boundary (0 or 255), the command continues executing remaining steps but PWM stays clamped at the boundary.

Full-Range Ramp Pattern

To ramp 0→255 smoothly:

ramp_wait(Slow, 1, Up, 127)    // 128 steps: PWM 0→128,   ~2.0s
ramp_wait(Slow, 1, Up, 127)    // 128 steps: PWM 128→255, ~2.0s

To ramp 255→0 smoothly:

ramp_wait(Slow, 1, Down, 127)  // 128 steps: PWM 255→127, ~2.0s
ramp_wait(Slow, 1, Down, 127)  // 128 steps: PWM 127→0,   ~2.0s

Total: 256 smooth 1-unit steps in ~4.0s. Uses 2 of 16 command slots per direction.

Pure Wait (Timed Delay)

When increment = 0, the command becomes a wait (no PWM change):

word = (prescale << 14) | (step_time << 8) | 0x00

Wait duration = step_time × prescale_period. Examples:

Prescale step_time Duration
Fast 20 9.8 ms
Fast 40 19.6 ms
Slow 32 499 ms
Slow 48 749 ms
Slow 63 983 ms

For waits longer than ~1s, chain multiple wait commands.

2. Set PWM Command

Instantly sets PWM to an absolute value. This is the ramp command with step_time = 0.

Bit:  15  14  13  12  11  10   9   8   7   6   5   4   3   2   1   0
       0   1   0   0   0   0   0   0  ├──────── PWM value ────────────┤
                                                  (8 bits, 0-255)

Encoding: word = 0x4000 | pwm_value

PWM value Word Effect
0 0x4000 LED off
128 0x4080 Half brightness
255 0x40FF Full brightness

Executes in 16 clock cycles (~488µs). Use for instant on/off in heartbeat-style patterns.

3. Go to Start

Resets program counter to 0. All-zero word.

word = 0x0000

Note: default value for all SRAM slots, so unused slots act as Go-to-Start.

4. Branch Command

Jump to a step number, with optional loop count.

Bit:  15  14  13  12  11  10   9   8   7   6   5   4   3   2   1   0
       1   0   1  ├──── loop_count ──────────┤   x   x  ├─ step_num ─┤
                        (6 bits, 0-63)                     (4 bits)
Field Bits Range Description
(fixed) 15:13 101 Branch command identifier
loop_count 12:7 0-63 0 = infinite loop, 1-63 = finite count
(don't care) 6:4 x Ignored by hardware
step_number 3:0 0-15 Target instruction address

Encoding: word = 0xA000 | (loop_count << 7) | step_number

Infinite loop to start: branch(0, 0)0xA000. This is the standard way to make a pattern repeat forever.

Nested loops are supported. The hardware maintains loop counters independently per nesting level.

5. End Command

Stops engine execution, optionally fires interrupt and/or resets PWM.

Bit:  15  14  13  12   11   10  ...  0
       1   1   0  int  reset   x ... x
Field Bit Description
int 12 1 = fire interrupt (sets STATUS register bit)
reset 11 1 = reset PWM to 0, 0 = keep current PWM value

Encoding: word = 0xC000 | (int << 12) | (reset << 11)

After End, the engine returns to Hold state and PC resets to 0. Not typically used in looping patterns (use Branch instead).

6. Trigger Command

Synchronize execution between engines. One engine can send a trigger that another engine waits for.

Bit:  15  14  13  12  11  10    9    8    7   6   5    4    3    2    1   0
       1   1   1   x   x   x  ├─ wait ──┤   x   x   x  ├── send ──────┤  x
                               E3  E2  E1                  E3   E2   E1
Field Bits Description
wait_engines 10:8 Bitmask: which engines to wait for before continuing
send_engines 3:1 Bitmask: which engines to notify

Bitmask mapping: bit 0 = Engine1, bit 1 = Engine2, bit 2 = Engine3.

Encoding: word = 0xE000 | (wait_engines << 8) | (send_engines << 1)

A single trigger command can both wait AND send. The engine blocks until all wait conditions are met, then sends its trigger signals.


Timing Reference Table

Pre-computed durations for common configurations.

Ramp durations (full 0-255 sweep = two commands)

Prescale step_time Steps per cmd Duration per cmd Full 0→255
Slow 1 128 2.0 s 4.0 s
Slow 2 128 4.0 s 8.0 s
Slow 4 128 8.0 s 16.0 s
Slow 8 128 15.9 s 31.9 s
Fast 1 128 62.7 ms 125 ms
Fast 4 128 251 ms 502 ms
Fast 10 128 627 ms 1.25 s

Wait durations

Prescale step_time Duration
Fast 1 0.49 ms
Fast 63 30.9 ms
Slow 1 15.6 ms
Slow 32 499 ms
Slow 63 983 ms

Maximum durations

Type Value
Single ramp cmd 128 × 63 × 15.6ms = 125s
Single wait cmd 63 × 15.6ms = 0.98s
Full 0→255 ramp 2 × 125s = 250s (max)

Program Construction Rules

Hard Limits

Constraint Value Notes
Commands per engine 16 16 × 2 bytes = 32 bytes SRAM per engine
Engines 3 Engine 1, 2, 3
Max increment per ramp 127 = 128 PWM steps = half of 0-255 range
Max step_time 63 6-bit field
Max loop_count (branch) 63 0 = infinite
Max step_number (branch target) 15 4-bit field
PWM range 0-255 8-bit, linear or logarithmic scale

Design Guidelines

  1. Always use increment=127 for smooth ramps. Lower values make the ramp shorter (fewer steps), not smoother.

  2. Two ramp commands per direction for full-range sweeps. One command only covers 128 of the 255 PWM units.

  3. End every looping program with branch(0, 0) — infinite loop back to step 0.

  4. Budget command slots carefully. A full breathe cycle needs: 2 (ramp up) + 2 (ramp down) + 1 (wait) + 1 (branch) = 6 slots out of 16.

  5. Use Set PWM for instant transitions. Ramp commands always take time; set_pwm is near-instant (~488µs).

  6. Chain waits for long pauses. Max single wait is ~1s (Slow, 63). For longer pauses, use two consecutive waits.

  7. Triggers are blocking. A wait-for-trigger command blocks the engine until the trigger is received. Design trigger chains to avoid deadlocks.

  8. Unused SRAM slots default to Go-to-Start (0x0000). If a branch jumps past the last command, execution will hit a go-to-start and loop from the beginning.

Pattern Switching

When switching between patterns, the LP5562 must be fully reset to avoid residual PWM state:

  1. Write 0xFF to Reset register (0x0D) — software reset, all registers return to defaults
  2. Wait 1ms
  3. Re-enable the chip (write to Enable register)
  4. Wait 500µs (startup delay)
  5. Reconfigure: clock source, LED current, LED mapping
  6. Load and run new engine programs

Without reset, engines that previously held a non-zero PWM value will keep that brightness visible during the new pattern's loading phase.


Reference Patterns

Five verified patterns with their engine programs and timing.

1. Breathe

Single engine, all RGB channels in unison. Smooth triangle wave.

Property Value
Engines used 1
Commands 6 / 16
Cycle time ~8.75s
LED mapping B,G,R → E1; W → I2C
Step  Command                              Hex     Duration  PWM range
─────────────────────────────────────────────────────────────────────────
0     ramp_wait(Slow, 1, Up, 127)          0x417F  2.0s      0 → 128
1     ramp_wait(Slow, 1, Up, 127)          0x417F  2.0s      128 → 255
2     ramp_wait(Slow, 1, Down, 127)        0x41FF  2.0s      255 → 127
3     ramp_wait(Slow, 1, Down, 127)        0x41FF  2.0s      127 → 0
4     wait(Slow, 48)                       0x7000  0.75s     (hold at 0)
5     branch(0, 0)                         0xA000  instant   (loop)

2. Heartbeat

Single engine, double-pulse with long rest. Mimics cardiac rhythm.

Property Value
Engines used 1
Commands 10 / 16
Cycle time ~1.56s
LED mapping B,G,R → E1; W → I2C
Step  Command                              Hex     Duration  Effect
─────────────────────────────────────────────────────────────────────────
0     set_pwm(255)                         0x40FF  instant   First beat ON
1     wait(Fast, 20)                       0x0A00  9.8ms     Hold bright
2     set_pwm(0)                           0x4000  instant   First beat OFF
3     wait(Fast, 40)                       0x1400  19.6ms    Inter-beat gap
4     set_pwm(255)                         0x40FF  instant   Second beat ON
5     wait(Fast, 20)                       0x0A00  9.8ms     Hold bright
6     set_pwm(0)                           0x4000  instant   Second beat OFF
7     wait(Slow, 63)                       0x5F00  983ms     Long rest pt.1
8     wait(Slow, 32)                       0x6000  499ms     Long rest pt.2
9     branch(0, 0)                         0xA000  instant   Loop forever

3. Slow Pulse

Single engine, very gentle ramp. Meditative pace.

Property Value
Engines used 1
Commands 7 / 16
Cycle time ~33.5s
LED mapping B,G,R → E1; W → I2C
Step  Command                              Hex     Duration  PWM range
─────────────────────────────────────────────────────────────────────────
0     ramp_wait(Slow, 4, Up, 127)          0x447F  8.0s      0 → 128
1     ramp_wait(Slow, 4, Up, 127)          0x447F  8.0s      128 → 255
2     wait(Slow, 32)                       0x6000  499ms     Hold at peak
3     ramp_wait(Slow, 4, Down, 127)        0x44FF  8.0s      255 → 127
4     ramp_wait(Slow, 4, Down, 127)        0x44FF  8.0s      127 → 0
5     wait(Slow, 63)                       0x5F00  983ms     Pause at bottom
6     branch(0, 0)                         0xA000  instant   Loop forever

4. RGB Cycle

Three engines, trigger-synchronized phase offset. Colors appear sequentially.

Property Value
Engines used 3
Commands 7 + 8 + 7 = 22 / 48
Cycle time ~9s per engine
LED mapping B → E1; G → E2; R → E3; W → I2C

Trigger chain: E1 ramps up, sends trigger → E2 starts, ramps up, sends trigger → E3 starts. Creates a staggered color sequence.

Engine 1 (Blue):

Step  Command                              Hex     Duration
─────────────────────────────────────────────────────────────
0     ramp_wait(Slow, 1, Up, 127)          0x417F  2.0s
1     ramp_wait(Slow, 1, Up, 127)          0x417F  2.0s
2     trigger(send=E2)                     0xE004  instant
3     ramp_wait(Slow, 1, Down, 127)        0x41FF  2.0s
4     ramp_wait(Slow, 1, Down, 127)        0x41FF  2.0s
5     wait(Slow, 63)                       0x5F00  983ms
6     branch(0, 0)                         0xA000  instant

Engine 2 (Green):

Step  Command                              Hex     Duration
─────────────────────────────────────────────────────────────
0     trigger(wait=E1)                     0xE100  blocks
1     ramp_wait(Slow, 1, Up, 127)          0x417F  2.0s
2     ramp_wait(Slow, 1, Up, 127)          0x417F  2.0s
3     trigger(send=E3)                     0xE008  instant
4     ramp_wait(Slow, 1, Down, 127)        0x41FF  2.0s
5     ramp_wait(Slow, 1, Down, 127)        0x41FF  2.0s
6     wait(Slow, 32)                       0x6000  499ms
7     branch(0, 0)                         0xA000  instant

Engine 3 (Red):

Step  Command                              Hex     Duration
─────────────────────────────────────────────────────────────
0     trigger(wait=E2)                     0xE200  blocks
1     ramp_wait(Slow, 1, Up, 127)          0x417F  2.0s
2     ramp_wait(Slow, 1, Up, 127)          0x417F  2.0s
3     ramp_wait(Slow, 1, Down, 127)        0x41FF  2.0s
4     ramp_wait(Slow, 1, Down, 127)        0x41FF  2.0s
5     wait(Slow, 32)                       0x6000  499ms
6     branch(0, 0)                         0xA000  instant

5. Color Wash

Three engines, free-running at different rates. Colors drift in and out of phase, creating evolving blended hues.

Property Value
Engines used 3
Commands 5 + 6 + 6 = 17 / 48
Cycle time E1: ~8.0s, E2: ~8.5s, E3: ~9.0s
LED mapping B → E1; G → E2; R → E3; W → I2C

Phase drift mechanism: Each engine has a slightly different cycle length (due to different pause durations at the bottom of the ramp). Over time, the engines desynchronize, causing the color mix to continuously evolve. No triggers needed.

Engine 1 (Blue) — 8.0s cycle, no pause:

Step  Command                              Hex     Duration
─────────────────────────────────────────────────────────────
0     ramp_wait(Slow, 1, Up, 127)          0x417F  2.0s
1     ramp_wait(Slow, 1, Up, 127)          0x417F  2.0s
2     ramp_wait(Slow, 1, Down, 127)        0x41FF  2.0s
3     ramp_wait(Slow, 1, Down, 127)        0x41FF  2.0s
4     branch(0, 0)                         0xA000  instant

Engine 2 (Green) — 8.5s cycle, 0.5s pause:

Step  Command                              Hex     Duration
─────────────────────────────────────────────────────────────
0     ramp_wait(Slow, 1, Up, 127)          0x417F  2.0s
1     ramp_wait(Slow, 1, Up, 127)          0x417F  2.0s
2     ramp_wait(Slow, 1, Down, 127)        0x41FF  2.0s
3     ramp_wait(Slow, 1, Down, 127)        0x41FF  2.0s
4     wait(Slow, 32)                       0x6000  499ms
5     branch(0, 0)                         0xA000  instant

Engine 3 (Red) — 9.0s cycle, 1.0s pause:

Step  Command                              Hex     Duration
─────────────────────────────────────────────────────────────
0     ramp_wait(Slow, 1, Up, 127)          0x417F  2.0s
1     ramp_wait(Slow, 1, Up, 127)          0x417F  2.0s
2     ramp_wait(Slow, 1, Down, 127)        0x41FF  2.0s
3     ramp_wait(Slow, 1, Down, 127)        0x41FF  2.0s
4     wait(Slow, 63)                       0x5F00  983ms
5     branch(0, 0)                         0xA000  instant

App Pattern Editor: Implementation Notes

Binary Representation

A pattern sent from the app to the implant (via NFC SRAM mailbox) needs:

  1. Per-engine program: Array of up to 16 u16 values (big-endian)
  2. LED channel mapping: Which engine drives which channel (4 × 2-bit values)
  3. LED current: Per-channel current setting (0-255 maps to 0-25.5mA)

Minimum data per pattern: 3 engines × 32 bytes + 1 byte mapping + 4 bytes current = 101 bytes. Fits easily in the NTAG5Link's 256-byte SRAM mailbox.

Encoding Commands in JavaScript/TypeScript

// Prescale constants
const PRESCALE_FAST = 0;  // 0.49ms per step
const PRESCALE_SLOW = 1;  // 15.6ms per step

// Ramp/Wait: smooth PWM transition or timed delay
function rampWait(
  prescale: 0 | 1,
  stepTime: number,   // 1-63
  direction: 'up' | 'down',
  increment: number   // 0-127 (0 = wait only)
): number {
  const ps = (prescale & 1) << 14;
  const st = (stepTime & 0x3F) << 8;
  const sign = direction === 'down' ? (1 << 7) : 0;
  const inc = increment & 0x7F;
  return ps | st | sign | inc;
}

// Pure wait (no PWM change)
function wait(prescale: 0 | 1, stepTime: number): number {
  return rampWait(prescale, stepTime, 'up', 0);
}

// Set PWM to absolute value (instant)
function setPwm(value: number): number {
  return 0x4000 | (value & 0xFF);
}

// Branch (jump to step, with loop count)
function branch(loopCount: number, stepNumber: number): number {
  return 0xA000 | ((loopCount & 0x3F) << 7) | (stepNumber & 0x0F);
}

// End program
function end(interrupt: boolean, resetPwm: boolean): number {
  return 0xC000 | (interrupt ? (1 << 12) : 0) | (resetPwm ? (1 << 11) : 0);
}

// Trigger (inter-engine sync)
// engineBits: bit0=E1, bit1=E2, bit2=E3
function trigger(waitEngines: number, sendEngines: number): number {
  return 0xE000 | ((waitEngines & 0x07) << 8) | ((sendEngines & 0x07) << 1);
}

Duration Calculator

const PRESCALE_PERIOD = [0.49, 15.6]; // ms per step for Fast, Slow

function rampDurationMs(
  prescale: 0 | 1,
  stepTime: number,
  increment: number
): number {
  if (increment === 0) {
    // Wait command: single step
    return stepTime * PRESCALE_PERIOD[prescale];
  }
  const steps = increment + 1;
  return steps * stepTime * PRESCALE_PERIOD[prescale];
}

// Full 0→255 ramp using two commands
function fullRampDurationMs(prescale: 0 | 1, stepTime: number): number {
  return 2 * rampDurationMs(prescale, stepTime, 127);
}

Pattern Validation

Before sending a pattern to the implant, validate:

function validateProgram(commands: number[]): string | null {
  if (commands.length > 16) return 'Max 16 commands per engine';
  if (commands.length === 0) return 'Program cannot be empty';

  // Check for infinite loop or end command
  const hasLoop = commands.some((cmd, i) => {
    const isGoToStart = cmd === 0x0000;
    const isBranch = (cmd >> 13) === 0b101;
    const isEnd = (cmd >> 13) === 0b110;
    return isGoToStart || isBranch || isEnd;
  });
  if (!hasLoop) return 'Program must contain a branch, end, or go-to-start';

  return null; // valid
}

Preview Animation

To show a real-time preview in the app, simulate engine execution:

function simulateEngine(commands: number[]): Generator<number> {
  // Returns PWM values over time, one per step
  // See timing math above for step duration
  // Key: each ramp step changes PWM by exactly 1 unit
  // Key: PWM clamps at 0 and 255 boundaries
}

The simulation should model:

  • Current PWM value (starts at 0 after reset)
  • Ramp direction and step count
  • Boundary clamping (0 and 255)
  • Branch targets and loop counts
  • Set PWM instant jumps
  • Wait pauses (no PWM change)

For multi-engine patterns with triggers, simulate all three engines and synchronize on trigger send/wait pairs.


Pitfalls and Lessons Learned

increment=1 does NOT mean "smooth"

The most common mistake. increment=1 means 2 total steps — the ramp finishes almost instantly. For smooth ramps, always use increment=127 (128 steps, the maximum). This is counterintuitive because "increment of 1" sounds like "change by 1 per step," but that's not what the field means. Every step always changes by 1; the field controls how many steps occur.

step_time=0 is Set PWM, not a fast ramp

The ramp/wait command and set_pwm command share the same encoding space. When step_time=0, the hardware interprets bits 7:0 as an absolute PWM value (8 bits) instead of sign + increment (1 + 7 bits). Never pass step_time=0 to a ramp function — use the dedicated set_pwm function instead.

Software reset is essential between patterns

Without a full software reset (0xFF to register 0x0D), residual PWM values from the previous pattern remain visible on LED channels. Toggling the EN pin puts the chip in standby but does NOT clear register state (VDD stays up, no POR). Always use software reset when switching patterns.

Engine load order matters

When loading multiple engines, each call to run_engine does a read-modify-write on the shared OP_MODE and ENABLE registers. The LP5562 requires:

  • = 153µs between consecutive OP_MODE register writes

  • = 488µs between consecutive ENABLE register writes

In practice, the I2C transaction overhead (~50-100µs per transfer at 400kHz) plus program data write (~660µs for 33 bytes) provides sufficient delay. A 200µs explicit delay between engine loads adds safety margin.

Trigger deadlocks

If Engine A waits for a trigger from Engine B, but Engine B also waits for a trigger from Engine A, both will block forever. Design trigger chains as DAGs — one engine starts freely, triggers the next, which triggers the next. The last engine loops back independently.

W channel is always I2C-direct

The LP5562's White channel cannot be mapped to an execution engine. It can only be controlled via direct I2C PWM register writes. For autonomous operation, treat W as static (set once at load time) or ignore it.

LED current and power budget

For energy-harvested operation, total LED current must stay within the NFC field's power output (~12.5mA max). With 4 channels at full brightness (25.5mA each), you'd draw 102mA — far beyond what NFC can provide.

Current firmware uses 2mA per channel (register value 20, since each unit = 0.1mA). The app should enforce a maximum total current budget and warn users when pattern brightness exceeds what the implant can sustain.