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 # 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 ## Build Commands
@@ -19,8 +19,9 @@ cargo hf2 --release
- Architecture: `thumbv6m-none-eabi` (ARM Cortex-M0+) - Architecture: `thumbv6m-none-eabi` (ARM Cortex-M0+)
- Dev board: Seeed XIAO M0 (SAMD21G18A) — will port to bare SAMD21E18A later - 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) - NFC: NTAG5Link Click board (MikroElektronika)
- LP5562: removed from design (requires 2.7V min, incompatible with 1.8V EH)
## Dependencies ## Dependencies
@@ -37,12 +38,13 @@ When porting to bare SAMD21E: replace `xiao_m0` with `atsamd-hal = { version = "
``` ```
src/ 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/ led/
mod.rs # LP5562 re-exports (LedController trait added later, M10) mod.rs # LED module re-exports
lp5562.rs # LP5562 driver (from ../ntag5-samd21-lp562/) pwm.rs # TCC0/TCC1 hardware PWM driver for 6 GPIO-direct LEDs
ntag5/ # NTAG5Link I2C slave driver (M4+) lp5562.rs # LP5562 driver (legacy, kept for reference)
pattern/ # Pattern format and engine builder (M5+) ntag5/ # NTAG5Link I2C slave driver
pattern/ # XBLK v2 format, software pattern engine, waveform LUTs
``` ```
## Architecture Conventions ## Architecture Conventions
@@ -55,22 +57,31 @@ src/
- **`const fn`**: Prefer `const fn` where possible (see `EngineCommand` builder in LP5562 driver). - **`const fn`**: Prefer `const fn` where possible (see `EngineCommand` builder in LP5562 driver).
- **No `unsafe`**: Avoid unless absolutely necessary for hardware access. - **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 | LED | TCC | Channel | SAMD21E Pin | XIAO Pin |
- **>= 488 us** between consecutive ENABLE register writes (engine exec changes) |-----|------|---------|-------------|----------|
- **>= 153 us** between consecutive OP_MODE register writes (engine mode changes) | 0 | TCC0 | WO[0] | PA04 | A1 |
- When transitioning from Run, set exec to Hold first, then change mode | 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 | | Device | Address | Notes |
| --------- | ------- | ------------------------------------------ | | --------- | ------- | --------------------------------- |
| LP5562 | 0x30 | ADDR_SEL pins both low (LP5562EVM default) |
| NTAG5Link | 0x54 | Default NTP53x2 I2C slave address | | NTAG5Link | 0x54 | Default NTP53x2 I2C slave address |
## NTAG5Link I2C Register Map (MCU-side) ## NTAG5Link I2C Register Map (MCU-side)
@@ -94,23 +105,24 @@ Key config constants (from `../ntag5sensor/vicinity/ntag5link.py`):
## Hardware Wiring (Dev Board) ## Hardware Wiring (Dev Board)
| XIAO Pin | Connection | Function | | XIAO Pin | Connection | Function |
| --------- | --------------------------- | ----------------------------------------------- | | -------- | --------------------- | ------------------------------------------- |
| A4 (SDA) | LP5562 SDA, NTAG5 SDA | Shared I2C data | | A1 (PA04)| LED 0 + resistor | TCC0/WO[0] PWM output |
| A5 (SCL) | LP5562 SCL, NTAG5 SCL | Shared I2C clock | | A2 (PA05)| LED 1 + resistor | TCC0/WO[1] PWM output |
| D0/A0 | LP5562 EN line (open-drain) | Wired-AND with hall sensor, pull-up to VCC | | A4 (SDA) | NTAG5 SDA | I2C data |
| TBD (EIC) | Hall sensor output | EIC wake + wired-AND to LP5562 EN line | | A5 (SCL) | NTAG5 SCL | I2C clock |
| TBD (EIC) | NTAG5 FD pin | Field detect / SRAM write indication (EIC wake) | | D2 (PA10)| LED 4 + resistor | TCC1/WO[0] PWM output |
| D3 (PA11)| LED 5 + resistor | TCC1/WO[1] PWM output |
**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)| NTAG5 FD pin | Field detect / SRAM write indication (EIC) |
| TBD | Hall sensor output | EIC wake, pattern cycling |
## Testing ## 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 - **PCSC reader**: Use `../ntag5sensor/` Python tooling with ACR1552 reader
- **Phone NFC**: VivoKey RawNFC app for SRAM mailbox testing - **Phone NFC**: VivoKey RawNFC app for SRAM mailbox testing
- **Phone app**: DT NFC Identifier for basic tag info - **Phone app**: DT NFC Identifier for basic tag info
## Sibling Projects ## 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`) - `../ntag5sensor/` — NTAG5Link command reference (`vicinity/ntag5link.py`), I2C patterns (`vicinity/i2cbase.py`)

View File

