Compare commits

...

10 Commits

Author SHA1 Message Date
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
michael
f437e4e281 Remove application-layer CRC from SRAM mailbox protocol
ISO15693 already provides CRC-16 at the transport layer, making
the application-layer CRC redundant. Simplifies header from 6 to
4 bytes, removes crc16_continue/verify_crc, and drops STATUS_BAD_CRC.
Also updates design doc and fixes markdown table formatting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:40:56 -08:00
michael
43cda8ac3d Add M6 SRAM mailbox design doc, implementation plan, mark M6 complete
- Protocol design: streaming single-hold NFC transfer, 7 commands
- Implementation plan: 7 tasks for subagent-driven development
- STATUS.md: M6 marked complete, current milestone now M7

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:20:52 -08:00
michael
3469944209 Wire SRAM mailbox polling into main idle loop (M6)
- Add FD pin (A1/PA04) as pull-up input for SRAM write detection
- Configure NTAG5 FD pin for SRAM-write-by-RF indication at boot
- Replace both idle loops with 200ms FD pin polling
- On command received: process via sram::process_command, then
  reload active pattern from EEPROM into LP5562
- I2C bus swapped between NTAG5 and LP5562 as needed (zero-cost
  move via release()/new())

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:19:39 -08:00
michael
8dfe9117af Add SRAM mailbox protocol with all 7 command handlers
Implements the NFC-to-MCU SRAM mailbox protocol in src/ntag5/sram.rs:
- Command parsing with CRC-16/CCITT-FALSE verification
- Response building with 4-byte-aligned SRAM writes
- WRITE_PATTERN: stores 112-byte pattern entries to EEPROM
- GET_STATUS: returns firmware version + library summary
- SET_ACTIVE: changes the active pattern index in EEPROM header
- SYNC_START/SYNC_END: bulk upload with deferred header CRC
- READ_LIBRARY/READ_NEXT: iterative pattern readback
- MailboxState struct for cross-command state tracking

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:16:12 -08:00
michael
a6f8c70051 Add NTAG5 SRAM read/write and FD pin config for M6 mailbox
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:13:06 -08:00
michael
2fade7ad8b Add pattern chaining support to XBLK format
Pattern entry bytes 108-109 now hold next_pattern (0xFF=loop forever,
0-8=chain to index) and loop_count. MCU will poll LP5562 STATUS register
to detect engine completion and load the successor pattern.

Updated XBLK format spec, Rust Pattern struct (serializer + deserializer),
and React Native app design doc (binary format, data model, resolved
open questions).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:52:33 -08:00
michael
a32c242859 M5 complete: boot-from-EEPROM with self-provisioning
XBLK binary format for pattern library in NTAG5 upper 1K EEPROM:
- 16-byte header (magic, version, pattern count, active index, CRC-16)
- Up to 9 × 112-byte fixed-size pattern entries
- Deserializer + serializer in src/pattern/mod.rs
- MCU self-provisions hardcoded patterns on first boot (no XBLK found)
- Subsequent boots load active pattern directly from EEPROM
- Python serializer tool for future PCSC-based pattern uploads

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:37:42 -08:00
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
michael
e7bc8834b2 M2 complete: LP5562 engine patterns running autonomously
Add src/pattern/ module with 3 engine patterns:
- Breathe: smooth ramp up/down (~2.5s cycle, 1 engine)
- Heartbeat: double-pulse with long rest (~1.6s cycle, 1 engine)
- RGB cycle: phase-offset breathing via trigger sync (3 engines)

Patterns support both RGBW and 3-channel monochrome LED modes via
LED_MAP configuration. Main loop cycles through all 3 patterns (8s
each) while LP5562 runs them autonomously — MCU just idles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:17:19 -08:00
18 changed files with 4869 additions and 151 deletions

View File

