Pivot from LP5562 to GPIO-direct PWM LEDs, add NTAG5 EH provisioning

LP5562 requires 2.7V min but NFC energy harvesting produces only 1.8V.
New architecture: SAMD21 drives 6 red LEDs directly via TCC0/TCC1
hardware PWM through current-limiting resistors.

Key changes:
- Add TCC PWM driver (src/led/pwm.rs) for 6 GPIO-direct LED channels
- Rewrite pattern engine: software waveform LUTs (sine/triangle/square/
  heartbeat) replace LP5562 hardware execution engines
- Add XBLK v2 EEPROM format with 16-byte pattern entries + playlist
- Add TC4 50Hz ISR for animation, power governor for current budget
- Add NTAG5 EH provisioning: persistent config + session trigger
- Critical finding: SRAM passthrough in persistent EEPROM blocks all
  NFC access when MCU unpowered — CONFIG_1 must be session-only
- Add provision_eh.py PCSC tool for ACR1552 reader
- Update CLAUDE.md and STATUS.md for new architecture

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
michael
2026-03-07 10:44:12 -08:00
parent f437e4e281
commit 5cd728c298
11 changed files with 1459 additions and 1099 deletions

View File

@@ -1,6 +1,6 @@
# 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,8 +19,9 @@ 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
@@ -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)
@@ -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,7 +1,7 @@
# xblink Project Status
**Current Milestone**: M7 — Sleep/Wake
**Last Updated**: 2026-03-05
**Current Milestone**: GPIO-Direct LED Pivot (replacing LP5562)
**Last Updated**: 2026-03-07
---
@@ -76,16 +76,37 @@
- [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
**Hardware needed**: + Hall sensor + multimeter
### 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
@@ -96,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
@@ -135,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,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,18 +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;
mod ntag5;
mod pattern;
use led::lp5562::{ClockSource, Lp5562, DEFAULT_ADDRESS};
use ntag5::Ntag5Link;
use ntag5::sram::MailboxState;
use pattern::LedMode;
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) {
@@ -32,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,
@@ -51,19 +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);
// NTAG5 FD pin on A1 (PA04) — input with pull-up (FD is open-drain)
let fd_pin = pins.a1.into_pull_up_input();
// 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);
// I2C on A4 (SDA) / A5 (SCL) at 400 kHz
// 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(),
@@ -73,156 +157,134 @@ fn main() -> ! {
pins.a5,
);
let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS);
// Default LED current: 2 mA per channel (20 × 0.1 mA)
let mut led_current: u8 = 20;
// Verify LP5562 is reachable before continuing
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(led_current, led_current, led_current, led_current)?;
Ok(())
})();
match i2c_ok {
Ok(()) => {
// === STATUS: solid LED on = LP5562 init OK ===
led.set_low().unwrap();
delay.delay_ms(500u32);
led.set_high().unwrap();
delay.delay_ms(1000u32);
// --- Try to load pattern library from NTAG5 EEPROM ---
let i2c = lp.release();
let mut ntag = Ntag5Link::new(i2c, ntag5::DEFAULT_ADDRESS);
// Config check (LED blinks only, no NDEF write)
// Default power budget
let mut budget: u8 = pattern::DEFAULT_BUDGET;
// 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();
// 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();
// 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; }
}
}
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) => {
if result.all_ok {
blink(&mut led, &mut delay, 2, 100);
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 {
blink(&mut led, &mut delay, 5, 60);
append(&mut buf, &mut i, b"RD_ERR");
}
} else {
append(&mut buf, &mut i, b"WR_ERR");
}
Err(_) => {
// NTAG5 not reachable — 1 long blink (not fatal)
led.set_low().unwrap();
delay.delay_ms(800u32);
led.set_high().unwrap();
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);
// Configure FD pin for SRAM-write-by-RF indication
let _ = ntag.configure_fd_sram_write(); // Best-effort, non-fatal
// Try reading XBLK library header from upper 1K
// --- 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));
// Read active pattern if header is valid
let eeprom_pattern = eeprom_ok.as_ref().and_then(|hdr| {
led_current = hdr.current;
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];
ntag.read_memory(pat_block, &mut pat_buf).ok()?;
pattern::parse_pattern_entry(&pat_buf)
});
match eeprom_pattern {
Some(pat) => {
// EEPROM pattern loaded — 3 fast blinks
blink(&mut led, &mut delay, 3, 80);
// Load pattern into LP5562 (swap I2C to LP5562 then back)
{
let i2c = ntag.release();
let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS);
if pattern::load_pattern(&mut lp, &pat, led_current, &mut delay).is_err() {
blink(&mut led, &mut delay, 10, 50);
}
let i2c = lp.release();
ntag = Ntag5Link::new(i2c, ntag5::DEFAULT_ADDRESS);
}
// Mailbox polling loop — LP5562 runs autonomously,
// MCU polls FD pin for NFC commands
let mut mbox = MailboxState::new();
loop {
delay.delay_ms(200u32);
if fd_pin.is_low().unwrap_or(false) {
let cmd_ok = ntag5::sram::process_command(
&mut ntag, &mut mbox, &mut delay,
);
if matches!(cmd_ok, Ok(true)) {
// Reload LP5562 from current EEPROM state
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) {
led_current = hdr.current;
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) {
// Swap I2C to LP5562 for reprogramming
let i2c = ntag.release();
let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS);
let _ = pattern::load_pattern(
&mut lp, &new_pat, led_current, &mut delay,
);
let i2c = lp.release();
ntag = Ntag5Link::new(i2c, ntag5::DEFAULT_ADDRESS);
active_pattern = pattern::parse_pattern_entry(&pat_buf);
}
}
}
}
}
}
}
}
None => {
// No XBLK in EEPROM — self-provision hardcoded patterns
// 2 long blinks = provisioning
blink(&mut led, &mut delay, 2, 300);
let mode = LedMode::Rgbw;
if active_pattern.is_none() {
// No XBLK in EEPROM — self-provision default patterns
blink(&mut led, &mut delay, 2, 350);
let patterns = [
pattern::breathe(mode),
pattern::heartbeat(mode),
pattern::slow_pulse(mode),
pattern::rgb_cycle(mode),
pattern::color_wash(mode),
pattern::breathe(),
pattern::heartbeat(),
pattern::wave_chase(),
pattern::slow_pulse(),
pattern::alternating_blink(),
];
// Serialize all pattern entries and write to EEPROM
let pat_base_block = pattern::LIBRARY_BASE_BLOCK
+ (pattern::HEADER_SIZE as u16 / 4);
let mut all_pat_data = [0u8; pattern::PATTERN_ENTRY_SIZE * 5];
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);
// Copy into combined buffer for CRC
let offset = i * pattern::PATTERN_ENTRY_SIZE;
all_pat_data[offset..offset + pattern::PATTERN_ENTRY_SIZE]
.copy_from_slice(&entry_buf);
// Write 112 bytes as 28 x 4-byte blocks
let entry_block = pat_base_block
+ (i as u16 * (pattern::PATTERN_ENTRY_SIZE as u16 / 4));
for blk in 0..28u16 {
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() {
@@ -234,12 +296,9 @@ fn main() -> ! {
}
if provision_ok {
// Write header last (so partial writes don't look valid)
let mut hdr_buf = [0u8; pattern::HEADER_SIZE];
pattern::serialize_header(
5, 0, led_current, 0x00,
&all_pat_data,
&mut hdr_buf,
5, 0, budget, false, 0, &mut hdr_buf,
);
for blk in 0..4u16 {
let bo = (blk as usize) * 4;
@@ -254,54 +313,84 @@ fn main() -> ! {
}
if provision_ok {
// Success — 4 fast blinks, then load pattern 0 from EEPROM
blink(&mut led, &mut delay, 4, 80);
// Re-read active pattern from what we just wrote
let mut pat_buf = [0u8; pattern::PATTERN_ENTRY_SIZE];
let loaded = ntag.read_memory(pat_base_block, &mut pat_buf).ok()
.and_then(|()| pattern::parse_pattern_entry(&pat_buf));
// Load into LP5562 (swap I2C), then swap back for mailbox
{
let i2c = ntag.release();
let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS);
if let Some(pat) = loaded {
if pattern::load_pattern(&mut lp, &pat, led_current, &mut delay).is_err() {
blink(&mut led, &mut delay, 10, 50);
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());
}
}
let i2c = lp.release();
ntag = Ntag5Link::new(i2c, ntag5::DEFAULT_ADDRESS);
} else {
blink(&mut led, &mut delay, 3, 250); // EEPROM pattern loaded
}
// Mailbox polling loop (same as EEPROM-loaded path)
// --- 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 {
delay.delay_ms(200u32);
if fd_pin.is_low().unwrap_or(false) {
// 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) {
led_current = hdr.current;
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_buf2 = [0u8; pattern::PATTERN_ENTRY_SIZE];
if ntag.read_memory(pat_block, &mut pat_buf2).is_ok() {
if let Some(new_pat) = pattern::parse_pattern_entry(&pat_buf2) {
let i2c = ntag.release();
let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS);
let _ = pattern::load_pattern(
&mut lp, &new_pat, led_current, &mut delay,
);
let i2c = lp.release();
ntag = Ntag5Link::new(i2c, ntag5::DEFAULT_ADDRESS);
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);
}
});
}
}
}
}
@@ -309,33 +398,35 @@ fn main() -> ! {
}
}
}
} else {
// Provisioning failed — fall back to hardcoded cycle
blink(&mut led, &mut delay, 8, 50);
let i2c = ntag.release();
let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS);
/// 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());
let mut idx = 0;
loop {
if pattern::load_pattern(
&mut lp, &patterns[idx], led_current, &mut delay,
).is_err() {
blink(&mut led, &mut delay, 10, 50);
}
delay.delay_ms(15000u32);
idx = (idx + 1) % patterns.len();
}
}
}
}
}
Err(_) => {
// === DIAGNOSTIC: fast blink forever = I2C error ===
loop {
blink(&mut led, &mut delay, 5, 80);
delay.delay_ms(500u32);
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());
}
}

