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>
This commit is contained in:
michael
2026-03-05 15:03:31 -08:00
parent e7bc8834b2
commit acb48902be
6 changed files with 1232 additions and 114 deletions

View File

@@ -179,6 +179,7 @@ Typical 3-engine pattern: 16 + 3×34 + 4 = **122 bytes** (fits easily in 2048B E
3. On EIC interrupt (FD pin), wake, process SRAM mailbox, return to sleep
4. Verify LP5562 keeps running patterns during MCU sleep
5. Measure current with multimeter: active vs standby vs total system
6. Verify I2C bus releases cleanly on STANDBY entry (SDA/SCL float high via pull-ups, no bus hold). With SRAM passthrough arbiter, an active I2C bus blocks NFC-side reads — confirmed during M4 testing.
**Power budget reference**:

View File

@@ -0,0 +1,675 @@
# 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
```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
```typescript
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:
```typescript
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:
```typescript
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.

View File

@@ -1,6 +1,6 @@
# xblink Project Status
**Current Milestone**: M3Hardcoded Pattern Library
**Current Milestone**: M5Boot-from-EEPROM
**Last Updated**: 2026-03-05
---
@@ -34,24 +34,26 @@
- [x] Verify LP5562 runs patterns autonomously (MCU idle loop)
- [x] Verify MCU can stop and switch patterns (8s cycle between all 3)
### M3: Hardcoded Pattern Library
### M3: Hardcoded Pattern Library (COMPLETE)
- [ ] Define 4-5 const patterns in firmware
- [ ] Implement pattern selector (compile-time or boot-cycle)
- [ ] Reduce LED current to 2-5mA/ch (EH-realistic budget)
- [ ] Document actual engine program structures (informs M5 format design)
- [x] Define 5 const patterns in firmware (breathe, heartbeat, slow_pulse, rgb_cycle, color_wash)
- [x] Implement pattern selector (cycle through patterns, 15s each)
- [x] Reduce LED current to 2mA/ch (EH-realistic budget)
- [x] Document engine program structures (`docs/lp5562-engine-reference.md`)
## Group B — Add NTAG5Link
**Hardware needed**: + NTAG5Link Click board + I2C jumper
### M4: NTAG5 EEPROM Read/Write
### M4: NTAG5 EEPROM Read/Write (COMPLETE)
- [ ] Write `Ntag5Link<I2C>` driver (`src/ntag5/mod.rs`)
- [ ] Implement register map (`src/ntag5/registers.rs`)
- [ ] Implement EEPROM block read/write
- [ ] Implement session register access
- [ ] Test round-trip: PCSC writes EEPROM, MCU reads back, displays on LEDs
- [x] Write `Ntag5Link<I2C>` driver (`src/ntag5/mod.rs`)
- [x] Implement session register access (CONFIG_0, CONFIG_1, EH_CONFIG)
- [x] Implement EEPROM block read/write with write-verify
- [x] Config check: read session regs, compare against expected, write NDEF result
- [x] NDEF Type 5 text record writer for config check output
- [x] Verified on hardware: config check passes, NDEF readable via NFC
- [ ] Register map module (`src/ntag5/registers.rs`) — deferred, constants in mod.rs for now
### M5: Boot-from-EEPROM

View File

@@ -15,8 +15,10 @@ use hal::prelude::*;
use pac::{CorePeripherals, Peripherals};
mod led;
mod ntag5;
mod pattern;
use led::lp5562::{ClockSource, Lp5562, DEFAULT_ADDRESS};
use ntag5::Ntag5Link;
use pattern::LedMode;
/// Blink the on-board LED n times (active-low: low=on, high=off)
@@ -69,45 +71,85 @@ fn main() -> ! {
let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS);
// Try to initialize LP5562 — blink error pattern on failure
// 2 mA per channel (20 × 0.1 mA) — realistic for EH power budget
const LED_CURRENT: u8 = 20;
// Verify LP5562 is reachable before entering pattern loop
let i2c_ok = (|| -> Result<(), led::lp5562::Error<_>> {
lp.enable()?;
delay.delay_ms(1u32); // >500us startup
lp.init_direct_control(ClockSource::Internal)?;
lp.set_all_current(50, 50, 50, 50)?;
lp.set_all_current(LED_CURRENT, LED_CURRENT, LED_CURRENT, LED_CURRENT)?;
Ok(())
})();
match i2c_ok {
Ok(()) => {
// === DIAGNOSTIC: solid LED on = LP5562 init OK ===
// === STATUS: solid LED on = LP5562 init OK ===
led.set_low().unwrap();
delay.delay_ms(500u32);
led.set_high().unwrap();
delay.delay_ms(1000u32); // 1s gap between status signals
// LED mode: Rgbw for EVM RGB LED, Mono3 for 3 separate LEDs
let mode = LedMode::Rgbw;
// Cycle through patterns, 8 seconds each
let patterns = [
pattern::breathe(mode),
pattern::heartbeat(mode),
pattern::slow_pulse(mode),
pattern::rgb_cycle(mode),
pattern::color_wash(mode),
];
let mut idx = 0;
loop {
// Blink pattern index (1, 2, or 3) to indicate which pattern
blink(&mut led, &mut delay, (idx + 1) as u8, 150);
// Load first pattern — LP5562 engines run autonomously
let _ = pattern::load_pattern(&mut lp, &patterns[0], LED_CURRENT, &mut delay);
// Load pattern — LP5562 runs autonomously
if pattern::load_pattern(&mut lp, &patterns[idx], &mut delay).is_err() {
// Release I2C from LP5562 (engines keep running) for NTAG5 check
let i2c = lp.release();
let mut ntag = Ntag5Link::new(i2c, ntag5::DEFAULT_ADDRESS);
// NTAG5 config check — read session registers, write NDEF result
// If NTAG5 isn't connected, this will fail silently (I2C NAK)
match ntag.check_and_write_ndef(&mut delay) {
Ok(true) => {
// Config OK — 2 short blinks
blink(&mut led, &mut delay, 2, 100);
}
Ok(false) => {
// Config mismatch — 5 fast blinks (warning)
blink(&mut led, &mut delay, 5, 60);
}
Err(ntag5::Error::VerifyFailed) => {
// EEPROM write-verify failed — SOS pattern (3 rapid, pause, 3 rapid)
blink(&mut led, &mut delay, 3, 40);
delay.delay_ms(300u32);
blink(&mut led, &mut delay, 3, 40);
}
Err(ntag5::Error::I2c(_)) => {
// NTAG5 not reachable — 1 long blink (not fatal)
led.set_low().unwrap();
delay.delay_ms(800u32);
led.set_high().unwrap();
}
}
// Reclaim I2C for LP5562
let i2c = ntag.release();
let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS);
// Pattern cycle loop — LP5562 engines run autonomously, no MCU LED indication
let mut idx = 1; // Already loaded pattern 0 above
loop {
if pattern::load_pattern(
&mut lp, &patterns[idx], LED_CURRENT, &mut delay,
).is_err() {
// I2C error loading pattern — fast blink
blink(&mut led, &mut delay, 10, 50);
}
// MCU idles while LP5562 runs the pattern
delay.delay_ms(8000u32);
delay.delay_ms(15000u32);
idx = (idx + 1) % patterns.len();
}

314
src/ntag5/mod.rs Normal file
View File

@@ -0,0 +1,314 @@
/// NTAG5Link I2C slave driver for NTP53x2.
///
/// Provides session register reads, EEPROM block read/write,
/// and NDEF Type 5 text record writing.
///
/// I2C protocol (Section 8.3.1.4 of NTP53x2 datasheet):
/// - READ MEMORY: write [BL_AD1, BL_AD0], read N bytes
/// - WRITE MEMORY: write [BL_AD1, BL_AD0, D0, D1, D2, D3]
/// - READ REGISTER: write [BL_AD1, BL_AD0, REGA], read 1 byte
/// - WRITE REGISTER: write [BL_AD1, BL_AD0, REGA, MASK, REGDATA]
use embedded_hal::i2c::I2c;
pub const DEFAULT_ADDRESS: u8 = 0x54;
// Session register I2C block addresses (16-bit)
pub const SESSION_CONFIG_REG: u16 = 0x10A1; // CONFIG_0_REG, CONFIG_1_REG, CONFIG_2_REG, RFU
pub const SESSION_EH_CONFIG_REG: u16 = 0x10A7; // EH_CONFIG_REG, RFU
pub const SESSION_I2C_SLAVE_REG: u16 = 0x10A9; // I2C_SLAVE_ADDR_REG, I2C_SLAVE_CONFIG_REG
// EEPROM user memory I2C block addresses
// Block 0 = CC (capability container), blocks 1+ = NDEF data
pub const EEPROM_BLOCK_0: u16 = 0x0000;
// Expected config values for xblink
// CONFIG_0: EH_MODE = low field strength (bits 3:2 = 10b)
pub const EXPECTED_CONFIG_0: u8 = 0x08;
// CONFIG_1: SRAM_ENABLE (bit 1) + ARBITER_MODE passthrough (bits 3:2 = 10b) + USE_CASE I2C slave (bits 5:4 = 00b)
pub const EXPECTED_CONFIG_1: u8 = 0x0A;
// EH_CONFIG: skip for now — EH is disabled (0x00) when powered externally.
// Set to 0x75 (EH_ENABLE + 3.0V + 12.5mA) when running from energy harvesting.
pub const EXPECTED_EH_CONFIG: u8 = 0x00;
// Masks for checking — only check the bits we care about
// CONFIG_0: bits 3:2 (EH_MODE) — ignore SRAM_COPY_EN, AUTO_STANDBY, LOCK_SESSION
pub const CONFIG_0_MASK: u8 = 0x0C;
// CONFIG_1: bits 5:4 (USE_CASE) + bits 3:2 (ARBITER) + bit 1 (SRAM_EN)
pub const CONFIG_1_MASK: u8 = 0x3E;
// EH_CONFIG: mask 0x00 — don't check EH config during external power testing
pub const EH_CONFIG_MASK: u8 = 0x00;
#[derive(Debug)]
pub enum Error<E> {
I2c(E),
/// EEPROM write-verify failed: read-back didn't match written data
VerifyFailed,
}
impl<E> From<E> for Error<E> {
fn from(e: E) -> Self {
Error::I2c(e)
}
}
pub struct Ntag5Link<I2C> {
i2c: I2C,
addr: u8,
}
/// Config check result for a single register
pub struct RegCheck {
/// Register name abbreviation (e.g., "C0", "C1", "EH")
pub name: [u8; 2],
/// Actual value read
pub actual: u8,
/// Expected value (after masking)
pub expected: u8,
/// Whether it matches
pub ok: bool,
}
/// Full config check result
pub struct ConfigResult {
pub checks: [RegCheck; 3],
/// True if all checks passed
pub all_ok: bool,
}
impl<I2C, E> Ntag5Link<I2C>
where
I2C: I2c<Error = E>,
{
pub fn new(i2c: I2C, addr: u8) -> Self {
Self { i2c, addr }
}
/// Consume the driver and return the I2C bus.
pub fn release(self) -> I2C {
self.i2c
}
// ---- Low-level I2C commands ----
/// Read N bytes from a 16-bit block address (READ MEMORY command).
/// Used for EEPROM and config memory.
pub fn read_memory(&mut self, block: u16, buf: &mut [u8]) -> Result<(), E> {
let addr_bytes = block.to_be_bytes();
self.i2c.write_read(self.addr, &addr_bytes, buf)
}
/// Write 4 bytes to a 16-bit block address (WRITE MEMORY command).
/// Used for EEPROM writes. Each EEPROM block is 4 bytes.
pub fn write_memory_block(&mut self, block: u16, data: &[u8; 4]) -> Result<(), E> {
let addr_bytes = block.to_be_bytes();
let mut buf = [0u8; 6];
buf[0] = addr_bytes[0];
buf[1] = addr_bytes[1];
buf[2] = data[0];
buf[3] = data[1];
buf[4] = data[2];
buf[5] = data[3];
self.i2c.write(self.addr, &buf)
}
/// Write 4 bytes then read back and verify. Returns Error::VerifyFailed
/// if the read-back doesn't match.
pub fn write_verify_block(
&mut self,
block: u16,
data: &[u8; 4],
delay: &mut impl embedded_hal::delay::DelayNs,
) -> Result<(), Error<E>> {
self.write_memory_block(block, data)?;
delay.delay_ms(5); // EEPROM write cycle ~5ms
let mut readback = [0u8; 4];
self.read_memory(block, &mut readback)?;
if readback != *data {
return Err(Error::VerifyFailed);
}
Ok(())
}
/// Read a single session register byte (READ REGISTER command).
/// Protocol: write [BL_AD1, BL_AD0, REGA], then read 1 byte.
pub fn read_register(&mut self, block: u16, reg_addr: u8) -> Result<u8, E> {
let addr_bytes = block.to_be_bytes();
let mut buf = [0u8; 1];
self.i2c.write_read(
self.addr,
&[addr_bytes[0], addr_bytes[1], reg_addr],
&mut buf,
)?;
Ok(buf[0])
}
// ---- Config check ----
/// Read session registers and compare against expected xblink config.
/// Session registers are always readable from I2C, even if config
/// memory is password/AES protected.
pub fn check_config(&mut self) -> Result<ConfigResult, E> {
// Read CONFIG_REG session register: bytes 0,1,2 = CONFIG_0, CONFIG_1, CONFIG_2
let c0 = self.read_register(SESSION_CONFIG_REG, 0)?;
let c1 = self.read_register(SESSION_CONFIG_REG, 1)?;
// Read EH_CONFIG_REG session register: byte 0 = EH_CONFIG
let eh = self.read_register(SESSION_EH_CONFIG_REG, 0)?;
let c0_ok = (c0 & CONFIG_0_MASK) == (EXPECTED_CONFIG_0 & CONFIG_0_MASK);
let c1_ok = (c1 & CONFIG_1_MASK) == (EXPECTED_CONFIG_1 & CONFIG_1_MASK);
let eh_ok = (eh & EH_CONFIG_MASK) == (EXPECTED_EH_CONFIG & EH_CONFIG_MASK);
let all_ok = c0_ok && c1_ok && eh_ok;
Ok(ConfigResult {
checks: [
RegCheck { name: *b"C0", actual: c0, expected: EXPECTED_CONFIG_0, ok: c0_ok },
RegCheck { name: *b"C1", actual: c1, expected: EXPECTED_CONFIG_1, ok: c1_ok },
RegCheck { name: *b"EH", actual: eh, expected: EXPECTED_EH_CONFIG, ok: eh_ok },
],
all_ok,
})
}
// ---- NDEF Type 5 text record ----
/// Write an NDEF Type 5 text record to EEPROM user memory.
///
/// Layout (NFC Forum Type 5 Tag):
/// - Block 0: CC (Capability Container) — 4 bytes
/// - Block 1+: TLV wrapper + NDEF message
///
/// The text record uses "en" language code, UTF-8 encoding.
pub fn write_ndef_text(&mut self, text: &[u8], delay: &mut impl embedded_hal::delay::DelayNs) -> Result<(), Error<E>> {
// CC (block 0) is already present from factory/provisioning — don't overwrite.
// NDEF message TLV:
// [0] 03 = NDEF message TLV type
// [1] len = total NDEF message length
// NDEF record header:
// [2] D1 = MB=1, ME=1, CF=0, SR=1, IL=0, TNF=01 (well-known)
// [3] 01 = type length (1 byte: "T")
// [4] payload_len = 3 + text.len() (status byte + "en" + text)
// [5] 54 = type: "T" (text record)
// NDEF payload:
// [6] 02 = status byte: UTF-8 (bit 7=0), language code length=2
// [7] 65 = 'e'
// [8] 6E = 'n'
// [9..] = text bytes
// After message:
// FE = terminator TLV
let payload_len = 3 + text.len(); // status + "en" + text
let ndef_len = 4 + payload_len; // header(3) + type(1) + payload
let tlv_len = 2 + ndef_len; // TLV type + TLV len + NDEF message
let total_bytes = tlv_len + 1; // + terminator TLV (FE)
// Build the message into a buffer (max ~80 bytes for our use)
let mut msg = [0u8; 80];
if total_bytes > msg.len() {
// Text too long, truncate silently — shouldn't happen for our short messages
return Ok(());
}
let mut i = 0;
msg[i] = 0x03; i += 1; // NDEF TLV type
msg[i] = ndef_len as u8; i += 1; // NDEF TLV length
msg[i] = 0xD1; i += 1; // NDEF record header: MB|ME|SR, TNF=well-known
msg[i] = 0x01; i += 1; // Type length = 1
msg[i] = payload_len as u8; i += 1; // Payload length
msg[i] = b'T'; i += 1; // Type = "T" (text)
msg[i] = 0x02; i += 1; // Status: UTF-8, lang len = 2
msg[i] = b'e'; i += 1;
msg[i] = b'n'; i += 1;
for &b in text {
msg[i] = b;
i += 1;
}
msg[i] = 0xFE; i += 1; // Terminator TLV
// Write to EEPROM in 4-byte blocks starting at block 1
let mut block = 1u16;
let mut offset = 0;
while offset < i {
let mut data = [0u8; 4];
for j in 0..4 {
if offset + j < i {
data[j] = msg[offset + j];
}
}
self.write_verify_block(block, &data, delay)?;
block += 1;
offset += 4;
}
Ok(())
}
/// Format the config check result as a human-readable NDEF text record.
/// Returns the number of bytes written to `buf`.
pub fn format_config_result(result: &ConfigResult, buf: &mut [u8]) -> usize {
let mut i = 0;
// Helper: append a byte slice
fn append(buf: &mut [u8], i: &mut usize, data: &[u8]) {
for &b in data {
if *i < buf.len() {
buf[*i] = b;
*i += 1;
}
}
}
// Helper: append hex byte as 2 ASCII chars
fn hex(buf: &mut [u8], i: &mut usize, val: u8) {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
if *i + 1 < buf.len() {
buf[*i] = HEX[(val >> 4) as usize];
*i += 1;
buf[*i] = HEX[(val & 0x0F) as usize];
*i += 1;
}
}
append(buf, &mut i, b"xblink cfg:");
if result.all_ok {
append(buf, &mut i, b" OK ");
} else {
append(buf, &mut i, b" BAD ");
}
for check in &result.checks {
append(buf, &mut i, &check.name);
append(buf, &mut i, b":");
hex(buf, &mut i, check.actual);
if check.ok {
append(buf, &mut i, b"ok ");
} else {
append(buf, &mut i, b"!=");
hex(buf, &mut i, check.expected);
append(buf, &mut i, b" ");
}
}
i
}
/// Run config check and write result as NDEF text record.
/// Returns Ok(true) if config matches, Ok(false) if mismatch,
/// Err(VerifyFailed) if EEPROM write-verify failed,
/// Err(I2c(e)) if I2C communication failed.
pub fn check_and_write_ndef(&mut self, delay: &mut impl embedded_hal::delay::DelayNs) -> Result<bool, Error<E>> {
let result = self.check_config()?;
let all_ok = result.all_ok;
let mut buf = [0u8; 64];
let len = Self::format_config_result(&result, &mut buf);
self.write_ndef_text(&buf[..len], delay)?;
Ok(all_ok)
}
}

View File

@@ -34,47 +34,30 @@ pub struct Pattern {
// Breathing: smooth ramp up/down, ~2.5s cycle
// ---------------------------------------------------------------------------
/// Breathing pattern — one engine, smooth sine-like ramp.
/// Breathing pattern — one engine, smooth ramp.
///
/// Slow prescale (15.6ms/step):
/// Ramp up: step_time=1, increment=4 → 64 steps × 15.6ms ≈ 1.0s (0→252)
/// Ramp down: step_time=1, increment=4 → 64 steps × 15.6ms ≈ 1.0s (252→0)
/// Wait: step_time=32 → 32 × 15.6ms ≈ 0.5s pause at bottom
/// Total: ~2.5s per cycle
/// LP5562 increment field = number of steps - 1 (max 127 = 128 steps).
/// Each step changes PWM by 1 unit. Full 0→255 needs two ramp commands.
///
/// Slow prescale (15.6ms/step), step_time=1:
/// Ramp up: 2 × 128 steps × 15.6ms = ~4.0s (0→128→255)
/// Ramp down: 2 × 128 steps × 15.6ms = ~4.0s (255→127→0)
/// Wait: step_time=48 → 48 × 15.6ms ≈ 0.75s pause at bottom
/// Total: ~8.75s per cycle
fn breathe_program() -> EngineProgram {
EngineProgram::from_commands(&[
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 4), // 0→252, ~1s
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 4), // 252→0, ~1s
EngineCommand::wait(Prescale::Slow, 32), // ~0.5s pause
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 0→128, ~2s
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 128→255, ~2s
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 255→127, ~2s
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 127→0, ~2s
EngineCommand::wait(Prescale::Slow, 48), // ~0.75s pause
EngineCommand::branch(0, 0), // loop forever
])
}
/// Load breathing pattern. All active channels breathe in sync.
pub fn breathe(mode: LedMode) -> Pattern {
let prog = breathe_program();
match mode {
LedMode::Rgbw => Pattern {
engine1: Some(prog),
engine2: None,
engine3: None,
// Engine1 drives R, G, B; W = I2C direct (off or static)
map_b: LedMapping::Engine1,
map_g: LedMapping::Engine1,
map_r: LedMapping::Engine1,
map_w: LedMapping::I2c,
},
LedMode::Mono3 => Pattern {
engine1: Some(prog),
engine2: None,
engine3: None,
// Engine1 drives all 3 mono LEDs in sync
map_b: LedMapping::Engine1,
map_g: LedMapping::Engine1,
map_r: LedMapping::Engine1,
map_w: LedMapping::I2c,
},
}
single_engine_pattern(breathe_program(), mode)
}
// ---------------------------------------------------------------------------
@@ -112,27 +95,7 @@ fn heartbeat_program() -> EngineProgram {
/// Load heartbeat pattern.
pub fn heartbeat(mode: LedMode) -> Pattern {
let prog = heartbeat_program();
match mode {
LedMode::Rgbw => Pattern {
engine1: Some(prog),
engine2: None,
engine3: None,
map_b: LedMapping::Engine1,
map_g: LedMapping::Engine1,
map_r: LedMapping::Engine1,
map_w: LedMapping::I2c,
},
LedMode::Mono3 => Pattern {
engine1: Some(prog),
engine2: None,
engine3: None,
map_b: LedMapping::Engine1,
map_g: LedMapping::Engine1,
map_r: LedMapping::Engine1,
map_w: LedMapping::I2c,
},
}
single_engine_pattern(heartbeat_program(), mode)
}
// ---------------------------------------------------------------------------
@@ -141,40 +104,44 @@ pub fn heartbeat(mode: LedMode) -> Pattern {
/// Phase-offset breathing using triggers for synchronization.
///
/// Engine 1 (leader): breathe up, send trigger, breathe down, wait, send trigger, loop
/// Engine 2 (follower): wait for trigger, breathe up, breathe down, wait, wait for trigger, loop
/// Engine 3 (follower): wait for trigger from E2, breathe up, breathe down, wait, loop
///
/// The trigger chain creates a 3-phase offset: E1 starts, signals E2, E2 signals E3.
/// Slow prescale (15.6ms/step), step_time=1, increment=127 (128 steps per command):
/// Ramp: 2 × 128 steps × 15.6ms = ~4s per full ramp (0→255 or 255→0)
/// E1 cycle: ~4s up + ~4s down + ~1s pause = ~9s
/// Trigger chain: E1 triggers E2 at ~4s, E2 triggers E3 at ~8s
fn rgb_cycle_engine1() -> EngineProgram {
EngineProgram::from_commands(&[
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 4), // 0: ramp up ~1s
EngineCommand::trigger(0, 0b010), // 1: send to E2
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 4), // 2: ramp down ~1s
EngineCommand::wait(Prescale::Slow, 32), // 3: pause ~0.5s
EngineCommand::wait(Prescale::Slow, 32), // 4: pause ~0.5s
EngineCommand::branch(0, 0), // 5: loop forever
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 0: 0→128
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 1: 128→255
EngineCommand::trigger(0, 0b010), // 2: send to E2
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 3: 255→127
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 4: 127→0
EngineCommand::wait(Prescale::Slow, 63), // 5: pause ~1.0s
EngineCommand::branch(0, 0), // 6: loop forever
])
}
fn rgb_cycle_engine2() -> EngineProgram {
EngineProgram::from_commands(&[
EngineCommand::trigger(0b001, 0), // 0: wait for E1
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 4), // 1: ramp up ~1s
EngineCommand::trigger(0, 0b100), // 2: send to E3
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 4), // 3: ramp down ~1s
EngineCommand::wait(Prescale::Slow, 32), // 4: pause ~0.5s
EngineCommand::branch(0, 0), // 5: loop forever
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 1: 0→128
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 2: 128→255
EngineCommand::trigger(0, 0b100), // 3: send to E3
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 4: 255→127
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 5: 127→0
EngineCommand::wait(Prescale::Slow, 32), // 6: pause ~0.5s
EngineCommand::branch(0, 0), // 7: loop forever
])
}
fn rgb_cycle_engine3() -> EngineProgram {
EngineProgram::from_commands(&[
EngineCommand::trigger(0b010, 0), // 0: wait for E2
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 4), // 1: ramp up ~1s
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 4), // 2: ramp down ~1s
EngineCommand::wait(Prescale::Slow, 32), // 3: pause ~0.5s
EngineCommand::branch(0, 0), // 4: loop forever
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 1: 0→128
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 2: 128→255
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 3: 255→127
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 4: 127→0
EngineCommand::wait(Prescale::Slow, 32), // 5: pause ~0.5s
EngineCommand::branch(0, 0), // 6: loop forever
])
}
@@ -205,38 +172,155 @@ pub fn rgb_cycle(mode: LedMode) -> Pattern {
}
// ---------------------------------------------------------------------------
// Staggered breathe: 3 engines, same breathe program, phase-offset
// Slow pulse: gentle ramp, dimmer peak, ~5s cycle
// ---------------------------------------------------------------------------
/// Load staggered breathing — all 3 channels breathe independently with phase offset.
pub fn staggered_breathe(mode: LedMode) -> Pattern {
// Same trigger-chain structure as rgb_cycle but using the same
// visual effect (breathe) on each channel. In RGBW mode this creates
// a color-shifting breathe; in mono mode it's a traveling wave.
rgb_cycle(mode)
/// Slow pulse — very gentle and long.
///
/// Slow prescale (15.6ms/step), step_time=4:
/// Ramp up: 2 × 128 steps × 62.4ms = ~16.0s (0→128→255)
/// Wait: step_time=32 → ~0.5s hold at peak
/// Ramp down: 2 × 128 steps × 62.4ms = ~16.0s (255→127→0)
/// Wait: step_time=63 → ~1.0s pause at bottom
/// Total: ~33.5s per cycle
fn slow_pulse_program() -> EngineProgram {
EngineProgram::from_commands(&[
EngineCommand::ramp_wait(Prescale::Slow, 4, RampDirection::Up, 127), // 0→128, ~8s
EngineCommand::ramp_wait(Prescale::Slow, 4, RampDirection::Up, 127), // 128→255, ~8s
EngineCommand::wait(Prescale::Slow, 32), // hold ~0.5s
EngineCommand::ramp_wait(Prescale::Slow, 4, RampDirection::Down, 127), // 255→127, ~8s
EngineCommand::ramp_wait(Prescale::Slow, 4, RampDirection::Down, 127), // 127→0, ~8s
EngineCommand::wait(Prescale::Slow, 63), // pause ~1.0s
EngineCommand::branch(0, 0), // loop forever
])
}
/// Load slow pulse pattern.
pub fn slow_pulse(mode: LedMode) -> Pattern {
single_engine_pattern(slow_pulse_program(), mode)
}
// ---------------------------------------------------------------------------
// Color wash: 3 engines, smooth overlapping ramps for blended color transitions
// ---------------------------------------------------------------------------
/// Color wash — free-running engines with different cycle lengths.
///
/// No triggers. Each engine breathes independently at a slightly different
/// rate, causing them to drift in and out of phase. Smooth single-PWM-unit
/// increments for clean color blending.
///
/// Slow prescale (15.6ms/step), step_time=1, increment=127 (128 steps per cmd):
/// Ramp: 2 × 128 steps × 15.6ms = ~4s per full ramp
///
/// E1 (Blue): up ~4s + down ~4s = ~8s cycle (no pause)
/// E2 (Green): up ~4s + down ~4s + ~0.5s pause = ~8.5s cycle
/// E3 (Red): up ~4s + down ~4s + ~1.0s pause = ~9s cycle
///
/// Phase drift: ~0.5s per cycle → colors shift noticeably every few cycles.
fn color_wash_engine1() -> EngineProgram {
// ~8s cycle (no pause)
EngineProgram::from_commands(&[
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 0→128
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 128→255
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 255→127
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 127→0
EngineCommand::branch(0, 0),
])
}
fn color_wash_engine2() -> EngineProgram {
// ~8.5s cycle (short pause at bottom)
EngineProgram::from_commands(&[
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 0→128
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 128→255
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 255→127
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 127→0
EngineCommand::wait(Prescale::Slow, 32), // pause ~0.5s
EngineCommand::branch(0, 0),
])
}
fn color_wash_engine3() -> EngineProgram {
// ~9s cycle (longer pause at bottom)
EngineProgram::from_commands(&[
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 0→128
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 128→255
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 255→127
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 127→0
EngineCommand::wait(Prescale::Slow, 63), // pause ~1.0s
EngineCommand::branch(0, 0),
])
}
/// Load color wash (RGBW: smooth hue transitions) or wave (mono: traveling slow pulse).
pub fn color_wash(mode: LedMode) -> Pattern {
match mode {
LedMode::Rgbw => Pattern {
engine1: Some(color_wash_engine1()),
engine2: Some(color_wash_engine2()),
engine3: Some(color_wash_engine3()),
map_b: LedMapping::Engine1,
map_g: LedMapping::Engine2,
map_r: LedMapping::Engine3,
map_w: LedMapping::I2c,
},
LedMode::Mono3 => Pattern {
engine1: Some(color_wash_engine1()),
engine2: Some(color_wash_engine2()),
engine3: Some(color_wash_engine3()),
map_b: LedMapping::Engine1,
map_g: LedMapping::Engine2,
map_r: LedMapping::Engine3,
map_w: LedMapping::I2c,
},
}
}
// ---------------------------------------------------------------------------
// Helper for single-engine patterns (all RGB channels mapped to Engine1)
// ---------------------------------------------------------------------------
fn single_engine_pattern(prog: EngineProgram, mode: LedMode) -> Pattern {
let _ = mode; // Same mapping for both modes
Pattern {
engine1: Some(prog),
engine2: None,
engine3: None,
map_b: LedMapping::Engine1,
map_g: LedMapping::Engine1,
map_r: LedMapping::Engine1,
map_w: LedMapping::I2c,
}
}
// ---------------------------------------------------------------------------
// Pattern loader
// ---------------------------------------------------------------------------
/// Load a pattern into the LP5562 and start engines running.
/// Software-reset LP5562, re-initialize, and load a pattern.
///
/// Caller must have already called `lp.enable()` and `lp.init_direct_control()`.
/// This function handles the full sequence: stop engines → set LED map → load programs → run.
/// Writes 0xFF to the Reset register (0x0D) which resets all registers to
/// defaults (PWM=0, engines disabled, current=17.5mA). Then re-initializes
/// and loads the new pattern. This guarantees zero residual state.
pub fn load_pattern<I2C, E>(
lp: &mut Lp5562<I2C>,
pattern: &Pattern,
current: u8,
delay: &mut impl embedded_hal::delay::DelayNs,
) -> Result<(), crate::led::lp5562::Error<E>>
where
I2C: I2c<Error = E>,
{
// Stop all engines first
lp.stop_engine(EngineId::Engine1).map_err(crate::led::lp5562::Error::I2c)?;
lp.stop_engine(EngineId::Engine2).map_err(crate::led::lp5562::Error::I2c)?;
lp.stop_engine(EngineId::Engine3).map_err(crate::led::lp5562::Error::I2c)?;
delay.delay_us(200); // >153us between OP_MODE writes
// Software reset: all registers to defaults, device enters STANDBY
lp.reset().map_err(crate::led::lp5562::Error::I2c)?;
delay.delay_ms(1); // allow reset to complete
// Re-initialize from clean state
lp.enable()?;
delay.delay_ms(1); // >500us after enable (datasheet: 500µs typical)
lp.init_direct_control(crate::led::lp5562::ClockSource::Internal)?;
lp.set_all_current(current, current, current, current)?;
// Set LED mapping
lp.set_led_mapping(Channel::Blue, pattern.map_b).map_err(crate::led::lp5562::Error::I2c)?;