@@ -1,6 +1,6 @@
# xblink Development Guide
# xBlink Development Guide
Embedded Rust firmware for a battery-free, NFC-powered LED implant. The NTAG5Link harvests energy from an NFC field and provides EEPROM storage for LED patterns. A SAMD21E MCU reads patterns from EEPROM, programs LP5562 execution engines, then sleeps while the LP5562 autonomously drives RGBW LEDs.
Embedded Rust firmware for a battery-free, NFC-powered LED implant. The NTAG5Link harvests energy from an NFC field (1.8V) and provides EEPROM storage for LED patterns. A SAMD21E MCU reads patterns from EEPROM, drives 6 red LEDs directly via TCC hardware PWM through current-limiting resistors, and updates duty cycles from a 50Hz timer ISR. The MCU stays in IDLE sleep during animation.
## Build Commands
@@ -19,13 +19,14 @@ cargo hf2 --release
- Architecture: `thumbv6m-none-eabi` (ARM Cortex-M0+)
- Dev board: Seeed XIAO M0 (SAMD21G18A) — will port to bare SAMD21E18A later
- LED driver: LP5562EVM (TI evaluation module, RGBW, I2C addr 0x30)
- LEDs: 6x low-Vf red LEDs (Kingbright APTD1608, Vf ~1.7V) driven via TCC PWM + resistors
- NFC: NTAG5Link Click board (MikroElektronika)
- LP5562: removed from design (requires 2.7V min, incompatible with 1.8V EH)
## Dependencies
| Crate | Version | Purpose |
|-------|---------|---------|
| -------------- | ------- | -------------------------------------------------------- |
| `xiao_m0` | 0.13 | Board support package (dev board phase) |
| `panic-halt` | 0.2 | Minimal panic handler for `no_std` |
| `cortex-m` | 0.7 | Cortex-M runtime, `critical-section-single-core` feature |
@@ -37,12 +38,13 @@ When porting to bare SAMD21E: replace `xiao_m0` with `atsamd-hal = { version = "
```
src/
main.rs # Entry point: boot, pattern load, sleep/wake loop
main.rs # Entry point: boot, EEPROM load, TC4 ISR animation, sleep/wake loop
led/
mod.rs # LP5562 re-exports (LedController trait added later, M10)
lp5562.rs # LP5562 driver (from ../ntag5-samd21-lp562/)
ntag5/ # NTAG5Link I2C slave driver (M4+)
pattern/ # Pattern format and engine builder (M5+)
mod.rs # LED module re-exports
pwm.rs # TCC0/TCC1 hardware PWM driver for 6 GPIO-direct LEDs
lp5562.rs # LP5562 driver (legacy, kept for reference)
ntag5/ # NTAG5Link I2C slave driver
pattern/ # XBLK v2 format, software pattern engine, waveform LUTs
```
## Architecture Conventions
@@ -55,22 +57,31 @@ src/
- **`const fn`**: Prefer `const fn` where possible (see `EngineCommand` builder in LP5562 driver).
- **No `unsafe`**: Avoid unless absolutely necessary for hardware access.
## LP5562 Timing Constraints
## LED PWM Architecture
The LP5562 driver does NOT enforce timing delays internally — the caller is responsible:
6 LEDs driven directly by SAMD21 TCC hardware PWM through current-limiting resistors (10-47 ohm).
- **>= 500 us** after `enable()` before any other commands
- **>= 488 us** between consecutive ENABLE register writes (engine exec changes)
- **>= 153 us** between consecutive OP_MODE register writes (engine mode changes)
- When transitioning from Run, set exec to Hold first, then change mode
| 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 |
## Key I2C Addresses
*PA06/PA07 conflict with I2C on XIAO M0. Dev board uses 4 LEDs (0,1,4,5).
Both devices share the same I2C bus (A4/A5). Non-conflicting addresses — no bus arbitration issues.
- **Animation**: TC4 ISR at 50Hz computes brightness from PatternEngine, writes TCC CC registers
- **Power governor**: Proportional scaling ensures total LED current stays within NTAG5 EH budget
- **Sleep**: IDLE mode during animation (~0.5mA MCU), STANDBY when no pattern active (~2uA)
## Key I2C Address
I2C bus (A4/A5) is used for NTAG5 only (LP5562 removed from design).
| Device | Address | Notes |
|--------|---------|-------|
| LP5562 | 0x30 | ADDR_SEL pins both low (LP5562EVM default) |
| --------- | ------- | --------------------------------- |
| NTAG5Link | 0x54 | Default NTP53x2 I2C slave address |
## NTAG5Link I2C Register Map (MCU-side)
@@ -78,7 +89,7 @@ Both devices share the same I2C bus (A4/A5). Non-conflicting addresses — no bu
The MCU accesses NTAG5Link as a standard I2C slave — plain register read/write, NOT the NFC-side ISO15693 custom commands used by the Python ntag5sensor tooling.
| Region | Address Range | Size | Notes |
|--------|--------------|------|-------|
| ------------------ | ----------------- | ---------- | --------------------------------- |
| Session registers | 0x00-0x06 | 7 bytes | Volatile, runtime config |
| Configuration | 0x37-0x3F | Varies | Persistent, EEPROM-backed |
| EH config | 0x3D | 1 byte | Energy harvesting voltage/current |
@@ -94,23 +105,24 @@ Key config constants (from `../ntag5sensor/vicinity/ntag5link.py`):
## Hardware Wiring (Dev Board)
| XIAO Pin | Connection | Function |
|----------|------------|----------|
| A4 (SDA) | LP5562 SDA, NTAG5 SDA | Shared I2C data |
| A5 (SCL) | LP5562 SCL, NTAG5 SCL | Shared I2C clock |
| D0/A0 | LP5562 EN line (open-drain) | Wired-AND with hall sensor, pull-up to VCC |
| TBD (EIC) | Hall sensor output | EIC wake + wired-AND to LP5562 EN line |
| TBD (EIC) | NTAG5 FD pin | Field detect / SRAM write indication (EIC wake) |
**LP5562 EN wired-AND**: D0/A0 (open-drain) and hall sensor (open-drain, active-low) both connect to LP5562 EN with a 1M pull-up. Either can force EN low. Magnet kills LEDs at hardware level regardless of MCU state.
| -------- | --------------------- | ------------------------------------------- |
| A1 (PA04)| LED 0 + resistor | TCC0/WO[0] PWM output |
| A2 (PA05)| LED 1 + resistor | TCC0/WO[1] PWM output |
| A4 (SDA) | NTAG5 SDA | I2C data |
| A5 (SCL) | NTAG5 SCL | I2C clock |
| D2 (PA10)| LED 4 + resistor | TCC1/WO[0] PWM output |
| D3 (PA11)| LED 5 + resistor | TCC1/WO[1] PWM output |
| A1 (PA04)| NTAG5 FD pin | Field detect / SRAM write indication (EIC) |
| TBD | Hall sensor output | EIC wake, pattern cycling |
## Testing
- **Hardware**: XIAO M0 + LP5562EVM + NTAG5Link Click (when jumpers available)
- **Hardware**: XIAO M0 + NTAG5Link Click + 4 LEDs with resistors on A1/A2/D2/D3
- **PCSC reader**: Use `../ntag5sensor/` Python tooling with ACR1552 reader
- **Phone NFC**: VivoKey RawNFC app for SRAM mailbox testing
- **Phone app**: DT NFC Identifier for basic tag info
## Sibling Projects
- `../ntag5-samd21-lp562/` — Original LP5562 driver + smoke test (xblink's `src/led/lp5562.rs` is synced from here)
- `../ntag5-samd21-lp562/` — Original LP5562 driver (legacy reference)
- `../ntag5sensor/` — NTAG5Link command reference (`vicinity/ntag5link.py`), I2C patterns (`vicinity/i2cbase.py`)

View File

@@ -1,4 +1,4 @@
# xblink
# xBlink
NFC-powered LED implant using NTAG5Link, SAMD21E, and LP5562.

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,7 +1,7 @@
# xblink Project Status
**Current Milestone**: M2 — Engine Patterns
**Last Updated**: 2026-03-05
**Current Milestone**: GPIO-Direct LED Pivot (replacing LP5562)
**Last Updated**: 2026-03-07
---
@@ -28,45 +28,74 @@
### M2: Engine Patterns
- [ ] Build breathing pattern (1 engine, ramp up/down, branch loop)
- [ ] Build heartbeat pattern (1 engine, fast ramp, slow decay)
- [ ] Build RGB color cycle (3 engines, trigger sync)
- [ ] Verify LP5562 runs patterns autonomously (MCU idle loop)
- [ ] Verify MCU can stop and switch patterns
- [x] Build breathing pattern (1 engine, ramp up/down, branch loop)
- [x] Build heartbeat pattern (1 engine, fast ramp, slow decay)
- [x] Build RGB cycle (3 engines, trigger-synced phase offset)
- [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
### M5: Boot-from-EEPROM (COMPLETE)
- [ ] Design binary pattern format (informed by M2-M3 experience)
- [ ] Implement pattern deserializer (`src/pattern/mod.rs`)
- [ ] Implement engine program builder (`src/pattern/engine.rs`)
- [ ] Update main.rs: boot → read EEPROM → parse → program LP5562 → idle
- [ ] Write Python serializer tool for PCSC pattern uploads
- [x] Design XBLK binary pattern format (`docs/plans/2026-03-05-eeprom-pattern-format.md`)
- [x] Implement XBLK deserializer (`src/pattern/mod.rs`: parse_header, parse_pattern_entry)
- [x] Implement XBLK serializer (`src/pattern/mod.rs`: serialize_pattern_entry, serialize_header, crc16)
- [x] Update main.rs: boot → read EEPROM → parse → program LP5562 → idle, with fallback
- [x] MCU self-provisioning: write hardcoded patterns to EEPROM on first boot
- [x] Write Python serializer tool (`tools/xblk_serialize.py`) for PCSC pattern uploads
- [x] Verified on hardware: self-provisioning writes XBLK, subsequent boots load from EEPROM
### M6: SRAM Mailbox
### M6: SRAM Mailbox (COMPLETE)
- [ ] Implement SRAM read/write (`src/ntag5/sram.rs`)
- [ ] Design command/response protocol
- [ ] Implement MCU-side protocol handler
- [ ] Test pattern update via NFC without power-cycling
- [x] Design SRAM mailbox protocol (`docs/plans/2026-03-05-sram-mailbox-protocol.md`)
- [x] Add NTAG5 `write_register`, SRAM read/write, FD pin configuration
- [x] Implement command protocol types and CRC helpers (`src/ntag5/sram.rs`)
- [x] Implement all 7 command handlers (WRITE_PATTERN, GET_STATUS, SET_ACTIVE, SYNC_START/END, READ_LIBRARY/NEXT)
- [x] Streaming transfer: single NFC hold for full library sync (one pattern per SRAM round-trip)
- [x] FD pin polling in main idle loop (200ms interval, A1/PA04)
- [x] I2C bus swapping for LP5562 reprogramming after pattern updates
- [ ] Hardware test: flash and verify with PCSC reader / phone app (pending FD pin wiring)
## GPIO-Direct LED Pivot (2026-03-07)
LP5562 requires VDD >= 2.7V, incompatible with 1.8V NFC energy harvesting.
Replaced with SAMD21E GPIO-direct PWM driving 6 red LEDs.
See `docs/plans/2026-03-06-gpio-led-pivot.md` for full design.
### LED Pivot Implementation
- [x] Design GPIO-direct PWM architecture (`docs/plans/2026-03-06-gpio-led-pivot.md`)
- [x] Implement software pattern engine with waveform LUTs (`src/pattern/mod.rs`)
- [x] Implement XBLK v2 EEPROM format (16-byte entries, playlist support)
- [x] Implement TCC PWM driver for 6 LED channels (`src/led/pwm.rs`)
- [x] Implement TC4 50Hz animation timer ISR
- [x] Implement power governor (proportional brightness scaling)
- [x] Update main.rs for GPIO-direct boot flow
- [x] Update SRAM mailbox protocol for v2 format (`src/ntag5/sram.rs`)
- [x] Update Python serializer (`tools/xblk_serialize.py`)
- [x] Verify `cargo build --release` compiles
- [ ] Hardware test: wire LEDs to A1/A2/D2/D3, flash and verify animations
- [ ] Measure power consumption with multimeter
## Group C — Power + Recovery
@@ -74,10 +103,10 @@
### M7: Sleep/Wake
- [ ] Configure SAMD21 EIC for FD pin wake
- [ ] Implement STANDBY sleep after LP5562 programming
- [ ] Verify LP5562 keeps running during MCU sleep
- [ ] Measure current: active vs standby vs total system
- [x] Configure SAMD21 EIC for FD pin wake
- [x] Implement IDLE sleep during animation (TC4 ISR drives LEDs)
- [x] Implement STANDBY sleep when no pattern active
- [ ] Measure current: IDLE (animating) vs STANDBY vs total system
### M8: Recovery Mode
@@ -88,28 +117,17 @@
### M9: Power Characterization
- [ ] Measure current at various LED current settings
- [ ] Test NTAG5Link EH output with phone NFC
- [ ] Find optimal brightness vs EH budget balance
- [ ] Measure current at various LED brightness / resistor values
- [ ] Test NTAG5Link EH output with phone NFC at 1.8V
- [ ] Tune power governor budget for optimal brightness
- [ ] Document real power numbers
## Group D — Abstraction + Polish
### M10: LedController Trait
- [ ] Define trait based on proven usage patterns from Groups A-C
- [ ] Implement for LP5562 wrapper
- [ ] Refactor main.rs to trait-based API
### M11: SimplePwm Driver
- [ ] Implement only if single-color LED hardware is available
## Group E — Future
## Group D — Future
### Custom PCB (SAMD21E)
- [ ] Port to `atsamd-hal` with `samd21e` feature
- [ ] Add LEDs 2-5 on PA06/PA07 (freed from I2C conflict on custom PCB)
- [ ] Custom `memory.x` linker script
- [ ] PCB design and fabrication
@@ -127,23 +145,21 @@
| Question | Status | Notes |
|----------|--------|-------|
| Energy harvesting power budget | TBD | Multimeter measurements in M9 |
| Optimal LED current per channel | TBD | Probably 2-5mA/ch, verify in M9 |
| Energy harvesting power budget | TBD | Multimeter measurements at 1.8V |
| Optimal LED resistor value | TBD | 10-47 ohm, affects brightness vs power |
| Power governor budget default | Set to 50 | Configurable via companion app |
| Firmware update strategy | Deferred | UF2 for dev, NFC OTA evaluated later |
| NTAG5Link I2C slave address | Assumed 0x54 | Verify in M4 with Click board |
| Pattern binary format | Deferred to M5 | Design after M2-M3 engine experience |
| Hall sensor + EN circuit | Designed | Wired-AND: hall + MCU open-drain on EN with 1M pull-up. See `docs/plans/2026-03-03-hall-en-design.md` |
| Hall sensor part selection | Decided | DRV5032FB (SOT-23, 8.4mT, prototype) → DRV5032FE (X2SON 1x1mm, final PCB) |
| EN pull-up value | Decided | 1M — zero steady-state draw, 3µA when EN low, ~10µs rise time |
| Hall sensor part selection | Decided | DRV5032FB (SOT-23, 8.4mT, prototype) |
## Hardware Inventory
| Item | Status | Notes |
|------|--------|-------|
| Seeed XIAO M0 | Available | Dev board MCU (SAMD21G18A) |
| LP5562EVM | Available | TI eval module, RGBW LEDs, I2C addr 0x30 |
| Mini-USB cable | Available | USB-C, used for flashing XIAO M0 |
| LP5562EVM | Available (unused) | Removed from design (VDD > 2.7V) |
| NTAG5 Link Click | Available, partially wired | Missing I2C jumper to XIAO |
| Hall effect sensor | Not available | Need to source — TI DRV5032FB (SOT-23) for prototype |
| Low-Vf red LEDs | Need to source | Kingbright APTD1608 or similar, Vf ~1.7V |
| Resistors (10-47 ohm) | Need to source | Current limiting for GPIO-direct LEDs |
| Hall effect sensor | Not available | Need to source — TI DRV5032FB (SOT-23) |
| ACR1552 PCSC reader | Available | For ntag5sensor Python tooling |
| Multimeter | Available | For power budget measurements |

View File

@@ -0,0 +1,120 @@
# EEPROM Pattern Library Format (M5)
## Memory Layout
The NTP53x2 has 2048 bytes (512 x 4-byte blocks) of user EEPROM.
| Region | Blocks | Bytes | Purpose |
|--------|--------|-------|---------|
| NFC/NDEF | 0-255 | 0-1023 | CC + NDEF data (phone-visible, MCU doesn't touch) |
| Pattern library | 256-511 | 1024-2047 | XBLK header + up to 9 patterns |
I2C base address for the pattern library: `0x0100` (block 256).
## Header (16 bytes, blocks 256-259)
```
Offset Size Field
0 4 Magic: "XBLK" (0x58 0x42 0x4C 0x4B)
4 1 Version: 0x01
5 1 Pattern count (1-9)
6 1 Active pattern index (0-based, wraps at count)
7 1 LED current (0-255, 0.1mA/step)
8 1 LED mode (0x00 = RGBW, 0x01 = Mono3)
9 5 Reserved (0x00)
14 2 CRC-16 over bytes 0-13 + all pattern data
```
Current and LED mode are global -- all patterns share them.
## Pattern Entry (112 bytes, 28 blocks each)
Fixed-size entries for direct seeking: `offset = 16 + (index * 112)`.
```
Offset Size Field
0 1 Engine count (0-3)
1 1 LED_MAP register value (raw LP5562 register byte)
2 4 Direct PWM [B, G, R, W] for I2C-mapped channels
6 2 Engine 1 command count (big-endian, 0 = unused)
8 32 Engine 1 commands (up to 16 x 2 bytes BE, pad with 0x0000)
40 2 Engine 2 command count
42 32 Engine 2 commands
74 2 Engine 3 command count
76 32 Engine 3 commands
108 1 Next pattern index (0xFF = loop forever, 0-8 = chain to that index)
109 1 Loop count (how many full cycles before chaining, 0 = use next_pattern as-is)
110 2 Reserved
```
Max capacity: `(1024 - 16) / 112 = 9` patterns.
The 16-command-per-engine limit is a hardware constraint of the LP5562 (48 bytes engine SRAM). The `branch` command enables infinite looping, so pattern duration is unlimited.
### Pattern Chaining
A pattern can specify a successor via `next_pattern_index`. When set to anything other than 0xFF, the pattern's engine programs should use finite-count `branch(0, N)` instead of `branch(0, 0)` (infinite loop). When all engines stop (detected by polling LP5562 STATUS register 0x0C for engine interrupt bits), the MCU loads the next pattern.
The LP5562 has no interrupt output pin — the MCU must poll STATUS periodically. During the idle loop, a poll every ~500ms is sufficient and costs negligible power vs. the LED current.
Chain examples:
- `next=0xFF`: Loop forever (default, current behavior)
- `next=1, loops=0`: Play once, then switch to pattern 1
- `next=0, loops=3`: Play 3 full cycles, then switch to pattern 0 (can create A→B→A→B sequences)
- Circular chains (A→B→A) create alternating pattern sequences
## Boot Sequence
1. Init LP5562 (enable, clock, current)
2. Read header at `0x0100` (4 blocks)
3. If magic = "XBLK" and CRC valid:
- Read pattern at `active_pattern_index`
- Deserialize into `Pattern` struct
- Load into LP5562 engines
4. If invalid (no magic, CRC mismatch, I2C error):
- Fall back to hardcoded breathe pattern (compiled into firmware)
5. Config check via session registers, report via LED blinks (no NDEF write)
6. Sleep (future: STANDBY)
## Pattern Switching (future, hall sensor)
1. Wake on hall EIC interrupt
2. Read header, increment `active_pattern_index` (wrap at `pattern_count`)
3. Write back the single block containing the index (1 EEPROM write, ~5ms)
4. Read and load the new pattern
5. Sleep
## Error Handling
- EEPROM read fails at boot: use hardcoded fallback
- CRC mismatch: use hardcoded fallback
- Pattern data malformed (engine_count > 3, cmd_count > 16): skip to next, wrap around; if all bad, use fallback
- Active index >= pattern count: reset to 0
## Python Serializer
`tools/xblk_serialize.py` converts JSON pattern definitions to binary and writes to EEPROM via ntag5sensor/PCSC.
Input JSON:
```json
{
"current": 20,
"mode": "rgbw",
"active": 0,
"patterns": [
{
"name": "breathe",
"led_map": {"b": "engine1", "g": "engine1", "r": "engine1", "w": "direct"},
"direct_pwm": [0, 0, 0, 0],
"engines": [
[18176, 18176, 26368, 26368, 15408, 40960],
[],
[]
]
}
]
}
```
Engine commands are raw u16 values. The serializer validates constraints, builds the binary blob, computes CRC-16, and writes to EEPROM blocks 256+ via I2C.

View File

@@ -0,0 +1,284 @@
# xblink LED Pattern Editor — React Native App Design
**Date**: 2026-03-05
**Status**: Draft — to be refined as firmware milestones (M2M6) progress
## Context
The xblink implant needs a companion phone app for creating and transferring LED animation patterns. The hardware constraints are tight: LP5562 has 3 engines x 16 steps each, NTAG5Link has 256-byte SRAM (transfer buffer) and ~1KB usable EEPROM (after NDEF). This design translates those constraints into an intuitive "hybrid timeline" editor — like a step sequencer for light brightness — with animated preview, preset patterns, and NFC transfer.
## Decisions Made
- **UI model**: Hybrid timeline (per-channel, tap to add set/ramp/wait steps)
- **Timing range**: 50ms14s full LP5562 capability
- **Transfer**: NTAG5 SRAM as 256-byte mailbox, EEPROM as persistent storage
- **Storage**: ~1KB EEPROM for patterns (~8 slots), MCU SRAM as runtime cache
- **Magnet toggle**: Tap-to-cycle through patterns (flashlight-style)
- **Preview**: Animated RGBW circles in-app, real-time as you edit
- **Stack**: Expo + react-native-nfc-manager + react-native-dt-theme + react-native-reanimated
## Hardware Constraints Reference
| Constraint | Value | UI Impact |
|-----------|-------|-----------|
| Engines | 3 (16 steps each) | R/G/B get timelines, W is static-only |
| Max user steps/engine | 14 (2 reserved for branch+end) | Step counter per channel |
| Increment max | 127 PWM steps/ramp | Large ramps consume 2 engine slots |
| Prescale | Fast 0.49ms, Slow 15.6ms | Per-step selector in editor |
| Step time | 163 | Per-step, multiplied by prescale |
| SRAM | 256 bytes (64x4B blocks) | Single-packet transfer for most patterns |
| EEPROM (patterns) | ~1024 bytes (blocks 256511) | ~8 patterns at 122B each |
| Pattern size | 112B fixed-size entry (3x34B engines + 6B header + 4B chain/reserved) | Fits in one SRAM packet |
| White channel | Direct I2C PWM only, no engine | Static brightness slider, no timeline |
## App Screens
### 1. Pattern Library (Home)
Ordered list of up to 8 patterns. Order = magnet cycle order.
```
PATTERN LIBRARY [+ Add]
"Tap magnet to cycle"
[drag] 1. Breathing [mini-preview]
R:wave G:wave B:-- W:50%
Duration: 2.4s | 6/14 steps
[drag] 2. Heartbeat [mini-preview]
R:pulse G:-- B:-- W:0%
Duration: 1.0s | 10/14 steps
[drag] 3. (empty slot)
Memory: 244/1024 bytes
[========= Sync to Implant =========]
```
- **Cards**: DTCard per pattern, drag handle for reorder, swipe-left to delete
- **Add button**: "Create New" or "From Preset" bottom sheet. Disabled at 8 patterns
- **Memory bar**: DTProgressBar, warning at 80%, error at 95%
- **Sync button**: Opens NFC transfer flow
- **Tap card**: Opens pattern editor
### 2. Pattern Editor
The core UI. 3 engine timelines (R, G, B) + static W slider + animated preview.
```
[< Back] Breathing [Save]
PREVIEW
(R) (G) (B) (W) (MIXED)
[> Play] [1x] [loop: on]
COMBINED COLOR STRIP
|====gradient showing mixed RGBW over time====|
0ms 2400ms
R 6/14 steps
|[SET 0]|===RAMP UP===|===RAMP DN===|[BR->0]|
G 6/14 steps
|[SET 0]|===RAMP UP===|===RAMP DN===|[BR->0]|
B 0/14 steps
(tap to add steps)
W Static: [=======|===] 128
(no engine — direct PWM only)
```
**Timeline blocks**:
- **Set PWM**: Narrow bar, shows brightness value (0255)
- **Ramp**: Wide bar proportional to duration, sloped fill (up/down)
- **Wait**: Wide bar, flat, hatched fill
- **Branch**: Arrow icon pointing to target step, shows loop count or infinity
**Interactions**:
- Tap empty space → Add Step sheet (Set / Ramp / Wait)
- Tap existing step → Step Editor modal
- Long press → Delete confirmation
- Step counter: DTChip, warning at 12+, error at 14
**Step Editor Modal**:
```
Edit Step: Ramp Up [Delete]
Type: [Set PWM] [Ramp] [Wait]
Target Brightness: [====|===] 200 (0-255)
Prescale: [Fast 0.49ms] [Slow 15.6ms]
Step Time: [--] 32 [++] (1-63)
Duration: 502ms
Note: This ramp uses 2 engine slots (delta > 127)
[Cancel] [Apply]
```
- For ramps, user sets absolute target brightness; app calculates increment + direction
- If delta > 127, show warning and deduct 2 from step budget
- Duration displayed as calculated value, updates live
**White channel**: Single slider. No timeline. Label explains constraint.
**Preview area**:
- 5 circles: R, G, B, W (individual) + MIXED (additive blend)
- Animated via react-native-reanimated (PWM 0255 → opacity 01)
- Play/pause, speed selector (0.25x/0.5x/1x/2x), loop toggle
- Playback cursor synced across all timelines
### 3. Preset Browser
Built-in patterns users can add to their library:
| Preset | Engines Used | Steps/Engine | Duration | Description |
|--------|-------------|-------------|----------|-------------|
| Breathing | 1 (all RGB) | 6 | ~4.0s | Slow ramp 0→255→0, all channels |
| Heartbeat | 1 (R only) | 10 | ~1.0s | Double pulse, red |
| Color Cycle | 3 (R+G+B) | 7 each | ~3.0s | Sequential fade with trigger sync |
| Strobe | 1 (all RGB) | 5 | ~100ms | Fast on/off, 10Hz |
| Candle | 2 (R+G) | 8 each | ~200ms | Warm flicker, pseudo-random ramps |
| Solid Color | 0 (all direct) | 0 | N/A | Static RGBW, color picker UI |
Each preset card shows animated preview circles.
### 4. NFC Transfer Flow
4-phase modal:
1. **Pre-flight**: List patterns to sync, total bytes, confirm
2. **Hold**: Pulsing NFC icon, "Hold phone against implant"
3. **Transferring**: Progress bar, per-pattern status, "DO NOT MOVE"
4. **Complete/Error**: Success with magnet-cycle reminder, or retry
## Data Model
```typescript
interface PatternLibrary {
version: 1;
patterns: Pattern[]; // Ordered, index = magnet cycle position
globalCurrent: number; // LED current 0-255
}
interface Pattern {
id: string;
name: string; // Max 32 chars
presetId: string | null;
engines: [EngineProgram, EngineProgram, EngineProgram];
channelAssignment: { red: EngineRef; green: EngineRef; blue: EngineRef; white: 'direct' };
whiteLevel: number; // 0-255
nextPattern: number; // 0xFF = loop forever, 0-8 = chain to index
loopCount: number; // Cycles before chaining (0 = chain on stop)
}
interface EngineProgram {
steps: EngineStep[]; // Max 14 user steps
}
type EngineStep =
| { type: 'set_pwm'; brightness: number }
| { type: 'ramp'; targetBrightness: number; prescale: 'fast' | 'slow'; stepTime: number }
| { type: 'wait'; prescale: 'fast' | 'slow'; stepTime: number };
type EngineRef = 'engine1' | 'engine2' | 'engine3' | 'direct';
```
**Step-to-LP5562 mapping**:
- `set_pwm``EngineCommand::set_pwm(value)` — 1 slot
- `ramp` → compute delta from current PWM; if <= 127: 1 `ramp_wait` (1 slot), if > 127: 2 commands (2 slots)
- `wait``EngineCommand::wait(prescale, stepTime)` — 1 slot
- Auto-appended: `branch(0, 0)` for infinite loop, or `branch(0, N)` for chained patterns — 1 reserved slot
## SRAM Transfer Protocol
**Packet format** (256 bytes max):
```
Byte Field
0 command (0x01=SYNC_START, 0x02=PATTERN_DATA, 0x03=SYNC_END)
1 sequence number (0-255)
2 flags (bit0=ACK_REQ, bit1=LAST_CHUNK, bit2=ERASE_FIRST)
3 total_patterns
4 pattern_index
5 chunk_index
6 chunk_count
7 reserved
8-9 payload_length (u16 LE)
10-11 crc16 (over header + payload)
12-255 payload (up to 244 bytes)
```
A single 3-engine pattern (122 bytes) fits in one packet. Transfer flow:
1. SYNC_START (global settings)
2. PATTERN_DATA x N (one per pattern, usually single-chunk)
3. SYNC_END (MCU commits to EEPROM, activates pattern 0)
**MCU response** (SRAM byte 0 = 0xFF):
```
0: 0xFF (response marker), 1: echo seq, 2: status (0=OK), 3: active_pattern
```
## Pattern Binary Format
Matches firmware spec `docs/plans/2026-03-05-eeprom-pattern-format.md`:
```
Header (16 bytes, blocks 256-259):
[0-3] Magic "XBLK"
[4] Version 0x01
[5] Pattern count (1-9)
[6] Active pattern index (0-based, wraps at count)
[7] LED current (0-255, 0.1mA/step)
[8] LED mode (0x00=RGBW, 0x01=Mono3)
[9-13] Reserved
[14-15] CRC-16 over bytes 0-13 + all pattern data
Pattern Entry (112 bytes each, fixed-size):
[0] Engine count (0-3)
[1] LED_MAP register byte
[2-5] Direct PWM [B, G, R, W]
[6-7] Engine 1 command count (BE)
[8-39] Engine 1 commands (16x2 bytes BE)
[40-41] Engine 2 command count
[42-73] Engine 2 commands
[74-75] Engine 3 command count
[76-107] Engine 3 commands
[108] Next pattern index (0xFF=loop forever, 0-8=chain to that index)
[109] Loop count (cycles before chaining, 0=chain on engine stop)
[110-111] Reserved
```
Max capacity: 9 patterns in 1024 bytes (upper 1K EEPROM).
## Reusable Code from Sibling Projects
| File | What to reuse |
|------|--------------|
| `dangerous-things-nfc-identifier/src/services/nfc/commands.ts` | `parseApduResponse`, `hexToBytes`, `bytesToHex`, NTAG5 command patterns |
| `dangerous-things-nfc-identifier/src/services/nfc/NFCManager.ts` | NFC session management |
| `dangerous-things-nfc-identifier/src/services/detection/ntag5sensor.ts` | SRAM read/write, energy harvesting control |
| `react-native-dt-theme/src/components/` | DTCard, DTButton, DTChip, DTModal, DTDrawer, DTProgressBar, DTQuantityStepper, DTSwitch, DTTextInput |
| `react-native-dt-theme/src/theme/` | Colors, typography, DTThemeProvider |
## Implementation Phases
1. **Project scaffold + data model** — Expo init, types, serialization, presets
2. **Pattern Library screen** — List, reorder, add/delete, memory bar
3. **Timeline Editor** — Channel timelines, step editor modal, W slider, step budget
4. **Animated Preview** — Reanimated circles, color strip, playback controls
5. **Binary Serialization** — XBLK encoder, CRC-16, SRAM packet framing
6. **NFC Transfer** — react-native-nfc-manager, ISO15693, transfer flow UI
7. **Polish** — Undo/redo, export/import, settings, onboarding
## Resolved Questions
- **EEPROM layout**: Lower 1K (blocks 0-255) = NFC/NDEF, upper 1K (blocks 256-511) = XBLK pattern library. Decided in M5.
- **Magnet cycle index**: Persists in EEPROM header byte [6] (active_pattern_index). Single block write on switch (~5ms).
- **Non-looping patterns**: Supported via pattern chaining (next_pattern field). Engine programs use finite `branch(0, N)` instead of infinite `branch(0, 0)`. MCU polls LP5562 STATUS register to detect engine completion, then loads the next pattern. No INT pin on LP5562 — polling only.
## Open Questions
- Trigger command exposure in UI (v1 hides it; presets use it internally)
- Pattern chaining UI: how to visualize A→B→C chains in the library screen (arrows? numbered sequence?)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,220 @@
# M6: SRAM Mailbox Protocol Design
**Date**: 2026-03-05
**Status**: Approved — ready for implementation
## Overview
The NTAG5Link's 256-byte SRAM (64 x 4-byte blocks at I2C address 0xF8-0xFF) serves as a bidirectional mailbox between the phone (NFC/RF side) and the MCU (I2C side). The MCU polls the FD pin to detect when the phone has written a command, processes it, and writes a response back to SRAM for the phone to read.
## Pin Allocation
| Pin | Function | Milestone |
|-----|----------|-----------|
| A0 (PA02) | LP5562 EN (open-drain, wired-AND with hall sensor) | M1 (done) |
| A1 (PA04) | NTAG5 FD (SRAM write / field detect, EXTINT[4]) | M6 |
| TBD | Hall sensor (EIC wake) | M8 |
## FD Pin Configuration
NTAG5 session register `FD_PIN_CFG` (0x06) set at boot to "SRAM RF write complete" mode:
- FD goes **low** when the RF side (phone) finishes writing to SRAM
- FD returns **high** when the I2C side (MCU) reads the SRAM
- Natural flow control for command/response cycles
Pin wiring: NTAG5 Click FD → XIAO A1 (PA04). Configured as GPIO input with pull-up (FD is open-drain on NTAG5).
M6 uses GPIO polling (~200ms interval in idle loop). M7 upgrades to EIC interrupt (EXTINT[4]) for wake-from-STANDBY.
## Command Packet Format
**Phone → MCU (written to SRAM, 256 bytes max):**
```
Byte Field
0 Command ID (0x01-0x7F)
1 Sequence number (0-255, incremented per packet)
2-3 Payload length (u16 LE)
4-255 Payload (up to 252 bytes)
```
No application-layer CRC — ISO15693 already provides CRC-16 at the transport layer.
**MCU → Phone (written back to SRAM):**
```
Byte Field
0 0xFF (response marker — distinguishes from commands)
1 Echo sequence number
2 Status (0x00=OK, 0x02=BAD_CMD, 0x03=EEPROM_FAIL, 0x04=INVALID_INDEX)
3 Payload length
4-255 Response payload
```
The 0xFF response marker lets the phone distinguish "MCU hasn't responded yet" (byte 0 still holds old command ID) from "response ready" (byte 0 = 0xFF).
## Command Set
| ID | Name | Payload (Phone → MCU) | Response Payload (MCU → Phone) |
|----|------|----------------------|-------------------------------|
| 0x01 | WRITE_PATTERN | index (1B) + pattern (112B) | none |
| 0x02 | GET_STATUS | none | version (1B) + pattern_count (1B) + active_index (1B) + led_current (1B) + led_mode (1B) |
| 0x03 | SET_ACTIVE | index (1B) | none |
| 0x05 | SYNC_START | count (1B) + current (1B) + mode (1B) | none |
| 0x06 | SYNC_END | none | none |
| 0x07 | READ_LIBRARY | none | pattern_count (1B) + active (1B) + current (1B) + mode (1B) |
| 0x08 | READ_NEXT | none | pattern data (112B) |
## Streaming Transfer Protocol
### Write (Phone → Implant): Full Library Sync
Single continuous NFC hold. Phone writes packets back-to-back, polling for MCU response between each.
```
Phone: Write SYNC_START (count=3, current=20, mode=RGBW) → SRAM
MCU: Erase library, write new header skeleton, respond OK
Phone: Poll byte 0 until 0xFF
Phone: Write WRITE_PATTERN (index=0, 112B pattern) → SRAM
MCU: Write 28 blocks to EEPROM (~140ms), respond OK
Phone: Poll byte 0 until 0xFF
Phone: Write WRITE_PATTERN (index=1, ...) → SRAM
MCU: Write 28 blocks to EEPROM, respond OK
Phone: Poll byte 0 until 0xFF
Phone: Write WRITE_PATTERN (index=2, ...) → SRAM
MCU: Write 28 blocks to EEPROM, respond OK
Phone: Poll byte 0 until 0xFF
Phone: Write SYNC_END → SRAM
MCU: Recalculate header CRC, set active=0, reprogram LP5562, respond OK
Phone: Poll byte 0 until 0xFF → done, show success
```
**Timing**: ~150ms per pattern (28 EEPROM blocks × 5ms each + I2C overhead). 9 patterns ≈ 1.4s of EEPROM writes. Total transfer including NFC overhead: under 3 seconds.
### Read (Implant → Phone): Library Download
Phone-driven — each READ_NEXT is an explicit request for the next pattern.
```
Phone: Write READ_LIBRARY → SRAM
MCU: Read header, respond with [pattern_count, active, current, mode]
Phone: Poll byte 0 until 0xFF, read header info
Phone: Write READ_NEXT (seq=1) → SRAM
MCU: Read pattern 0 from EEPROM, write 112B to response payload
Phone: Poll byte 0 until 0xFF, read pattern 0
Phone: Write READ_NEXT (seq=2) → SRAM
MCU: Read pattern 1 from EEPROM, write 112B to response payload
Phone: Poll byte 0 until 0xFF, read pattern 1
(repeat until pattern_count exhausted)
```
**Timing**: EEPROM reads are fast (~1ms per block). Full 9-pattern read-back under 1 second.
### Single-Pattern Update
For quick edits without replacing the whole library:
```
Phone: Write WRITE_PATTERN (index=2, 112B) → SRAM
MCU: Write to EEPROM, update header CRC
If index == active pattern: reprogram LP5562 immediately
Respond OK
```
## MCU Processing Flow
### M6 Idle Loop
```rust
loop {
delay_ms(200);
if fd_pin.is_low() {
process_sram_command(&mut ntag, &mut lp5562, &mut delay);
}
}
```
### Command Processing
1. Read SRAM bytes 0-3 (header)
2. Validate command ID (0x01-0x08)
3. Read remaining payload bytes based on length field
4. Execute command (see per-command logic below)
5. Write response to SRAM
6. Return to polling
### Per-Command Logic
**SYNC_START (0x05)**:
- Store target `pattern_count`, `led_current`, `led_mode` in local vars
- Write new XBLK header with count=0 (partial header, CRC updated at SYNC_END)
- Reset internal write index to 0
**WRITE_PATTERN (0x01)** (during sync or standalone):
- Validate index < 9
- Write 28 blocks to EEPROM at offset `16 + (index × 112)`, verify each block
- If standalone (not in SYNC): update header pattern count and CRC
- If `index == active_pattern`: reprogram LP5562
**SYNC_END (0x06)**:
- Update header: set pattern_count, recalculate CRC-16 over all data
- Set active_pattern = 0
- Read pattern 0, reprogram LP5562
- Clear sync state
**GET_STATUS (0x02)**:
- Read XBLK header from EEPROM
- Return `[0x01, count, active, current, mode]`
**SET_ACTIVE (0x03)**:
- Validate index < pattern_count
- Update header active_pattern byte (single EEPROM block write)
- Read new pattern, reprogram LP5562
**READ_LIBRARY (0x07)**:
- Read XBLK header
- Store pattern_count for READ_NEXT iteration
- Reset read index to 0
- Return `[count, active, current, mode]`
**READ_NEXT (0x08)**:
- Read pattern at current read index from EEPROM
- Return 112 bytes of pattern data
- Increment read index
## I2C Bus Sharing
No special handling required. LP5562 engines run autonomously on the chip's internal oscillator after programming no ongoing I2C traffic. The MCU switches between NTAG5 (0x54) and LP5562 (0x30) by address as needed. No engine pause necessary.
## Error Handling
- **Unknown command**: Respond with `BAD_CMD` (0x02)
- **EEPROM write failure**: Respond with `EEPROM_FAIL` (0x03), library may be in partial state
- **Invalid pattern index**: Respond with `INVALID_INDEX` (0x04)
- **Phone timeout**: If phone stops sending during SYNC, library is partial. Next SYNC_START will erase and restart. No persistent corruption header CRC won't match partial data, so boot falls back to hardcoded patterns.
- **Garbage in SRAM**: Bad command ID error response, resume polling. Each command is self-contained, no state to corrupt.
- **Transport integrity**: ISO15693 provides CRC-16 at the link layer no application-layer CRC needed.
## Phone-Side Polling
After writing a command to SRAM, the phone polls byte 0 via ISO15693 READ SINGLE BLOCK:
- If byte 0 != 0xFF MCU hasn't responded yet, poll again
- If byte 0 == 0xFF response ready, read full response
- Sequence number in response must match sent sequence prevents reading stale responses
- Timeout after 2 seconds per command show error to user
## Implementation Phases
1. **NTAG5 SRAM driver** `src/ntag5/sram.rs`: block read/write at 0xF8-0xFF
2. **FD pin setup** Session register config at boot, GPIO input on A1
3. **Command parser** Read SRAM, validate header, dispatch
4. **Command handlers** WRITE_PATTERN, GET_STATUS, SET_ACTIVE first
5. **Streaming sync** SYNC_START/END, READ_LIBRARY/NEXT
6. **Testing** PCSC reader with Python test script, then VivoKey RawNFC app

View File

@@ -0,0 +1,144 @@
# 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):
```rust
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).