View File

@@ -25,11 +25,17 @@ pub const SRAM_BASE_BLOCK: u16 = 0x10F8;
pub const SRAM_BLOCK_COUNT: u16 = 64;
pub const SRAM_SIZE: usize = 256;
// Session register for ED/FD pin configuration
pub const SESSION_ED_FD_PIN_CFG: u16 = 0x10A3;
// 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
// FD pin mode: active on SRAM write by RF, cleared on I2C read
pub const FD_MODE_SRAM_RF_WRITE: u8 = 0x04;
// 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
@@ -40,17 +46,22 @@ pub const EEPROM_BLOCK_0: u16 = 0x0000;
pub const EXPECTED_CONFIG_0: u8 = 0x08;
// CONFIG_1: SRAM_ENABLE (bit 1) + ARBITER_MODE passthrough (bits 3:2 = 10b) + USE_CASE I2C slave (bits 5:4 = 00b)
pub const EXPECTED_CONFIG_1: u8 = 0x0A;
// EH_CONFIG: skip for now — EH is disabled (0x00) when powered externally.
// Set to 0x75 (EH_ENABLE + 3.0V + 12.5mA) when running from energy harvesting.
pub const EXPECTED_EH_CONFIG: u8 = 0x00;
// 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: mask 0x00 — don't check EH config during external power testing
pub const EH_CONFIG_MASK: u8 = 0x00;
// 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> {
@@ -189,12 +200,79 @@ where
Ok(())
}
/// Configure FD pin for SRAM-write-by-RF indication.
/// 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> {
// ED_FD_PIN_CFG register index 1 within the session block
// Bits 2:0 control FD output mode
self.write_register(SESSION_ED_FD_PIN_CFG, 0x01, 0x07, FD_MODE_SRAM_RF_WRITE)
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 ----