@@ -1,7 +1,7 @@
# xblink Project Status # xblink Project Status
**Current Milestone**: M7 — Sleep/Wake **Current Milestone**: GPIO-Direct LED Pivot (replacing LP5562)
**Last Updated**: 2026-03-05 **Last Updated**: 2026-03-07
--- ---
@@ -76,16 +76,37 @@
- [x] I2C bus swapping for LP5562 reprogramming after pattern updates - [x] I2C bus swapping for LP5562 reprogramming after pattern updates
- [ ] Hardware test: flash and verify with PCSC reader / phone app (pending FD pin wiring) - [ ] 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 ## Group C — Power + Recovery
**Hardware needed**: + Hall sensor + multimeter **Hardware needed**: + Hall sensor + multimeter
### M7: Sleep/Wake ### M7: Sleep/Wake
- [ ] Configure SAMD21 EIC for FD pin wake - [x] Configure SAMD21 EIC for FD pin wake
- [ ] Implement STANDBY sleep after LP5562 programming - [x] Implement IDLE sleep during animation (TC4 ISR drives LEDs)
- [ ] Verify LP5562 keeps running during MCU sleep - [x] Implement STANDBY sleep when no pattern active
- [ ] Measure current: active vs standby vs total system - [ ] Measure current: IDLE (animating) vs STANDBY vs total system
### M8: Recovery Mode ### M8: Recovery Mode
@@ -96,28 +117,17 @@
### M9: Power Characterization ### M9: Power Characterization
- [ ] Measure current at various LED current settings - [ ] Measure current at various LED brightness / resistor values
- [ ] Test NTAG5Link EH output with phone NFC - [ ] Test NTAG5Link EH output with phone NFC at 1.8V
- [ ] Find optimal brightness vs EH budget balance - [ ] Tune power governor budget for optimal brightness
- [ ] Document real power numbers - [ ] Document real power numbers
## Group D — Abstraction + Polish ## Group D — Future
### 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
### Custom PCB (SAMD21E) ### Custom PCB (SAMD21E)
- [ ] Port to `atsamd-hal` with `samd21e` feature - [ ] 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 - [ ] Custom `memory.x` linker script
- [ ] PCB design and fabrication - [ ] PCB design and fabrication
@@ -135,23 +145,21 @@
| Question | Status | Notes | | Question | Status | Notes |
|----------|--------|-------| |----------|--------|-------|
| Energy harvesting power budget | TBD | Multimeter measurements in M9 | | Energy harvesting power budget | TBD | Multimeter measurements at 1.8V |
| Optimal LED current per channel | TBD | Probably 2-5mA/ch, verify in M9 | | 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 | | Firmware update strategy | Deferred | UF2 for dev, NFC OTA evaluated later |
| NTAG5Link I2C slave address | Assumed 0x54 | Verify in M4 with Click board | | Hall sensor part selection | Decided | DRV5032FB (SOT-23, 8.4mT, prototype) |
| 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 |
## Hardware Inventory ## Hardware Inventory
| Item | Status | Notes | | Item | Status | Notes |
|------|--------|-------| |------|--------|-------|
| Seeed XIAO M0 | Available | Dev board MCU (SAMD21G18A) | | Seeed XIAO M0 | Available | Dev board MCU (SAMD21G18A) |
| LP5562EVM | Available | TI eval module, RGBW LEDs, I2C addr 0x30 | | LP5562EVM | Available (unused) | Removed from design (VDD > 2.7V) |
| Mini-USB cable | Available | USB-C, used for flashing XIAO M0 |
| NTAG5 Link Click | Available, partially wired | Missing I2C jumper to XIAO | | 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 | | ACR1552 PCSC reader | Available | For ntag5sensor Python tooling |
| Multimeter | Available | For power budget measurements | | 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 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::hal;
use bsp::pac; use bsp::pac;
use hal::clock::GenericClockController; use hal::clock::{ClockGenId, GenericClockController};
use hal::delay::Delay; use hal::delay::Delay;
use hal::eic::pin::{ExtInt4, Sense};
use hal::eic::EIC;
use hal::gpio::PullUpInterrupt;
use hal::prelude::*; 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 led;
mod ntag5; mod ntag5;
mod pattern; mod pattern;
use led::lp5562::{ClockSource, Lp5562, DEFAULT_ADDRESS};
use ntag5::Ntag5Link; use ntag5::Ntag5Link;
use ntag5::sram::MailboxState; 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) /// 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) { 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] #[entry]
fn main() -> ! { fn main() -> ! {
let mut peripherals = Peripherals::take().unwrap(); 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( let mut clocks = GenericClockController::with_external_32kosc(
peripherals.GCLK, peripherals.GCLK,
@@ -51,19 +114,40 @@ fn main() -> ! {
let mut led: bsp::Led0 = pins.led0.into_push_pull_output(); let mut led: bsp::Led0 = pins.led0.into_push_pull_output();
led.set_high().unwrap(); // LED off (active low) led.set_high().unwrap(); // LED off (active low)
// === DIAGNOSTIC: 3 slow blinks = firmware is alive === // === DIAGNOSTIC: 3 blinks = firmware is alive ===
blink(&mut led, &mut delay, 3, 200); blink(&mut led, &mut delay, 3, 250);
delay.delay_ms(500u32); delay.delay_ms(1500u32);
// LP5562 hardware enable on D0/A0 — drive high to power on // --- EIC setup for FD pin wake from STANDBY ---
let mut lp_en = pins.a0.into_push_pull_output(); let gclk2 = clocks
lp_en.set_high().unwrap(); .configure_gclk_divider_and_source(
delay.delay_ms(10u32); // Let LP5562 power stabilize 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) // FD pin on A1 (PA04) → ExtInt4, pull-up (FD is open-drain)
let fd_pin = pins.a1.into_pull_up_input(); 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( let i2c = bsp::i2c_master(
&mut clocks, &mut clocks,
400u32.kHz(), 400u32.kHz(),
@@ -73,156 +157,134 @@ fn main() -> ! {
pins.a5, 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); 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() { match ntag.check_config() {
Ok(result) => { Ok(result) => {
if result.all_ok { append(&mut buf, &mut i, b"CFG:");
blink(&mut led, &mut delay, 2, 100); 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 { } 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(_) => { append(&mut buf, &mut i, b" ");
// NTAG5 not reachable — 1 long blink (not fatal)
led.set_low().unwrap(); // ED/FD config readback
delay.delay_ms(800u32); append(&mut buf, &mut i, b"ED:");
led.set_high().unwrap(); 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); delay.delay_ms(500u32);
// Configure FD pin for SRAM-write-by-RF indication // --- Try loading pattern from EEPROM ---
let _ = ntag.configure_fd_sram_write(); // Best-effort, non-fatal
// Try reading XBLK library header from upper 1K
let mut header_buf = [0u8; pattern::HEADER_SIZE]; let mut header_buf = [0u8; pattern::HEADER_SIZE];
let eeprom_ok = ntag.read_memory(pattern::LIBRARY_BASE_BLOCK, &mut header_buf).ok() let eeprom_ok = ntag.read_memory(pattern::LIBRARY_BASE_BLOCK, &mut header_buf).ok()
.and_then(|()| pattern::parse_header(&header_buf)); .and_then(|()| pattern::parse_header(&header_buf));
// Read active pattern if header is valid let mut active_pattern: Option<PatternDef> = None;
let eeprom_pattern = eeprom_ok.as_ref().and_then(|hdr| {
led_current = hdr.current; if let Some(ref hdr) = eeprom_ok {
budget = hdr.budget;
let pat_block = pattern::LIBRARY_BASE_BLOCK let pat_block = pattern::LIBRARY_BASE_BLOCK
+ (pattern::HEADER_SIZE as u16 / 4) + (pattern::HEADER_SIZE as u16 / 4)
+ (hdr.active_index as u16 * (pattern::PATTERN_ENTRY_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]; 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 ntag.read_memory(pat_block, &mut pat_buf).is_ok() {
if let Some(new_pat) = pattern::parse_pattern_entry(&pat_buf) { active_pattern = 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);
} }
} }
}
}
}
}
}
}
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 = [ let patterns = [
pattern::breathe(mode), pattern::breathe(),
pattern::heartbeat(mode), pattern::heartbeat(),
pattern::slow_pulse(mode), pattern::wave_chase(),
pattern::rgb_cycle(mode), pattern::slow_pulse(),
pattern::color_wash(mode), pattern::alternating_blink(),
]; ];
// Serialize all pattern entries and write to EEPROM
let pat_base_block = pattern::LIBRARY_BASE_BLOCK let pat_base_block = pattern::LIBRARY_BASE_BLOCK
+ (pattern::HEADER_SIZE as u16 / 4); + (pattern::HEADER_SIZE as u16 / 4);
let mut all_pat_data = [0u8; pattern::PATTERN_ENTRY_SIZE * 5];
let mut provision_ok = true; let mut provision_ok = true;
for (i, pat) in patterns.iter().enumerate() { for (i, pat) in patterns.iter().enumerate() {
let mut entry_buf = [0u8; pattern::PATTERN_ENTRY_SIZE]; let mut entry_buf = [0u8; pattern::PATTERN_ENTRY_SIZE];
pattern::serialize_pattern_entry(pat, &mut entry_buf); 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 let entry_block = pat_base_block
+ (i as u16 * (pattern::PATTERN_ENTRY_SIZE as u16 / 4)); + (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 bo = (blk as usize) * 4;
let chunk = [entry_buf[bo], entry_buf[bo+1], entry_buf[bo+2], entry_buf[bo+3]]; 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() { if ntag.write_verify_block(entry_block + blk, &chunk, &mut delay).is_err() {
@@ -234,12 +296,9 @@ fn main() -> ! {
} }
if provision_ok { if provision_ok {
// Write header last (so partial writes don't look valid)
let mut hdr_buf = [0u8; pattern::HEADER_SIZE]; let mut hdr_buf = [0u8; pattern::HEADER_SIZE];
pattern::serialize_header( pattern::serialize_header(
5, 0, led_current, 0x00, 5, 0, budget, false, 0, &mut hdr_buf,
&all_pat_data,
&mut hdr_buf,
); );
for blk in 0..4u16 { for blk in 0..4u16 {
let bo = (blk as usize) * 4; let bo = (blk as usize) * 4;
@@ -254,54 +313,83 @@ fn main() -> ! {
} }
if provision_ok { if provision_ok {
// Success — 4 fast blinks, then load pattern 0 from EEPROM blink(&mut led, &mut delay, 4, 250);
blink(&mut led, &mut delay, 4, 80); active_pattern = Some(pattern::breathe());
} else {
// Re-read active pattern from what we just wrote blink(&mut led, &mut delay, 8, 200);
let mut pat_buf = [0u8; pattern::PATTERN_ENTRY_SIZE]; // Fall back to hardcoded breathe
let loaded = ntag.read_memory(pat_base_block, &mut pat_buf).ok() active_pattern = Some(pattern::breathe());
.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);
} }
} } else {
let i2c = lp.release(); blink(&mut led, &mut delay, 3, 250); // EEPROM pattern loaded
ntag = Ntag5Link::new(i2c, ntag5::DEFAULT_ADDRESS);
} }
// 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(); let mut mbox = MailboxState::new();
loop { loop {
delay.delay_ms(200u32); // IDLE sleep (not STANDBY) — TC4 and TCC keep running
if fd_pin.is_low().unwrap_or(false) { 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( let cmd_ok = ntag5::sram::process_command(
&mut ntag, &mut mbox, &mut delay, &mut ntag, &mut mbox, &mut delay,
); );
if matches!(cmd_ok, Ok(true)) { if matches!(cmd_ok, Ok(true)) {
// Pattern library was modified — reload from EEPROM
let mut hdr_buf = [0u8; pattern::HEADER_SIZE]; let mut hdr_buf = [0u8; pattern::HEADER_SIZE];
if ntag.read_memory(pattern::LIBRARY_BASE_BLOCK, &mut hdr_buf).is_ok() { if ntag.read_memory(pattern::LIBRARY_BASE_BLOCK, &mut hdr_buf).is_ok() {
if let Some(hdr) = pattern::parse_header(&hdr_buf) { if let Some(hdr) = pattern::parse_header(&hdr_buf) {
led_current = hdr.current; budget = hdr.budget;
let pat_block = pattern::LIBRARY_BASE_BLOCK let pat_block = pattern::LIBRARY_BASE_BLOCK
+ (pattern::HEADER_SIZE as u16 / 4) + (pattern::HEADER_SIZE as u16 / 4)
+ (hdr.active_index as u16 + (hdr.active_index as u16
* (pattern::PATTERN_ENTRY_SIZE as u16 / 4)); * (pattern::PATTERN_ENTRY_SIZE as u16 / 4));
let mut pat_buf2 = [0u8; pattern::PATTERN_ENTRY_SIZE]; let mut pat_buf = [0u8; pattern::PATTERN_ENTRY_SIZE];
if ntag.read_memory(pat_block, &mut pat_buf2).is_ok() { if ntag.read_memory(pat_block, &mut pat_buf).is_ok() {
if let Some(new_pat) = pattern::parse_pattern_entry(&pat_buf2) { if let Some(new_pat) = pattern::parse_pattern_entry(&pat_buf) {
let i2c = ntag.release(); cortex_m::interrupt::free(|cs| {
let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS); if let Some(ref mut eng) = *ENGINE.borrow(cs).borrow_mut() {
let _ = pattern::load_pattern( eng.budget = budget as u16;
&mut lp, &new_pat, led_current, &mut delay, eng.load(new_pat);
); }
let i2c = lp.release(); });
ntag = Ntag5Link::new(i2c, ntag5::DEFAULT_ADDRESS);
} }
} }
} }
@@ -309,33 +397,36 @@ fn main() -> ! {
} }
} }
} }
} else { }
// Provisioning failed — fall back to hardcoded cycle
blink(&mut led, &mut delay, 8, 50); /// TC4 interrupt handler — 50Hz animation tick.
/// Computes LED brightness from pattern engine and writes TCC duty cycles.
let i2c = ntag.release(); #[interrupt]
let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS); fn TC4() {
let tc4 = unsafe { &*pac::TC4::ptr() };
let mut idx = 0; // Clear MC0 interrupt flag
loop { tc4.count16().intflag.write(|w| w.mc0().set_bit());
if pattern::load_pattern(
&mut lp, &patterns[idx], led_current, &mut delay, cortex_m::interrupt::free(|cs| {
).is_err() { if let Some(ref mut engine) = *ENGINE.borrow(cs).borrow_mut() {
blink(&mut led, &mut delay, 10, 50); let mut output = [0u8; NUM_LEDS];
} engine.tick(&mut output);
delay.delay_ms(15000u32); led::pwm::set_all(&output);
idx = (idx + 1) % patterns.len();
} if engine.repeats_done() {
} *REPEATS_DONE.borrow(cs).borrow_mut() = true;
} }
} }
} });
Err(_) => { }
// === DIAGNOSTIC: fast blink forever = I2C error ===
loop { /// EIC interrupt handler — clears flag, main loop checks and processes.
blink(&mut led, &mut delay, 5, 80); #[interrupt]
delay.delay_ms(500u32); 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_BLOCK_COUNT: u16 = 64;
pub const SRAM_SIZE: usize = 256; pub const SRAM_SIZE: usize = 256;
// Session register for ED/FD pin configuration // Config EEPROM I2C block addresses (NFC block 0x3D → I2C 0x103D)
pub const SESSION_ED_FD_PIN_CFG: u16 = 0x10A3; 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 // ED/FD pin config is byte 2 within the EH_CONFIG block (0x103D / session 0x10A7)
pub const FD_MODE_SRAM_RF_WRITE: u8 = 0x04; // 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 // EEPROM user memory I2C block addresses
// Block 0 = CC (capability container), blocks 1+ = NDEF data // 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; 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) // 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; pub const EXPECTED_CONFIG_1: u8 = 0x0A;
// EH_CONFIG: skip for now — EH is disabled (0x00) when powered externally. // EH_CONFIG: EH_ENABLE + VOUT_V_SEL_1_8V + DISABLE_POWER_CHECK + VOUT_I_SEL_6_5mA = 0x59
// Set to 0x75 (EH_ENABLE + 3.0V + 12.5mA) when running from energy harvesting. // Bit 0: EH_ENABLE = 1
pub const EXPECTED_EH_CONFIG: u8 = 0x00; // 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 // 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 // CONFIG_0: bits 3:2 (EH_MODE) — ignore SRAM_COPY_EN, AUTO_STANDBY, LOCK_SESSION
pub const CONFIG_0_MASK: u8 = 0x0C; pub const CONFIG_0_MASK: u8 = 0x0C;
// CONFIG_1: bits 5:4 (USE_CASE) + bits 3:2 (ARBITER) + bit 1 (SRAM_EN) // CONFIG_1: bits 5:4 (USE_CASE) + bits 3:2 (ARBITER) + bit 1 (SRAM_EN)
pub const CONFIG_1_MASK: u8 = 0x3E; pub const CONFIG_1_MASK: u8 = 0x3E;
// EH_CONFIG: mask 0x00 — don't check EH config during external power testing // EH_CONFIG: check EH_ENABLE (bit 0) + VOUT_V_SEL (bits 2:1), ignore current limit + status bits
pub const EH_CONFIG_MASK: u8 = 0x00; pub const EH_CONFIG_MASK: u8 = 0x07;
#[derive(Debug)] #[derive(Debug)]
pub enum Error<E> { pub enum Error<E> {
@@ -189,12 +200,79 @@ where
Ok(()) 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. /// 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> { pub fn configure_fd_sram_write(&mut self) -> Result<(), E> {
// ED_FD_PIN_CFG register index 1 within the session block self.write_register(
// Bits 2:0 control FD output mode SESSION_EH_CONFIG_REG, 2, 0xFF,
self.write_register(SESSION_ED_FD_PIN_CFG, 0x01, 0x07, FD_MODE_SRAM_RF_WRITE) 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 ---- // ---- Config check ----

View File

@@ -13,7 +13,7 @@ use embedded_hal::i2c::I2c;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Firmware version reported in GET_STATUS response. /// Firmware version reported in GET_STATUS response.
pub const FIRMWARE_VERSION: u8 = 0x01; pub const FIRMWARE_VERSION: u8 = 0x02;
// Command IDs (phone -> MCU) // Command IDs (phone -> MCU)
pub const CMD_WRITE_PATTERN: u8 = 0x01; pub const CMD_WRITE_PATTERN: u8 = 0x01;
@@ -49,10 +49,8 @@ pub struct MailboxState {
pub syncing: bool, pub syncing: bool,
/// Pattern count supplied by SYNC_START. /// Pattern count supplied by SYNC_START.
pub sync_count: u8, pub sync_count: u8,
/// Current (mA setting) supplied by SYNC_START. /// Budget supplied by SYNC_START.
pub sync_current: u8, pub sync_budget: u8,
/// LED mode supplied by SYNC_START.
pub sync_mode: u8,
/// Next pattern index for READ_NEXT. /// Next pattern index for READ_NEXT.
pub read_index: u8, pub read_index: u8,
/// Total patterns available for READ_NEXT iteration. /// Total patterns available for READ_NEXT iteration.
@@ -64,8 +62,7 @@ impl MailboxState {
Self { Self {
syncing: false, syncing: false,
sync_count: 0, sync_count: 0,
sync_current: 0, sync_budget: pattern::DEFAULT_BUDGET,
sync_mode: 0,
read_index: 0, read_index: 0,
read_count: 0, read_count: 0,
} }
@@ -113,13 +110,14 @@ fn build_response(buf: &mut [u8], seq: u8, status: u8, payload: &[u8]) -> usize
// EEPROM helpers // 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 { 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. /// Read the XBLK library header from EEPROM.
/// Returns None if magic/version don't match.
fn read_library_header<I2C, E>( fn read_library_header<I2C, E>(
ntag: &mut Ntag5Link<I2C>, ntag: &mut Ntag5Link<I2C>,
) -> Result<Option<pattern::LibraryHeader>, ntag5::Error<E>> ) -> Result<Option<pattern::LibraryHeader>, ntag5::Error<E>>
@@ -149,46 +147,21 @@ where
Ok(()) Ok(())
} }
/// Read all pattern data from EEPROM for the given count (for CRC computation). /// Recalculate the header CRC and write the updated header back to EEPROM.
/// 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.
fn recalculate_header_crc<I2C, E>( fn recalculate_header_crc<I2C, E>(
ntag: &mut Ntag5Link<I2C>, ntag: &mut Ntag5Link<I2C>,
count: u8, count: u8,
active: u8, active: u8,
current: u8, budget: u8,
led_mode: u8, has_playlist: bool,
playlist_count: u8,
delay: &mut impl embedded_hal::delay::DelayNs, delay: &mut impl embedded_hal::delay::DelayNs,
) -> Result<(), ntag5::Error<E>> ) -> Result<(), ntag5::Error<E>>
where where
I2C: I2c<Error = E>, 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]; 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) write_header_to_eeprom(ntag, &hdr, delay)
} }
@@ -203,14 +176,15 @@ where
{ {
let hdr = match read_library_header(ntag)? { let hdr = match read_library_header(ntag)? {
Some(h) => h, Some(h) => h,
None => return Ok(()), // no valid header, nothing to update None => return Ok(()),
}; };
recalculate_header_crc( recalculate_header_crc(
ntag, ntag,
hdr.pattern_count, hdr.pattern_count,
hdr.active_index, hdr.active_index,
hdr.current, hdr.budget,
hdr.led_mode, hdr.has_playlist,
hdr.playlist_count,
delay, delay,
) )
} }
@@ -228,23 +202,20 @@ fn handle_get_status<I2C, E>(
where where
I2C: I2c<Error = E>, I2C: I2c<Error = E>,
{ {
let mut payload = [0u8; 5]; let mut payload = [0u8; 4];
payload[0] = FIRMWARE_VERSION; payload[0] = FIRMWARE_VERSION;
match read_library_header(ntag)? { match read_library_header(ntag)? {
Some(h) => { Some(h) => {
payload[1] = h.pattern_count; payload[1] = h.pattern_count;
payload[2] = h.active_index; payload[2] = h.active_index;
payload[3] = h.current; payload[3] = h.budget;
payload[4] = h.led_mode;
}
None => {
// No valid header — return zeros
} }
None => {}
} }
Ok(build_response(rsp_buf, seq, STATUS_OK, &payload)) 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>( fn handle_write_pattern<I2C, E>(
ntag: &mut Ntag5Link<I2C>, ntag: &mut Ntag5Link<I2C>,
state: &MailboxState, state: &MailboxState,
@@ -264,10 +235,11 @@ where
return Ok(build_response(rsp_buf, seq, STATUS_INVALID_INDEX, &[])); 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 base_block = pattern_block(index);
let pattern_data = &payload[1..1 + pattern::PATTERN_ENTRY_SIZE]; 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 off = (i as usize) * 4;
let mut chunk = [0u8; 4]; let mut chunk = [0u8; 4];
chunk.copy_from_slice(&pattern_data[off..off + 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 { if !state.syncing {
match update_header_after_write(ntag, delay) { match update_header_after_write(ntag, delay) {
Ok(()) => {} Ok(()) => {}
@@ -319,9 +291,10 @@ where
return Ok(build_response(rsp_buf, seq, STATUS_INVALID_INDEX, &[])); return Ok(build_response(rsp_buf, seq, STATUS_INVALID_INDEX, &[]));
} }
// Recalculate header with new active index match recalculate_header_crc(
match recalculate_header_crc(ntag, hdr.pattern_count, index, hdr.current, hdr.led_mode, delay) ntag, hdr.pattern_count, index, hdr.budget,
{ hdr.has_playlist, hdr.playlist_count, delay,
) {
Ok(()) => {} Ok(()) => {}
Err(ntag5::Error::VerifyFailed) => { Err(ntag5::Error::VerifyFailed) => {
return Ok(build_response(rsp_buf, seq, STATUS_EEPROM_FAIL, &[])); return Ok(build_response(rsp_buf, seq, STATUS_EEPROM_FAIL, &[]));
@@ -339,7 +312,7 @@ fn handle_sync_start(
payload: &[u8], payload: &[u8],
rsp_buf: &mut [u8], rsp_buf: &mut [u8],
) -> usize { ) -> usize {
if payload.len() < 3 { if payload.len() < 2 {
return build_response(rsp_buf, seq, STATUS_BAD_CMD, &[]); return build_response(rsp_buf, seq, STATUS_BAD_CMD, &[]);
} }
let count = payload[0]; let count = payload[0];
@@ -348,8 +321,7 @@ fn handle_sync_start(
} }
state.syncing = true; state.syncing = true;
state.sync_count = count; state.sync_count = count;
state.sync_current = payload[1]; state.sync_budget = payload[1];
state.sync_mode = payload[2];
build_response(rsp_buf, seq, STATUS_OK, &[]) build_response(rsp_buf, seq, STATUS_OK, &[])
} }
@@ -368,14 +340,8 @@ where
return Ok(build_response(rsp_buf, seq, STATUS_BAD_CMD, &[])); return Ok(build_response(rsp_buf, seq, STATUS_BAD_CMD, &[]));
} }
// Active index defaults to 0
match recalculate_header_crc( match recalculate_header_crc(
ntag, ntag, state.sync_count, 0, state.sync_budget, false, 0, delay,
state.sync_count,
0, // active_index = 0
state.sync_current,
state.sync_mode,
delay,
) { ) {
Ok(()) => {} Ok(()) => {}
Err(ntag5::Error::VerifyFailed) => { Err(ntag5::Error::VerifyFailed) => {
@@ -402,13 +368,12 @@ fn handle_read_library<I2C, E>(
where where
I2C: I2c<Error = E>, I2C: I2c<Error = E>,
{ {
let mut payload = [0u8; 4]; let mut payload = [0u8; 3];
match read_library_header(ntag)? { match read_library_header(ntag)? {
Some(h) => { Some(h) => {
payload[0] = h.pattern_count; payload[0] = h.pattern_count;
payload[1] = h.active_index; payload[1] = h.active_index;
payload[2] = h.current; payload[2] = h.budget;
payload[3] = h.led_mode;
state.read_index = 0; state.read_index = 0;
state.read_count = h.pattern_count; state.read_count = h.pattern_count;
} }
@@ -420,7 +385,7 @@ where
Ok(build_response(rsp_buf, seq, STATUS_OK, &payload)) 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>( fn handle_read_next<I2C, E>(
ntag: &mut Ntag5Link<I2C>, ntag: &mut Ntag5Link<I2C>,
state: &mut MailboxState, state: &mut MailboxState,
@@ -447,11 +412,8 @@ where
/// Read SRAM, dispatch the command, and write the response back. /// Read SRAM, dispatch the command, and write the response back.
/// ///
/// Returns `Ok(true)` if a command was processed, `Ok(false)` if no valid /// Returns `Ok(true)` if a command was processed that modifies the pattern library,
/// command was found in SRAM (e.g., empty buffer, bad header, bad CRC). /// `Ok(false)` if no valid command or no library change.
///
/// I2C errors propagate as `Err`. EEPROM verify failures are reported via
/// a STATUS_EEPROM_FAIL response (not as Err).
pub fn process_command<I2C, E>( pub fn process_command<I2C, E>(
ntag: &mut Ntag5Link<I2C>, ntag: &mut Ntag5Link<I2C>,
state: &mut MailboxState, state: &mut MailboxState,
@@ -494,5 +456,11 @@ where
}; };
ntag.write_sram_blocks(0, &rsp[..rsp_len])?; 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. //! Replaces LP5562 hardware execution engines. The MCU drives 6 LEDs via
//! The LP5562 runs these autonomously — the MCU can sleep after loading. //! 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::{ /// Number of LED channels.
Channel, EngineCommand, EngineId, EngineProgram, LedMapping, Lp5562, Prescale, RampDirection, 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. const SINE_VALUES: [u8; 64] = [
#[derive(Clone, Copy, Debug)] 0, 6, 12, 19, 25, 31, 37, 43, 49, 56, 62, 68, 74, 80, 86, 91,
pub enum LedMode { 97, 103, 109, 114, 120, 125, 131, 136, 141, 146, 151, 156, 161, 166, 170, 175,
/// Single RGBW LED (e.g., LP5562EVM D1). Engines map to R, G, B; W is I2C-direct. 179, 183, 187, 191, 195, 199, 202, 206, 209, 212, 215, 218, 220, 223, 225, 228,
Rgbw, 230, 232, 233, 235, 237, 238, 239, 241, 242, 243, 243, 244, 245, 245, 245, 245,
/// 3 independent monochrome LEDs. Engines map to B, G, R channels (one each). ];
Mono3,
}
/// A complete pattern: up to 3 engine programs + LED mapping. /// Heartbeat waveform: 256-entry full cycle.
pub struct Pattern { /// Double-pulse cardiac shape: two sharp peaks with a rest period.
pub engine1: Option<EngineProgram>, const HEARTBEAT_LUT: [u8; 256] = {
pub engine2: Option<EngineProgram>, let mut table = [0u8; 256];
pub engine3: Option<EngineProgram>, // First beat: indices 0-31 (sharp rise/fall)
/// LED_MAP: which engine (or I2C direct) drives each channel. let mut i = 0;
/// Index: [B, G, R, W] → LedMapping value. while i < 16 {
pub map_b: LedMapping, table[i] = (i as u8) * 16; // 0 → 240
pub map_g: LedMapping, i += 1;
pub map_r: LedMapping, }
pub map_w: LedMapping, while i < 32 {
/// Next pattern index after this one completes (0xFF = loop forever). table[i] = (31 - i as u8) * 16; // 240 → 0
pub next_pattern: u8, i += 1;
/// Number of full cycles before chaining (0 = chain immediately on engine stop). }
pub loop_count: u8, // Gap: 32-63
} // Second beat: indices 64-95 (slightly weaker)
i = 64;
while i < 80 {
table[i] = ((i - 64) as u8) * 12; // 0 → 180
i += 1;
}
while i < 96 {
table[i] = ((95 - i) as u8) * 12; // 180 → 0
i += 1;
}
// Rest: indices 96-255 = 0 (already zeroed)
table
};
// --------------------------------------------------------------------------- /// Sample a waveform at position `pos` (0-255 maps to one full cycle, 0-360 degrees).
// Breathing: smooth ramp up/down, ~2.5s cycle fn sample_waveform(waveform: Waveform, pos: u8) -> u8 {
// --------------------------------------------------------------------------- match waveform {
Waveform::Sine => {
/// Breathing pattern — one engine, smooth ramp. // 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.
/// LP5562 increment field = number of steps - 1 (max 127 = 128 steps). // Map pos 0-255 to a full sine period (0 → peak → 0 → peak → 0) ... no.
/// Each step changes PWM by 1 unit. Full 0→255 needs two ramp commands. // Better: simple 0→255→0 breathing shape over 256 steps.
/// // pos 0..63: rising (quarter 1)
/// Slow prescale (15.6ms/step), step_time=1: // pos 64..127: falling from peak (quarter 2, mirror)
/// Ramp up: 2 × 128 steps × 15.6ms = ~4.0s (0→128→255) // pos 128..191: rising again (quarter 3 = same as 1)
/// Ramp down: 2 × 128 steps × 15.6ms = ~4.0s (255→127→0) // pos 192..255: falling again (quarter 4 = same as 2)
/// Wait: step_time=48 → 48 × 15.6ms ≈ 0.75s pause at bottom // No — that's two cycles. For one full breath cycle:
/// Total: ~8.75s per cycle // pos 0..127: 0→255 (half sine, rising)
fn breathe_program() -> EngineProgram { // pos 128..255: 255→0 (half sine, falling)
EngineProgram::from_commands(&[ let half = pos as u16;
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 0→128, ~2s if half < 128 {
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 128→255, ~2s // Rising: sample quarter sine and mirror
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 255→127, ~2s let idx = if half < 64 {
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 127→0, ~2s SINE_QUARTER[half as usize]
EngineCommand::wait(Prescale::Slow, 48), // ~0.75s pause } else {
EngineCommand::branch(0, 0), // loop forever SINE_QUARTER[127 - half as usize]
]) };
} // Scale: quarter sine peaks at 245, we want 255
let scaled = (idx as u16 * 255) / 245;
/// Load breathing pattern. All active channels breathe in sync. if scaled > 255 { 255 } else { scaled as u8 }
pub fn breathe(mode: LedMode) -> Pattern { } else {
single_engine_pattern(breathe_program(), mode) // Falling: mirror of rising
} let mirror = 255 - pos;
let half_m = mirror as u16;
// --------------------------------------------------------------------------- let idx = if half_m < 64 {
// Heartbeat: double-pulse with long pause, ~1.6s cycle SINE_QUARTER[half_m as usize]
// --------------------------------------------------------------------------- } else {
SINE_QUARTER[127 - half_m as usize]
/// Heartbeat pattern — fast double-pulse, long rest. };
/// let scaled = (idx as u16 * 255) / 245;
/// Fast prescale (0.49ms/step): if scaled > 255 { 255 } else { scaled as u8 }
/// set_pwm 255 → snap on }
/// wait fast, st=20 → 20 × 0.49ms ≈ 10ms hold }
/// set_pwm 0 → snap off Waveform::Triangle => {
/// wait fast, st=40 → 40 × 0.49ms ≈ 20ms gap // 0→255→0 linear triangle
/// set_pwm 255 → second beat if pos < 128 {
/// wait fast, st=20 → 10ms hold (pos as u16 * 2) as u8
/// set_pwm 0 → snap off } else {
/// Slow prescale for the long rest: ((255 - pos as u16) * 2) as u8
/// 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 Waveform::Square => {
fn heartbeat_program() -> EngineProgram { // On for first half, off for second half
EngineProgram::from_commands(&[ if pos < 128 { 255 } else { 0 }
EngineCommand::set_pwm(255), // 0: first beat ON }
EngineCommand::wait(Prescale::Fast, 20), // 1: hold ~10ms Waveform::Heartbeat => {
EngineCommand::set_pwm(0), // 2: first beat OFF HEARTBEAT_LUT[pos as usize]
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
])
}
/// 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
])
}
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
])
}
/// 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. /// A single pattern definition, loaded from EEPROM or hardcoded.
/// #[derive(Clone, Copy)]
/// Slow prescale (15.6ms/step), step_time=4: pub struct PatternDef {
/// Ramp up: 2 × 128 steps × 62.4ms = ~16.0s (0→128→255) pub waveform: Waveform,
/// Wait: step_time=32 → ~0.5s hold at peak pub cycle_len: u8, // ticks per full cycle (1-255)
/// Ramp down: 2 × 128 steps × 62.4ms = ~16.0s (255→127→0) pub phase: [u8; NUM_LEDS], // phase offset per LED (0-255 = 0-360 degrees)
/// Wait: step_time=63 → ~1.0s pause at bottom pub envelope: [u8; NUM_LEDS], // max brightness per LED
/// Total: ~33.5s per cycle pub repeat_count: u8, // playlist: times to play before advancing (0xFF=forever)
fn slow_pulse_program() -> EngineProgram {
EngineProgram::from_commands(&[
EngineCommand::ramp_wait(Prescale::Slow, 4, RampDirection::Up, 127), // 0→128, ~8s
EngineCommand::ramp_wait(Prescale::Slow, 4, RampDirection::Up, 127), // 128→255, ~8s
EngineCommand::wait(Prescale::Slow, 32), // hold ~0.5s
EngineCommand::ramp_wait(Prescale::Slow, 4, RampDirection::Down, 127), // 255→127, ~8s
EngineCommand::ramp_wait(Prescale::Slow, 4, RampDirection::Down, 127), // 127→0, ~8s
EngineCommand::wait(Prescale::Slow, 63), // pause ~1.0s
EngineCommand::branch(0, 0), // loop forever
])
} }
/// Load slow pulse pattern. /// Runtime pattern engine state.
pub fn slow_pulse(mode: LedMode) -> Pattern { pub struct PatternEngine {
single_engine_pattern(slow_pulse_program(), mode) pub pattern: PatternDef,
pub tick: u16, // current tick within cycle
pub cycle_count: u16, // completed cycles (for repeat_count tracking)
pub budget: u16, // power governor budget (sum of all LED values must not exceed this)
} }
// --------------------------------------------------------------------------- impl PatternEngine {
// Color wash: 3 engines, smooth overlapping ramps for blended color transitions pub fn new(pattern: PatternDef, budget: u8) -> Self {
// --------------------------------------------------------------------------- PatternEngine {
pattern,
tick: 0,
cycle_count: 0,
budget: budget as u16,
}
}
/// Color wash — free-running engines with different cycle lengths. /// Compute brightness for all LEDs at the current tick, applying governor.
/// /// Call this from the TC4 ISR at 50Hz.
/// No triggers. Each engine breathes independently at a slightly different pub fn tick(&mut self, output: &mut [u8; NUM_LEDS]) {
/// rate, causing them to drift in and out of phase. Smooth single-PWM-unit let cycle = self.pattern.cycle_len as u16;
/// increments for clean color blending. if cycle == 0 {
/// for v in output.iter_mut() { *v = 0; }
/// Slow prescale (15.6ms/step), step_time=1, increment=127 (128 steps per cmd): return;
/// Ramp: 2 × 128 steps × 15.6ms = ~4s per full ramp }
///
/// E1 (Blue): up ~4s + down ~4s = ~8s cycle (no pause)
/// E2 (Green): up ~4s + down ~4s + ~0.5s pause = ~8.5s cycle
/// E3 (Red): up ~4s + down ~4s + ~1.0s pause = ~9s cycle
///
/// Phase drift: ~0.5s per cycle → colors shift noticeably every few cycles.
fn color_wash_engine1() -> EngineProgram {
// ~8s cycle (no pause)
EngineProgram::from_commands(&[
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 0→128
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 128→255
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 255→127
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 127→0
EngineCommand::branch(0, 0),
])
}
fn color_wash_engine2() -> EngineProgram { // Compute raw brightness per LED
// ~8.5s cycle (short pause at bottom) for i in 0..NUM_LEDS {
EngineProgram::from_commands(&[ // Map tick to 0-255 position within the waveform cycle
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 0→128 // tick ranges from 0 to cycle_len-1
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 128→255 // phase[i] offsets in units of 1/256 of a cycle
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 255→127 let pos = ((self.tick as u32 * 256 / cycle as u32)
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 127→0 + self.pattern.phase[i] as u32) % 256;
EngineCommand::wait(Prescale::Slow, 32), // pause ~0.5s let raw = sample_waveform(self.pattern.waveform, pos as u8);
EngineCommand::branch(0, 0), // Scale by envelope (max brightness for this LED)
]) output[i] = ((raw as u16 * self.pattern.envelope[i] as u16) / 255) as u8;
} }
fn color_wash_engine3() -> EngineProgram { // Power governor: scale down if total exceeds budget
// ~9s cycle (longer pause at bottom) if self.budget > 0 {
EngineProgram::from_commands(&[ let total: u16 = output.iter().map(|&v| v as u16).sum();
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 0→128 if total > self.budget {
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 128→255 let scale = (self.budget * 256) / total;
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 255→127 for v in output.iter_mut() {
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 127→0 *v = ((*v as u16 * scale) / 256) as u8;
EngineCommand::wait(Prescale::Slow, 63), // pause ~1.0s }
EngineCommand::branch(0, 0), }
]) }
}
/// Load color wash (RGBW: smooth hue transitions) or wave (mono: traveling slow pulse). // Advance tick
pub fn color_wash(mode: LedMode) -> Pattern { self.tick += 1;
match mode { if self.tick >= cycle {
LedMode::Rgbw => Pattern { self.tick = 0;
engine1: Some(color_wash_engine1()), if self.cycle_count < u16::MAX {
engine2: Some(color_wash_engine2()), self.cycle_count += 1;
engine3: Some(color_wash_engine3()), }
map_b: LedMapping::Engine1, }
map_g: LedMapping::Engine2, }
map_r: LedMapping::Engine3,
map_w: LedMapping::I2c, /// Check if this pattern's repeat count has been reached.
next_pattern: 0xFF, pub fn repeats_done(&self) -> bool {
loop_count: 0, if self.pattern.repeat_count == 0xFF {
}, return false; // loop forever
LedMode::Mono3 => Pattern { }
engine1: Some(color_wash_engine1()), self.cycle_count >= self.pattern.repeat_count as u16
engine2: Some(color_wash_engine2()), }
engine3: Some(color_wash_engine3()),
map_b: LedMapping::Engine1, /// Load a new pattern, resetting tick and cycle count.
map_g: LedMapping::Engine2, pub fn load(&mut self, pattern: PatternDef) {
map_r: LedMapping::Engine3, self.pattern = pattern;
map_w: LedMapping::I2c, self.tick = 0;
next_pattern: 0xFF, self.cycle_count = 0;
loop_count: 0,
},
} }
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helper for single-engine patterns (all RGB channels mapped to Engine1) // Predefined patterns
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
fn single_engine_pattern(prog: EngineProgram, mode: LedMode) -> Pattern { /// Breathing: smooth sine, all LEDs in phase, ~2.5s cycle
let _ = mode; // Same mapping for both modes pub fn breathe() -> PatternDef {
Pattern { PatternDef {
engine1: Some(prog), waveform: Waveform::Sine,
engine2: None, cycle_len: 125, // 125 ticks = 2.5s at 50Hz
engine3: None, phase: [0, 0, 0, 0, 0, 0],
map_b: LedMapping::Engine1, envelope: [255, 255, 255, 255, 255, 255],
map_g: LedMapping::Engine1, repeat_count: 0xFF,
map_r: LedMapping::Engine1, }
map_w: LedMapping::I2c, }
next_pattern: 0xFF,
loop_count: 0, /// 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 // XBLK v2 EEPROM format
// ---------------------------------------------------------------------------
/// 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
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// EEPROM base block for the pattern library (upper 1K, block 256). /// 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"; pub const XBLK_MAGIC: [u8; 4] = *b"XBLK";
/// Format version. /// Format version.
pub const XBLK_VERSION: u8 = 0x01; pub const XBLK_VERSION: u8 = 0x02;
/// Header size in bytes. /// Header size in bytes.
pub const HEADER_SIZE: usize = 16; pub const HEADER_SIZE: usize = 16;
/// Pattern entry size in bytes (fixed for direct seeking). /// Pattern entry size in bytes.
pub const PATTERN_ENTRY_SIZE: usize = 112; pub const PATTERN_ENTRY_SIZE: usize = 16;
/// Maximum patterns that fit in 1024 bytes: (1024 - 16) / 112 = 9. /// Maximum playlist entries.
pub const MAX_PATTERNS: usize = 9; 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 struct LibraryHeader {
pub pattern_count: u8, pub pattern_count: u8,
pub active_index: u8, pub active_index: u8,
pub current: u8, pub budget: u8,
pub led_mode: 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> { pub fn parse_header(buf: &[u8; HEADER_SIZE]) -> Option<LibraryHeader> {
if buf[0..4] != XBLK_MAGIC { if buf[0..4] != XBLK_MAGIC {
return None; return None;
@@ -397,147 +344,82 @@ pub fn parse_header(buf: &[u8; HEADER_SIZE]) -> Option<LibraryHeader> {
if buf[4] != XBLK_VERSION { if buf[4] != XBLK_VERSION {
return None; return None;
} }
let count = buf[5]; let flags = buf[5];
let count = buf[6];
if count == 0 || count as usize > MAX_PATTERNS { if count == 0 || count as usize > MAX_PATTERNS {
return None; 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 { Some(LibraryHeader {
pattern_count: count, pattern_count: count,
active_index: buf[6] % count, // wrap if out of range active_index: buf[7] % count,
current: buf[7], budget: buf[8],
led_mode: buf[8], has_playlist: flags & 0x01 != 0,
playlist_count: buf[9],
}) })
} }
/// Parse a 112-byte pattern entry into a Pattern struct. /// Parse a 16-byte pattern entry into a PatternDef.
/// Returns None if the data is malformed. pub fn parse_pattern_entry(buf: &[u8; PATTERN_ENTRY_SIZE]) -> Option<PatternDef> {
pub fn parse_pattern_entry(buf: &[u8; PATTERN_ENTRY_SIZE]) -> Option<Pattern> { let waveform = Waveform::from_u8(buf[0])?;
let engine_count = buf[0]; let cycle_len = buf[1];
if engine_count > 3 { if cycle_len == 0 {
return None; return None;
} }
let mut phase = [0u8; NUM_LEDS];
let led_map_reg = buf[1]; phase.copy_from_slice(&buf[2..8]);
// Decode LED_MAP register: 2 bits per channel [W(7:6), R(5:4), G(3:2), B(1:0)] let mut envelope = [0u8; NUM_LEDS];
let map_b = led_map_byte_to_mapping(led_map_reg & 0x03)?; envelope.copy_from_slice(&buf[8..14]);
let map_g = led_map_byte_to_mapping((led_map_reg >> 2) & 0x03)?; Some(PatternDef {
let map_r = led_map_byte_to_mapping((led_map_reg >> 4) & 0x03)?; waveform,
let map_w = led_map_byte_to_mapping((led_map_reg >> 6) & 0x03)?; cycle_len,
phase,
// Direct PWM values at bytes 2-5 (unused for now, engines override) envelope,
// let _direct_pwm = [buf[2], buf[3], buf[4], buf[5]]; repeat_count: buf[14],
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],
}) })
} }
/// Parse a 34-byte engine section: 2 bytes command count (BE) + 32 bytes commands. /// Serialize a PatternDef into a 16-byte buffer.
/// Returns Some(None) for unused engines, Some(Some(prog)) for valid, None for malformed. pub fn serialize_pattern_entry(p: &PatternDef, buf: &mut [u8; PATTERN_ENTRY_SIZE]) {
fn parse_engine_program(buf: &[u8]) -> Option<Option<EngineProgram>> { buf[0] = p.waveform as u8;
let cmd_count = ((buf[0] as u16) << 8 | buf[1] as u16) as usize; buf[1] = p.cycle_len;
if cmd_count == 0 { buf[2..8].copy_from_slice(&p.phase);
return Some(None); buf[8..14].copy_from_slice(&p.envelope);
} buf[14] = p.repeat_count;
if cmd_count > 16 { buf[15] = 0; // reserved
return None; // malformed
}
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;
}
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. /// Serialize the XBLK v2 header into a 16-byte buffer.
fn led_map_byte_to_mapping(val: u8) -> Option<LedMapping> { pub fn serialize_header(
match val { pattern_count: u8,
0b00 => Some(LedMapping::I2c), active_index: u8,
0b01 => Some(LedMapping::Engine1), budget: u8,
0b10 => Some(LedMapping::Engine2), has_playlist: bool,
0b11 => Some(LedMapping::Engine3), playlist_count: u8,
_ => None, 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;
} }
/// Build the raw LED_MAP register byte from a Pattern's mapping fields. /// CRC-16/CCITT-FALSE.
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).
pub fn crc16(data: &[u8]) -> u16 { pub fn crc16(data: &[u8]) -> u16 {
let mut crc: u16 = 0xFFFF; let mut crc: u16 = 0xFFFF;
for &b in data { for &b in data {
@@ -553,40 +435,3 @@ pub fn crc16(data: &[u8]) -> u16 {
} }
crc 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 #!/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 Converts pattern definitions to the XBLK v2 binary format (GPIO-direct PWM,
to NTAG5 EEPROM blocks 256+ (upper 1K) via ntag5sensor ISO15693 commands. 16-byte pattern entries) and writes them to NTAG5 EEPROM blocks 256+
(upper 1K) via ntag5sensor ISO15693 commands.
Usage: Usage:
# Serialize built-in patterns to binary file: # Serialize built-in patterns to binary file:
@@ -22,27 +23,23 @@ import struct
import sys import sys
import os 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_MAGIC = b"XBLK"
XBLK_VERSION = 0x01 XBLK_VERSION = 0x02
HEADER_SIZE = 16 HEADER_SIZE = 16
PATTERN_ENTRY_SIZE = 112 PATTERN_ENTRY_SIZE = 16
MAX_PATTERNS = 9 MAX_PATTERNS = 61
MAX_COMMANDS_PER_ENGINE = 16 MAX_PLAYLIST = 32
NUM_LEDS = 6
# EEPROM block offset for pattern library (upper 1K) # EEPROM block offset for pattern library (upper 1K)
LIBRARY_BASE_BLOCK = 256 LIBRARY_BASE_BLOCK = 256
# LED_MAP encoding helpers # Waveform IDs
LED_MAP_LOOKUP = { WAVEFORM_LOOKUP = {
"direct": 0b00, "i2c": 0b00, "sine": 0, "triangle": 1, "square": 2, "heartbeat": 3,
"engine1": 0b01, "e1": 0b01,
"engine2": 0b10, "e2": 0b10,
"engine3": 0b11, "e3": 0b11,
} }
LED_MODE_LOOKUP = {"rgbw": 0x00, "mono3": 0x01}
def crc16(data: bytes) -> int: def crc16(data: bytes) -> int:
"""CRC-16/CCITT-FALSE.""" """CRC-16/CCITT-FALSE."""
@@ -58,215 +55,131 @@ def crc16(data: bytes) -> int:
return crc 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: def encode_pattern(pat: dict) -> bytes:
"""Encode a single pattern dict to PATTERN_ENTRY_SIZE bytes.""" """Encode a single pattern dict to PATTERN_ENTRY_SIZE bytes."""
engines = pat.get("engines", [[], [], []]) waveform = WAVEFORM_LOOKUP.get(pat.get("waveform", "sine"), 0)
while len(engines) < 3: cycle_len = pat.get("cycle_len", 125)
engines.append([]) 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) # Pad/truncate to NUM_LEDS
led_map_reg = encode_led_map(pat.get("led_map", {})) phase = (phase + [0] * NUM_LEDS)[:NUM_LEDS]
direct_pwm = pat.get("direct_pwm", [0, 0, 0, 0]) envelope = (envelope + [255] * NUM_LEDS)[:NUM_LEDS]
while len(direct_pwm) < 4:
direct_pwm.append(0)
buf = bytearray(PATTERN_ENTRY_SIZE) buf = bytearray(PATTERN_ENTRY_SIZE)
buf[0] = engine_count buf[0] = waveform & 0xFF
buf[1] = led_map_reg buf[1] = cycle_len & 0xFF
buf[2:6] = bytes(direct_pwm[:4]) buf[2:8] = bytes(phase)
buf[8:14] = bytes(envelope)
for eng_idx, cmds in enumerate(engines[:3]): buf[14] = repeat_count & 0xFF
if len(cmds) > MAX_COMMANDS_PER_ENGINE: buf[15] = 0 # reserved
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)
return bytes(buf) return bytes(buf)
def encode_library(config: dict) -> bytes: 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", []) patterns = config.get("patterns", [])
if len(patterns) == 0: if len(patterns) == 0:
raise ValueError("No patterns defined") raise ValueError("No patterns defined")
if len(patterns) > MAX_PATTERNS: if len(patterns) > MAX_PATTERNS:
raise ValueError(f"Too many patterns: {len(patterns)} (max {MAX_PATTERNS})") raise ValueError(f"Too many patterns: {len(patterns)} (max {MAX_PATTERNS})")
current = config.get("current", 20) budget = config.get("budget", 50)
mode = LED_MODE_LOOKUP.get(config.get("mode", "rgbw"), 0x00)
active = config.get("active", 0) % len(patterns) 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 = bytearray(HEADER_SIZE)
header[0:4] = XBLK_MAGIC header[0:4] = XBLK_MAGIC
header[4] = XBLK_VERSION header[4] = XBLK_VERSION
header[5] = len(patterns) header[5] = 0x01 if has_playlist else 0x00 # flags
header[6] = active header[6] = len(patterns)
header[7] = current & 0xFF header[7] = active
header[8] = mode header[8] = budget & 0xFF
# bytes 9-13 reserved header[9] = len(playlist) & 0xFF
# bytes 14-15 CRC (filled below) # 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 # Build pattern data
pat_data = b"" pat_data = b""
for pat in patterns: for pat in patterns:
pat_data += encode_pattern(pat) pat_data += encode_pattern(pat)
# Compute CRC over header (bytes 0-13) + all pattern data # Build playlist data
crc = crc16(bytes(header[:14]) + pat_data) playlist_data = bytes(playlist + [0] * (MAX_PLAYLIST - len(playlist)))
struct.pack_into(">H", header, 14, crc)
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) # 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: def builtin_patterns() -> dict:
"""Return the 5 built-in patterns as a config 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 { return {
"current": 20, "budget": 50,
"mode": "rgbw",
"active": 0, "active": 0,
"patterns": [ "patterns": [
{"name": "breathe", "led_map": single_rgb_map, "engines": [breathe_cmds, [], []]}, {
{"name": "heartbeat", "led_map": single_rgb_map, "engines": [heartbeat_cmds, [], []]}, "name": "breathe",
{"name": "slow_pulse", "led_map": single_rgb_map, "engines": [slow_pulse_cmds, [], []]}, "waveform": "sine",
{"name": "rgb_cycle", "led_map": triple_map, "engines": [rgb_e1, rgb_e2, rgb_e3]}, "cycle_len": 125, # 2.5s
{"name": "color_wash", "led_map": triple_map, "engines": [wash_e1, wash_e2, wash_e3]}, "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): def write_to_ntag5(data: bytes):
"""Write binary data to NTAG5 EEPROM blocks 256+ via ntag5sensor.""" """Write binary data to NTAG5 EEPROM blocks 256+ via ntag5sensor."""
# Add ntag5sensor to path
ntag5sensor_path = os.path.join(os.path.dirname(__file__), "..", "..", "ntag5sensor") ntag5sensor_path = os.path.join(os.path.dirname(__file__), "..", "..", "ntag5sensor")
sys.path.insert(0, ntag5sensor_path) 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}") 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): for i in range(0, len(data), 4):
block = LIBRARY_BASE_BLOCK + i // 4 block = LIBRARY_BASE_BLOCK + i // 4
chunk = data[i:i+4] chunk = data[i:i+4]
if len(chunk) < 4: if len(chunk) < 4:
chunk = chunk + b'\x00' * (4 - len(chunk)) chunk = chunk + b'\x00' * (4 - len(chunk))
# Use ISO15693 WRITE SINGLE BLOCK (unaddressed) flags = ISO_FLAG_DATA_RATE | 0x08
# Block address needs protocol extension for blocks > 255
flags = ISO_FLAG_DATA_RATE | 0x08 # data rate + protocol extension
cmd = bytes([flags, 0x21]) + struct.pack("<H", block) + chunk cmd = bytes([flags, 0x21]) + struct.pack("<H", block) + chunk
reader.transmit_iso15693(cmd, True) reader.transmit_iso15693(cmd, True)
# EEPROM write cycle delay
import time import time
time.sleep(0.006) time.sleep(0.006)
@@ -302,11 +211,10 @@ def write_to_ntag5(data: bytes):
def main(): 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("--json", help="JSON pattern definition file")
parser.add_argument("--output", "-o", help="Output binary 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("--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") parser.add_argument("--dump", action="store_true", help="Hex dump the binary")
args = parser.parse_args() args = parser.parse_args()