View File

@@ -1 +1,2 @@
pub mod lp5562;
pub mod pwm;

139
src/led/pwm.rs Normal file
View File

@@ -0,0 +1,139 @@
//! TCC hardware PWM driver for 6 GPIO-direct LEDs.
//!
//! Uses TCC0 (4 channels: WO[0]-WO[3]) and TCC1 (2 channels: WO[0]-WO[1])
//! to drive 6 LEDs via current-limiting resistors.
//!
//! Pin assignments (SAMD21E target, XIAO M0 dev board):
//! LED 0: TCC0/WO[0] on PA04 (XIAO A1)
//! LED 1: TCC0/WO[1] on PA05 (XIAO A2)
//! LED 2: TCC0/WO[2] on PA06 (XIAO A3) — conflicts with I2C on XIAO
//! LED 3: TCC0/WO[3] on PA07 (XIAO A4) — conflicts with I2C on XIAO
//! LED 4: TCC1/WO[0] on PA10 (XIAO D2)
//! LED 5: TCC1/WO[1] on PA11 (XIAO D3)
//!
//! For dev board: only LEDs 0, 1, 4, 5 are usable (4 LEDs).
use crate::pattern::NUM_LEDS;
/// Initialize TCC0 and TCC1 for PWM output.
///
/// # Safety
/// Must be called once during init. Caller must ensure PM and GCLK are configured.
pub unsafe fn init() {
let pm = &*crate::pac::PM::ptr();
let gclk = &*crate::pac::GCLK::ptr();
let port = &*crate::pac::PORT::ptr();
let tcc0 = &*crate::pac::TCC0::ptr();
let tcc1 = &*crate::pac::TCC1::ptr();
// Enable GCLK0 for TCC0/TCC1 (generic clock ID 0x1A = 26)
gclk.clkctrl.write(|w| {
w.id().bits(0x1A)
.gen().gclk0()
.clken().set_bit()
});
while gclk.status.read().syncbusy().bit_is_set() {}
// Enable TCC0 and TCC1 in Power Manager
pm.apbcmask.modify(|_, w| {
w.tcc0_().set_bit()
.tcc1_().set_bit()
});
// --- Pin muxing: function E (0x04) for TCC ---
// PA04 (even in group 2)
port.pmux0_[2].modify(|_, w| w.pmuxe().bits(0x04));
port.pincfg0_[4].modify(|_, w| w.pmuxen().set_bit());
// PA05 (odd in group 2)
port.pmux0_[2].modify(|_, w| w.pmuxo().bits(0x04));
port.pincfg0_[5].modify(|_, w| w.pmuxen().set_bit());
// PA10 (even in group 5)
port.pmux0_[5].modify(|_, w| w.pmuxe().bits(0x04));
port.pincfg0_[10].modify(|_, w| w.pmuxen().set_bit());
// PA11 (odd in group 5)
port.pmux0_[5].modify(|_, w| w.pmuxo().bits(0x04));
port.pincfg0_[11].modify(|_, w| w.pmuxen().set_bit());
// --- Configure TCC0 ---
tcc0.ctrla.modify(|_, w| w.enable().clear_bit());
while tcc0.syncbusy.read().enable().bit_is_set() {}
tcc0.ctrla.write(|w| {
w.prescaler().div1()
.prescsync().presc()
});
tcc0.wave.write(|w| w.wavegen().npwm());
while tcc0.syncbusy.read().wave().bit_is_set() {}
// Period = 255 (8-bit resolution, ~31.4 kHz at 8 MHz)
tcc0.per().write(|w| w.bits(255));
while tcc0.syncbusy.read().per().bit_is_set() {}
// All channels start at 0
tcc0.cc()[0].write(|w| w.bits(0));
tcc0.cc()[1].write(|w| w.bits(0));
tcc0.cc()[2].write(|w| w.bits(0));
tcc0.cc()[3].write(|w| w.bits(0));
while tcc0.syncbusy.read().cc0().bit_is_set() {}
while tcc0.syncbusy.read().cc1().bit_is_set() {}
while tcc0.syncbusy.read().cc2().bit_is_set() {}
while tcc0.syncbusy.read().cc3().bit_is_set() {}
tcc0.ctrla.modify(|_, w| w.enable().set_bit());
while tcc0.syncbusy.read().enable().bit_is_set() {}
// --- Configure TCC1 ---
tcc1.ctrla.modify(|_, w| w.enable().clear_bit());
while tcc1.syncbusy.read().enable().bit_is_set() {}
tcc1.ctrla.write(|w| {
w.prescaler().div1()
.prescsync().presc()
});
tcc1.wave.write(|w| w.wavegen().npwm());
while tcc1.syncbusy.read().wave().bit_is_set() {}
tcc1.per().write(|w| w.bits(255));
while tcc1.syncbusy.read().per().bit_is_set() {}
tcc1.cc()[0].write(|w| w.bits(0));
tcc1.cc()[1].write(|w| w.bits(0));
while tcc1.syncbusy.read().cc0().bit_is_set() {}
while tcc1.syncbusy.read().cc1().bit_is_set() {}
tcc1.ctrla.modify(|_, w| w.enable().set_bit());
while tcc1.syncbusy.read().enable().bit_is_set() {}
}
/// Set duty cycle for a single LED channel (0-255).
#[inline]
pub fn set_duty(channel: u8, duty: u8) {
unsafe {
match channel {
0..=3 => {
let tcc0 = &*crate::pac::TCC0::ptr();
tcc0.cc()[channel as usize].write(|w| w.bits(duty as u32));
}
4..=5 => {
let tcc1 = &*crate::pac::TCC1::ptr();
tcc1.cc()[(channel - 4) as usize].write(|w| w.bits(duty as u32));
}
_ => {}
}
}
}
/// Set duty cycles for all 6 LEDs at once.
pub fn set_all(duties: &[u8; NUM_LEDS]) {
for (i, &d) in duties.iter().enumerate() {
set_duty(i as u8, d);
}
}
/// Turn all LEDs off.
pub fn all_off() {
let zeros = [0u8; NUM_LEDS];
set_all(&zeros);
}