View File

@@ -13,7 +13,7 @@ use embedded_hal::i2c::I2c;
// ---------------------------------------------------------------------------
/// Firmware version reported in GET_STATUS response.
pub const FIRMWARE_VERSION: u8 = 0x01;
pub const FIRMWARE_VERSION: u8 = 0x02;
// Command IDs (phone -> MCU)
pub const CMD_WRITE_PATTERN: u8 = 0x01;
@@ -49,10 +49,8 @@ pub struct MailboxState {
pub syncing: bool,
/// Pattern count supplied by SYNC_START.
pub sync_count: u8,
/// Current (mA setting) supplied by SYNC_START.
pub sync_current: u8,
/// LED mode supplied by SYNC_START.
pub sync_mode: 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.
@@ -64,8 +62,7 @@ impl MailboxState {
Self {
syncing: false,
sync_count: 0,
sync_current: 0,
sync_mode: 0,
sync_budget: pattern::DEFAULT_BUDGET,
read_index: 0,
read_count: 0,
}
@@ -113,13 +110,14 @@ fn build_response(buf: &mut [u8], seq: u8, status: u8, payload: &[u8]) -> usize
// EEPROM helpers
// ---------------------------------------------------------------------------
/// Block address for pattern N in the EEPROM library.
/// 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 + 4 + (index as u16) * 28
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.
/// Returns None if magic/version don't match.
fn read_library_header<I2C, E>(
ntag: &mut Ntag5Link<I2C>,
) -> Result<Option<pattern::LibraryHeader>, ntag5::Error<E>>
@@ -149,46 +147,21 @@ where
Ok(())
}
/// Read all pattern data from EEPROM for the given count (for CRC computation).
/// Returns the number of bytes read into `all_data`.
fn read_all_pattern_data<I2C, E>(
ntag: &mut Ntag5Link<I2C>,
count: u8,
all_data: &mut [u8],
) -> Result<usize, ntag5::Error<E>>
where
I2C: I2c<Error = E>,
{
let total = count as usize * pattern::PATTERN_ENTRY_SIZE;
// Read in chunks — read_memory can handle arbitrary lengths
// but we read per-pattern for clarity
for i in 0..count as usize {
let block = pattern_block(i as u8);
let off = i * pattern::PATTERN_ENTRY_SIZE;
ntag.read_memory(block, &mut all_data[off..off + pattern::PATTERN_ENTRY_SIZE])?;
}
Ok(total)
}
/// Recalculate the header CRC based on current EEPROM pattern data,
/// then write the updated header back.
/// 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,
current: u8,
led_mode: 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>,
{
// Read all pattern data for CRC
let mut all_data = [0u8; pattern::MAX_PATTERNS * pattern::PATTERN_ENTRY_SIZE];
let data_len = read_all_pattern_data(ntag, count, &mut all_data)?;
let mut hdr = [0u8; pattern::HEADER_SIZE];
pattern::serialize_header(count, active, current, led_mode, &all_data[..data_len], &mut hdr);
pattern::serialize_header(count, active, budget, has_playlist, playlist_count, &mut hdr);
write_header_to_eeprom(ntag, &hdr, delay)
}
@@ -203,14 +176,15 @@ where
{
let hdr = match read_library_header(ntag)? {
Some(h) => h,
None => return Ok(()), // no valid header, nothing to update
None => return Ok(()),
};
recalculate_header_crc(
ntag,
hdr.pattern_count,
hdr.active_index,
hdr.current,
hdr.led_mode,
hdr.budget,
hdr.has_playlist,
hdr.playlist_count,
delay,
)
}
@@ -228,23 +202,20 @@ fn handle_get_status<I2C, E>(
where
I2C: I2c<Error = E>,
{
let mut payload = [0u8; 5];
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.current;
payload[4] = h.led_mode;
}
None => {
// No valid header — return zeros
payload[3] = h.budget;
}
None => {}
}
Ok(build_response(rsp_buf, seq, STATUS_OK, &payload))
}
/// WRITE_PATTERN (0x01): Write a 112-byte pattern entry to EEPROM.
/// WRITE_PATTERN (0x01): Write a 16-byte pattern entry to EEPROM.
fn handle_write_pattern<I2C, E>(
ntag: &mut Ntag5Link<I2C>,
state: &MailboxState,
@@ -264,10 +235,11 @@ where
return Ok(build_response(rsp_buf, seq, STATUS_INVALID_INDEX, &[]));
}
// Write 112 bytes = 28 blocks
// Write 16 bytes = 4 blocks
let base_block = pattern_block(index);
let pattern_data = &payload[1..1 + pattern::PATTERN_ENTRY_SIZE];
for i in 0..28u16 {
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]);
@@ -280,7 +252,7 @@ where
}
}
// If not syncing, update header CRC to reflect changed pattern data
// If not syncing, update header CRC
if !state.syncing {
match update_header_after_write(ntag, delay) {
Ok(()) => {}
@@ -319,9 +291,10 @@ where
return Ok(build_response(rsp_buf, seq, STATUS_INVALID_INDEX, &[]));
}
// Recalculate header with new active index
match recalculate_header_crc(ntag, hdr.pattern_count, index, hdr.current, hdr.led_mode, delay)
{
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, &[]));
@@ -339,7 +312,7 @@ fn handle_sync_start(
payload: &[u8],
rsp_buf: &mut [u8],
) -> usize {
if payload.len() < 3 {
if payload.len() < 2 {
return build_response(rsp_buf, seq, STATUS_BAD_CMD, &[]);
}
let count = payload[0];
@@ -348,8 +321,7 @@ fn handle_sync_start(
}
state.syncing = true;
state.sync_count = count;
state.sync_current = payload[1];
state.sync_mode = payload[2];
state.sync_budget = payload[1];
build_response(rsp_buf, seq, STATUS_OK, &[])
}
@@ -368,14 +340,8 @@ where
return Ok(build_response(rsp_buf, seq, STATUS_BAD_CMD, &[]));
}
// Active index defaults to 0
match recalculate_header_crc(
ntag,
state.sync_count,
0, // active_index = 0
state.sync_current,
state.sync_mode,
delay,
ntag, state.sync_count, 0, state.sync_budget, false, 0, delay,
) {
Ok(()) => {}
Err(ntag5::Error::VerifyFailed) => {
@@ -402,13 +368,12 @@ fn handle_read_library<I2C, E>(
where
I2C: I2c<Error = E>,
{
let mut payload = [0u8; 4];
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.current;
payload[3] = h.led_mode;
payload[2] = h.budget;
state.read_index = 0;
state.read_count = h.pattern_count;
}
@@ -420,7 +385,7 @@ where
Ok(build_response(rsp_buf, seq, STATUS_OK, &payload))
}
/// READ_NEXT (0x08): Return the next pattern entry (112 bytes).
/// READ_NEXT (0x08): Return the next pattern entry (16 bytes).
fn handle_read_next<I2C, E>(
ntag: &mut Ntag5Link<I2C>,
state: &mut MailboxState,
@@ -447,11 +412,8 @@ where
/// Read SRAM, dispatch the command, and write the response back.
///
/// Returns `Ok(true)` if a command was processed, `Ok(false)` if no valid
/// command was found in SRAM (e.g., empty buffer, bad header, bad CRC).
///
/// I2C errors propagate as `Err`. EEPROM verify failures are reported via
/// a STATUS_EEPROM_FAIL response (not as Err).
/// 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,
@@ -494,5 +456,11 @@ where
};
ntag.write_sram_blocks(0, &rsp[..rsp_len])?;
Ok(true)
// 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)
}

View File

@@ -1,366 +1,306 @@
//! Predefined LP5562 engine patterns for xblink.
//! Software pattern engine for GPIO-direct PWM LED control.
//!
//! Each pattern is a set of engine programs + LED_MAP configuration.
//! The LP5562 runs these autonomously — the MCU can sleep after loading.
//! 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.
use crate::led::lp5562::{
Channel, EngineCommand, EngineId, EngineProgram, LedMapping, Lp5562, Prescale, RampDirection,
/// 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
};
use embedded_hal::i2c::I2c;
/// LED hardware configuration.
#[derive(Clone, Copy, Debug)]
pub enum LedMode {
/// Single RGBW LED (e.g., LP5562EVM D1). Engines map to R, G, B; W is I2C-direct.
Rgbw,
/// 3 independent monochrome LEDs. Engines map to B, G, R channels (one each).
Mono3,
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;
}
/// A complete pattern: up to 3 engine programs + LED mapping.
pub struct Pattern {
pub engine1: Option<EngineProgram>,
pub engine2: Option<EngineProgram>,
pub engine3: Option<EngineProgram>,
/// LED_MAP: which engine (or I2C direct) drives each channel.
/// Index: [B, G, R, W] → LedMapping value.
pub map_b: LedMapping,
pub map_g: LedMapping,
pub map_r: LedMapping,
pub map_w: LedMapping,
/// Next pattern index after this one completes (0xFF = loop forever).
pub next_pattern: u8,
/// Number of full cycles before chaining (0 = chain immediately on engine stop).
pub loop_count: u8,
while i < 32 {
table[i] = (31 - i as u8) * 16; // 240 → 0
i += 1;
}
// ---------------------------------------------------------------------------
// Breathing: smooth ramp up/down, ~2.5s cycle
// ---------------------------------------------------------------------------
/// Breathing pattern — one engine, smooth ramp.
///
/// LP5562 increment field = number of steps - 1 (max 127 = 128 steps).
/// Each step changes PWM by 1 unit. Full 0→255 needs two ramp commands.
///
/// Slow prescale (15.6ms/step), step_time=1:
/// Ramp up: 2 × 128 steps × 15.6ms = ~4.0s (0→128→255)
/// Ramp down: 2 × 128 steps × 15.6ms = ~4.0s (255→127→0)
/// Wait: step_time=48 → 48 × 15.6ms ≈ 0.75s pause at bottom
/// Total: ~8.75s per cycle
fn breathe_program() -> EngineProgram {
EngineProgram::from_commands(&[
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 0→128, ~2s
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 128→255, ~2s
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 255→127, ~2s
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 127→0, ~2s
EngineCommand::wait(Prescale::Slow, 48), // ~0.75s pause
EngineCommand::branch(0, 0), // loop forever
])
// 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;
}
/// Load breathing pattern. All active channels breathe in sync.
pub fn breathe(mode: LedMode) -> Pattern {
single_engine_pattern(breathe_program(), mode)
while i < 96 {
table[i] = ((95 - i) as u8) * 12; // 180 → 0
i += 1;
}
// Rest: indices 96-255 = 0 (already zeroed)
table
};
// ---------------------------------------------------------------------------
// Heartbeat: double-pulse with long pause, ~1.6s cycle
// ---------------------------------------------------------------------------
/// Heartbeat pattern — fast double-pulse, long rest.
///
/// Fast prescale (0.49ms/step):
/// set_pwm 255 → snap on
/// wait fast, st=20 → 20 × 0.49ms ≈ 10ms hold
/// set_pwm 0 → snap off
/// wait fast, st=40 → 40 × 0.49ms ≈ 20ms gap
/// set_pwm 255 → second beat
/// wait fast, st=20 → 10ms hold
/// set_pwm 0 → snap off
/// Slow prescale for the long rest:
/// wait slow, st=63 → 63 × 15.6ms ≈ 1.0s
/// wait slow, st=32 → 32 × 15.6ms ≈ 0.5s (total rest ~1.5s)
/// branch 0 → loop
fn heartbeat_program() -> EngineProgram {
EngineProgram::from_commands(&[
EngineCommand::set_pwm(255), // 0: first beat ON
EngineCommand::wait(Prescale::Fast, 20), // 1: hold ~10ms
EngineCommand::set_pwm(0), // 2: first beat OFF
EngineCommand::wait(Prescale::Fast, 40), // 3: gap ~20ms
EngineCommand::set_pwm(255), // 4: second beat ON
EngineCommand::wait(Prescale::Fast, 20), // 5: hold ~10ms
EngineCommand::set_pwm(0), // 6: second beat OFF
EngineCommand::wait(Prescale::Slow, 63), // 7: rest ~1.0s
EngineCommand::wait(Prescale::Slow, 32), // 8: rest ~0.5s
EngineCommand::branch(0, 0), // 9: loop forever
])
/// 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 }
}
/// Load heartbeat pattern.
pub fn heartbeat(mode: LedMode) -> Pattern {
single_engine_pattern(heartbeat_program(), mode)
}
// ---------------------------------------------------------------------------
// RGB cycle / staggered chase: 3 engines, trigger-synced phase offset
// ---------------------------------------------------------------------------
/// Phase-offset breathing using triggers for synchronization.
///
/// Slow prescale (15.6ms/step), step_time=1, increment=127 (128 steps per command):
/// Ramp: 2 × 128 steps × 15.6ms = ~4s per full ramp (0→255 or 255→0)
/// E1 cycle: ~4s up + ~4s down + ~1s pause = ~9s
/// Trigger chain: E1 triggers E2 at ~4s, E2 triggers E3 at ~8s
fn rgb_cycle_engine1() -> EngineProgram {
EngineProgram::from_commands(&[
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 0: 0→128
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 1: 128→255
EngineCommand::trigger(0, 0b010), // 2: send to E2
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 3: 255→127
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 4: 127→0
EngineCommand::wait(Prescale::Slow, 63), // 5: pause ~1.0s
EngineCommand::branch(0, 0), // 6: loop forever
])
Waveform::Triangle => {
// 0→255→0 linear triangle
if pos < 128 {
(pos as u16 * 2) as u8
} else {
((255 - pos as u16) * 2) as u8
}
fn rgb_cycle_engine2() -> EngineProgram {
EngineProgram::from_commands(&[
EngineCommand::trigger(0b001, 0), // 0: wait for E1
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 1: 0→128
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 2: 128→255
EngineCommand::trigger(0, 0b100), // 3: send to E3
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 4: 255→127
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 5: 127→0
EngineCommand::wait(Prescale::Slow, 32), // 6: pause ~0.5s
EngineCommand::branch(0, 0), // 7: loop forever
])
}
fn rgb_cycle_engine3() -> EngineProgram {
EngineProgram::from_commands(&[
EngineCommand::trigger(0b010, 0), // 0: wait for E2
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 1: 0→128
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 2: 128→255
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 3: 255→127
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 4: 127→0
EngineCommand::wait(Prescale::Slow, 32), // 5: pause ~0.5s
EngineCommand::branch(0, 0), // 6: loop forever
])
Waveform::Square => {
// On for first half, off for second half
if pos < 128 { 255 } else { 0 }
}
Waveform::Heartbeat => {
HEARTBEAT_LUT[pos as usize]
}
/// Load RGB cycle (RGBW mode) or staggered chase (mono mode).
pub fn rgb_cycle(mode: LedMode) -> Pattern {
match mode {
LedMode::Rgbw => Pattern {
engine1: Some(rgb_cycle_engine1()),
engine2: Some(rgb_cycle_engine2()),
engine3: Some(rgb_cycle_engine3()),
// Each engine drives one color channel
map_b: LedMapping::Engine1,
map_g: LedMapping::Engine2,
map_r: LedMapping::Engine3,
map_w: LedMapping::I2c,
next_pattern: 0xFF,
loop_count: 0,
},
LedMode::Mono3 => Pattern {
engine1: Some(rgb_cycle_engine1()),
engine2: Some(rgb_cycle_engine2()),
engine3: Some(rgb_cycle_engine3()),
// Each engine drives one physical LED
map_b: LedMapping::Engine1,
map_g: LedMapping::Engine2,
map_r: LedMapping::Engine3,
map_w: LedMapping::I2c,
next_pattern: 0xFF,
loop_count: 0,
},
}
}
// ---------------------------------------------------------------------------
// Slow pulse: gentle ramp, dimmer peak, ~5s cycle
// Pattern state
// ---------------------------------------------------------------------------
/// Slow pulse — very gentle and long.
///
/// Slow prescale (15.6ms/step), step_time=4:
/// Ramp up: 2 × 128 steps × 62.4ms = ~16.0s (0→128→255)
/// Wait: step_time=32 → ~0.5s hold at peak
/// Ramp down: 2 × 128 steps × 62.4ms = ~16.0s (255→127→0)
/// Wait: step_time=63 → ~1.0s pause at bottom
/// Total: ~33.5s per cycle
fn slow_pulse_program() -> EngineProgram {
EngineProgram::from_commands(&[
EngineCommand::ramp_wait(Prescale::Slow, 4, RampDirection::Up, 127), // 0→128, ~8s
EngineCommand::ramp_wait(Prescale::Slow, 4, RampDirection::Up, 127), // 128→255, ~8s
EngineCommand::wait(Prescale::Slow, 32), // hold ~0.5s
EngineCommand::ramp_wait(Prescale::Slow, 4, RampDirection::Down, 127), // 255→127, ~8s
EngineCommand::ramp_wait(Prescale::Slow, 4, RampDirection::Down, 127), // 127→0, ~8s
EngineCommand::wait(Prescale::Slow, 63), // pause ~1.0s
EngineCommand::branch(0, 0), // loop forever
])
/// 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)
}
/// Load slow pulse pattern.
pub fn slow_pulse(mode: LedMode) -> Pattern {
single_engine_pattern(slow_pulse_program(), mode)
/// 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)
}
// ---------------------------------------------------------------------------
// Color wash: 3 engines, smooth overlapping ramps for blended color transitions
// ---------------------------------------------------------------------------
/// Color wash — free-running engines with different cycle lengths.
///
/// No triggers. Each engine breathes independently at a slightly different
/// rate, causing them to drift in and out of phase. Smooth single-PWM-unit
/// increments for clean color blending.
///
/// Slow prescale (15.6ms/step), step_time=1, increment=127 (128 steps per cmd):
/// Ramp: 2 × 128 steps × 15.6ms = ~4s per full ramp
///
/// E1 (Blue): up ~4s + down ~4s = ~8s cycle (no pause)
/// E2 (Green): up ~4s + down ~4s + ~0.5s pause = ~8.5s cycle
/// E3 (Red): up ~4s + down ~4s + ~1.0s pause = ~9s cycle
///
/// Phase drift: ~0.5s per cycle → colors shift noticeably every few cycles.
fn color_wash_engine1() -> EngineProgram {
// ~8s cycle (no pause)
EngineProgram::from_commands(&[
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 0→128
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 128→255
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 255→127
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 127→0
EngineCommand::branch(0, 0),
])
impl PatternEngine {
pub fn new(pattern: PatternDef, budget: u8) -> Self {
PatternEngine {
pattern,
tick: 0,
cycle_count: 0,
budget: budget as u16,
}
}
fn color_wash_engine2() -> EngineProgram {
// ~8.5s cycle (short pause at bottom)
EngineProgram::from_commands(&[
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 0→128
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 128→255
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 255→127
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 127→0
EngineCommand::wait(Prescale::Slow, 32), // pause ~0.5s
EngineCommand::branch(0, 0),
])
/// 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;
}
fn color_wash_engine3() -> EngineProgram {
// ~9s cycle (longer pause at bottom)
EngineProgram::from_commands(&[
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 0→128
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 128→255
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 255→127
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 127→0
EngineCommand::wait(Prescale::Slow, 63), // pause ~1.0s
EngineCommand::branch(0, 0),
])
// 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;
}
/// Load color wash (RGBW: smooth hue transitions) or wave (mono: traveling slow pulse).
pub fn color_wash(mode: LedMode) -> Pattern {
match mode {
LedMode::Rgbw => Pattern {
engine1: Some(color_wash_engine1()),
engine2: Some(color_wash_engine2()),
engine3: Some(color_wash_engine3()),
map_b: LedMapping::Engine1,
map_g: LedMapping::Engine2,
map_r: LedMapping::Engine3,
map_w: LedMapping::I2c,
next_pattern: 0xFF,
loop_count: 0,
},
LedMode::Mono3 => Pattern {
engine1: Some(color_wash_engine1()),
engine2: Some(color_wash_engine2()),
engine3: Some(color_wash_engine3()),
map_b: LedMapping::Engine1,
map_g: LedMapping::Engine2,
map_r: LedMapping::Engine3,
map_w: LedMapping::I2c,
next_pattern: 0xFF,
loop_count: 0,
},
// 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;
}
}
// ---------------------------------------------------------------------------
// Helper for single-engine patterns (all RGB channels mapped to Engine1)
// Predefined patterns
// ---------------------------------------------------------------------------
fn single_engine_pattern(prog: EngineProgram, mode: LedMode) -> Pattern {
let _ = mode; // Same mapping for both modes
Pattern {
engine1: Some(prog),
engine2: None,
engine3: None,
map_b: LedMapping::Engine1,
map_g: LedMapping::Engine1,
map_r: LedMapping::Engine1,
map_w: LedMapping::I2c,
next_pattern: 0xFF,
loop_count: 0,
/// 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,
}
}
// ---------------------------------------------------------------------------
// Pattern loader
// ---------------------------------------------------------------------------
/// Software-reset LP5562, re-initialize, and load a pattern.
///
/// Writes 0xFF to the Reset register (0x0D) which resets all registers to
/// defaults (PWM=0, engines disabled, current=17.5mA). Then re-initializes
/// and loads the new pattern. This guarantees zero residual state.
pub fn load_pattern<I2C, E>(
lp: &mut Lp5562<I2C>,
pattern: &Pattern,
current: u8,
delay: &mut impl embedded_hal::delay::DelayNs,
) -> Result<(), crate::led::lp5562::Error<E>>
where
I2C: I2c<Error = E>,
{
// Software reset: all registers to defaults, device enters STANDBY
lp.reset().map_err(crate::led::lp5562::Error::I2c)?;
delay.delay_ms(1); // allow reset to complete
// Re-initialize from clean state
lp.enable()?;
delay.delay_ms(1); // >500us after enable (datasheet: 500µs typical)
lp.init_direct_control(crate::led::lp5562::ClockSource::Internal)?;
lp.set_all_current(current, current, current, current)?;
// Set LED mapping
lp.set_led_mapping(Channel::Blue, pattern.map_b).map_err(crate::led::lp5562::Error::I2c)?;
lp.set_led_mapping(Channel::Green, pattern.map_g).map_err(crate::led::lp5562::Error::I2c)?;
lp.set_led_mapping(Channel::Red, pattern.map_r).map_err(crate::led::lp5562::Error::I2c)?;
lp.set_led_mapping(Channel::White, pattern.map_w).map_err(crate::led::lp5562::Error::I2c)?;
// Load and run each engine that has a program
if let Some(ref prog) = pattern.engine1 {
lp.run_engine(EngineId::Engine1, prog)?;
delay.delay_us(200);
}
if let Some(ref prog) = pattern.engine2 {
lp.run_engine(EngineId::Engine2, prog)?;
delay.delay_us(200);
}
if let Some(ref prog) = pattern.engine3 {
lp.run_engine(EngineId::Engine3, prog)?;
delay.delay_us(200);
}
Ok(())
}
// ---------------------------------------------------------------------------
// XBLK EEPROM pattern library format
// XBLK v2 EEPROM format
// ---------------------------------------------------------------------------
/// EEPROM base block for the pattern library (upper 1K, block 256).
@@ -370,26 +310,33 @@ pub const LIBRARY_BASE_BLOCK: u16 = 0x0100;
pub const XBLK_MAGIC: [u8; 4] = *b"XBLK";
/// Format version.
pub const XBLK_VERSION: u8 = 0x01;
pub const XBLK_VERSION: u8 = 0x02;
/// Header size in bytes.
pub const HEADER_SIZE: usize = 16;
/// Pattern entry size in bytes (fixed for direct seeking).
pub const PATTERN_ENTRY_SIZE: usize = 112;
/// Pattern entry size in bytes.
pub const PATTERN_ENTRY_SIZE: usize = 16;
/// Maximum patterns that fit in 1024 bytes: (1024 - 16) / 112 = 9.
pub const MAX_PATTERNS: usize = 9;
/// Maximum playlist entries.
pub const MAX_PLAYLIST: usize = 32;
/// Parsed XBLK library header.
/// 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 current: u8,
pub led_mode: u8,
pub budget: u8,
pub has_playlist: bool,
pub playlist_count: u8,
}
/// Parse a 16-byte XBLK header. Returns None if magic or version is invalid.
/// 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;
@@ -397,147 +344,82 @@ pub fn parse_header(buf: &[u8; HEADER_SIZE]) -> Option<LibraryHeader> {
if buf[4] != XBLK_VERSION {
return None;
}
let count = buf[5];
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[6] % count, // wrap if out of range
current: buf[7],
led_mode: buf[8],
active_index: buf[7] % count,
budget: buf[8],
has_playlist: flags & 0x01 != 0,
playlist_count: buf[9],
})
}
/// Parse a 112-byte pattern entry into a Pattern struct.
/// Returns None if the data is malformed.
pub fn parse_pattern_entry(buf: &[u8; PATTERN_ENTRY_SIZE]) -> Option<Pattern> {
let engine_count = buf[0];
if engine_count > 3 {
/// 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 led_map_reg = buf[1];
// Decode LED_MAP register: 2 bits per channel [W(7:6), R(5:4), G(3:2), B(1:0)]
let map_b = led_map_byte_to_mapping(led_map_reg & 0x03)?;
let map_g = led_map_byte_to_mapping((led_map_reg >> 2) & 0x03)?;
let map_r = led_map_byte_to_mapping((led_map_reg >> 4) & 0x03)?;
let map_w = led_map_byte_to_mapping((led_map_reg >> 6) & 0x03)?;
// Direct PWM values at bytes 2-5 (unused for now, engines override)
// let _direct_pwm = [buf[2], buf[3], buf[4], buf[5]];
let engine1 = parse_engine_program(&buf[6..40])?;
let engine2 = parse_engine_program(&buf[40..74])?;
let engine3 = parse_engine_program(&buf[74..108])?;
Some(Pattern {
engine1,
engine2,
engine3,
map_b,
map_g,
map_r,
map_w,
next_pattern: buf[108],
loop_count: buf[109],
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],
})
}
/// Parse a 34-byte engine section: 2 bytes command count (BE) + 32 bytes commands.
/// Returns Some(None) for unused engines, Some(Some(prog)) for valid, None for malformed.
fn parse_engine_program(buf: &[u8]) -> Option<Option<EngineProgram>> {
let cmd_count = ((buf[0] as u16) << 8 | buf[1] as u16) as usize;
if cmd_count == 0 {
return Some(None);
}
if cmd_count > 16 {
return None; // malformed
/// 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
}
let mut commands = [0u16; 16];
for i in 0..cmd_count {
let offset = 2 + i * 2;
commands[i] = (buf[offset] as u16) << 8 | buf[offset + 1] as u16;
/// 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;
}
let mut prog = EngineProgram::new();
for i in 0..cmd_count {
let _ = prog.push(commands[i]);
}
Some(Some(prog))
}
/// Convert a 2-bit LED_MAP field to a LedMapping enum value.
fn led_map_byte_to_mapping(val: u8) -> Option<LedMapping> {
match val {
0b00 => Some(LedMapping::I2c),
0b01 => Some(LedMapping::Engine1),
0b10 => Some(LedMapping::Engine2),
0b11 => Some(LedMapping::Engine3),
_ => None,
}
}
/// Build the raw LED_MAP register byte from a Pattern's mapping fields.
pub fn pattern_to_led_map_byte(p: &Pattern) -> u8 {
(p.map_b as u8)
| ((p.map_g as u8) << 2)
| ((p.map_r as u8) << 4)
| ((p.map_w as u8) << 6)
}
// ---------------------------------------------------------------------------
// XBLK serializer (MCU-side, for self-provisioning)
// ---------------------------------------------------------------------------
/// Serialize a Pattern into a 112-byte XBLK entry buffer.
pub fn serialize_pattern_entry(p: &Pattern, buf: &mut [u8; PATTERN_ENTRY_SIZE]) {
// Zero the buffer
for b in buf.iter_mut() {
*b = 0;
}
// Engine count
let mut count = 0u8;
if p.engine1.is_some() { count += 1; }
if p.engine2.is_some() { count += 1; }
if p.engine3.is_some() { count += 1; }
buf[0] = count;
// LED_MAP register byte
buf[1] = pattern_to_led_map_byte(p);
// Direct PWM [B, G, R, W] — bytes 2-5, leave as 0 for engine patterns
// Engine programs
serialize_engine(&p.engine1, &mut buf[6..40]);
serialize_engine(&p.engine2, &mut buf[40..74]);
serialize_engine(&p.engine3, &mut buf[74..108]);
// Chaining
buf[108] = p.next_pattern;
buf[109] = p.loop_count;
}
/// Serialize an optional engine program into a 34-byte section.
fn serialize_engine(eng: &Option<EngineProgram>, buf: &mut [u8]) {
match eng {
None => {
buf[0] = 0;
buf[1] = 0;
}
Some(prog) => {
let len = prog.len();
buf[0] = (len >> 8) as u8;
buf[1] = len as u8;
let bytes = prog.as_bytes(); // 32-byte SRAM image, big-endian
buf[2..34].copy_from_slice(&bytes);
}
}
}
/// CRC-16/CCITT-FALSE (matching Python serializer).
/// CRC-16/CCITT-FALSE.
pub fn crc16(data: &[u8]) -> u16 {
let mut crc: u16 = 0xFFFF;
for &b in data {
@@ -553,40 +435,3 @@ pub fn crc16(data: &[u8]) -> u16 {
}
crc
}
/// Serialize the XBLK header into a 16-byte buffer.
/// CRC is computed over header bytes 0-13 + pattern data.
pub fn serialize_header(
pattern_count: u8,
active_index: u8,
current: u8,
led_mode: u8,
pattern_data_crc_input: &[u8],
buf: &mut [u8; HEADER_SIZE],
) {
buf[0..4].copy_from_slice(&XBLK_MAGIC);
buf[4] = XBLK_VERSION;
buf[5] = pattern_count;
buf[6] = active_index;
buf[7] = current;
buf[8] = led_mode;
for i in 9..14 {
buf[i] = 0;
}
// CRC over header[0..14] + pattern data
let mut crc = crc16(&buf[0..14]);
// Continue CRC over pattern data
for &b in pattern_data_crc_input {
crc ^= (b as u16) << 8;
for _ in 0..8 {
if crc & 0x8000 != 0 {
crc = (crc << 1) ^ 0x1021;
} else {
crc <<= 1;
}
crc &= 0xFFFF;
}
}
buf[14] = (crc >> 8) as u8;
buf[15] = crc as u8;
}

166
tools/provision_eh.py Normal file
View File

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

View File

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