View File

@@ -9,13 +9,32 @@ use bsp::entry;
use bsp::hal;
use bsp::pac;
use hal::clock::GenericClockController;
use hal::clock::{ClockGenId, GenericClockController};
use hal::delay::Delay;
use hal::eic::pin::{ExtInt4, Sense};
use hal::eic::EIC;
use hal::gpio::PullUpInterrupt;
use hal::prelude::*;
use pac::{CorePeripherals, Peripherals};
use pac::{interrupt, CorePeripherals, Peripherals};
use cortex_m::peripheral::NVIC;
use core::cell::RefCell;
use cortex_m::interrupt::Mutex;
mod led;
use led::lp5562::{ClockSource, Lp5562, DEFAULT_ADDRESS};
mod ntag5;
mod pattern;
use ntag5::Ntag5Link;
use ntag5::sram::MailboxState;
use ntag5::SESSION_CONFIG_REG;
use pattern::{PatternEngine, PatternDef, NUM_LEDS};
/// Global pattern engine, accessed from TC4 ISR and main loop.
static ENGINE: Mutex<RefCell<Option<PatternEngine>>> = Mutex::new(RefCell::new(None));
/// Flag set by TC4 ISR: pattern repeats are done, main loop should advance playlist.
static REPEATS_DONE: Mutex<RefCell<bool>> = Mutex::new(RefCell::new(false));
/// Blink the on-board LED n times (active-low: low=on, high=off)
fn blink<D: embedded_hal::delay::DelayNs>(led: &mut bsp::Led0, delay: &mut D, times: u8, ms: u16) {
@@ -27,10 +46,59 @@ fn blink<D: embedded_hal::delay::DelayNs>(led: &mut bsp::Led0, delay: &mut D, ti
}
}
/// Configure TC4 for 50Hz interrupt (animation tick).
unsafe fn init_tc4() {
let pm = &*pac::PM::ptr();
let gclk = &*pac::GCLK::ptr();
let tc4 = &*pac::TC4::ptr();
// Enable GCLK0 for TC4/TC5 (generic clock ID 0x1C = 28)
gclk.clkctrl.write(|w| {
w.id().bits(0x1C) // GCLK_TC4_TC5
.gen().gclk0()
.clken().set_bit()
});
while gclk.status.read().syncbusy().bit_is_set() {}
// Enable TC4 in Power Manager
pm.apbcmask.modify(|_, w| w.tc4_().set_bit());
// Disable TC4 first
tc4.count16().ctrla.modify(|_, w| w.enable().clear_bit());
while tc4.count16().status.read().syncbusy().bit_is_set() {}
// Software reset
tc4.count16().ctrla.modify(|_, w| w.swrst().set_bit());
while tc4.count16().status.read().syncbusy().bit_is_set() {}
// Configure: 16-bit mode, prescaler /256, match frequency mode
// 8 MHz / 256 = 31250 Hz. For 50 Hz: 31250 / 50 = 625 counts.
tc4.count16().ctrla.write(|w| {
w.mode().count16()
.prescaler().div256()
.wavegen().mfrq()
});
// Set compare value for 50Hz
tc4.count16().cc[0].write(|w| unsafe { w.cc().bits(624) }); // 625 - 1
// Enable MC0 interrupt
tc4.count16().intenset.write(|w| w.mc0().set_bit());
// Enable TC4
tc4.count16().ctrla.modify(|_, w| w.enable().set_bit());
while tc4.count16().status.read().syncbusy().bit_is_set() {}
}
#[entry]
fn main() -> ! {
let mut peripherals = Peripherals::take().unwrap();
let core = CorePeripherals::take().unwrap();
let mut core = CorePeripherals::take().unwrap();
// Boot on internal oscillator first — minimal current draw (~0.3mA at 1MHz).
// This gives EH VOUT time to stabilize before we switch to 8MHz and init peripherals.
// Busy-wait ~50ms for VOUT ramp-up before doing anything.
cortex_m::asm::delay(50_000); // ~50ms at 1MHz internal oscillator
let mut clocks = GenericClockController::with_external_32kosc(
peripherals.GCLK,
@@ -46,16 +114,40 @@ fn main() -> ! {
let mut led: bsp::Led0 = pins.led0.into_push_pull_output();
led.set_high().unwrap(); // LED off (active low)
// === DIAGNOSTIC: 3 slow blinks = firmware is alive ===
blink(&mut led, &mut delay, 3, 200);
delay.delay_ms(500u32);
// === DIAGNOSTIC: 3 blinks = firmware is alive ===
blink(&mut led, &mut delay, 3, 250);
delay.delay_ms(1500u32);
// LP5562 hardware enable on D0/A0 — drive high to power on
let mut lp_en = pins.a0.into_push_pull_output();
lp_en.set_high().unwrap();
delay.delay_ms(10u32); // Let LP5562 power stabilize
// --- EIC setup for FD pin wake from STANDBY ---
let gclk2 = clocks
.configure_gclk_divider_and_source(
ClockGenId::GCLK2,
1,
pac::gclk::genctrl::SRCSELECT_A::OSC8M,
false,
)
.unwrap();
let eic_clock = clocks.eic(&gclk2).unwrap();
let mut eic = EIC::init(&mut peripherals.PM, eic_clock, peripherals.EIC);
// I2C on A4 (SDA) / A5 (SCL) at 400 kHz
// FD pin on A1 (PA04) → ExtInt4, pull-up (FD is open-drain)
let fd_pin: hal::gpio::Pin<_, PullUpInterrupt> = pins.a1.into();
let mut extint4 = ExtInt4::new(fd_pin);
extint4.sense(&mut eic, Sense::FALL);
extint4.filter(&mut eic, true);
extint4.enable_interrupt(&mut eic);
extint4.enable_interrupt_wake(&mut eic);
// Enable EIC interrupt in NVIC
unsafe {
core.NVIC.set_priority(interrupt::EIC, 2);
NVIC::unmask(interrupt::EIC);
}
// --- Initialize TCC PWM for LEDs ---
unsafe { led::pwm::init(); }
// I2C on A4 (SDA) / A5 (SCL) at 400 kHz — for NTAG5 only
let i2c = bsp::i2c_master(
&mut clocks,
400u32.kHz(),
@@ -65,48 +157,276 @@ fn main() -> ! {
pins.a5,
);
let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS);
let mut ntag = Ntag5Link::new(i2c, ntag5::DEFAULT_ADDRESS);
// Try to initialize LP5562 — blink error pattern on failure
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)?;
Ok(())
})();
// Default power budget
let mut budget: u8 = pattern::DEFAULT_BUDGET;
match i2c_ok {
Ok(()) => {
// === DIAGNOSTIC: solid LED on = LP5562 init OK ===
led.set_low().unwrap();
delay.delay_ms(1000u32);
// Write all xblink config to persistent EEPROM (CONFIG + EH + ED).
// Only needs to succeed once — subsequent boots will already have it.
let provision_ok = ntag.provision_all_config(&mut delay).is_ok();
// M1 smoke test: cycle through RGBW channels
let on: u8 = 255;
let step: u16 = 500;
// Set session registers for this boot (persistent CONFIG stays at safe defaults).
let eh_ok = ntag.configure_eh_session().is_ok();
let cfg_ok = ntag.configure_config_session().is_ok();
loop {
lp.set_all_pwm(on, 0, 0, 0).ok(); // Blue
delay.delay_ms(step as u32);
lp.set_all_pwm(0, on, 0, 0).ok(); // Green
delay.delay_ms(step as u32);
lp.set_all_pwm(0, 0, on, 0).ok(); // Red
delay.delay_ms(step as u32);
lp.set_all_pwm(0, 0, 0, on).ok(); // White
delay.delay_ms(step as u32);
lp.set_all_pwm(on, on, on, on).ok(); // All on
delay.delay_ms(step as u32);
let _ = lp.all_off();
delay.delay_ms(step as u32);
// Write full diagnostic to NDEF text record (readable via phone NFC)
{
let mut buf = [0u8; 128];
let mut i = 0;
fn append(buf: &mut [u8], i: &mut usize, data: &[u8]) {
for &b in data {
if *i < buf.len() { buf[*i] = b; *i += 1; }
}
}
Err(_) => {
// === DIAGNOSTIC: fast blink forever = I2C error ===
loop {
blink(&mut led, &mut delay, 5, 80);
fn hex(buf: &mut [u8], i: &mut usize, val: u8) {
const H: &[u8; 16] = b"0123456789ABCDEF";
if *i + 1 < buf.len() {
buf[*i] = H[(val >> 4) as usize]; *i += 1;
buf[*i] = H[(val & 0x0F) as usize]; *i += 1;
}
}
// Config check results
match ntag.check_config() {
Ok(result) => {
append(&mut buf, &mut i, b"CFG:");
if result.all_ok { append(&mut buf, &mut i, b"OK "); }
else { append(&mut buf, &mut i, b"BAD "); }
for c in &result.checks {
append(&mut buf, &mut i, &c.name);
append(&mut buf, &mut i, b":");
hex(&mut buf, &mut i, c.actual);
if !c.ok {
append(&mut buf, &mut i, b"!=");
hex(&mut buf, &mut i, c.expected);
}
append(&mut buf, &mut i, b" ");
}
}
Err(_) => { append(&mut buf, &mut i, b"CFG:I2C_ERR "); }
}
// Config provision result
append(&mut buf, &mut i, b"PROV:");
if provision_ok { append(&mut buf, &mut i, b"OK "); }
else { append(&mut buf, &mut i, b"FAIL "); }
// EH session register readback
append(&mut buf, &mut i, b"EH:");
if eh_ok {
if let Ok(v) = ntag.read_register(ntag5::SESSION_EH_CONFIG_REG, 0) {
hex(&mut buf, &mut i, v);
// Also read EH_LOAD_OK status (bit 7)
if v & 0x80 != 0 { append(&mut buf, &mut i, b"/LOAD_OK"); }
else { append(&mut buf, &mut i, b"/no_load"); }
} else {
append(&mut buf, &mut i, b"RD_ERR");
}
} else {
append(&mut buf, &mut i, b"WR_ERR");
}
append(&mut buf, &mut i, b" ");
// ED/FD config readback
append(&mut buf, &mut i, b"ED:");
if let Ok(v) = ntag.read_register(ntag5::SESSION_EH_CONFIG_REG, 2) {
hex(&mut buf, &mut i, v);
} else {
append(&mut buf, &mut i, b"ERR");
}
// Write as NDEF text record
let _ = ntag.write_ndef_text(&buf[..i], &mut delay);
}
blink(&mut led, &mut delay, 1, 250);
delay.delay_ms(500u32);
// --- Try loading pattern from EEPROM ---
let mut header_buf = [0u8; pattern::HEADER_SIZE];
let eeprom_ok = ntag.read_memory(pattern::LIBRARY_BASE_BLOCK, &mut header_buf).ok()
.and_then(|()| pattern::parse_header(&header_buf));
let mut active_pattern: Option<PatternDef> = None;
if let Some(ref hdr) = eeprom_ok {
budget = hdr.budget;
let pat_block = pattern::LIBRARY_BASE_BLOCK
+ (pattern::HEADER_SIZE as u16 / 4)
+ (hdr.active_index as u16 * (pattern::PATTERN_ENTRY_SIZE as u16 / 4));
let mut pat_buf = [0u8; pattern::PATTERN_ENTRY_SIZE];
if ntag.read_memory(pat_block, &mut pat_buf).is_ok() {
active_pattern = pattern::parse_pattern_entry(&pat_buf);
}
}
if active_pattern.is_none() {
// No XBLK in EEPROM — self-provision default patterns
blink(&mut led, &mut delay, 2, 350);
let patterns = [
pattern::breathe(),
pattern::heartbeat(),
pattern::wave_chase(),
pattern::slow_pulse(),
pattern::alternating_blink(),
];
let pat_base_block = pattern::LIBRARY_BASE_BLOCK
+ (pattern::HEADER_SIZE as u16 / 4);
let mut provision_ok = true;
for (i, pat) in patterns.iter().enumerate() {
let mut entry_buf = [0u8; pattern::PATTERN_ENTRY_SIZE];
pattern::serialize_pattern_entry(pat, &mut entry_buf);
let entry_block = pat_base_block
+ (i as u16 * (pattern::PATTERN_ENTRY_SIZE as u16 / 4));
for blk in 0..(pattern::PATTERN_ENTRY_SIZE as u16 / 4) {
let bo = (blk as usize) * 4;
let chunk = [entry_buf[bo], entry_buf[bo+1], entry_buf[bo+2], entry_buf[bo+3]];
if ntag.write_verify_block(entry_block + blk, &chunk, &mut delay).is_err() {
provision_ok = false;
break;
}
}
if !provision_ok { break; }
}
if provision_ok {
let mut hdr_buf = [0u8; pattern::HEADER_SIZE];
pattern::serialize_header(
5, 0, budget, false, 0, &mut hdr_buf,
);
for blk in 0..4u16 {
let bo = (blk as usize) * 4;
let chunk = [hdr_buf[bo], hdr_buf[bo+1], hdr_buf[bo+2], hdr_buf[bo+3]];
if ntag.write_verify_block(
pattern::LIBRARY_BASE_BLOCK + blk, &chunk, &mut delay,
).is_err() {
provision_ok = false;
break;
}
}
}
if provision_ok {
blink(&mut led, &mut delay, 4, 250);
active_pattern = Some(pattern::breathe());
} else {
blink(&mut led, &mut delay, 8, 200);
// Fall back to hardcoded breathe
active_pattern = Some(pattern::breathe());
}
} else {
blink(&mut led, &mut delay, 3, 250); // EEPROM pattern loaded
}
// --- Start animation ---
if let Some(pat) = active_pattern {
cortex_m::interrupt::free(|cs| {
ENGINE.borrow(cs).replace(Some(PatternEngine::new(pat, budget)));
});
}
// Start TC4 (50Hz animation timer) and enable its interrupt
unsafe {
init_tc4();
core.NVIC.set_priority(interrupt::TC4, 1); // Higher priority than EIC
NVIC::unmask(interrupt::TC4);
}
// === DIAGNOSTIC: blink arbiter mode before entering idle loop ===
if let Ok(c1) = ntag.read_register(SESSION_CONFIG_REG, 1) {
let arbiter = (c1 >> 2) & 0x03;
blink(&mut led, &mut delay, arbiter + 1, 150);
delay.delay_ms(500u32);
let sram_en = (c1 >> 1) & 0x01;
blink(&mut led, &mut delay, sram_en + 1, 400);
} else {
blink(&mut led, &mut delay, 9, 100);
}
delay.delay_ms(1500u32);
led.set_high().unwrap(); // LED off
// ============================================================
// Sleep/Wake loop — MCU in IDLE, TC4 ISR drives LEDs.
// Wake fully on EIC (FD pin) for SRAM mailbox commands.
// ============================================================
let mut mbox = MailboxState::new();
loop {
// IDLE sleep (not STANDBY) — TC4 and TCC keep running
cortex_m::asm::wfi();
// Check if woken by EIC (FD pin = SRAM write by RF)
let eic_reg = unsafe { &*pac::EIC::ptr() };
if eic_reg.intflag.read().extint4().bit_is_set() {
extint4.clear_interrupt();
// Process SRAM mailbox command
let cmd_ok = ntag5::sram::process_command(
&mut ntag, &mut mbox, &mut delay,
);
if matches!(cmd_ok, Ok(true)) {
// Pattern library was modified — reload from EEPROM
let mut hdr_buf = [0u8; pattern::HEADER_SIZE];
if ntag.read_memory(pattern::LIBRARY_BASE_BLOCK, &mut hdr_buf).is_ok() {
if let Some(hdr) = pattern::parse_header(&hdr_buf) {
budget = hdr.budget;
let pat_block = pattern::LIBRARY_BASE_BLOCK
+ (pattern::HEADER_SIZE as u16 / 4)
+ (hdr.active_index as u16
* (pattern::PATTERN_ENTRY_SIZE as u16 / 4));
let mut pat_buf = [0u8; pattern::PATTERN_ENTRY_SIZE];
if ntag.read_memory(pat_block, &mut pat_buf).is_ok() {
if let Some(new_pat) = pattern::parse_pattern_entry(&pat_buf) {
cortex_m::interrupt::free(|cs| {
if let Some(ref mut eng) = *ENGINE.borrow(cs).borrow_mut() {
eng.budget = budget as u16;
eng.load(new_pat);
}
});
}
}
}
}
}
}
}
}
/// TC4 interrupt handler — 50Hz animation tick.
/// Computes LED brightness from pattern engine and writes TCC duty cycles.
#[interrupt]
fn TC4() {
let tc4 = unsafe { &*pac::TC4::ptr() };
// Clear MC0 interrupt flag
tc4.count16().intflag.write(|w| w.mc0().set_bit());
cortex_m::interrupt::free(|cs| {
if let Some(ref mut engine) = *ENGINE.borrow(cs).borrow_mut() {
let mut output = [0u8; NUM_LEDS];
engine.tick(&mut output);
led::pwm::set_all(&output);
if engine.repeats_done() {
*REPEATS_DONE.borrow(cs).borrow_mut() = true;
}
}
});
}
/// EIC interrupt handler — clears flag, main loop checks and processes.
#[interrupt]
fn EIC() {
// Don't clear here — let main loop detect it via intflag read
// Just need the handler to exist so WFI returns
let eic = unsafe { &*pac::EIC::ptr() };
if eic.intflag.read().extint4().bit_is_set() {
eic.intflag.modify(|_, w| w.extint4().set_bit());
}
}

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

@@ -0,0 +1,446 @@
/// 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]
pub mod sram;
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
// SRAM I2C block address range (64 blocks × 4 bytes = 256 bytes)
pub const SRAM_BASE_BLOCK: u16 = 0x10F8;
pub const SRAM_BLOCK_COUNT: u16 = 64;
pub const SRAM_SIZE: usize = 256;
// Config EEPROM I2C block addresses (NFC block 0x3D → I2C 0x103D)
pub const CONFIG_EH_BLOCK: u16 = 0x103D; // EH_CONFIG block (persistent)
pub const CONFIG_DEV_SEC_BLOCK: u16 = 0x103F; // DEV_SEC_CONFIG block
// ED/FD pin config is byte 2 within the EH_CONFIG block (0x103D / session 0x10A7)
// ED_CONFIG values (from NTP53x2 datasheet / ntag5link.py):
// 0x00 = disabled
// 0x01 = NFC field detect
// 0x04 = NFC-to-I2C pass-through (SRAM write by RF)
// 0x0C = write to synch block
pub const ED_CONFIG_NFC_TO_I2C_PASS_THROUGH: u8 = 0x04;
// 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: EH_ENABLE + VOUT_V_SEL_1_8V + DISABLE_POWER_CHECK + VOUT_I_SEL_6_5mA = 0x59
// Bit 0: EH_ENABLE = 1
// Bits 2:1: VOUT_V_SEL = 00 (1.8V)
// Bit 3: DISABLE_POWER_CHECK = 1 (skip power check, VOUT comes up immediately)
// Bits 6:4: VOUT_I_SEL = 101 (6.5mA)
pub const EXPECTED_EH_CONFIG: u8 = 0x59;
// Session EH trigger bit (bit 3 in session register = EH_TRIGGER, different from persistent bit 3)
pub const EH_SESSION_TRIGGER: u8 = 0x08;
// 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: check EH_ENABLE (bit 0) + VOUT_V_SEL (bits 2:1), ignore current limit + status bits
pub const EH_CONFIG_MASK: u8 = 0x07;
#[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])
}
/// Write a single session register byte (WRITE REGISTER command).
/// Protocol: write [BL_AD1, BL_AD0, REGA, MASK, REGDATA].
/// The MASK selects which bits to modify (1 = modify, 0 = keep).
pub fn write_register(&mut self, block: u16, reg_addr: u8, mask: u8, data: u8) -> Result<(), E> {
let addr_bytes = block.to_be_bytes();
self.i2c.write(
self.addr,
&[addr_bytes[0], addr_bytes[1], reg_addr, mask, data],
)
}
// ---- SRAM access ----
/// Read N bytes from SRAM starting at byte offset 0.
/// SRAM is at I2C blocks 0x10F8-0x10FF (64 blocks × 4 bytes = 256 bytes).
/// Always reads from the start of SRAM (block-aligned).
pub fn read_sram(&mut self, buf: &mut [u8]) -> Result<(), E> {
self.read_memory(SRAM_BASE_BLOCK, buf)
}
/// Write data to SRAM starting at block offset from SRAM base.
/// Data length must be a multiple of 4 (NTAG5 block size).
pub fn write_sram_blocks(&mut self, block_offset: u16, data: &[u8]) -> Result<(), E> {
for (i, chunk) in data.chunks(4).enumerate() {
let mut block_data = [0u8; 4];
for (j, &b) in chunk.iter().enumerate() {
block_data[j] = b;
}
self.write_memory_block(SRAM_BASE_BLOCK + block_offset + i as u16, &block_data)?;
}
Ok(())
}
/// Configure ED/FD pin for SRAM-write-by-RF indication.
/// FD goes low when phone writes to SRAM, returns high when MCU reads SRAM.
///
/// Sets ED_CONFIG via session register (volatile, resets on power cycle).
pub fn configure_fd_sram_write(&mut self) -> Result<(), E> {
self.write_register(
SESSION_EH_CONFIG_REG, 2, 0xFF,
ED_CONFIG_NFC_TO_I2C_PASS_THROUGH,
)
}
/// Enable EH via session register (volatile, must run each boot).
///
/// Config EEPROM (block 0x3D) is NOT writable from I2C — only from NFC
/// using WRITE CONFIG (0xC1) via PCSC reader. Session register writes
/// work immediately but reset on power cycle.
///
/// Session register 0x10A7: byte 0 = EH_CONFIG, byte 2 = ED_CONFIG
pub fn configure_eh_session(&mut self) -> Result<(), E> {
// Write EH_CONFIG + EH_TRIGGER to session register byte 0
// Session bit 3 = EH_TRIGGER (different from persistent bit 3 = DISABLE_POWER_CHECK)
self.write_register(SESSION_EH_CONFIG_REG, 0, 0xFF, EXPECTED_EH_CONFIG | EH_SESSION_TRIGGER)?;
// Write ED_CONFIG to session register byte 2 (FD pin = SRAM pass-through)
self.write_register(SESSION_EH_CONFIG_REG, 2, 0xFF, ED_CONFIG_NFC_TO_I2C_PASS_THROUGH)?;
Ok(())
}
/// Write EH_CONFIG + ED_CONFIG to persistent config EEPROM (block 0x103D).
/// This must be done via I2C when no NFC field is present (USB-powered).
/// After writing, the NTAG5 will automatically enable EH on subsequent power-ups.
/// Block format: [EH_CONFIG, 0x00, ED_CONFIG, 0x00]
pub fn provision_eh_persistent(
&mut self,
delay: &mut impl embedded_hal::delay::DelayNs,
) -> Result<(), Error<E>> {
let data = [EXPECTED_EH_CONFIG, 0x00, ED_CONFIG_NFC_TO_I2C_PASS_THROUGH, 0x00];
self.write_verify_block(CONFIG_EH_BLOCK, &data, delay)
}
/// Write EH config to persistent EEPROM and reset CONFIG to defaults.
///
/// NOTE: CONFIG_0/CONFIG_1 must NOT be persisted with SRAM passthrough —
/// it breaks NFC EEPROM access when I2C bus is floating (MCU unpowered).
/// Use configure_config_session() to set CONFIG at boot instead.
pub fn provision_all_config(
&mut self,
delay: &mut impl embedded_hal::delay::DelayNs,
) -> Result<(), Error<E>> {
// Write EH + ED config FIRST (most important for automatic power-up)
let eh_data = [EXPECTED_EH_CONFIG, 0x00, ED_CONFIG_NFC_TO_I2C_PASS_THROUGH, 0x00];
self.write_memory_block(CONFIG_EH_BLOCK, &eh_data)?;
delay.delay_ms(5); // EEPROM write cycle
// Then CONFIG_0 (EH_MODE=low field strength), clear CONFIG_1
let config_data = [EXPECTED_CONFIG_0, 0x00, 0x00, 0x00];
self.write_memory_block(0x1037, &config_data)?;
delay.delay_ms(5);
Ok(())
}
/// Set CONFIG_1 via session register (volatile, safe).
/// Enables SRAM passthrough only while MCU is active on the I2C bus.
pub fn configure_config_session(&mut self) -> Result<(), E> {
self.write_register(SESSION_CONFIG_REG, 1, 0xFF, EXPECTED_CONFIG_1)?;
Ok(())
}
/// Read DEV_SEC_CONFIG block (0x103F) to check security/protection status.
/// Returns the 4 raw bytes: [DEV_SEC_CONFIG, SRAM_CONF_PROT, PP_AREA1_LSB, PP_AREA1_MSB]
pub fn read_dev_sec_config(&mut self) -> Result<[u8; 4], E> {
let mut buf = [0u8; 4];
self.read_memory(CONFIG_DEV_SEC_BLOCK, &mut buf)?;
Ok(buf)
}
// ---- 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)
}
}

466
src/ntag5/sram.rs Normal file
View File

@@ -0,0 +1,466 @@
//! SRAM mailbox command/response protocol for NFC<->MCU communication.
//!
//! The phone writes a command packet to NTAG5Link SRAM via NFC.
//! The MCU reads it over I2C, processes the command, and writes
//! a response packet back to SRAM for the phone to read.
use crate::ntag5::{self, Ntag5Link, SRAM_SIZE};
use crate::pattern;
use embedded_hal::i2c::I2c;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// Firmware version reported in GET_STATUS response.
pub const FIRMWARE_VERSION: u8 = 0x02;
// Command IDs (phone -> MCU)
pub const CMD_WRITE_PATTERN: u8 = 0x01;
pub const CMD_GET_STATUS: u8 = 0x02;
pub const CMD_SET_ACTIVE: u8 = 0x03;
pub const CMD_SYNC_START: u8 = 0x05;
pub const CMD_SYNC_END: u8 = 0x06;
pub const CMD_READ_LIBRARY: u8 = 0x07;
pub const CMD_READ_NEXT: u8 = 0x08;
// Response marker
pub const RESPONSE_MARKER: u8 = 0xFF;
// Status codes
pub const STATUS_OK: u8 = 0x00;
pub const STATUS_BAD_CMD: u8 = 0x02;
pub const STATUS_EEPROM_FAIL: u8 = 0x03;
pub const STATUS_INVALID_INDEX: u8 = 0x04;
/// Command header size (cmd + seq + payload_len_u16).
pub const CMD_HEADER_SIZE: usize = 4;
/// Response header size (marker + seq + status + payload_len).
pub const RSP_HEADER_SIZE: usize = 4;
// ---------------------------------------------------------------------------
// Mailbox state
// ---------------------------------------------------------------------------
/// Persistent state for the mailbox protocol across commands.
pub struct MailboxState {
/// True while a SYNC_START..SYNC_END sequence is in progress.
pub syncing: bool,
/// Pattern count supplied by SYNC_START.
pub sync_count: u8,
/// Budget supplied by SYNC_START.
pub sync_budget: u8,
/// Next pattern index for READ_NEXT.
pub read_index: u8,
/// Total patterns available for READ_NEXT iteration.
pub read_count: u8,
}
impl MailboxState {
pub fn new() -> Self {
Self {
syncing: false,
sync_count: 0,
sync_budget: pattern::DEFAULT_BUDGET,
read_index: 0,
read_count: 0,
}
}
}
// ---------------------------------------------------------------------------
// Protocol parse / build
// ---------------------------------------------------------------------------
/// Parse the 4-byte command header. Returns (cmd, seq, payload_len) or None
/// if the buffer is too short or the command ID is out of range.
fn parse_header(buf: &[u8]) -> Option<(u8, u8, u16)> {
if buf.len() < CMD_HEADER_SIZE {
return None;
}
let cmd = buf[0];
if cmd == 0 || cmd >= 0x80 {
return None;
}
let seq = buf[1];
let payload_len = (buf[2] as u16) | ((buf[3] as u16) << 8); // LE
Some((cmd, seq, payload_len))
}
/// Build a response packet into `buf`. Returns the total number of bytes
/// written (padded to a multiple of 4 for SRAM block alignment).
fn build_response(buf: &mut [u8], seq: u8, status: u8, payload: &[u8]) -> usize {
let plen = payload.len();
buf[0] = RESPONSE_MARKER;
buf[1] = seq;
buf[2] = status;
buf[3] = plen as u8;
buf[RSP_HEADER_SIZE..RSP_HEADER_SIZE + plen].copy_from_slice(payload);
// Pad total length to multiple of 4
let raw_len = RSP_HEADER_SIZE + plen;
let padded = (raw_len + 3) & !3;
for b in buf[raw_len..padded].iter_mut() {
*b = 0;
}
padded
}
// ---------------------------------------------------------------------------
// EEPROM helpers
// ---------------------------------------------------------------------------
/// Block address for pattern N in the EEPROM library (v2: 16-byte entries = 4 blocks each).
fn pattern_block(index: u8) -> u16 {
pattern::LIBRARY_BASE_BLOCK
+ (pattern::HEADER_SIZE as u16 / 4)
+ (index as u16) * (pattern::PATTERN_ENTRY_SIZE as u16 / 4)
}
/// Read the XBLK library header from EEPROM.
fn read_library_header<I2C, E>(
ntag: &mut Ntag5Link<I2C>,
) -> Result<Option<pattern::LibraryHeader>, ntag5::Error<E>>
where
I2C: I2c<Error = E>,
{
let mut hdr_buf = [0u8; pattern::HEADER_SIZE];
ntag.read_memory(pattern::LIBRARY_BASE_BLOCK, &mut hdr_buf)?;
Ok(pattern::parse_header(&hdr_buf))
}
/// Write the 16-byte header to EEPROM (4 blocks starting at LIBRARY_BASE_BLOCK).
fn write_header_to_eeprom<I2C, E>(
ntag: &mut Ntag5Link<I2C>,
hdr: &[u8; pattern::HEADER_SIZE],
delay: &mut impl embedded_hal::delay::DelayNs,
) -> Result<(), ntag5::Error<E>>
where
I2C: I2c<Error = E>,
{
for i in 0..4u16 {
let mut chunk = [0u8; 4];
let off = (i as usize) * 4;
chunk.copy_from_slice(&hdr[off..off + 4]);
ntag.write_verify_block(pattern::LIBRARY_BASE_BLOCK + i, &chunk, delay)?;
}
Ok(())
}
/// Recalculate the header CRC and write the updated header back to EEPROM.
fn recalculate_header_crc<I2C, E>(
ntag: &mut Ntag5Link<I2C>,
count: u8,
active: u8,
budget: u8,
has_playlist: bool,
playlist_count: u8,
delay: &mut impl embedded_hal::delay::DelayNs,
) -> Result<(), ntag5::Error<E>>
where
I2C: I2c<Error = E>,
{
let mut hdr = [0u8; pattern::HEADER_SIZE];
pattern::serialize_header(count, active, budget, has_playlist, playlist_count, &mut hdr);
write_header_to_eeprom(ntag, &hdr, delay)
}
/// After a standalone WRITE_PATTERN (not during sync), re-read the header
/// and recalculate its CRC to account for the new pattern data.
fn update_header_after_write<I2C, E>(
ntag: &mut Ntag5Link<I2C>,
delay: &mut impl embedded_hal::delay::DelayNs,
) -> Result<(), ntag5::Error<E>>
where
I2C: I2c<Error = E>,
{
let hdr = match read_library_header(ntag)? {
Some(h) => h,
None => return Ok(()),
};
recalculate_header_crc(
ntag,
hdr.pattern_count,
hdr.active_index,
hdr.budget,
hdr.has_playlist,
hdr.playlist_count,
delay,
)
}
// ---------------------------------------------------------------------------
// Command handlers
// ---------------------------------------------------------------------------
/// GET_STATUS (0x02): Return firmware version + library summary.
fn handle_get_status<I2C, E>(
ntag: &mut Ntag5Link<I2C>,
seq: u8,
rsp_buf: &mut [u8],
) -> Result<usize, ntag5::Error<E>>
where
I2C: I2c<Error = E>,
{
let mut payload = [0u8; 4];
payload[0] = FIRMWARE_VERSION;
match read_library_header(ntag)? {
Some(h) => {
payload[1] = h.pattern_count;
payload[2] = h.active_index;
payload[3] = h.budget;
}
None => {}
}
Ok(build_response(rsp_buf, seq, STATUS_OK, &payload))
}
/// WRITE_PATTERN (0x01): Write a 16-byte pattern entry to EEPROM.
fn handle_write_pattern<I2C, E>(
ntag: &mut Ntag5Link<I2C>,
state: &MailboxState,
seq: u8,
payload: &[u8],
rsp_buf: &mut [u8],
delay: &mut impl embedded_hal::delay::DelayNs,
) -> Result<usize, ntag5::Error<E>>
where
I2C: I2c<Error = E>,
{
if payload.len() < 1 + pattern::PATTERN_ENTRY_SIZE {
return Ok(build_response(rsp_buf, seq, STATUS_INVALID_INDEX, &[]));
}
let index = payload[0];
if index as usize >= pattern::MAX_PATTERNS {
return Ok(build_response(rsp_buf, seq, STATUS_INVALID_INDEX, &[]));
}
// Write 16 bytes = 4 blocks
let base_block = pattern_block(index);
let pattern_data = &payload[1..1 + pattern::PATTERN_ENTRY_SIZE];
let blocks = pattern::PATTERN_ENTRY_SIZE / 4;
for i in 0..blocks as u16 {
let off = (i as usize) * 4;
let mut chunk = [0u8; 4];
chunk.copy_from_slice(&pattern_data[off..off + 4]);
match ntag.write_verify_block(base_block + i, &chunk, delay) {
Ok(()) => {}
Err(ntag5::Error::VerifyFailed) => {
return Ok(build_response(rsp_buf, seq, STATUS_EEPROM_FAIL, &[]));
}
Err(e) => return Err(e),
}
}
// If not syncing, update header CRC
if !state.syncing {
match update_header_after_write(ntag, delay) {
Ok(()) => {}
Err(ntag5::Error::VerifyFailed) => {
return Ok(build_response(rsp_buf, seq, STATUS_EEPROM_FAIL, &[]));
}
Err(e) => return Err(e),
}
}
Ok(build_response(rsp_buf, seq, STATUS_OK, &[]))
}
/// SET_ACTIVE (0x03): Change the active pattern index in the EEPROM header.
fn handle_set_active<I2C, E>(
ntag: &mut Ntag5Link<I2C>,
seq: u8,
payload: &[u8],
rsp_buf: &mut [u8],
delay: &mut impl embedded_hal::delay::DelayNs,
) -> Result<usize, ntag5::Error<E>>
where
I2C: I2c<Error = E>,
{
if payload.is_empty() {
return Ok(build_response(rsp_buf, seq, STATUS_INVALID_INDEX, &[]));
}
let index = payload[0];
let hdr = match read_library_header(ntag)? {
Some(h) => h,
None => return Ok(build_response(rsp_buf, seq, STATUS_EEPROM_FAIL, &[])),
};
if index >= hdr.pattern_count {
return Ok(build_response(rsp_buf, seq, STATUS_INVALID_INDEX, &[]));
}
match recalculate_header_crc(
ntag, hdr.pattern_count, index, hdr.budget,
hdr.has_playlist, hdr.playlist_count, delay,
) {
Ok(()) => {}
Err(ntag5::Error::VerifyFailed) => {
return Ok(build_response(rsp_buf, seq, STATUS_EEPROM_FAIL, &[]));
}
Err(e) => return Err(e),
}
Ok(build_response(rsp_buf, seq, STATUS_OK, &[]))
}
/// SYNC_START (0x05): Begin a bulk pattern upload session.
fn handle_sync_start(
state: &mut MailboxState,
seq: u8,
payload: &[u8],
rsp_buf: &mut [u8],
) -> usize {
if payload.len() < 2 {
return build_response(rsp_buf, seq, STATUS_BAD_CMD, &[]);
}
let count = payload[0];
if count == 0 || count as usize > pattern::MAX_PATTERNS {
return build_response(rsp_buf, seq, STATUS_INVALID_INDEX, &[]);
}
state.syncing = true;
state.sync_count = count;
state.sync_budget = payload[1];
build_response(rsp_buf, seq, STATUS_OK, &[])
}
/// SYNC_END (0x06): Finalize bulk upload — write the XBLK header with CRC.
fn handle_sync_end<I2C, E>(
ntag: &mut Ntag5Link<I2C>,
state: &mut MailboxState,
seq: u8,
rsp_buf: &mut [u8],
delay: &mut impl embedded_hal::delay::DelayNs,
) -> Result<usize, ntag5::Error<E>>
where
I2C: I2c<Error = E>,
{
if !state.syncing {
return Ok(build_response(rsp_buf, seq, STATUS_BAD_CMD, &[]));
}
match recalculate_header_crc(
ntag, state.sync_count, 0, state.sync_budget, false, 0, delay,
) {
Ok(()) => {}
Err(ntag5::Error::VerifyFailed) => {
state.syncing = false;
return Ok(build_response(rsp_buf, seq, STATUS_EEPROM_FAIL, &[]));
}
Err(e) => {
state.syncing = false;
return Err(e);
}
}
state.syncing = false;
Ok(build_response(rsp_buf, seq, STATUS_OK, &[]))
}
/// READ_LIBRARY (0x07): Return library summary and prepare for READ_NEXT.
fn handle_read_library<I2C, E>(
ntag: &mut Ntag5Link<I2C>,
state: &mut MailboxState,
seq: u8,
rsp_buf: &mut [u8],
) -> Result<usize, ntag5::Error<E>>
where
I2C: I2c<Error = E>,
{
let mut payload = [0u8; 3];
match read_library_header(ntag)? {
Some(h) => {
payload[0] = h.pattern_count;
payload[1] = h.active_index;
payload[2] = h.budget;
state.read_index = 0;
state.read_count = h.pattern_count;
}
None => {
state.read_index = 0;
state.read_count = 0;
}
}
Ok(build_response(rsp_buf, seq, STATUS_OK, &payload))
}
/// READ_NEXT (0x08): Return the next pattern entry (16 bytes).
fn handle_read_next<I2C, E>(
ntag: &mut Ntag5Link<I2C>,
state: &mut MailboxState,
seq: u8,
rsp_buf: &mut [u8],
) -> Result<usize, ntag5::Error<E>>
where
I2C: I2c<Error = E>,
{
if state.read_index >= state.read_count {
return Ok(build_response(rsp_buf, seq, STATUS_INVALID_INDEX, &[]));
}
let mut entry = [0u8; pattern::PATTERN_ENTRY_SIZE];
let block = pattern_block(state.read_index);
ntag.read_memory(block, &mut entry)?;
state.read_index += 1;
Ok(build_response(rsp_buf, seq, STATUS_OK, &entry))
}
// ---------------------------------------------------------------------------
// Main entry point
// ---------------------------------------------------------------------------
/// Read SRAM, dispatch the command, and write the response back.
///
/// Returns `Ok(true)` if a command was processed that modifies the pattern library,
/// `Ok(false)` if no valid command or no library change.
pub fn process_command<I2C, E>(
ntag: &mut Ntag5Link<I2C>,
state: &mut MailboxState,
delay: &mut impl embedded_hal::delay::DelayNs,
) -> Result<bool, ntag5::Error<E>>
where
I2C: I2c<Error = E>,
{
// Read full SRAM
let mut sram = [0u8; SRAM_SIZE];
ntag.read_sram(&mut sram)?;
// Parse header
let (cmd, seq, payload_len) = match parse_header(&sram) {
Some(h) => h,
None => return Ok(false),
};
// Bounds check
let total = CMD_HEADER_SIZE + payload_len as usize;
if total > SRAM_SIZE {
return Ok(false);
}
let payload = &sram[CMD_HEADER_SIZE..total];
// Dispatch
let mut rsp = [0u8; SRAM_SIZE];
let rsp_len = match cmd {
CMD_GET_STATUS => handle_get_status(ntag, seq, &mut rsp)?,
CMD_WRITE_PATTERN => {
handle_write_pattern(ntag, state, seq, payload, &mut rsp, delay)?
}
CMD_SET_ACTIVE => handle_set_active(ntag, seq, payload, &mut rsp, delay)?,
CMD_SYNC_START => handle_sync_start(state, seq, payload, &mut rsp),
CMD_SYNC_END => handle_sync_end(ntag, state, seq, &mut rsp, delay)?,
CMD_READ_LIBRARY => handle_read_library(ntag, state, seq, &mut rsp)?,
CMD_READ_NEXT => handle_read_next(ntag, state, seq, &mut rsp)?,
_ => build_response(&mut rsp, seq, STATUS_BAD_CMD, &[]),
};
ntag.write_sram_blocks(0, &rsp[..rsp_len])?;
// Return true for commands that modify pattern library
let library_changed = matches!(
cmd,
CMD_WRITE_PATTERN | CMD_SET_ACTIVE | CMD_SYNC_END
);
Ok(library_changed)
}

437
src/pattern/mod.rs Normal file
View File

@@ -0,0 +1,437 @@
//! Software pattern engine for GPIO-direct PWM LED control.
//!
//! Replaces LP5562 hardware execution engines. The MCU drives 6 LEDs via
//! TCC hardware PWM, updating duty cycles from a TC4 ISR at 50Hz.
//!
//! XBLK v2 EEPROM format: 16-byte header + 16-byte pattern entries + playlist.
/// Number of LED channels.
pub const NUM_LEDS: usize = 6;
/// Animation tick rate in Hz.
pub const TICK_RATE_HZ: u32 = 50;
// ---------------------------------------------------------------------------
// Waveform types and lookup tables
// ---------------------------------------------------------------------------
/// Available waveform shapes.
#[derive(Clone, Copy, Debug, PartialEq)]
#[repr(u8)]
pub enum Waveform {
Sine = 0,
Triangle = 1,
Square = 2,
Heartbeat = 3,
}
impl Waveform {
pub fn from_u8(v: u8) -> Option<Self> {
match v {
0 => Some(Waveform::Sine),
1 => Some(Waveform::Triangle),
2 => Some(Waveform::Square),
3 => Some(Waveform::Heartbeat),
_ => None,
}
}
}
/// 64-entry quarter-wave sine table (0-255 output range).
/// Full wave is reconstructed by mirroring: indices 0..63 = rising first quarter,
/// 64..127 = falling second quarter (mirror), 128..191 = negative third (zero),
/// 192..255 = negative fourth (zero). For unipolar: mirror to get full 0-255-0 cycle.
const SINE_QUARTER: [u8; 64] = {
// Approximate sin(x) for x in [0, pi/2], scaled to 0-255.
// Generated from: round(255 * sin(i * pi / 128)) for i in 0..64
let mut table = [0u8; 64];
let mut i = 0;
while i < 64 {
// Fixed-point sine approximation using Taylor series:
// sin(x) ~ x - x^3/6 + x^5/120, where x = i * pi / 128
// We use a precomputed table for accuracy.
// These values are: round(255 * sin(i * pi / 128))
table[i] = SINE_VALUES[i];
i += 1;
}
table
};
const SINE_VALUES: [u8; 64] = [
0, 6, 12, 19, 25, 31, 37, 43, 49, 56, 62, 68, 74, 80, 86, 91,
97, 103, 109, 114, 120, 125, 131, 136, 141, 146, 151, 156, 161, 166, 170, 175,
179, 183, 187, 191, 195, 199, 202, 206, 209, 212, 215, 218, 220, 223, 225, 228,
230, 232, 233, 235, 237, 238, 239, 241, 242, 243, 243, 244, 245, 245, 245, 245,
];
/// Heartbeat waveform: 256-entry full cycle.
/// Double-pulse cardiac shape: two sharp peaks with a rest period.
const HEARTBEAT_LUT: [u8; 256] = {
let mut table = [0u8; 256];
// First beat: indices 0-31 (sharp rise/fall)
let mut i = 0;
while i < 16 {
table[i] = (i as u8) * 16; // 0 → 240
i += 1;
}
while i < 32 {
table[i] = (31 - i as u8) * 16; // 240 → 0
i += 1;
}
// Gap: 32-63
// Second beat: indices 64-95 (slightly weaker)
i = 64;
while i < 80 {
table[i] = ((i - 64) as u8) * 12; // 0 → 180
i += 1;
}
while i < 96 {
table[i] = ((95 - i) as u8) * 12; // 180 → 0
i += 1;
}
// Rest: indices 96-255 = 0 (already zeroed)
table
};
/// Sample a waveform at position `pos` (0-255 maps to one full cycle, 0-360 degrees).
fn sample_waveform(waveform: Waveform, pos: u8) -> u8 {
match waveform {
Waveform::Sine => {
// Unipolar sine: 0 at pos=0, 255 at pos=64, 0 at pos=128, stays 0 for 128-255
// Actually for LED breathing we want: 0→255→0 over the full cycle.
// Map pos 0-255 to a full sine period (0 → peak → 0 → peak → 0) ... no.
// Better: simple 0→255→0 breathing shape over 256 steps.
// pos 0..63: rising (quarter 1)
// pos 64..127: falling from peak (quarter 2, mirror)
// pos 128..191: rising again (quarter 3 = same as 1)
// pos 192..255: falling again (quarter 4 = same as 2)
// No — that's two cycles. For one full breath cycle:
// pos 0..127: 0→255 (half sine, rising)
// pos 128..255: 255→0 (half sine, falling)
let half = pos as u16;
if half < 128 {
// Rising: sample quarter sine and mirror
let idx = if half < 64 {
SINE_QUARTER[half as usize]
} else {
SINE_QUARTER[127 - half as usize]
};
// Scale: quarter sine peaks at 245, we want 255
let scaled = (idx as u16 * 255) / 245;
if scaled > 255 { 255 } else { scaled as u8 }
} else {
// Falling: mirror of rising
let mirror = 255 - pos;
let half_m = mirror as u16;
let idx = if half_m < 64 {
SINE_QUARTER[half_m as usize]
} else {
SINE_QUARTER[127 - half_m as usize]
};
let scaled = (idx as u16 * 255) / 245;
if scaled > 255 { 255 } else { scaled as u8 }
}
}
Waveform::Triangle => {
// 0→255→0 linear triangle
if pos < 128 {
(pos as u16 * 2) as u8
} else {
((255 - pos as u16) * 2) as u8
}
}
Waveform::Square => {
// On for first half, off for second half
if pos < 128 { 255 } else { 0 }
}
Waveform::Heartbeat => {
HEARTBEAT_LUT[pos as usize]
}
}
}
// ---------------------------------------------------------------------------
// Pattern state
// ---------------------------------------------------------------------------
/// A single pattern definition, loaded from EEPROM or hardcoded.
#[derive(Clone, Copy)]
pub struct PatternDef {
pub waveform: Waveform,
pub cycle_len: u8, // ticks per full cycle (1-255)
pub phase: [u8; NUM_LEDS], // phase offset per LED (0-255 = 0-360 degrees)
pub envelope: [u8; NUM_LEDS], // max brightness per LED
pub repeat_count: u8, // playlist: times to play before advancing (0xFF=forever)
}
/// Runtime pattern engine state.
pub struct PatternEngine {
pub pattern: PatternDef,
pub tick: u16, // current tick within cycle
pub cycle_count: u16, // completed cycles (for repeat_count tracking)
pub budget: u16, // power governor budget (sum of all LED values must not exceed this)
}
impl PatternEngine {
pub fn new(pattern: PatternDef, budget: u8) -> Self {
PatternEngine {
pattern,
tick: 0,
cycle_count: 0,
budget: budget as u16,
}
}
/// Compute brightness for all LEDs at the current tick, applying governor.
/// Call this from the TC4 ISR at 50Hz.
pub fn tick(&mut self, output: &mut [u8; NUM_LEDS]) {
let cycle = self.pattern.cycle_len as u16;
if cycle == 0 {
for v in output.iter_mut() { *v = 0; }
return;
}
// Compute raw brightness per LED
for i in 0..NUM_LEDS {
// Map tick to 0-255 position within the waveform cycle
// tick ranges from 0 to cycle_len-1
// phase[i] offsets in units of 1/256 of a cycle
let pos = ((self.tick as u32 * 256 / cycle as u32)
+ self.pattern.phase[i] as u32) % 256;
let raw = sample_waveform(self.pattern.waveform, pos as u8);
// Scale by envelope (max brightness for this LED)
output[i] = ((raw as u16 * self.pattern.envelope[i] as u16) / 255) as u8;
}
// Power governor: scale down if total exceeds budget
if self.budget > 0 {
let total: u16 = output.iter().map(|&v| v as u16).sum();
if total > self.budget {
let scale = (self.budget * 256) / total;
for v in output.iter_mut() {
*v = ((*v as u16 * scale) / 256) as u8;
}
}
}
// Advance tick
self.tick += 1;
if self.tick >= cycle {
self.tick = 0;
if self.cycle_count < u16::MAX {
self.cycle_count += 1;
}
}
}
/// Check if this pattern's repeat count has been reached.
pub fn repeats_done(&self) -> bool {
if self.pattern.repeat_count == 0xFF {
return false; // loop forever
}
self.cycle_count >= self.pattern.repeat_count as u16
}
/// Load a new pattern, resetting tick and cycle count.
pub fn load(&mut self, pattern: PatternDef) {
self.pattern = pattern;
self.tick = 0;
self.cycle_count = 0;
}
}
// ---------------------------------------------------------------------------
// Predefined patterns
// ---------------------------------------------------------------------------
/// Breathing: smooth sine, all LEDs in phase, ~2.5s cycle
pub fn breathe() -> PatternDef {
PatternDef {
waveform: Waveform::Sine,
cycle_len: 125, // 125 ticks = 2.5s at 50Hz
phase: [0, 0, 0, 0, 0, 0],
envelope: [255, 255, 255, 255, 255, 255],
repeat_count: 0xFF,
}
}
/// Heartbeat: double-pulse cardiac, ~1.6s cycle
pub fn heartbeat() -> PatternDef {
PatternDef {
waveform: Waveform::Heartbeat,
cycle_len: 80, // 80 ticks = 1.6s
phase: [0, 0, 0, 0, 0, 0],
envelope: [255, 255, 255, 255, 255, 255],
repeat_count: 0xFF,
}
}
/// Wave chase: sine with 60-degree phase offsets between LEDs, ~2s cycle
pub fn wave_chase() -> PatternDef {
PatternDef {
waveform: Waveform::Sine,
cycle_len: 100, // 2s
phase: [0, 43, 85, 128, 170, 213], // ~60 degree spacing
envelope: [255, 255, 255, 255, 255, 255],
repeat_count: 0xFF,
}
}
/// Slow pulse: very gentle and long, ~5s cycle
pub fn slow_pulse() -> PatternDef {
PatternDef {
waveform: Waveform::Triangle,
cycle_len: 250, // 5s
phase: [0, 0, 0, 0, 0, 0],
envelope: [200, 200, 200, 200, 200, 200],
repeat_count: 0xFF,
}
}
/// Alternating blink: odds and evens alternate, ~1s cycle
pub fn alternating_blink() -> PatternDef {
PatternDef {
waveform: Waveform::Square,
cycle_len: 50, // 1s
phase: [0, 128, 0, 128, 0, 128],
envelope: [255, 255, 255, 255, 255, 255],
repeat_count: 0xFF,
}
}
// ---------------------------------------------------------------------------
// XBLK v2 EEPROM format
// ---------------------------------------------------------------------------
/// EEPROM base block for the pattern library (upper 1K, block 256).
pub const LIBRARY_BASE_BLOCK: u16 = 0x0100;
/// Magic bytes identifying a valid XBLK library.
pub const XBLK_MAGIC: [u8; 4] = *b"XBLK";
/// Format version.
pub const XBLK_VERSION: u8 = 0x02;
/// Header size in bytes.
pub const HEADER_SIZE: usize = 16;
/// Pattern entry size in bytes.
pub const PATTERN_ENTRY_SIZE: usize = 16;
/// Maximum playlist entries.
pub const MAX_PLAYLIST: usize = 32;
/// Maximum patterns that fit: (1024 - 16 header - 32 playlist) / 16 = 61.
pub const MAX_PATTERNS: usize = 61;
/// Default power governor budget (sum of all LED brightness values).
pub const DEFAULT_BUDGET: u8 = 50;
/// Parsed XBLK v2 library header.
pub struct LibraryHeader {
pub pattern_count: u8,
pub active_index: u8,
pub budget: u8,
pub has_playlist: bool,
pub playlist_count: u8,
}
/// Parse a 16-byte XBLK v2 header. Returns None if magic or version is invalid.
pub fn parse_header(buf: &[u8; HEADER_SIZE]) -> Option<LibraryHeader> {
if buf[0..4] != XBLK_MAGIC {
return None;
}
if buf[4] != XBLK_VERSION {
return None;
}
let flags = buf[5];
let count = buf[6];
if count == 0 || count as usize > MAX_PATTERNS {
return None;
}
// Verify CRC
let stored_crc = (buf[14] as u16) << 8 | buf[15] as u16;
let computed_crc = crc16(&buf[0..14]);
if stored_crc != computed_crc {
return None;
}
Some(LibraryHeader {
pattern_count: count,
active_index: buf[7] % count,
budget: buf[8],
has_playlist: flags & 0x01 != 0,
playlist_count: buf[9],
})
}
/// Parse a 16-byte pattern entry into a PatternDef.
pub fn parse_pattern_entry(buf: &[u8; PATTERN_ENTRY_SIZE]) -> Option<PatternDef> {
let waveform = Waveform::from_u8(buf[0])?;
let cycle_len = buf[1];
if cycle_len == 0 {
return None;
}
let mut phase = [0u8; NUM_LEDS];
phase.copy_from_slice(&buf[2..8]);
let mut envelope = [0u8; NUM_LEDS];
envelope.copy_from_slice(&buf[8..14]);
Some(PatternDef {
waveform,
cycle_len,
phase,
envelope,
repeat_count: buf[14],
})
}
/// Serialize a PatternDef into a 16-byte buffer.
pub fn serialize_pattern_entry(p: &PatternDef, buf: &mut [u8; PATTERN_ENTRY_SIZE]) {
buf[0] = p.waveform as u8;
buf[1] = p.cycle_len;
buf[2..8].copy_from_slice(&p.phase);
buf[8..14].copy_from_slice(&p.envelope);
buf[14] = p.repeat_count;
buf[15] = 0; // reserved
}
/// Serialize the XBLK v2 header into a 16-byte buffer.
pub fn serialize_header(
pattern_count: u8,
active_index: u8,
budget: u8,
has_playlist: bool,
playlist_count: u8,
buf: &mut [u8; HEADER_SIZE],
) {
buf[0..4].copy_from_slice(&XBLK_MAGIC);
buf[4] = XBLK_VERSION;
buf[5] = if has_playlist { 0x01 } else { 0x00 };
buf[6] = pattern_count;
buf[7] = active_index;
buf[8] = budget;
buf[9] = playlist_count;
buf[10] = 0; // reserved
buf[11] = 0;
buf[12] = 0;
buf[13] = 0;
let crc = crc16(&buf[0..14]);
buf[14] = (crc >> 8) as u8;
buf[15] = crc as u8;
}
/// CRC-16/CCITT-FALSE.
pub fn crc16(data: &[u8]) -> u16 {
let mut crc: u16 = 0xFFFF;
for &b in data {
crc ^= (b as u16) << 8;
for _ in 0..8 {
if crc & 0x8000 != 0 {
crc = (crc << 1) ^ 0x1021;
} else {
crc <<= 1;
}
crc &= 0xFFFF;
}
}
crc
}

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()

247
tools/xblk_serialize.py Normal file
View File

@@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""
XBLK v2 pattern library serializer for xblink.
Converts pattern definitions to the XBLK v2 binary format (GPIO-direct PWM,
16-byte pattern entries) and writes them to NTAG5 EEPROM blocks 256+
(upper 1K) via ntag5sensor ISO15693 commands.
Usage:
# Serialize built-in patterns to binary file:
python xblk_serialize.py --output patterns.bin
# Write to NTAG5 via PCSC reader:
python xblk_serialize.py --write
# Load from JSON file:
python xblk_serialize.py --json patterns.json --write
"""
import argparse
import json
import struct
import sys
import os
# XBLK v2 format constants (must match src/pattern/mod.rs)
XBLK_MAGIC = b"XBLK"
XBLK_VERSION = 0x02
HEADER_SIZE = 16
PATTERN_ENTRY_SIZE = 16
MAX_PATTERNS = 61
MAX_PLAYLIST = 32
NUM_LEDS = 6
# EEPROM block offset for pattern library (upper 1K)
LIBRARY_BASE_BLOCK = 256
# Waveform IDs
WAVEFORM_LOOKUP = {
"sine": 0, "triangle": 1, "square": 2, "heartbeat": 3,
}
def crc16(data: bytes) -> int:
"""CRC-16/CCITT-FALSE."""
crc = 0xFFFF
for b in data:
crc ^= b << 8
for _ in range(8):
if crc & 0x8000:
crc = (crc << 1) ^ 0x1021
else:
crc <<= 1
crc &= 0xFFFF
return crc
def encode_pattern(pat: dict) -> bytes:
"""Encode a single pattern dict to PATTERN_ENTRY_SIZE bytes."""
waveform = WAVEFORM_LOOKUP.get(pat.get("waveform", "sine"), 0)
cycle_len = pat.get("cycle_len", 125)
phase = pat.get("phase", [0] * NUM_LEDS)
envelope = pat.get("envelope", [255] * NUM_LEDS)
repeat_count = pat.get("repeat_count", 0xFF)
# Pad/truncate to NUM_LEDS
phase = (phase + [0] * NUM_LEDS)[:NUM_LEDS]
envelope = (envelope + [255] * NUM_LEDS)[:NUM_LEDS]
buf = bytearray(PATTERN_ENTRY_SIZE)
buf[0] = waveform & 0xFF
buf[1] = cycle_len & 0xFF
buf[2:8] = bytes(phase)
buf[8:14] = bytes(envelope)
buf[14] = repeat_count & 0xFF
buf[15] = 0 # reserved
return bytes(buf)
def encode_library(config: dict) -> bytes:
"""Encode a full XBLK v2 library (header + patterns + playlist) to bytes."""
patterns = config.get("patterns", [])
if len(patterns) == 0:
raise ValueError("No patterns defined")
if len(patterns) > MAX_PATTERNS:
raise ValueError(f"Too many patterns: {len(patterns)} (max {MAX_PATTERNS})")
budget = config.get("budget", 50)
active = config.get("active", 0) % len(patterns)
playlist = config.get("playlist", [])
has_playlist = len(playlist) > 0
if len(playlist) > MAX_PLAYLIST:
raise ValueError(f"Playlist too long: {len(playlist)} (max {MAX_PLAYLIST})")
# Build header
header = bytearray(HEADER_SIZE)
header[0:4] = XBLK_MAGIC
header[4] = XBLK_VERSION
header[5] = 0x01 if has_playlist else 0x00 # flags
header[6] = len(patterns)
header[7] = active
header[8] = budget & 0xFF
header[9] = len(playlist) & 0xFF
# bytes 10-13 reserved
# Compute CRC over header bytes 0-13
crc = crc16(bytes(header[:14]))
struct.pack_into(">H", header, 14, crc)
# Build pattern data
pat_data = b""
for pat in patterns:
pat_data += encode_pattern(pat)
# Build playlist data
playlist_data = bytes(playlist + [0] * (MAX_PLAYLIST - len(playlist)))
result = bytes(header) + pat_data
if has_playlist:
result += playlist_data[:MAX_PLAYLIST]
return result
# ---------------------------------------------------------------------------
# Built-in patterns (matching src/pattern/mod.rs)
# ---------------------------------------------------------------------------
def builtin_patterns() -> dict:
"""Return the 5 built-in patterns as a config dict."""
return {
"budget": 50,
"active": 0,
"patterns": [
{
"name": "breathe",
"waveform": "sine",
"cycle_len": 125, # 2.5s
"phase": [0, 0, 0, 0, 0, 0],
"envelope": [255, 255, 255, 255, 255, 255],
"repeat_count": 0xFF,
},
{
"name": "heartbeat",
"waveform": "heartbeat",
"cycle_len": 80, # 1.6s
"phase": [0, 0, 0, 0, 0, 0],
"envelope": [255, 255, 255, 255, 255, 255],
"repeat_count": 0xFF,
},
{
"name": "wave_chase",
"waveform": "sine",
"cycle_len": 100, # 2s
"phase": [0, 43, 85, 128, 170, 213],
"envelope": [255, 255, 255, 255, 255, 255],
"repeat_count": 0xFF,
},
{
"name": "slow_pulse",
"waveform": "triangle",
"cycle_len": 250, # 5s
"phase": [0, 0, 0, 0, 0, 0],
"envelope": [200, 200, 200, 200, 200, 200],
"repeat_count": 0xFF,
},
{
"name": "alternating_blink",
"waveform": "square",
"cycle_len": 50, # 1s
"phase": [0, 128, 0, 128, 0, 128],
"envelope": [255, 255, 255, 255, 255, 255],
"repeat_count": 0xFF,
},
],
}
def write_to_ntag5(data: bytes):
"""Write binary data to NTAG5 EEPROM blocks 256+ via ntag5sensor."""
ntag5sensor_path = os.path.join(os.path.dirname(__file__), "..", "..", "ntag5sensor")
sys.path.insert(0, ntag5sensor_path)
from vicinity.iso15693 import ISO15693, ISO_FLAG_DATA_RATE
from vicinity.ntag5link import Ntag5Link
reader = ISO15693()
chip = Ntag5Link(reader)
print(f"Writing {len(data)} bytes to EEPROM blocks {LIBRARY_BASE_BLOCK}-{LIBRARY_BASE_BLOCK + len(data)//4 - 1}")
for i in range(0, len(data), 4):
block = LIBRARY_BASE_BLOCK + i // 4
chunk = data[i:i+4]
if len(chunk) < 4:
chunk = chunk + b'\x00' * (4 - len(chunk))
flags = ISO_FLAG_DATA_RATE | 0x08
cmd = bytes([flags, 0x21]) + struct.pack("<H", block) + chunk
reader.transmit_iso15693(cmd, True)
import time
time.sleep(0.006)
if (i // 4) % 10 == 0:
print(f" Block {block}...")
print("Done!")
def main():
parser = argparse.ArgumentParser(description="XBLK v2 pattern library serializer")
parser.add_argument("--json", help="JSON pattern definition file")
parser.add_argument("--output", "-o", help="Output binary file")
parser.add_argument("--write", action="store_true", help="Write to NTAG5 via PCSC")
parser.add_argument("--dump", action="store_true", help="Hex dump the binary")
args = parser.parse_args()
if args.json:
with open(args.json) as f:
config = json.load(f)
else:
config = builtin_patterns()
data = encode_library(config)
print(f"Library: {len(config['patterns'])} patterns, {len(data)} bytes")
print(f"CRC-16: 0x{struct.unpack_from('>H', data, 14)[0]:04X}")
if args.dump:
for i in range(0, len(data), 16):
hex_str = " ".join(f"{b:02X}" for b in data[i:i+16])
ascii_str = "".join(chr(b) if 32 <= b < 127 else "." for b in data[i:i+16])
print(f" {i:04X}: {hex_str:<48s} {ascii_str}")
if args.output:
with open(args.output, "wb") as f:
f.write(data)
print(f"Written to {args.output}")
if args.write:
write_to_ntag5(data)
if __name__ == "__main__":
main()