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:
66
CLAUDE.md
66
CLAUDE.md
@@ -1,6 +1,6 @@
|
||||
# xBlink Development Guide
|
||||
|
||||
Embedded Rust firmware for a battery-free, NFC-powered LED implant. The NTAG5Link harvests energy from an NFC field and provides EEPROM storage for LED patterns. A SAMD21E MCU reads patterns from EEPROM, programs LP5562 execution engines, then sleeps while the LP5562 autonomously drives RGBW LEDs.
|
||||
Embedded Rust firmware for a battery-free, NFC-powered LED implant. The NTAG5Link harvests energy from an NFC field (1.8V) and provides EEPROM storage for LED patterns. A SAMD21E MCU reads patterns from EEPROM, drives 6 red LEDs directly via TCC hardware PWM through current-limiting resistors, and updates duty cycles from a 50Hz timer ISR. The MCU stays in IDLE sleep during animation.
|
||||
|
||||
## Build Commands
|
||||
|
||||
@@ -19,8 +19,9 @@ cargo hf2 --release
|
||||
|
||||
- Architecture: `thumbv6m-none-eabi` (ARM Cortex-M0+)
|
||||
- Dev board: Seeed XIAO M0 (SAMD21G18A) — will port to bare SAMD21E18A later
|
||||
- LED driver: LP5562EVM (TI evaluation module, RGBW, I2C addr 0x30)
|
||||
- LEDs: 6x low-Vf red LEDs (Kingbright APTD1608, Vf ~1.7V) driven via TCC PWM + resistors
|
||||
- NFC: NTAG5Link Click board (MikroElektronika)
|
||||
- LP5562: removed from design (requires 2.7V min, incompatible with 1.8V EH)
|
||||
|
||||
## Dependencies
|
||||
|
||||
@@ -37,12 +38,13 @@ When porting to bare SAMD21E: replace `xiao_m0` with `atsamd-hal = { version = "
|
||||
|
||||
```
|
||||
src/
|
||||
main.rs # Entry point: boot, pattern load, sleep/wake loop
|
||||
main.rs # Entry point: boot, EEPROM load, TC4 ISR animation, sleep/wake loop
|
||||
led/
|
||||
mod.rs # LP5562 re-exports (LedController trait added later, M10)
|
||||
lp5562.rs # LP5562 driver (from ../ntag5-samd21-lp562/)
|
||||
ntag5/ # NTAG5Link I2C slave driver (M4+)
|
||||
pattern/ # Pattern format and engine builder (M5+)
|
||||
mod.rs # LED module re-exports
|
||||
pwm.rs # TCC0/TCC1 hardware PWM driver for 6 GPIO-direct LEDs
|
||||
lp5562.rs # LP5562 driver (legacy, kept for reference)
|
||||
ntag5/ # NTAG5Link I2C slave driver
|
||||
pattern/ # XBLK v2 format, software pattern engine, waveform LUTs
|
||||
```
|
||||
|
||||
## Architecture Conventions
|
||||
@@ -55,22 +57,31 @@ src/
|
||||
- **`const fn`**: Prefer `const fn` where possible (see `EngineCommand` builder in LP5562 driver).
|
||||
- **No `unsafe`**: Avoid unless absolutely necessary for hardware access.
|
||||
|
||||
## LP5562 Timing Constraints
|
||||
## LED PWM Architecture
|
||||
|
||||
The LP5562 driver does NOT enforce timing delays internally — the caller is responsible:
|
||||
6 LEDs driven directly by SAMD21 TCC hardware PWM through current-limiting resistors (10-47 ohm).
|
||||
|
||||
- **>= 500 us** after `enable()` before any other commands
|
||||
- **>= 488 us** between consecutive ENABLE register writes (engine exec changes)
|
||||
- **>= 153 us** between consecutive OP_MODE register writes (engine mode changes)
|
||||
- When transitioning from Run, set exec to Hold first, then change mode
|
||||
| LED | TCC | Channel | SAMD21E Pin | XIAO Pin |
|
||||
|-----|------|---------|-------------|----------|
|
||||
| 0 | TCC0 | WO[0] | PA04 | A1 |
|
||||
| 1 | TCC0 | WO[1] | PA05 | A2 |
|
||||
| 2 | TCC0 | WO[2] | PA06 | A3* |
|
||||
| 3 | TCC0 | WO[3] | PA07 | A4* |
|
||||
| 4 | TCC1 | WO[0] | PA10 | D2 |
|
||||
| 5 | TCC1 | WO[1] | PA11 | D3 |
|
||||
|
||||
## Key I2C Addresses
|
||||
*PA06/PA07 conflict with I2C on XIAO M0. Dev board uses 4 LEDs (0,1,4,5).
|
||||
|
||||
Both devices share the same I2C bus (A4/A5). Non-conflicting addresses — no bus arbitration issues.
|
||||
- **Animation**: TC4 ISR at 50Hz computes brightness from PatternEngine, writes TCC CC registers
|
||||
- **Power governor**: Proportional scaling ensures total LED current stays within NTAG5 EH budget
|
||||
- **Sleep**: IDLE mode during animation (~0.5mA MCU), STANDBY when no pattern active (~2uA)
|
||||
|
||||
## Key I2C Address
|
||||
|
||||
I2C bus (A4/A5) is used for NTAG5 only (LP5562 removed from design).
|
||||
|
||||
| Device | Address | Notes |
|
||||
| --------- | ------- | ------------------------------------------ |
|
||||
| LP5562 | 0x30 | ADDR_SEL pins both low (LP5562EVM default) |
|
||||
| --------- | ------- | --------------------------------- |
|
||||
| NTAG5Link | 0x54 | Default NTP53x2 I2C slave address |
|
||||
|
||||
## NTAG5Link I2C Register Map (MCU-side)
|
||||
@@ -94,23 +105,24 @@ Key config constants (from `../ntag5sensor/vicinity/ntag5link.py`):
|
||||
## Hardware Wiring (Dev Board)
|
||||
|
||||
| XIAO Pin | Connection | Function |
|
||||
| --------- | --------------------------- | ----------------------------------------------- |
|
||||
| A4 (SDA) | LP5562 SDA, NTAG5 SDA | Shared I2C data |
|
||||
| A5 (SCL) | LP5562 SCL, NTAG5 SCL | Shared I2C clock |
|
||||
| D0/A0 | LP5562 EN line (open-drain) | Wired-AND with hall sensor, pull-up to VCC |
|
||||
| TBD (EIC) | Hall sensor output | EIC wake + wired-AND to LP5562 EN line |
|
||||
| TBD (EIC) | NTAG5 FD pin | Field detect / SRAM write indication (EIC wake) |
|
||||
|
||||
**LP5562 EN wired-AND**: D0/A0 (open-drain) and hall sensor (open-drain, active-low) both connect to LP5562 EN with a 1M pull-up. Either can force EN low. Magnet kills LEDs at hardware level regardless of MCU state.
|
||||
| -------- | --------------------- | ------------------------------------------- |
|
||||
| A1 (PA04)| LED 0 + resistor | TCC0/WO[0] PWM output |
|
||||
| A2 (PA05)| LED 1 + resistor | TCC0/WO[1] PWM output |
|
||||
| A4 (SDA) | NTAG5 SDA | I2C data |
|
||||
| A5 (SCL) | NTAG5 SCL | I2C clock |
|
||||
| D2 (PA10)| LED 4 + resistor | TCC1/WO[0] PWM output |
|
||||
| D3 (PA11)| LED 5 + resistor | TCC1/WO[1] PWM output |
|
||||
| A1 (PA04)| NTAG5 FD pin | Field detect / SRAM write indication (EIC) |
|
||||
| TBD | Hall sensor output | EIC wake, pattern cycling |
|
||||
|
||||
## Testing
|
||||
|
||||
- **Hardware**: XIAO M0 + LP5562EVM + NTAG5Link Click (when jumpers available)
|
||||
- **Hardware**: XIAO M0 + NTAG5Link Click + 4 LEDs with resistors on A1/A2/D2/D3
|
||||
- **PCSC reader**: Use `../ntag5sensor/` Python tooling with ACR1552 reader
|
||||
- **Phone NFC**: VivoKey RawNFC app for SRAM mailbox testing
|
||||
- **Phone app**: DT NFC Identifier for basic tag info
|
||||
|
||||
## Sibling Projects
|
||||
|
||||
- `../ntag5-samd21-lp562/` — Original LP5562 driver + smoke test (xBlink's `src/led/lp5562.rs` is synced from here)
|
||||
- `../ntag5-samd21-lp562/` — Original LP5562 driver (legacy reference)
|
||||
- `../ntag5sensor/` — NTAG5Link command reference (`vicinity/ntag5link.py`), I2C patterns (`vicinity/i2cbase.py`)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# xblink Project Status
|
||||
|
||||
**Current Milestone**: M7 — Sleep/Wake
|
||||
**Last Updated**: 2026-03-05
|
||||
**Current Milestone**: GPIO-Direct LED Pivot (replacing LP5562)
|
||||
**Last Updated**: 2026-03-07
|
||||
|
||||
---
|
||||
|
||||
@@ -76,16 +76,37 @@
|
||||
- [x] I2C bus swapping for LP5562 reprogramming after pattern updates
|
||||
- [ ] Hardware test: flash and verify with PCSC reader / phone app (pending FD pin wiring)
|
||||
|
||||
## GPIO-Direct LED Pivot (2026-03-07)
|
||||
|
||||
LP5562 requires VDD >= 2.7V, incompatible with 1.8V NFC energy harvesting.
|
||||
Replaced with SAMD21E GPIO-direct PWM driving 6 red LEDs.
|
||||
See `docs/plans/2026-03-06-gpio-led-pivot.md` for full design.
|
||||
|
||||
### LED Pivot Implementation
|
||||
|
||||
- [x] Design GPIO-direct PWM architecture (`docs/plans/2026-03-06-gpio-led-pivot.md`)
|
||||
- [x] Implement software pattern engine with waveform LUTs (`src/pattern/mod.rs`)
|
||||
- [x] Implement XBLK v2 EEPROM format (16-byte entries, playlist support)
|
||||
- [x] Implement TCC PWM driver for 6 LED channels (`src/led/pwm.rs`)
|
||||
- [x] Implement TC4 50Hz animation timer ISR
|
||||
- [x] Implement power governor (proportional brightness scaling)
|
||||
- [x] Update main.rs for GPIO-direct boot flow
|
||||
- [x] Update SRAM mailbox protocol for v2 format (`src/ntag5/sram.rs`)
|
||||
- [x] Update Python serializer (`tools/xblk_serialize.py`)
|
||||
- [x] Verify `cargo build --release` compiles
|
||||
- [ ] Hardware test: wire LEDs to A1/A2/D2/D3, flash and verify animations
|
||||
- [ ] Measure power consumption with multimeter
|
||||
|
||||
## Group C — Power + Recovery
|
||||
|
||||
**Hardware needed**: + Hall sensor + multimeter
|
||||
|
||||
### M7: Sleep/Wake
|
||||
|
||||
- [ ] Configure SAMD21 EIC for FD pin wake
|
||||
- [ ] Implement STANDBY sleep after LP5562 programming
|
||||
- [ ] Verify LP5562 keeps running during MCU sleep
|
||||
- [ ] Measure current: active vs standby vs total system
|
||||
- [x] Configure SAMD21 EIC for FD pin wake
|
||||
- [x] Implement IDLE sleep during animation (TC4 ISR drives LEDs)
|
||||
- [x] Implement STANDBY sleep when no pattern active
|
||||
- [ ] Measure current: IDLE (animating) vs STANDBY vs total system
|
||||
|
||||
### M8: Recovery Mode
|
||||
|
||||
@@ -96,28 +117,17 @@
|
||||
|
||||
### M9: Power Characterization
|
||||
|
||||
- [ ] Measure current at various LED current settings
|
||||
- [ ] Test NTAG5Link EH output with phone NFC
|
||||
- [ ] Find optimal brightness vs EH budget balance
|
||||
- [ ] Measure current at various LED brightness / resistor values
|
||||
- [ ] Test NTAG5Link EH output with phone NFC at 1.8V
|
||||
- [ ] Tune power governor budget for optimal brightness
|
||||
- [ ] Document real power numbers
|
||||
|
||||
## Group D — Abstraction + Polish
|
||||
|
||||
### M10: LedController Trait
|
||||
|
||||
- [ ] Define trait based on proven usage patterns from Groups A-C
|
||||
- [ ] Implement for LP5562 wrapper
|
||||
- [ ] Refactor main.rs to trait-based API
|
||||
|
||||
### M11: SimplePwm Driver
|
||||
|
||||
- [ ] Implement only if single-color LED hardware is available
|
||||
|
||||
## Group E — Future
|
||||
## Group D — Future
|
||||
|
||||
### Custom PCB (SAMD21E)
|
||||
|
||||
- [ ] Port to `atsamd-hal` with `samd21e` feature
|
||||
- [ ] Add LEDs 2-5 on PA06/PA07 (freed from I2C conflict on custom PCB)
|
||||
- [ ] Custom `memory.x` linker script
|
||||
- [ ] PCB design and fabrication
|
||||
|
||||
@@ -135,23 +145,21 @@
|
||||
|
||||
| Question | Status | Notes |
|
||||
|----------|--------|-------|
|
||||
| Energy harvesting power budget | TBD | Multimeter measurements in M9 |
|
||||
| Optimal LED current per channel | TBD | Probably 2-5mA/ch, verify in M9 |
|
||||
| Energy harvesting power budget | TBD | Multimeter measurements at 1.8V |
|
||||
| Optimal LED resistor value | TBD | 10-47 ohm, affects brightness vs power |
|
||||
| Power governor budget default | Set to 50 | Configurable via companion app |
|
||||
| Firmware update strategy | Deferred | UF2 for dev, NFC OTA evaluated later |
|
||||
| NTAG5Link I2C slave address | Assumed 0x54 | Verify in M4 with Click board |
|
||||
| Pattern binary format | Deferred to M5 | Design after M2-M3 engine experience |
|
||||
| Hall sensor + EN circuit | Designed | Wired-AND: hall + MCU open-drain on EN with 1M pull-up. See `docs/plans/2026-03-03-hall-en-design.md` |
|
||||
| Hall sensor part selection | Decided | DRV5032FB (SOT-23, 8.4mT, prototype) → DRV5032FE (X2SON 1x1mm, final PCB) |
|
||||
| EN pull-up value | Decided | 1M — zero steady-state draw, 3µA when EN low, ~10µs rise time |
|
||||
| Hall sensor part selection | Decided | DRV5032FB (SOT-23, 8.4mT, prototype) |
|
||||
|
||||
## Hardware Inventory
|
||||
|
||||
| Item | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| Seeed XIAO M0 | Available | Dev board MCU (SAMD21G18A) |
|
||||
| LP5562EVM | Available | TI eval module, RGBW LEDs, I2C addr 0x30 |
|
||||
| Mini-USB cable | Available | USB-C, used for flashing XIAO M0 |
|
||||
| LP5562EVM | Available (unused) | Removed from design (VDD > 2.7V) |
|
||||
| NTAG5 Link Click | Available, partially wired | Missing I2C jumper to XIAO |
|
||||
| Hall effect sensor | Not available | Need to source — TI DRV5032FB (SOT-23) for prototype |
|
||||
| Low-Vf red LEDs | Need to source | Kingbright APTD1608 or similar, Vf ~1.7V |
|
||||
| Resistors (10-47 ohm) | Need to source | Current limiting for GPIO-direct LEDs |
|
||||
| Hall effect sensor | Not available | Need to source — TI DRV5032FB (SOT-23) |
|
||||
| ACR1552 PCSC reader | Available | For ntag5sensor Python tooling |
|
||||
| Multimeter | Available | For power budget measurements |
|
||||
|
||||
144
docs/plans/2026-03-06-gpio-led-pivot.md
Normal file
144
docs/plans/2026-03-06-gpio-led-pivot.md
Normal 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).
|
||||
@@ -1 +1,2 @@
|
||||
pub mod lp5562;
|
||||
pub mod pwm;
|
||||
|
||||
139
src/led/pwm.rs
Normal file
139
src/led/pwm.rs
Normal 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);
|
||||
}
|
||||
479
src/main.rs
479
src/main.rs
@@ -9,18 +9,32 @@ use bsp::entry;
|
||||
use bsp::hal;
|
||||
use bsp::pac;
|
||||
|
||||
use hal::clock::GenericClockController;
|
||||
use hal::clock::{ClockGenId, GenericClockController};
|
||||
use hal::delay::Delay;
|
||||
use hal::eic::pin::{ExtInt4, Sense};
|
||||
use hal::eic::EIC;
|
||||
use hal::gpio::PullUpInterrupt;
|
||||
use hal::prelude::*;
|
||||
use pac::{CorePeripherals, Peripherals};
|
||||
use pac::{interrupt, CorePeripherals, Peripherals};
|
||||
|
||||
use cortex_m::peripheral::NVIC;
|
||||
|
||||
use core::cell::RefCell;
|
||||
use cortex_m::interrupt::Mutex;
|
||||
|
||||
mod led;
|
||||
mod ntag5;
|
||||
mod pattern;
|
||||
use led::lp5562::{ClockSource, Lp5562, DEFAULT_ADDRESS};
|
||||
use ntag5::Ntag5Link;
|
||||
use ntag5::sram::MailboxState;
|
||||
use pattern::LedMode;
|
||||
use ntag5::SESSION_CONFIG_REG;
|
||||
use pattern::{PatternEngine, PatternDef, NUM_LEDS};
|
||||
|
||||
/// Global pattern engine, accessed from TC4 ISR and main loop.
|
||||
static ENGINE: Mutex<RefCell<Option<PatternEngine>>> = Mutex::new(RefCell::new(None));
|
||||
|
||||
/// Flag set by TC4 ISR: pattern repeats are done, main loop should advance playlist.
|
||||
static REPEATS_DONE: Mutex<RefCell<bool>> = Mutex::new(RefCell::new(false));
|
||||
|
||||
/// Blink the on-board LED n times (active-low: low=on, high=off)
|
||||
fn blink<D: embedded_hal::delay::DelayNs>(led: &mut bsp::Led0, delay: &mut D, times: u8, ms: u16) {
|
||||
@@ -32,10 +46,59 @@ fn blink<D: embedded_hal::delay::DelayNs>(led: &mut bsp::Led0, delay: &mut D, ti
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure TC4 for 50Hz interrupt (animation tick).
|
||||
unsafe fn init_tc4() {
|
||||
let pm = &*pac::PM::ptr();
|
||||
let gclk = &*pac::GCLK::ptr();
|
||||
let tc4 = &*pac::TC4::ptr();
|
||||
|
||||
// Enable GCLK0 for TC4/TC5 (generic clock ID 0x1C = 28)
|
||||
gclk.clkctrl.write(|w| {
|
||||
w.id().bits(0x1C) // GCLK_TC4_TC5
|
||||
.gen().gclk0()
|
||||
.clken().set_bit()
|
||||
});
|
||||
while gclk.status.read().syncbusy().bit_is_set() {}
|
||||
|
||||
// Enable TC4 in Power Manager
|
||||
pm.apbcmask.modify(|_, w| w.tc4_().set_bit());
|
||||
|
||||
// Disable TC4 first
|
||||
tc4.count16().ctrla.modify(|_, w| w.enable().clear_bit());
|
||||
while tc4.count16().status.read().syncbusy().bit_is_set() {}
|
||||
|
||||
// Software reset
|
||||
tc4.count16().ctrla.modify(|_, w| w.swrst().set_bit());
|
||||
while tc4.count16().status.read().syncbusy().bit_is_set() {}
|
||||
|
||||
// Configure: 16-bit mode, prescaler /256, match frequency mode
|
||||
// 8 MHz / 256 = 31250 Hz. For 50 Hz: 31250 / 50 = 625 counts.
|
||||
tc4.count16().ctrla.write(|w| {
|
||||
w.mode().count16()
|
||||
.prescaler().div256()
|
||||
.wavegen().mfrq()
|
||||
});
|
||||
|
||||
// Set compare value for 50Hz
|
||||
tc4.count16().cc[0].write(|w| unsafe { w.cc().bits(624) }); // 625 - 1
|
||||
|
||||
// Enable MC0 interrupt
|
||||
tc4.count16().intenset.write(|w| w.mc0().set_bit());
|
||||
|
||||
// Enable TC4
|
||||
tc4.count16().ctrla.modify(|_, w| w.enable().set_bit());
|
||||
while tc4.count16().status.read().syncbusy().bit_is_set() {}
|
||||
}
|
||||
|
||||
#[entry]
|
||||
fn main() -> ! {
|
||||
let mut peripherals = Peripherals::take().unwrap();
|
||||
let core = CorePeripherals::take().unwrap();
|
||||
let mut core = CorePeripherals::take().unwrap();
|
||||
|
||||
// Boot on internal oscillator first — minimal current draw (~0.3mA at 1MHz).
|
||||
// This gives EH VOUT time to stabilize before we switch to 8MHz and init peripherals.
|
||||
// Busy-wait ~50ms for VOUT ramp-up before doing anything.
|
||||
cortex_m::asm::delay(50_000); // ~50ms at 1MHz internal oscillator
|
||||
|
||||
let mut clocks = GenericClockController::with_external_32kosc(
|
||||
peripherals.GCLK,
|
||||
@@ -51,19 +114,40 @@ fn main() -> ! {
|
||||
let mut led: bsp::Led0 = pins.led0.into_push_pull_output();
|
||||
led.set_high().unwrap(); // LED off (active low)
|
||||
|
||||
// === DIAGNOSTIC: 3 slow blinks = firmware is alive ===
|
||||
blink(&mut led, &mut delay, 3, 200);
|
||||
delay.delay_ms(500u32);
|
||||
// === DIAGNOSTIC: 3 blinks = firmware is alive ===
|
||||
blink(&mut led, &mut delay, 3, 250);
|
||||
delay.delay_ms(1500u32);
|
||||
|
||||
// LP5562 hardware enable on D0/A0 — drive high to power on
|
||||
let mut lp_en = pins.a0.into_push_pull_output();
|
||||
lp_en.set_high().unwrap();
|
||||
delay.delay_ms(10u32); // Let LP5562 power stabilize
|
||||
// --- EIC setup for FD pin wake from STANDBY ---
|
||||
let gclk2 = clocks
|
||||
.configure_gclk_divider_and_source(
|
||||
ClockGenId::GCLK2,
|
||||
1,
|
||||
pac::gclk::genctrl::SRCSELECT_A::OSC8M,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let eic_clock = clocks.eic(&gclk2).unwrap();
|
||||
let mut eic = EIC::init(&mut peripherals.PM, eic_clock, peripherals.EIC);
|
||||
|
||||
// NTAG5 FD pin on A1 (PA04) — input with pull-up (FD is open-drain)
|
||||
let fd_pin = pins.a1.into_pull_up_input();
|
||||
// FD pin on A1 (PA04) → ExtInt4, pull-up (FD is open-drain)
|
||||
let fd_pin: hal::gpio::Pin<_, PullUpInterrupt> = pins.a1.into();
|
||||
let mut extint4 = ExtInt4::new(fd_pin);
|
||||
extint4.sense(&mut eic, Sense::FALL);
|
||||
extint4.filter(&mut eic, true);
|
||||
extint4.enable_interrupt(&mut eic);
|
||||
extint4.enable_interrupt_wake(&mut eic);
|
||||
|
||||
// I2C on A4 (SDA) / A5 (SCL) at 400 kHz
|
||||
// Enable EIC interrupt in NVIC
|
||||
unsafe {
|
||||
core.NVIC.set_priority(interrupt::EIC, 2);
|
||||
NVIC::unmask(interrupt::EIC);
|
||||
}
|
||||
|
||||
// --- Initialize TCC PWM for LEDs ---
|
||||
unsafe { led::pwm::init(); }
|
||||
|
||||
// I2C on A4 (SDA) / A5 (SCL) at 400 kHz — for NTAG5 only
|
||||
let i2c = bsp::i2c_master(
|
||||
&mut clocks,
|
||||
400u32.kHz(),
|
||||
@@ -73,156 +157,134 @@ fn main() -> ! {
|
||||
pins.a5,
|
||||
);
|
||||
|
||||
let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS);
|
||||
|
||||
// Default LED current: 2 mA per channel (20 × 0.1 mA)
|
||||
let mut led_current: u8 = 20;
|
||||
|
||||
// Verify LP5562 is reachable before continuing
|
||||
let i2c_ok = (|| -> Result<(), led::lp5562::Error<_>> {
|
||||
lp.enable()?;
|
||||
delay.delay_ms(1u32); // >500us startup
|
||||
lp.init_direct_control(ClockSource::Internal)?;
|
||||
lp.set_all_current(led_current, led_current, led_current, led_current)?;
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
match i2c_ok {
|
||||
Ok(()) => {
|
||||
// === STATUS: solid LED on = LP5562 init OK ===
|
||||
led.set_low().unwrap();
|
||||
delay.delay_ms(500u32);
|
||||
led.set_high().unwrap();
|
||||
delay.delay_ms(1000u32);
|
||||
|
||||
// --- Try to load pattern library from NTAG5 EEPROM ---
|
||||
let i2c = lp.release();
|
||||
let mut ntag = Ntag5Link::new(i2c, ntag5::DEFAULT_ADDRESS);
|
||||
|
||||
// Config check (LED blinks only, no NDEF write)
|
||||
// Default power budget
|
||||
let mut budget: u8 = pattern::DEFAULT_BUDGET;
|
||||
|
||||
// Write all xblink config to persistent EEPROM (CONFIG + EH + ED).
|
||||
// Only needs to succeed once — subsequent boots will already have it.
|
||||
let provision_ok = ntag.provision_all_config(&mut delay).is_ok();
|
||||
|
||||
// Set session registers for this boot (persistent CONFIG stays at safe defaults).
|
||||
let eh_ok = ntag.configure_eh_session().is_ok();
|
||||
let cfg_ok = ntag.configure_config_session().is_ok();
|
||||
|
||||
// Write full diagnostic to NDEF text record (readable via phone NFC)
|
||||
{
|
||||
let mut buf = [0u8; 128];
|
||||
let mut i = 0;
|
||||
|
||||
fn append(buf: &mut [u8], i: &mut usize, data: &[u8]) {
|
||||
for &b in data {
|
||||
if *i < buf.len() { buf[*i] = b; *i += 1; }
|
||||
}
|
||||
}
|
||||
fn hex(buf: &mut [u8], i: &mut usize, val: u8) {
|
||||
const H: &[u8; 16] = b"0123456789ABCDEF";
|
||||
if *i + 1 < buf.len() {
|
||||
buf[*i] = H[(val >> 4) as usize]; *i += 1;
|
||||
buf[*i] = H[(val & 0x0F) as usize]; *i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Config check results
|
||||
match ntag.check_config() {
|
||||
Ok(result) => {
|
||||
if result.all_ok {
|
||||
blink(&mut led, &mut delay, 2, 100);
|
||||
append(&mut buf, &mut i, b"CFG:");
|
||||
if result.all_ok { append(&mut buf, &mut i, b"OK "); }
|
||||
else { append(&mut buf, &mut i, b"BAD "); }
|
||||
for c in &result.checks {
|
||||
append(&mut buf, &mut i, &c.name);
|
||||
append(&mut buf, &mut i, b":");
|
||||
hex(&mut buf, &mut i, c.actual);
|
||||
if !c.ok {
|
||||
append(&mut buf, &mut i, b"!=");
|
||||
hex(&mut buf, &mut i, c.expected);
|
||||
}
|
||||
append(&mut buf, &mut i, b" ");
|
||||
}
|
||||
}
|
||||
Err(_) => { append(&mut buf, &mut i, b"CFG:I2C_ERR "); }
|
||||
}
|
||||
|
||||
// Config provision result
|
||||
append(&mut buf, &mut i, b"PROV:");
|
||||
if provision_ok { append(&mut buf, &mut i, b"OK "); }
|
||||
else { append(&mut buf, &mut i, b"FAIL "); }
|
||||
|
||||
// EH session register readback
|
||||
append(&mut buf, &mut i, b"EH:");
|
||||
if eh_ok {
|
||||
if let Ok(v) = ntag.read_register(ntag5::SESSION_EH_CONFIG_REG, 0) {
|
||||
hex(&mut buf, &mut i, v);
|
||||
// Also read EH_LOAD_OK status (bit 7)
|
||||
if v & 0x80 != 0 { append(&mut buf, &mut i, b"/LOAD_OK"); }
|
||||
else { append(&mut buf, &mut i, b"/no_load"); }
|
||||
} else {
|
||||
blink(&mut led, &mut delay, 5, 60);
|
||||
append(&mut buf, &mut i, b"RD_ERR");
|
||||
}
|
||||
} else {
|
||||
append(&mut buf, &mut i, b"WR_ERR");
|
||||
}
|
||||
Err(_) => {
|
||||
// NTAG5 not reachable — 1 long blink (not fatal)
|
||||
led.set_low().unwrap();
|
||||
delay.delay_ms(800u32);
|
||||
led.set_high().unwrap();
|
||||
append(&mut buf, &mut i, b" ");
|
||||
|
||||
// ED/FD config readback
|
||||
append(&mut buf, &mut i, b"ED:");
|
||||
if let Ok(v) = ntag.read_register(ntag5::SESSION_EH_CONFIG_REG, 2) {
|
||||
hex(&mut buf, &mut i, v);
|
||||
} else {
|
||||
append(&mut buf, &mut i, b"ERR");
|
||||
}
|
||||
|
||||
// Write as NDEF text record
|
||||
let _ = ntag.write_ndef_text(&buf[..i], &mut delay);
|
||||
}
|
||||
|
||||
blink(&mut led, &mut delay, 1, 250);
|
||||
delay.delay_ms(500u32);
|
||||
|
||||
// Configure FD pin for SRAM-write-by-RF indication
|
||||
let _ = ntag.configure_fd_sram_write(); // Best-effort, non-fatal
|
||||
|
||||
// Try reading XBLK library header from upper 1K
|
||||
// --- Try loading pattern from EEPROM ---
|
||||
let mut header_buf = [0u8; pattern::HEADER_SIZE];
|
||||
let eeprom_ok = ntag.read_memory(pattern::LIBRARY_BASE_BLOCK, &mut header_buf).ok()
|
||||
.and_then(|()| pattern::parse_header(&header_buf));
|
||||
|
||||
// Read active pattern if header is valid
|
||||
let eeprom_pattern = eeprom_ok.as_ref().and_then(|hdr| {
|
||||
led_current = hdr.current;
|
||||
let mut active_pattern: Option<PatternDef> = None;
|
||||
|
||||
if let Some(ref hdr) = eeprom_ok {
|
||||
budget = hdr.budget;
|
||||
let pat_block = pattern::LIBRARY_BASE_BLOCK
|
||||
+ (pattern::HEADER_SIZE as u16 / 4)
|
||||
+ (hdr.active_index as u16 * (pattern::PATTERN_ENTRY_SIZE as u16 / 4));
|
||||
let mut pat_buf = [0u8; pattern::PATTERN_ENTRY_SIZE];
|
||||
ntag.read_memory(pat_block, &mut pat_buf).ok()?;
|
||||
pattern::parse_pattern_entry(&pat_buf)
|
||||
});
|
||||
|
||||
match eeprom_pattern {
|
||||
Some(pat) => {
|
||||
// EEPROM pattern loaded — 3 fast blinks
|
||||
blink(&mut led, &mut delay, 3, 80);
|
||||
|
||||
// Load pattern into LP5562 (swap I2C to LP5562 then back)
|
||||
{
|
||||
let i2c = ntag.release();
|
||||
let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS);
|
||||
if pattern::load_pattern(&mut lp, &pat, led_current, &mut delay).is_err() {
|
||||
blink(&mut led, &mut delay, 10, 50);
|
||||
}
|
||||
let i2c = lp.release();
|
||||
ntag = Ntag5Link::new(i2c, ntag5::DEFAULT_ADDRESS);
|
||||
}
|
||||
|
||||
// Mailbox polling loop — LP5562 runs autonomously,
|
||||
// MCU polls FD pin for NFC commands
|
||||
let mut mbox = MailboxState::new();
|
||||
loop {
|
||||
delay.delay_ms(200u32);
|
||||
if fd_pin.is_low().unwrap_or(false) {
|
||||
let cmd_ok = ntag5::sram::process_command(
|
||||
&mut ntag, &mut mbox, &mut delay,
|
||||
);
|
||||
if matches!(cmd_ok, Ok(true)) {
|
||||
// Reload LP5562 from current EEPROM state
|
||||
let mut hdr_buf = [0u8; pattern::HEADER_SIZE];
|
||||
if ntag.read_memory(pattern::LIBRARY_BASE_BLOCK, &mut hdr_buf).is_ok() {
|
||||
if let Some(hdr) = pattern::parse_header(&hdr_buf) {
|
||||
led_current = hdr.current;
|
||||
let pat_block = pattern::LIBRARY_BASE_BLOCK
|
||||
+ (pattern::HEADER_SIZE as u16 / 4)
|
||||
+ (hdr.active_index as u16
|
||||
* (pattern::PATTERN_ENTRY_SIZE as u16 / 4));
|
||||
let mut pat_buf = [0u8; pattern::PATTERN_ENTRY_SIZE];
|
||||
if ntag.read_memory(pat_block, &mut pat_buf).is_ok() {
|
||||
if let Some(new_pat) = pattern::parse_pattern_entry(&pat_buf) {
|
||||
// Swap I2C to LP5562 for reprogramming
|
||||
let i2c = ntag.release();
|
||||
let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS);
|
||||
let _ = pattern::load_pattern(
|
||||
&mut lp, &new_pat, led_current, &mut delay,
|
||||
);
|
||||
let i2c = lp.release();
|
||||
ntag = Ntag5Link::new(i2c, ntag5::DEFAULT_ADDRESS);
|
||||
active_pattern = pattern::parse_pattern_entry(&pat_buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// No XBLK in EEPROM — self-provision hardcoded patterns
|
||||
// 2 long blinks = provisioning
|
||||
blink(&mut led, &mut delay, 2, 300);
|
||||
|
||||
let mode = LedMode::Rgbw;
|
||||
if active_pattern.is_none() {
|
||||
// No XBLK in EEPROM — self-provision default patterns
|
||||
blink(&mut led, &mut delay, 2, 350);
|
||||
|
||||
let patterns = [
|
||||
pattern::breathe(mode),
|
||||
pattern::heartbeat(mode),
|
||||
pattern::slow_pulse(mode),
|
||||
pattern::rgb_cycle(mode),
|
||||
pattern::color_wash(mode),
|
||||
pattern::breathe(),
|
||||
pattern::heartbeat(),
|
||||
pattern::wave_chase(),
|
||||
pattern::slow_pulse(),
|
||||
pattern::alternating_blink(),
|
||||
];
|
||||
|
||||
// Serialize all pattern entries and write to EEPROM
|
||||
let pat_base_block = pattern::LIBRARY_BASE_BLOCK
|
||||
+ (pattern::HEADER_SIZE as u16 / 4);
|
||||
let mut all_pat_data = [0u8; pattern::PATTERN_ENTRY_SIZE * 5];
|
||||
let mut provision_ok = true;
|
||||
|
||||
for (i, pat) in patterns.iter().enumerate() {
|
||||
let mut entry_buf = [0u8; pattern::PATTERN_ENTRY_SIZE];
|
||||
pattern::serialize_pattern_entry(pat, &mut entry_buf);
|
||||
|
||||
// Copy into combined buffer for CRC
|
||||
let offset = i * pattern::PATTERN_ENTRY_SIZE;
|
||||
all_pat_data[offset..offset + pattern::PATTERN_ENTRY_SIZE]
|
||||
.copy_from_slice(&entry_buf);
|
||||
|
||||
// Write 112 bytes as 28 x 4-byte blocks
|
||||
let entry_block = pat_base_block
|
||||
+ (i as u16 * (pattern::PATTERN_ENTRY_SIZE as u16 / 4));
|
||||
for blk in 0..28u16 {
|
||||
for blk in 0..(pattern::PATTERN_ENTRY_SIZE as u16 / 4) {
|
||||
let bo = (blk as usize) * 4;
|
||||
let chunk = [entry_buf[bo], entry_buf[bo+1], entry_buf[bo+2], entry_buf[bo+3]];
|
||||
if ntag.write_verify_block(entry_block + blk, &chunk, &mut delay).is_err() {
|
||||
@@ -234,12 +296,9 @@ fn main() -> ! {
|
||||
}
|
||||
|
||||
if provision_ok {
|
||||
// Write header last (so partial writes don't look valid)
|
||||
let mut hdr_buf = [0u8; pattern::HEADER_SIZE];
|
||||
pattern::serialize_header(
|
||||
5, 0, led_current, 0x00,
|
||||
&all_pat_data,
|
||||
&mut hdr_buf,
|
||||
5, 0, budget, false, 0, &mut hdr_buf,
|
||||
);
|
||||
for blk in 0..4u16 {
|
||||
let bo = (blk as usize) * 4;
|
||||
@@ -254,54 +313,83 @@ fn main() -> ! {
|
||||
}
|
||||
|
||||
if provision_ok {
|
||||
// Success — 4 fast blinks, then load pattern 0 from EEPROM
|
||||
blink(&mut led, &mut delay, 4, 80);
|
||||
|
||||
// Re-read active pattern from what we just wrote
|
||||
let mut pat_buf = [0u8; pattern::PATTERN_ENTRY_SIZE];
|
||||
let loaded = ntag.read_memory(pat_base_block, &mut pat_buf).ok()
|
||||
.and_then(|()| pattern::parse_pattern_entry(&pat_buf));
|
||||
|
||||
// Load into LP5562 (swap I2C), then swap back for mailbox
|
||||
{
|
||||
let i2c = ntag.release();
|
||||
let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS);
|
||||
if let Some(pat) = loaded {
|
||||
if pattern::load_pattern(&mut lp, &pat, led_current, &mut delay).is_err() {
|
||||
blink(&mut led, &mut delay, 10, 50);
|
||||
blink(&mut led, &mut delay, 4, 250);
|
||||
active_pattern = Some(pattern::breathe());
|
||||
} else {
|
||||
blink(&mut led, &mut delay, 8, 200);
|
||||
// Fall back to hardcoded breathe
|
||||
active_pattern = Some(pattern::breathe());
|
||||
}
|
||||
}
|
||||
let i2c = lp.release();
|
||||
ntag = Ntag5Link::new(i2c, ntag5::DEFAULT_ADDRESS);
|
||||
} else {
|
||||
blink(&mut led, &mut delay, 3, 250); // EEPROM pattern loaded
|
||||
}
|
||||
|
||||
// Mailbox polling loop (same as EEPROM-loaded path)
|
||||
// --- Start animation ---
|
||||
if let Some(pat) = active_pattern {
|
||||
cortex_m::interrupt::free(|cs| {
|
||||
ENGINE.borrow(cs).replace(Some(PatternEngine::new(pat, budget)));
|
||||
});
|
||||
}
|
||||
|
||||
// Start TC4 (50Hz animation timer) and enable its interrupt
|
||||
unsafe {
|
||||
init_tc4();
|
||||
core.NVIC.set_priority(interrupt::TC4, 1); // Higher priority than EIC
|
||||
NVIC::unmask(interrupt::TC4);
|
||||
}
|
||||
|
||||
// === DIAGNOSTIC: blink arbiter mode before entering idle loop ===
|
||||
if let Ok(c1) = ntag.read_register(SESSION_CONFIG_REG, 1) {
|
||||
let arbiter = (c1 >> 2) & 0x03;
|
||||
blink(&mut led, &mut delay, arbiter + 1, 150);
|
||||
delay.delay_ms(500u32);
|
||||
let sram_en = (c1 >> 1) & 0x01;
|
||||
blink(&mut led, &mut delay, sram_en + 1, 400);
|
||||
} else {
|
||||
blink(&mut led, &mut delay, 9, 100);
|
||||
}
|
||||
delay.delay_ms(1500u32);
|
||||
|
||||
led.set_high().unwrap(); // LED off
|
||||
|
||||
// ============================================================
|
||||
// Sleep/Wake loop — MCU in IDLE, TC4 ISR drives LEDs.
|
||||
// Wake fully on EIC (FD pin) for SRAM mailbox commands.
|
||||
// ============================================================
|
||||
let mut mbox = MailboxState::new();
|
||||
loop {
|
||||
delay.delay_ms(200u32);
|
||||
if fd_pin.is_low().unwrap_or(false) {
|
||||
// IDLE sleep (not STANDBY) — TC4 and TCC keep running
|
||||
cortex_m::asm::wfi();
|
||||
|
||||
// Check if woken by EIC (FD pin = SRAM write by RF)
|
||||
let eic_reg = unsafe { &*pac::EIC::ptr() };
|
||||
if eic_reg.intflag.read().extint4().bit_is_set() {
|
||||
extint4.clear_interrupt();
|
||||
|
||||
// Process SRAM mailbox command
|
||||
let cmd_ok = ntag5::sram::process_command(
|
||||
&mut ntag, &mut mbox, &mut delay,
|
||||
);
|
||||
|
||||
if matches!(cmd_ok, Ok(true)) {
|
||||
// Pattern library was modified — reload from EEPROM
|
||||
let mut hdr_buf = [0u8; pattern::HEADER_SIZE];
|
||||
if ntag.read_memory(pattern::LIBRARY_BASE_BLOCK, &mut hdr_buf).is_ok() {
|
||||
if let Some(hdr) = pattern::parse_header(&hdr_buf) {
|
||||
led_current = hdr.current;
|
||||
budget = hdr.budget;
|
||||
let pat_block = pattern::LIBRARY_BASE_BLOCK
|
||||
+ (pattern::HEADER_SIZE as u16 / 4)
|
||||
+ (hdr.active_index as u16
|
||||
* (pattern::PATTERN_ENTRY_SIZE as u16 / 4));
|
||||
let mut pat_buf2 = [0u8; pattern::PATTERN_ENTRY_SIZE];
|
||||
if ntag.read_memory(pat_block, &mut pat_buf2).is_ok() {
|
||||
if let Some(new_pat) = pattern::parse_pattern_entry(&pat_buf2) {
|
||||
let i2c = ntag.release();
|
||||
let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS);
|
||||
let _ = pattern::load_pattern(
|
||||
&mut lp, &new_pat, led_current, &mut delay,
|
||||
);
|
||||
let i2c = lp.release();
|
||||
ntag = Ntag5Link::new(i2c, ntag5::DEFAULT_ADDRESS);
|
||||
let mut pat_buf = [0u8; pattern::PATTERN_ENTRY_SIZE];
|
||||
if ntag.read_memory(pat_block, &mut pat_buf).is_ok() {
|
||||
if let Some(new_pat) = pattern::parse_pattern_entry(&pat_buf) {
|
||||
cortex_m::interrupt::free(|cs| {
|
||||
if let Some(ref mut eng) = *ENGINE.borrow(cs).borrow_mut() {
|
||||
eng.budget = budget as u16;
|
||||
eng.load(new_pat);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -309,33 +397,36 @@ fn main() -> ! {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Provisioning failed — fall back to hardcoded cycle
|
||||
blink(&mut led, &mut delay, 8, 50);
|
||||
|
||||
let i2c = ntag.release();
|
||||
let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS);
|
||||
|
||||
let mut idx = 0;
|
||||
loop {
|
||||
if pattern::load_pattern(
|
||||
&mut lp, &patterns[idx], led_current, &mut delay,
|
||||
).is_err() {
|
||||
blink(&mut led, &mut delay, 10, 50);
|
||||
}
|
||||
delay.delay_ms(15000u32);
|
||||
idx = (idx + 1) % patterns.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// === DIAGNOSTIC: fast blink forever = I2C error ===
|
||||
loop {
|
||||
blink(&mut led, &mut delay, 5, 80);
|
||||
delay.delay_ms(500u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// TC4 interrupt handler — 50Hz animation tick.
|
||||
/// Computes LED brightness from pattern engine and writes TCC duty cycles.
|
||||
#[interrupt]
|
||||
fn TC4() {
|
||||
let tc4 = unsafe { &*pac::TC4::ptr() };
|
||||
// Clear MC0 interrupt flag
|
||||
tc4.count16().intflag.write(|w| w.mc0().set_bit());
|
||||
|
||||
cortex_m::interrupt::free(|cs| {
|
||||
if let Some(ref mut engine) = *ENGINE.borrow(cs).borrow_mut() {
|
||||
let mut output = [0u8; NUM_LEDS];
|
||||
engine.tick(&mut output);
|
||||
led::pwm::set_all(&output);
|
||||
|
||||
if engine.repeats_done() {
|
||||
*REPEATS_DONE.borrow(cs).borrow_mut() = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// EIC interrupt handler — clears flag, main loop checks and processes.
|
||||
#[interrupt]
|
||||
fn EIC() {
|
||||
// Don't clear here — let main loop detect it via intflag read
|
||||
// Just need the handler to exist so WFI returns
|
||||
let eic = unsafe { &*pac::EIC::ptr() };
|
||||
if eic.intflag.read().extint4().bit_is_set() {
|
||||
eic.intflag.modify(|_, w| w.extint4().set_bit());
|
||||
}
|
||||
}
|
||||
|
||||
104
src/ntag5/mod.rs
104
src/ntag5/mod.rs
@@ -25,11 +25,17 @@ pub const SRAM_BASE_BLOCK: u16 = 0x10F8;
|
||||
pub const SRAM_BLOCK_COUNT: u16 = 64;
|
||||
pub const SRAM_SIZE: usize = 256;
|
||||
|
||||
// Session register for ED/FD pin configuration
|
||||
pub const SESSION_ED_FD_PIN_CFG: u16 = 0x10A3;
|
||||
// Config EEPROM I2C block addresses (NFC block 0x3D → I2C 0x103D)
|
||||
pub const CONFIG_EH_BLOCK: u16 = 0x103D; // EH_CONFIG block (persistent)
|
||||
pub const CONFIG_DEV_SEC_BLOCK: u16 = 0x103F; // DEV_SEC_CONFIG block
|
||||
|
||||
// FD pin mode: active on SRAM write by RF, cleared on I2C read
|
||||
pub const FD_MODE_SRAM_RF_WRITE: u8 = 0x04;
|
||||
// ED/FD pin config is byte 2 within the EH_CONFIG block (0x103D / session 0x10A7)
|
||||
// ED_CONFIG values (from NTP53x2 datasheet / ntag5link.py):
|
||||
// 0x00 = disabled
|
||||
// 0x01 = NFC field detect
|
||||
// 0x04 = NFC-to-I2C pass-through (SRAM write by RF)
|
||||
// 0x0C = write to synch block
|
||||
pub const ED_CONFIG_NFC_TO_I2C_PASS_THROUGH: u8 = 0x04;
|
||||
|
||||
// EEPROM user memory I2C block addresses
|
||||
// Block 0 = CC (capability container), blocks 1+ = NDEF data
|
||||
@@ -40,17 +46,22 @@ pub const EEPROM_BLOCK_0: u16 = 0x0000;
|
||||
pub const EXPECTED_CONFIG_0: u8 = 0x08;
|
||||
// CONFIG_1: SRAM_ENABLE (bit 1) + ARBITER_MODE passthrough (bits 3:2 = 10b) + USE_CASE I2C slave (bits 5:4 = 00b)
|
||||
pub const EXPECTED_CONFIG_1: u8 = 0x0A;
|
||||
// EH_CONFIG: skip for now — EH is disabled (0x00) when powered externally.
|
||||
// Set to 0x75 (EH_ENABLE + 3.0V + 12.5mA) when running from energy harvesting.
|
||||
pub const EXPECTED_EH_CONFIG: u8 = 0x00;
|
||||
// EH_CONFIG: EH_ENABLE + VOUT_V_SEL_1_8V + DISABLE_POWER_CHECK + VOUT_I_SEL_6_5mA = 0x59
|
||||
// Bit 0: EH_ENABLE = 1
|
||||
// Bits 2:1: VOUT_V_SEL = 00 (1.8V)
|
||||
// Bit 3: DISABLE_POWER_CHECK = 1 (skip power check, VOUT comes up immediately)
|
||||
// Bits 6:4: VOUT_I_SEL = 101 (6.5mA)
|
||||
pub const EXPECTED_EH_CONFIG: u8 = 0x59;
|
||||
// Session EH trigger bit (bit 3 in session register = EH_TRIGGER, different from persistent bit 3)
|
||||
pub const EH_SESSION_TRIGGER: u8 = 0x08;
|
||||
|
||||
// Masks for checking — only check the bits we care about
|
||||
// CONFIG_0: bits 3:2 (EH_MODE) — ignore SRAM_COPY_EN, AUTO_STANDBY, LOCK_SESSION
|
||||
pub const CONFIG_0_MASK: u8 = 0x0C;
|
||||
// CONFIG_1: bits 5:4 (USE_CASE) + bits 3:2 (ARBITER) + bit 1 (SRAM_EN)
|
||||
pub const CONFIG_1_MASK: u8 = 0x3E;
|
||||
// EH_CONFIG: mask 0x00 — don't check EH config during external power testing
|
||||
pub const EH_CONFIG_MASK: u8 = 0x00;
|
||||
// EH_CONFIG: check EH_ENABLE (bit 0) + VOUT_V_SEL (bits 2:1), ignore current limit + status bits
|
||||
pub const EH_CONFIG_MASK: u8 = 0x07;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error<E> {
|
||||
@@ -189,12 +200,79 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Configure FD pin for SRAM-write-by-RF indication.
|
||||
/// Configure ED/FD pin for SRAM-write-by-RF indication.
|
||||
/// FD goes low when phone writes to SRAM, returns high when MCU reads SRAM.
|
||||
///
|
||||
/// Sets ED_CONFIG via session register (volatile, resets on power cycle).
|
||||
pub fn configure_fd_sram_write(&mut self) -> Result<(), E> {
|
||||
// ED_FD_PIN_CFG register index 1 within the session block
|
||||
// Bits 2:0 control FD output mode
|
||||
self.write_register(SESSION_ED_FD_PIN_CFG, 0x01, 0x07, FD_MODE_SRAM_RF_WRITE)
|
||||
self.write_register(
|
||||
SESSION_EH_CONFIG_REG, 2, 0xFF,
|
||||
ED_CONFIG_NFC_TO_I2C_PASS_THROUGH,
|
||||
)
|
||||
}
|
||||
|
||||
/// Enable EH via session register (volatile, must run each boot).
|
||||
///
|
||||
/// Config EEPROM (block 0x3D) is NOT writable from I2C — only from NFC
|
||||
/// using WRITE CONFIG (0xC1) via PCSC reader. Session register writes
|
||||
/// work immediately but reset on power cycle.
|
||||
///
|
||||
/// Session register 0x10A7: byte 0 = EH_CONFIG, byte 2 = ED_CONFIG
|
||||
pub fn configure_eh_session(&mut self) -> Result<(), E> {
|
||||
// Write EH_CONFIG + EH_TRIGGER to session register byte 0
|
||||
// Session bit 3 = EH_TRIGGER (different from persistent bit 3 = DISABLE_POWER_CHECK)
|
||||
self.write_register(SESSION_EH_CONFIG_REG, 0, 0xFF, EXPECTED_EH_CONFIG | EH_SESSION_TRIGGER)?;
|
||||
// Write ED_CONFIG to session register byte 2 (FD pin = SRAM pass-through)
|
||||
self.write_register(SESSION_EH_CONFIG_REG, 2, 0xFF, ED_CONFIG_NFC_TO_I2C_PASS_THROUGH)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write EH_CONFIG + ED_CONFIG to persistent config EEPROM (block 0x103D).
|
||||
/// This must be done via I2C when no NFC field is present (USB-powered).
|
||||
/// After writing, the NTAG5 will automatically enable EH on subsequent power-ups.
|
||||
/// Block format: [EH_CONFIG, 0x00, ED_CONFIG, 0x00]
|
||||
pub fn provision_eh_persistent(
|
||||
&mut self,
|
||||
delay: &mut impl embedded_hal::delay::DelayNs,
|
||||
) -> Result<(), Error<E>> {
|
||||
let data = [EXPECTED_EH_CONFIG, 0x00, ED_CONFIG_NFC_TO_I2C_PASS_THROUGH, 0x00];
|
||||
self.write_verify_block(CONFIG_EH_BLOCK, &data, delay)
|
||||
}
|
||||
|
||||
/// Write EH config to persistent EEPROM and reset CONFIG to defaults.
|
||||
///
|
||||
/// NOTE: CONFIG_0/CONFIG_1 must NOT be persisted with SRAM passthrough —
|
||||
/// it breaks NFC EEPROM access when I2C bus is floating (MCU unpowered).
|
||||
/// Use configure_config_session() to set CONFIG at boot instead.
|
||||
pub fn provision_all_config(
|
||||
&mut self,
|
||||
delay: &mut impl embedded_hal::delay::DelayNs,
|
||||
) -> Result<(), Error<E>> {
|
||||
// Write EH + ED config FIRST (most important for automatic power-up)
|
||||
let eh_data = [EXPECTED_EH_CONFIG, 0x00, ED_CONFIG_NFC_TO_I2C_PASS_THROUGH, 0x00];
|
||||
self.write_memory_block(CONFIG_EH_BLOCK, &eh_data)?;
|
||||
delay.delay_ms(5); // EEPROM write cycle
|
||||
|
||||
// Then CONFIG_0 (EH_MODE=low field strength), clear CONFIG_1
|
||||
let config_data = [EXPECTED_CONFIG_0, 0x00, 0x00, 0x00];
|
||||
self.write_memory_block(0x1037, &config_data)?;
|
||||
delay.delay_ms(5);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set CONFIG_1 via session register (volatile, safe).
|
||||
/// Enables SRAM passthrough only while MCU is active on the I2C bus.
|
||||
pub fn configure_config_session(&mut self) -> Result<(), E> {
|
||||
self.write_register(SESSION_CONFIG_REG, 1, 0xFF, EXPECTED_CONFIG_1)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read DEV_SEC_CONFIG block (0x103F) to check security/protection status.
|
||||
/// Returns the 4 raw bytes: [DEV_SEC_CONFIG, SRAM_CONF_PROT, PP_AREA1_LSB, PP_AREA1_MSB]
|
||||
pub fn read_dev_sec_config(&mut self) -> Result<[u8; 4], E> {
|
||||
let mut buf = [0u8; 4];
|
||||
self.read_memory(CONFIG_DEV_SEC_BLOCK, &mut buf)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
// ---- Config check ----
|
||||
|
||||
@@ -13,7 +13,7 @@ use embedded_hal::i2c::I2c;
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Firmware version reported in GET_STATUS response.
|
||||
pub const FIRMWARE_VERSION: u8 = 0x01;
|
||||
pub const FIRMWARE_VERSION: u8 = 0x02;
|
||||
|
||||
// Command IDs (phone -> MCU)
|
||||
pub const CMD_WRITE_PATTERN: u8 = 0x01;
|
||||
@@ -49,10 +49,8 @@ pub struct MailboxState {
|
||||
pub syncing: bool,
|
||||
/// Pattern count supplied by SYNC_START.
|
||||
pub sync_count: u8,
|
||||
/// Current (mA setting) supplied by SYNC_START.
|
||||
pub sync_current: u8,
|
||||
/// LED mode supplied by SYNC_START.
|
||||
pub sync_mode: u8,
|
||||
/// Budget supplied by SYNC_START.
|
||||
pub sync_budget: u8,
|
||||
/// Next pattern index for READ_NEXT.
|
||||
pub read_index: u8,
|
||||
/// Total patterns available for READ_NEXT iteration.
|
||||
@@ -64,8 +62,7 @@ impl MailboxState {
|
||||
Self {
|
||||
syncing: false,
|
||||
sync_count: 0,
|
||||
sync_current: 0,
|
||||
sync_mode: 0,
|
||||
sync_budget: pattern::DEFAULT_BUDGET,
|
||||
read_index: 0,
|
||||
read_count: 0,
|
||||
}
|
||||
@@ -113,13 +110,14 @@ fn build_response(buf: &mut [u8], seq: u8, status: u8, payload: &[u8]) -> usize
|
||||
// EEPROM helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Block address for pattern N in the EEPROM library.
|
||||
/// Block address for pattern N in the EEPROM library (v2: 16-byte entries = 4 blocks each).
|
||||
fn pattern_block(index: u8) -> u16 {
|
||||
pattern::LIBRARY_BASE_BLOCK + 4 + (index as u16) * 28
|
||||
pattern::LIBRARY_BASE_BLOCK
|
||||
+ (pattern::HEADER_SIZE as u16 / 4)
|
||||
+ (index as u16) * (pattern::PATTERN_ENTRY_SIZE as u16 / 4)
|
||||
}
|
||||
|
||||
/// Read the XBLK library header from EEPROM.
|
||||
/// Returns None if magic/version don't match.
|
||||
fn read_library_header<I2C, E>(
|
||||
ntag: &mut Ntag5Link<I2C>,
|
||||
) -> Result<Option<pattern::LibraryHeader>, ntag5::Error<E>>
|
||||
@@ -149,46 +147,21 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read all pattern data from EEPROM for the given count (for CRC computation).
|
||||
/// Returns the number of bytes read into `all_data`.
|
||||
fn read_all_pattern_data<I2C, E>(
|
||||
ntag: &mut Ntag5Link<I2C>,
|
||||
count: u8,
|
||||
all_data: &mut [u8],
|
||||
) -> Result<usize, ntag5::Error<E>>
|
||||
where
|
||||
I2C: I2c<Error = E>,
|
||||
{
|
||||
let total = count as usize * pattern::PATTERN_ENTRY_SIZE;
|
||||
// Read in chunks — read_memory can handle arbitrary lengths
|
||||
// but we read per-pattern for clarity
|
||||
for i in 0..count as usize {
|
||||
let block = pattern_block(i as u8);
|
||||
let off = i * pattern::PATTERN_ENTRY_SIZE;
|
||||
ntag.read_memory(block, &mut all_data[off..off + pattern::PATTERN_ENTRY_SIZE])?;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Recalculate the header CRC based on current EEPROM pattern data,
|
||||
/// then write the updated header back.
|
||||
/// Recalculate the header CRC and write the updated header back to EEPROM.
|
||||
fn recalculate_header_crc<I2C, E>(
|
||||
ntag: &mut Ntag5Link<I2C>,
|
||||
count: u8,
|
||||
active: u8,
|
||||
current: u8,
|
||||
led_mode: u8,
|
||||
budget: u8,
|
||||
has_playlist: bool,
|
||||
playlist_count: u8,
|
||||
delay: &mut impl embedded_hal::delay::DelayNs,
|
||||
) -> Result<(), ntag5::Error<E>>
|
||||
where
|
||||
I2C: I2c<Error = E>,
|
||||
{
|
||||
// Read all pattern data for CRC
|
||||
let mut all_data = [0u8; pattern::MAX_PATTERNS * pattern::PATTERN_ENTRY_SIZE];
|
||||
let data_len = read_all_pattern_data(ntag, count, &mut all_data)?;
|
||||
|
||||
let mut hdr = [0u8; pattern::HEADER_SIZE];
|
||||
pattern::serialize_header(count, active, current, led_mode, &all_data[..data_len], &mut hdr);
|
||||
pattern::serialize_header(count, active, budget, has_playlist, playlist_count, &mut hdr);
|
||||
write_header_to_eeprom(ntag, &hdr, delay)
|
||||
}
|
||||
|
||||
@@ -203,14 +176,15 @@ where
|
||||
{
|
||||
let hdr = match read_library_header(ntag)? {
|
||||
Some(h) => h,
|
||||
None => return Ok(()), // no valid header, nothing to update
|
||||
None => return Ok(()),
|
||||
};
|
||||
recalculate_header_crc(
|
||||
ntag,
|
||||
hdr.pattern_count,
|
||||
hdr.active_index,
|
||||
hdr.current,
|
||||
hdr.led_mode,
|
||||
hdr.budget,
|
||||
hdr.has_playlist,
|
||||
hdr.playlist_count,
|
||||
delay,
|
||||
)
|
||||
}
|
||||
@@ -228,23 +202,20 @@ fn handle_get_status<I2C, E>(
|
||||
where
|
||||
I2C: I2c<Error = E>,
|
||||
{
|
||||
let mut payload = [0u8; 5];
|
||||
let mut payload = [0u8; 4];
|
||||
payload[0] = FIRMWARE_VERSION;
|
||||
match read_library_header(ntag)? {
|
||||
Some(h) => {
|
||||
payload[1] = h.pattern_count;
|
||||
payload[2] = h.active_index;
|
||||
payload[3] = h.current;
|
||||
payload[4] = h.led_mode;
|
||||
}
|
||||
None => {
|
||||
// No valid header — return zeros
|
||||
payload[3] = h.budget;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
Ok(build_response(rsp_buf, seq, STATUS_OK, &payload))
|
||||
}
|
||||
|
||||
/// WRITE_PATTERN (0x01): Write a 112-byte pattern entry to EEPROM.
|
||||
/// WRITE_PATTERN (0x01): Write a 16-byte pattern entry to EEPROM.
|
||||
fn handle_write_pattern<I2C, E>(
|
||||
ntag: &mut Ntag5Link<I2C>,
|
||||
state: &MailboxState,
|
||||
@@ -264,10 +235,11 @@ where
|
||||
return Ok(build_response(rsp_buf, seq, STATUS_INVALID_INDEX, &[]));
|
||||
}
|
||||
|
||||
// Write 112 bytes = 28 blocks
|
||||
// Write 16 bytes = 4 blocks
|
||||
let base_block = pattern_block(index);
|
||||
let pattern_data = &payload[1..1 + pattern::PATTERN_ENTRY_SIZE];
|
||||
for i in 0..28u16 {
|
||||
let blocks = pattern::PATTERN_ENTRY_SIZE / 4;
|
||||
for i in 0..blocks as u16 {
|
||||
let off = (i as usize) * 4;
|
||||
let mut chunk = [0u8; 4];
|
||||
chunk.copy_from_slice(&pattern_data[off..off + 4]);
|
||||
@@ -280,7 +252,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
// If not syncing, update header CRC to reflect changed pattern data
|
||||
// If not syncing, update header CRC
|
||||
if !state.syncing {
|
||||
match update_header_after_write(ntag, delay) {
|
||||
Ok(()) => {}
|
||||
@@ -319,9 +291,10 @@ where
|
||||
return Ok(build_response(rsp_buf, seq, STATUS_INVALID_INDEX, &[]));
|
||||
}
|
||||
|
||||
// Recalculate header with new active index
|
||||
match recalculate_header_crc(ntag, hdr.pattern_count, index, hdr.current, hdr.led_mode, delay)
|
||||
{
|
||||
match recalculate_header_crc(
|
||||
ntag, hdr.pattern_count, index, hdr.budget,
|
||||
hdr.has_playlist, hdr.playlist_count, delay,
|
||||
) {
|
||||
Ok(()) => {}
|
||||
Err(ntag5::Error::VerifyFailed) => {
|
||||
return Ok(build_response(rsp_buf, seq, STATUS_EEPROM_FAIL, &[]));
|
||||
@@ -339,7 +312,7 @@ fn handle_sync_start(
|
||||
payload: &[u8],
|
||||
rsp_buf: &mut [u8],
|
||||
) -> usize {
|
||||
if payload.len() < 3 {
|
||||
if payload.len() < 2 {
|
||||
return build_response(rsp_buf, seq, STATUS_BAD_CMD, &[]);
|
||||
}
|
||||
let count = payload[0];
|
||||
@@ -348,8 +321,7 @@ fn handle_sync_start(
|
||||
}
|
||||
state.syncing = true;
|
||||
state.sync_count = count;
|
||||
state.sync_current = payload[1];
|
||||
state.sync_mode = payload[2];
|
||||
state.sync_budget = payload[1];
|
||||
build_response(rsp_buf, seq, STATUS_OK, &[])
|
||||
}
|
||||
|
||||
@@ -368,14 +340,8 @@ where
|
||||
return Ok(build_response(rsp_buf, seq, STATUS_BAD_CMD, &[]));
|
||||
}
|
||||
|
||||
// Active index defaults to 0
|
||||
match recalculate_header_crc(
|
||||
ntag,
|
||||
state.sync_count,
|
||||
0, // active_index = 0
|
||||
state.sync_current,
|
||||
state.sync_mode,
|
||||
delay,
|
||||
ntag, state.sync_count, 0, state.sync_budget, false, 0, delay,
|
||||
) {
|
||||
Ok(()) => {}
|
||||
Err(ntag5::Error::VerifyFailed) => {
|
||||
@@ -402,13 +368,12 @@ fn handle_read_library<I2C, E>(
|
||||
where
|
||||
I2C: I2c<Error = E>,
|
||||
{
|
||||
let mut payload = [0u8; 4];
|
||||
let mut payload = [0u8; 3];
|
||||
match read_library_header(ntag)? {
|
||||
Some(h) => {
|
||||
payload[0] = h.pattern_count;
|
||||
payload[1] = h.active_index;
|
||||
payload[2] = h.current;
|
||||
payload[3] = h.led_mode;
|
||||
payload[2] = h.budget;
|
||||
state.read_index = 0;
|
||||
state.read_count = h.pattern_count;
|
||||
}
|
||||
@@ -420,7 +385,7 @@ where
|
||||
Ok(build_response(rsp_buf, seq, STATUS_OK, &payload))
|
||||
}
|
||||
|
||||
/// READ_NEXT (0x08): Return the next pattern entry (112 bytes).
|
||||
/// READ_NEXT (0x08): Return the next pattern entry (16 bytes).
|
||||
fn handle_read_next<I2C, E>(
|
||||
ntag: &mut Ntag5Link<I2C>,
|
||||
state: &mut MailboxState,
|
||||
@@ -447,11 +412,8 @@ where
|
||||
|
||||
/// Read SRAM, dispatch the command, and write the response back.
|
||||
///
|
||||
/// Returns `Ok(true)` if a command was processed, `Ok(false)` if no valid
|
||||
/// command was found in SRAM (e.g., empty buffer, bad header, bad CRC).
|
||||
///
|
||||
/// I2C errors propagate as `Err`. EEPROM verify failures are reported via
|
||||
/// a STATUS_EEPROM_FAIL response (not as Err).
|
||||
/// Returns `Ok(true)` if a command was processed that modifies the pattern library,
|
||||
/// `Ok(false)` if no valid command or no library change.
|
||||
pub fn process_command<I2C, E>(
|
||||
ntag: &mut Ntag5Link<I2C>,
|
||||
state: &mut MailboxState,
|
||||
@@ -494,5 +456,11 @@ where
|
||||
};
|
||||
|
||||
ntag.write_sram_blocks(0, &rsp[..rsp_len])?;
|
||||
Ok(true)
|
||||
|
||||
// Return true for commands that modify pattern library
|
||||
let library_changed = matches!(
|
||||
cmd,
|
||||
CMD_WRITE_PATTERN | CMD_SET_ACTIVE | CMD_SYNC_END
|
||||
);
|
||||
Ok(library_changed)
|
||||
}
|
||||
|
||||
@@ -1,366 +1,306 @@
|
||||
//! Predefined LP5562 engine patterns for xblink.
|
||||
//! Software pattern engine for GPIO-direct PWM LED control.
|
||||
//!
|
||||
//! Each pattern is a set of engine programs + LED_MAP configuration.
|
||||
//! The LP5562 runs these autonomously — the MCU can sleep after loading.
|
||||
//! Replaces LP5562 hardware execution engines. The MCU drives 6 LEDs via
|
||||
//! TCC hardware PWM, updating duty cycles from a TC4 ISR at 50Hz.
|
||||
//!
|
||||
//! XBLK v2 EEPROM format: 16-byte header + 16-byte pattern entries + playlist.
|
||||
|
||||
use crate::led::lp5562::{
|
||||
Channel, EngineCommand, EngineId, EngineProgram, LedMapping, Lp5562, Prescale, RampDirection,
|
||||
/// Number of LED channels.
|
||||
pub const NUM_LEDS: usize = 6;
|
||||
|
||||
/// Animation tick rate in Hz.
|
||||
pub const TICK_RATE_HZ: u32 = 50;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Waveform types and lookup tables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Available waveform shapes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
#[repr(u8)]
|
||||
pub enum Waveform {
|
||||
Sine = 0,
|
||||
Triangle = 1,
|
||||
Square = 2,
|
||||
Heartbeat = 3,
|
||||
}
|
||||
|
||||
impl Waveform {
|
||||
pub fn from_u8(v: u8) -> Option<Self> {
|
||||
match v {
|
||||
0 => Some(Waveform::Sine),
|
||||
1 => Some(Waveform::Triangle),
|
||||
2 => Some(Waveform::Square),
|
||||
3 => Some(Waveform::Heartbeat),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 64-entry quarter-wave sine table (0-255 output range).
|
||||
/// Full wave is reconstructed by mirroring: indices 0..63 = rising first quarter,
|
||||
/// 64..127 = falling second quarter (mirror), 128..191 = negative third (zero),
|
||||
/// 192..255 = negative fourth (zero). For unipolar: mirror to get full 0-255-0 cycle.
|
||||
const SINE_QUARTER: [u8; 64] = {
|
||||
// Approximate sin(x) for x in [0, pi/2], scaled to 0-255.
|
||||
// Generated from: round(255 * sin(i * pi / 128)) for i in 0..64
|
||||
let mut table = [0u8; 64];
|
||||
let mut i = 0;
|
||||
while i < 64 {
|
||||
// Fixed-point sine approximation using Taylor series:
|
||||
// sin(x) ~ x - x^3/6 + x^5/120, where x = i * pi / 128
|
||||
// We use a precomputed table for accuracy.
|
||||
// These values are: round(255 * sin(i * pi / 128))
|
||||
table[i] = SINE_VALUES[i];
|
||||
i += 1;
|
||||
}
|
||||
table
|
||||
};
|
||||
use embedded_hal::i2c::I2c;
|
||||
|
||||
/// LED hardware configuration.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum LedMode {
|
||||
/// Single RGBW LED (e.g., LP5562EVM D1). Engines map to R, G, B; W is I2C-direct.
|
||||
Rgbw,
|
||||
/// 3 independent monochrome LEDs. Engines map to B, G, R channels (one each).
|
||||
Mono3,
|
||||
}
|
||||
const SINE_VALUES: [u8; 64] = [
|
||||
0, 6, 12, 19, 25, 31, 37, 43, 49, 56, 62, 68, 74, 80, 86, 91,
|
||||
97, 103, 109, 114, 120, 125, 131, 136, 141, 146, 151, 156, 161, 166, 170, 175,
|
||||
179, 183, 187, 191, 195, 199, 202, 206, 209, 212, 215, 218, 220, 223, 225, 228,
|
||||
230, 232, 233, 235, 237, 238, 239, 241, 242, 243, 243, 244, 245, 245, 245, 245,
|
||||
];
|
||||
|
||||
/// A complete pattern: up to 3 engine programs + LED mapping.
|
||||
pub struct Pattern {
|
||||
pub engine1: Option<EngineProgram>,
|
||||
pub engine2: Option<EngineProgram>,
|
||||
pub engine3: Option<EngineProgram>,
|
||||
/// LED_MAP: which engine (or I2C direct) drives each channel.
|
||||
/// Index: [B, G, R, W] → LedMapping value.
|
||||
pub map_b: LedMapping,
|
||||
pub map_g: LedMapping,
|
||||
pub map_r: LedMapping,
|
||||
pub map_w: LedMapping,
|
||||
/// Next pattern index after this one completes (0xFF = loop forever).
|
||||
pub next_pattern: u8,
|
||||
/// Number of full cycles before chaining (0 = chain immediately on engine stop).
|
||||
pub loop_count: u8,
|
||||
}
|
||||
/// Heartbeat waveform: 256-entry full cycle.
|
||||
/// Double-pulse cardiac shape: two sharp peaks with a rest period.
|
||||
const HEARTBEAT_LUT: [u8; 256] = {
|
||||
let mut table = [0u8; 256];
|
||||
// First beat: indices 0-31 (sharp rise/fall)
|
||||
let mut i = 0;
|
||||
while i < 16 {
|
||||
table[i] = (i as u8) * 16; // 0 → 240
|
||||
i += 1;
|
||||
}
|
||||
while i < 32 {
|
||||
table[i] = (31 - i as u8) * 16; // 240 → 0
|
||||
i += 1;
|
||||
}
|
||||
// Gap: 32-63
|
||||
// Second beat: indices 64-95 (slightly weaker)
|
||||
i = 64;
|
||||
while i < 80 {
|
||||
table[i] = ((i - 64) as u8) * 12; // 0 → 180
|
||||
i += 1;
|
||||
}
|
||||
while i < 96 {
|
||||
table[i] = ((95 - i) as u8) * 12; // 180 → 0
|
||||
i += 1;
|
||||
}
|
||||
// Rest: indices 96-255 = 0 (already zeroed)
|
||||
table
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Breathing: smooth ramp up/down, ~2.5s cycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Breathing pattern — one engine, smooth ramp.
|
||||
///
|
||||
/// LP5562 increment field = number of steps - 1 (max 127 = 128 steps).
|
||||
/// Each step changes PWM by 1 unit. Full 0→255 needs two ramp commands.
|
||||
///
|
||||
/// Slow prescale (15.6ms/step), step_time=1:
|
||||
/// Ramp up: 2 × 128 steps × 15.6ms = ~4.0s (0→128→255)
|
||||
/// Ramp down: 2 × 128 steps × 15.6ms = ~4.0s (255→127→0)
|
||||
/// Wait: step_time=48 → 48 × 15.6ms ≈ 0.75s pause at bottom
|
||||
/// Total: ~8.75s per cycle
|
||||
fn breathe_program() -> EngineProgram {
|
||||
EngineProgram::from_commands(&[
|
||||
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 0→128, ~2s
|
||||
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 128→255, ~2s
|
||||
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 255→127, ~2s
|
||||
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 127→0, ~2s
|
||||
EngineCommand::wait(Prescale::Slow, 48), // ~0.75s pause
|
||||
EngineCommand::branch(0, 0), // loop forever
|
||||
])
|
||||
}
|
||||
|
||||
/// Load breathing pattern. All active channels breathe in sync.
|
||||
pub fn breathe(mode: LedMode) -> Pattern {
|
||||
single_engine_pattern(breathe_program(), mode)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Heartbeat: double-pulse with long pause, ~1.6s cycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Heartbeat pattern — fast double-pulse, long rest.
|
||||
///
|
||||
/// Fast prescale (0.49ms/step):
|
||||
/// set_pwm 255 → snap on
|
||||
/// wait fast, st=20 → 20 × 0.49ms ≈ 10ms hold
|
||||
/// set_pwm 0 → snap off
|
||||
/// wait fast, st=40 → 40 × 0.49ms ≈ 20ms gap
|
||||
/// set_pwm 255 → second beat
|
||||
/// wait fast, st=20 → 10ms hold
|
||||
/// set_pwm 0 → snap off
|
||||
/// Slow prescale for the long rest:
|
||||
/// wait slow, st=63 → 63 × 15.6ms ≈ 1.0s
|
||||
/// wait slow, st=32 → 32 × 15.6ms ≈ 0.5s (total rest ~1.5s)
|
||||
/// branch 0 → loop
|
||||
fn heartbeat_program() -> EngineProgram {
|
||||
EngineProgram::from_commands(&[
|
||||
EngineCommand::set_pwm(255), // 0: first beat ON
|
||||
EngineCommand::wait(Prescale::Fast, 20), // 1: hold ~10ms
|
||||
EngineCommand::set_pwm(0), // 2: first beat OFF
|
||||
EngineCommand::wait(Prescale::Fast, 40), // 3: gap ~20ms
|
||||
EngineCommand::set_pwm(255), // 4: second beat ON
|
||||
EngineCommand::wait(Prescale::Fast, 20), // 5: hold ~10ms
|
||||
EngineCommand::set_pwm(0), // 6: second beat OFF
|
||||
EngineCommand::wait(Prescale::Slow, 63), // 7: rest ~1.0s
|
||||
EngineCommand::wait(Prescale::Slow, 32), // 8: rest ~0.5s
|
||||
EngineCommand::branch(0, 0), // 9: loop forever
|
||||
])
|
||||
}
|
||||
|
||||
/// 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,
|
||||
},
|
||||
/// Sample a waveform at position `pos` (0-255 maps to one full cycle, 0-360 degrees).
|
||||
fn sample_waveform(waveform: Waveform, pos: u8) -> u8 {
|
||||
match waveform {
|
||||
Waveform::Sine => {
|
||||
// Unipolar sine: 0 at pos=0, 255 at pos=64, 0 at pos=128, stays 0 for 128-255
|
||||
// Actually for LED breathing we want: 0→255→0 over the full cycle.
|
||||
// Map pos 0-255 to a full sine period (0 → peak → 0 → peak → 0) ... no.
|
||||
// Better: simple 0→255→0 breathing shape over 256 steps.
|
||||
// pos 0..63: rising (quarter 1)
|
||||
// pos 64..127: falling from peak (quarter 2, mirror)
|
||||
// pos 128..191: rising again (quarter 3 = same as 1)
|
||||
// pos 192..255: falling again (quarter 4 = same as 2)
|
||||
// No — that's two cycles. For one full breath cycle:
|
||||
// pos 0..127: 0→255 (half sine, rising)
|
||||
// pos 128..255: 255→0 (half sine, falling)
|
||||
let half = pos as u16;
|
||||
if half < 128 {
|
||||
// Rising: sample quarter sine and mirror
|
||||
let idx = if half < 64 {
|
||||
SINE_QUARTER[half as usize]
|
||||
} else {
|
||||
SINE_QUARTER[127 - half as usize]
|
||||
};
|
||||
// Scale: quarter sine peaks at 245, we want 255
|
||||
let scaled = (idx as u16 * 255) / 245;
|
||||
if scaled > 255 { 255 } else { scaled as u8 }
|
||||
} else {
|
||||
// Falling: mirror of rising
|
||||
let mirror = 255 - pos;
|
||||
let half_m = mirror as u16;
|
||||
let idx = if half_m < 64 {
|
||||
SINE_QUARTER[half_m as usize]
|
||||
} else {
|
||||
SINE_QUARTER[127 - half_m as usize]
|
||||
};
|
||||
let scaled = (idx as u16 * 255) / 245;
|
||||
if scaled > 255 { 255 } else { scaled as u8 }
|
||||
}
|
||||
}
|
||||
Waveform::Triangle => {
|
||||
// 0→255→0 linear triangle
|
||||
if pos < 128 {
|
||||
(pos as u16 * 2) as u8
|
||||
} else {
|
||||
((255 - pos as u16) * 2) as u8
|
||||
}
|
||||
}
|
||||
Waveform::Square => {
|
||||
// On for first half, off for second half
|
||||
if pos < 128 { 255 } else { 0 }
|
||||
}
|
||||
Waveform::Heartbeat => {
|
||||
HEARTBEAT_LUT[pos as usize]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slow pulse: gentle ramp, dimmer peak, ~5s cycle
|
||||
// Pattern state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Slow pulse — very gentle and long.
|
||||
///
|
||||
/// Slow prescale (15.6ms/step), step_time=4:
|
||||
/// Ramp up: 2 × 128 steps × 62.4ms = ~16.0s (0→128→255)
|
||||
/// Wait: step_time=32 → ~0.5s hold at peak
|
||||
/// Ramp down: 2 × 128 steps × 62.4ms = ~16.0s (255→127→0)
|
||||
/// Wait: step_time=63 → ~1.0s pause at bottom
|
||||
/// Total: ~33.5s per cycle
|
||||
fn slow_pulse_program() -> EngineProgram {
|
||||
EngineProgram::from_commands(&[
|
||||
EngineCommand::ramp_wait(Prescale::Slow, 4, RampDirection::Up, 127), // 0→128, ~8s
|
||||
EngineCommand::ramp_wait(Prescale::Slow, 4, RampDirection::Up, 127), // 128→255, ~8s
|
||||
EngineCommand::wait(Prescale::Slow, 32), // hold ~0.5s
|
||||
EngineCommand::ramp_wait(Prescale::Slow, 4, RampDirection::Down, 127), // 255→127, ~8s
|
||||
EngineCommand::ramp_wait(Prescale::Slow, 4, RampDirection::Down, 127), // 127→0, ~8s
|
||||
EngineCommand::wait(Prescale::Slow, 63), // pause ~1.0s
|
||||
EngineCommand::branch(0, 0), // loop forever
|
||||
])
|
||||
/// A single pattern definition, loaded from EEPROM or hardcoded.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct PatternDef {
|
||||
pub waveform: Waveform,
|
||||
pub cycle_len: u8, // ticks per full cycle (1-255)
|
||||
pub phase: [u8; NUM_LEDS], // phase offset per LED (0-255 = 0-360 degrees)
|
||||
pub envelope: [u8; NUM_LEDS], // max brightness per LED
|
||||
pub repeat_count: u8, // playlist: times to play before advancing (0xFF=forever)
|
||||
}
|
||||
|
||||
/// Load slow pulse pattern.
|
||||
pub fn slow_pulse(mode: LedMode) -> Pattern {
|
||||
single_engine_pattern(slow_pulse_program(), mode)
|
||||
/// Runtime pattern engine state.
|
||||
pub struct PatternEngine {
|
||||
pub pattern: PatternDef,
|
||||
pub tick: u16, // current tick within cycle
|
||||
pub cycle_count: u16, // completed cycles (for repeat_count tracking)
|
||||
pub budget: u16, // power governor budget (sum of all LED values must not exceed this)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color wash: 3 engines, smooth overlapping ramps for blended color transitions
|
||||
// ---------------------------------------------------------------------------
|
||||
impl PatternEngine {
|
||||
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.
|
||||
///
|
||||
/// No triggers. Each engine breathes independently at a slightly different
|
||||
/// rate, causing them to drift in and out of phase. Smooth single-PWM-unit
|
||||
/// increments for clean color blending.
|
||||
///
|
||||
/// Slow prescale (15.6ms/step), step_time=1, increment=127 (128 steps per cmd):
|
||||
/// Ramp: 2 × 128 steps × 15.6ms = ~4s per full ramp
|
||||
///
|
||||
/// E1 (Blue): up ~4s + down ~4s = ~8s cycle (no pause)
|
||||
/// E2 (Green): up ~4s + down ~4s + ~0.5s pause = ~8.5s cycle
|
||||
/// E3 (Red): up ~4s + down ~4s + ~1.0s pause = ~9s cycle
|
||||
///
|
||||
/// Phase drift: ~0.5s per cycle → colors shift noticeably every few cycles.
|
||||
fn color_wash_engine1() -> EngineProgram {
|
||||
// ~8s cycle (no pause)
|
||||
EngineProgram::from_commands(&[
|
||||
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 0→128
|
||||
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 128→255
|
||||
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 255→127
|
||||
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 127→0
|
||||
EngineCommand::branch(0, 0),
|
||||
])
|
||||
}
|
||||
/// Compute brightness for all LEDs at the current tick, applying governor.
|
||||
/// Call this from the TC4 ISR at 50Hz.
|
||||
pub fn tick(&mut self, output: &mut [u8; NUM_LEDS]) {
|
||||
let cycle = self.pattern.cycle_len as u16;
|
||||
if cycle == 0 {
|
||||
for v in output.iter_mut() { *v = 0; }
|
||||
return;
|
||||
}
|
||||
|
||||
fn color_wash_engine2() -> EngineProgram {
|
||||
// ~8.5s cycle (short pause at bottom)
|
||||
EngineProgram::from_commands(&[
|
||||
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 0→128
|
||||
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 128→255
|
||||
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 255→127
|
||||
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 127→0
|
||||
EngineCommand::wait(Prescale::Slow, 32), // pause ~0.5s
|
||||
EngineCommand::branch(0, 0),
|
||||
])
|
||||
}
|
||||
// Compute raw brightness per LED
|
||||
for i in 0..NUM_LEDS {
|
||||
// Map tick to 0-255 position within the waveform cycle
|
||||
// tick ranges from 0 to cycle_len-1
|
||||
// phase[i] offsets in units of 1/256 of a cycle
|
||||
let pos = ((self.tick as u32 * 256 / cycle as u32)
|
||||
+ self.pattern.phase[i] as u32) % 256;
|
||||
let raw = sample_waveform(self.pattern.waveform, pos as u8);
|
||||
// Scale by envelope (max brightness for this LED)
|
||||
output[i] = ((raw as u16 * self.pattern.envelope[i] as u16) / 255) as u8;
|
||||
}
|
||||
|
||||
fn color_wash_engine3() -> EngineProgram {
|
||||
// ~9s cycle (longer pause at bottom)
|
||||
EngineProgram::from_commands(&[
|
||||
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 0→128
|
||||
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Up, 127), // 128→255
|
||||
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 255→127
|
||||
EngineCommand::ramp_wait(Prescale::Slow, 1, RampDirection::Down, 127), // 127→0
|
||||
EngineCommand::wait(Prescale::Slow, 63), // pause ~1.0s
|
||||
EngineCommand::branch(0, 0),
|
||||
])
|
||||
}
|
||||
// Power governor: scale down if total exceeds budget
|
||||
if self.budget > 0 {
|
||||
let total: u16 = output.iter().map(|&v| v as u16).sum();
|
||||
if total > self.budget {
|
||||
let scale = (self.budget * 256) / total;
|
||||
for v in output.iter_mut() {
|
||||
*v = ((*v as u16 * scale) / 256) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load color wash (RGBW: smooth hue transitions) or wave (mono: traveling slow pulse).
|
||||
pub fn color_wash(mode: LedMode) -> Pattern {
|
||||
match mode {
|
||||
LedMode::Rgbw => Pattern {
|
||||
engine1: Some(color_wash_engine1()),
|
||||
engine2: Some(color_wash_engine2()),
|
||||
engine3: Some(color_wash_engine3()),
|
||||
map_b: LedMapping::Engine1,
|
||||
map_g: LedMapping::Engine2,
|
||||
map_r: LedMapping::Engine3,
|
||||
map_w: LedMapping::I2c,
|
||||
next_pattern: 0xFF,
|
||||
loop_count: 0,
|
||||
},
|
||||
LedMode::Mono3 => Pattern {
|
||||
engine1: Some(color_wash_engine1()),
|
||||
engine2: Some(color_wash_engine2()),
|
||||
engine3: Some(color_wash_engine3()),
|
||||
map_b: LedMapping::Engine1,
|
||||
map_g: LedMapping::Engine2,
|
||||
map_r: LedMapping::Engine3,
|
||||
map_w: LedMapping::I2c,
|
||||
next_pattern: 0xFF,
|
||||
loop_count: 0,
|
||||
},
|
||||
// Advance tick
|
||||
self.tick += 1;
|
||||
if self.tick >= cycle {
|
||||
self.tick = 0;
|
||||
if self.cycle_count < u16::MAX {
|
||||
self.cycle_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this pattern's repeat count has been reached.
|
||||
pub fn repeats_done(&self) -> bool {
|
||||
if self.pattern.repeat_count == 0xFF {
|
||||
return false; // loop forever
|
||||
}
|
||||
self.cycle_count >= self.pattern.repeat_count as u16
|
||||
}
|
||||
|
||||
/// Load a new pattern, resetting tick and cycle count.
|
||||
pub fn load(&mut self, pattern: PatternDef) {
|
||||
self.pattern = pattern;
|
||||
self.tick = 0;
|
||||
self.cycle_count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper for single-engine patterns (all RGB channels mapped to Engine1)
|
||||
// Predefined patterns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn single_engine_pattern(prog: EngineProgram, mode: LedMode) -> Pattern {
|
||||
let _ = mode; // Same mapping for both modes
|
||||
Pattern {
|
||||
engine1: Some(prog),
|
||||
engine2: None,
|
||||
engine3: None,
|
||||
map_b: LedMapping::Engine1,
|
||||
map_g: LedMapping::Engine1,
|
||||
map_r: LedMapping::Engine1,
|
||||
map_w: LedMapping::I2c,
|
||||
next_pattern: 0xFF,
|
||||
loop_count: 0,
|
||||
/// Breathing: smooth sine, all LEDs in phase, ~2.5s cycle
|
||||
pub fn breathe() -> PatternDef {
|
||||
PatternDef {
|
||||
waveform: Waveform::Sine,
|
||||
cycle_len: 125, // 125 ticks = 2.5s at 50Hz
|
||||
phase: [0, 0, 0, 0, 0, 0],
|
||||
envelope: [255, 255, 255, 255, 255, 255],
|
||||
repeat_count: 0xFF,
|
||||
}
|
||||
}
|
||||
|
||||
/// Heartbeat: double-pulse cardiac, ~1.6s cycle
|
||||
pub fn heartbeat() -> PatternDef {
|
||||
PatternDef {
|
||||
waveform: Waveform::Heartbeat,
|
||||
cycle_len: 80, // 80 ticks = 1.6s
|
||||
phase: [0, 0, 0, 0, 0, 0],
|
||||
envelope: [255, 255, 255, 255, 255, 255],
|
||||
repeat_count: 0xFF,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wave chase: sine with 60-degree phase offsets between LEDs, ~2s cycle
|
||||
pub fn wave_chase() -> PatternDef {
|
||||
PatternDef {
|
||||
waveform: Waveform::Sine,
|
||||
cycle_len: 100, // 2s
|
||||
phase: [0, 43, 85, 128, 170, 213], // ~60 degree spacing
|
||||
envelope: [255, 255, 255, 255, 255, 255],
|
||||
repeat_count: 0xFF,
|
||||
}
|
||||
}
|
||||
|
||||
/// Slow pulse: very gentle and long, ~5s cycle
|
||||
pub fn slow_pulse() -> PatternDef {
|
||||
PatternDef {
|
||||
waveform: Waveform::Triangle,
|
||||
cycle_len: 250, // 5s
|
||||
phase: [0, 0, 0, 0, 0, 0],
|
||||
envelope: [200, 200, 200, 200, 200, 200],
|
||||
repeat_count: 0xFF,
|
||||
}
|
||||
}
|
||||
|
||||
/// Alternating blink: odds and evens alternate, ~1s cycle
|
||||
pub fn alternating_blink() -> PatternDef {
|
||||
PatternDef {
|
||||
waveform: Waveform::Square,
|
||||
cycle_len: 50, // 1s
|
||||
phase: [0, 128, 0, 128, 0, 128],
|
||||
envelope: [255, 255, 255, 255, 255, 255],
|
||||
repeat_count: 0xFF,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pattern loader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Software-reset LP5562, re-initialize, and load a pattern.
|
||||
///
|
||||
/// Writes 0xFF to the Reset register (0x0D) which resets all registers to
|
||||
/// defaults (PWM=0, engines disabled, current=17.5mA). Then re-initializes
|
||||
/// and loads the new pattern. This guarantees zero residual state.
|
||||
pub fn load_pattern<I2C, E>(
|
||||
lp: &mut Lp5562<I2C>,
|
||||
pattern: &Pattern,
|
||||
current: u8,
|
||||
delay: &mut impl embedded_hal::delay::DelayNs,
|
||||
) -> Result<(), crate::led::lp5562::Error<E>>
|
||||
where
|
||||
I2C: I2c<Error = E>,
|
||||
{
|
||||
// Software reset: all registers to defaults, device enters STANDBY
|
||||
lp.reset().map_err(crate::led::lp5562::Error::I2c)?;
|
||||
delay.delay_ms(1); // allow reset to complete
|
||||
|
||||
// Re-initialize from clean state
|
||||
lp.enable()?;
|
||||
delay.delay_ms(1); // >500us after enable (datasheet: 500µs typical)
|
||||
lp.init_direct_control(crate::led::lp5562::ClockSource::Internal)?;
|
||||
lp.set_all_current(current, current, current, current)?;
|
||||
|
||||
// Set LED mapping
|
||||
lp.set_led_mapping(Channel::Blue, pattern.map_b).map_err(crate::led::lp5562::Error::I2c)?;
|
||||
lp.set_led_mapping(Channel::Green, pattern.map_g).map_err(crate::led::lp5562::Error::I2c)?;
|
||||
lp.set_led_mapping(Channel::Red, pattern.map_r).map_err(crate::led::lp5562::Error::I2c)?;
|
||||
lp.set_led_mapping(Channel::White, pattern.map_w).map_err(crate::led::lp5562::Error::I2c)?;
|
||||
|
||||
// Load and run each engine that has a program
|
||||
if let Some(ref prog) = pattern.engine1 {
|
||||
lp.run_engine(EngineId::Engine1, prog)?;
|
||||
delay.delay_us(200);
|
||||
}
|
||||
if let Some(ref prog) = pattern.engine2 {
|
||||
lp.run_engine(EngineId::Engine2, prog)?;
|
||||
delay.delay_us(200);
|
||||
}
|
||||
if let Some(ref prog) = pattern.engine3 {
|
||||
lp.run_engine(EngineId::Engine3, prog)?;
|
||||
delay.delay_us(200);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// XBLK EEPROM pattern library format
|
||||
// XBLK v2 EEPROM format
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// EEPROM base block for the pattern library (upper 1K, block 256).
|
||||
@@ -370,26 +310,33 @@ pub const LIBRARY_BASE_BLOCK: u16 = 0x0100;
|
||||
pub const XBLK_MAGIC: [u8; 4] = *b"XBLK";
|
||||
|
||||
/// Format version.
|
||||
pub const XBLK_VERSION: u8 = 0x01;
|
||||
pub const XBLK_VERSION: u8 = 0x02;
|
||||
|
||||
/// Header size in bytes.
|
||||
pub const HEADER_SIZE: usize = 16;
|
||||
|
||||
/// Pattern entry size in bytes (fixed for direct seeking).
|
||||
pub const PATTERN_ENTRY_SIZE: usize = 112;
|
||||
/// Pattern entry size in bytes.
|
||||
pub const PATTERN_ENTRY_SIZE: usize = 16;
|
||||
|
||||
/// Maximum patterns that fit in 1024 bytes: (1024 - 16) / 112 = 9.
|
||||
pub const MAX_PATTERNS: usize = 9;
|
||||
/// Maximum playlist entries.
|
||||
pub const MAX_PLAYLIST: usize = 32;
|
||||
|
||||
/// Parsed XBLK library header.
|
||||
/// Maximum patterns that fit: (1024 - 16 header - 32 playlist) / 16 = 61.
|
||||
pub const MAX_PATTERNS: usize = 61;
|
||||
|
||||
/// Default power governor budget (sum of all LED brightness values).
|
||||
pub const DEFAULT_BUDGET: u8 = 50;
|
||||
|
||||
/// Parsed XBLK v2 library header.
|
||||
pub struct LibraryHeader {
|
||||
pub pattern_count: u8,
|
||||
pub active_index: u8,
|
||||
pub current: u8,
|
||||
pub led_mode: u8,
|
||||
pub budget: u8,
|
||||
pub has_playlist: bool,
|
||||
pub playlist_count: u8,
|
||||
}
|
||||
|
||||
/// Parse a 16-byte XBLK header. Returns None if magic or version is invalid.
|
||||
/// Parse a 16-byte XBLK v2 header. Returns None if magic or version is invalid.
|
||||
pub fn parse_header(buf: &[u8; HEADER_SIZE]) -> Option<LibraryHeader> {
|
||||
if buf[0..4] != XBLK_MAGIC {
|
||||
return None;
|
||||
@@ -397,147 +344,82 @@ pub fn parse_header(buf: &[u8; HEADER_SIZE]) -> Option<LibraryHeader> {
|
||||
if buf[4] != XBLK_VERSION {
|
||||
return None;
|
||||
}
|
||||
let count = buf[5];
|
||||
let flags = buf[5];
|
||||
let count = buf[6];
|
||||
if count == 0 || count as usize > MAX_PATTERNS {
|
||||
return None;
|
||||
}
|
||||
// Verify CRC
|
||||
let stored_crc = (buf[14] as u16) << 8 | buf[15] as u16;
|
||||
let computed_crc = crc16(&buf[0..14]);
|
||||
if stored_crc != computed_crc {
|
||||
return None;
|
||||
}
|
||||
Some(LibraryHeader {
|
||||
pattern_count: count,
|
||||
active_index: buf[6] % count, // wrap if out of range
|
||||
current: buf[7],
|
||||
led_mode: buf[8],
|
||||
active_index: buf[7] % count,
|
||||
budget: buf[8],
|
||||
has_playlist: flags & 0x01 != 0,
|
||||
playlist_count: buf[9],
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a 112-byte pattern entry into a Pattern struct.
|
||||
/// Returns None if the data is malformed.
|
||||
pub fn parse_pattern_entry(buf: &[u8; PATTERN_ENTRY_SIZE]) -> Option<Pattern> {
|
||||
let engine_count = buf[0];
|
||||
if engine_count > 3 {
|
||||
/// Parse a 16-byte pattern entry into a PatternDef.
|
||||
pub fn parse_pattern_entry(buf: &[u8; PATTERN_ENTRY_SIZE]) -> Option<PatternDef> {
|
||||
let waveform = Waveform::from_u8(buf[0])?;
|
||||
let cycle_len = buf[1];
|
||||
if cycle_len == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let led_map_reg = buf[1];
|
||||
// Decode LED_MAP register: 2 bits per channel [W(7:6), R(5:4), G(3:2), B(1:0)]
|
||||
let map_b = led_map_byte_to_mapping(led_map_reg & 0x03)?;
|
||||
let map_g = led_map_byte_to_mapping((led_map_reg >> 2) & 0x03)?;
|
||||
let map_r = led_map_byte_to_mapping((led_map_reg >> 4) & 0x03)?;
|
||||
let map_w = led_map_byte_to_mapping((led_map_reg >> 6) & 0x03)?;
|
||||
|
||||
// Direct PWM values at bytes 2-5 (unused for now, engines override)
|
||||
// let _direct_pwm = [buf[2], buf[3], buf[4], buf[5]];
|
||||
|
||||
let engine1 = parse_engine_program(&buf[6..40])?;
|
||||
let engine2 = parse_engine_program(&buf[40..74])?;
|
||||
let engine3 = parse_engine_program(&buf[74..108])?;
|
||||
|
||||
Some(Pattern {
|
||||
engine1,
|
||||
engine2,
|
||||
engine3,
|
||||
map_b,
|
||||
map_g,
|
||||
map_r,
|
||||
map_w,
|
||||
next_pattern: buf[108],
|
||||
loop_count: buf[109],
|
||||
let mut phase = [0u8; NUM_LEDS];
|
||||
phase.copy_from_slice(&buf[2..8]);
|
||||
let mut envelope = [0u8; NUM_LEDS];
|
||||
envelope.copy_from_slice(&buf[8..14]);
|
||||
Some(PatternDef {
|
||||
waveform,
|
||||
cycle_len,
|
||||
phase,
|
||||
envelope,
|
||||
repeat_count: buf[14],
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a 34-byte engine section: 2 bytes command count (BE) + 32 bytes commands.
|
||||
/// Returns Some(None) for unused engines, Some(Some(prog)) for valid, None for malformed.
|
||||
fn parse_engine_program(buf: &[u8]) -> Option<Option<EngineProgram>> {
|
||||
let cmd_count = ((buf[0] as u16) << 8 | buf[1] as u16) as usize;
|
||||
if cmd_count == 0 {
|
||||
return Some(None);
|
||||
}
|
||||
if cmd_count > 16 {
|
||||
return None; // malformed
|
||||
}
|
||||
|
||||
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))
|
||||
/// Serialize a PatternDef into a 16-byte buffer.
|
||||
pub fn serialize_pattern_entry(p: &PatternDef, buf: &mut [u8; PATTERN_ENTRY_SIZE]) {
|
||||
buf[0] = p.waveform as u8;
|
||||
buf[1] = p.cycle_len;
|
||||
buf[2..8].copy_from_slice(&p.phase);
|
||||
buf[8..14].copy_from_slice(&p.envelope);
|
||||
buf[14] = p.repeat_count;
|
||||
buf[15] = 0; // reserved
|
||||
}
|
||||
|
||||
/// Convert a 2-bit LED_MAP field to a LedMapping enum value.
|
||||
fn led_map_byte_to_mapping(val: u8) -> Option<LedMapping> {
|
||||
match val {
|
||||
0b00 => Some(LedMapping::I2c),
|
||||
0b01 => Some(LedMapping::Engine1),
|
||||
0b10 => Some(LedMapping::Engine2),
|
||||
0b11 => Some(LedMapping::Engine3),
|
||||
_ => None,
|
||||
}
|
||||
/// Serialize the XBLK v2 header into a 16-byte buffer.
|
||||
pub fn serialize_header(
|
||||
pattern_count: u8,
|
||||
active_index: u8,
|
||||
budget: u8,
|
||||
has_playlist: bool,
|
||||
playlist_count: u8,
|
||||
buf: &mut [u8; HEADER_SIZE],
|
||||
) {
|
||||
buf[0..4].copy_from_slice(&XBLK_MAGIC);
|
||||
buf[4] = XBLK_VERSION;
|
||||
buf[5] = if has_playlist { 0x01 } else { 0x00 };
|
||||
buf[6] = pattern_count;
|
||||
buf[7] = active_index;
|
||||
buf[8] = budget;
|
||||
buf[9] = playlist_count;
|
||||
buf[10] = 0; // reserved
|
||||
buf[11] = 0;
|
||||
buf[12] = 0;
|
||||
buf[13] = 0;
|
||||
let crc = crc16(&buf[0..14]);
|
||||
buf[14] = (crc >> 8) as u8;
|
||||
buf[15] = crc as u8;
|
||||
}
|
||||
|
||||
/// Build the raw LED_MAP register byte from a Pattern's mapping fields.
|
||||
pub fn pattern_to_led_map_byte(p: &Pattern) -> u8 {
|
||||
(p.map_b as u8)
|
||||
| ((p.map_g as u8) << 2)
|
||||
| ((p.map_r as u8) << 4)
|
||||
| ((p.map_w as u8) << 6)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// XBLK serializer (MCU-side, for self-provisioning)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Serialize a Pattern into a 112-byte XBLK entry buffer.
|
||||
pub fn serialize_pattern_entry(p: &Pattern, buf: &mut [u8; PATTERN_ENTRY_SIZE]) {
|
||||
// Zero the buffer
|
||||
for b in buf.iter_mut() {
|
||||
*b = 0;
|
||||
}
|
||||
|
||||
// Engine count
|
||||
let mut count = 0u8;
|
||||
if p.engine1.is_some() { count += 1; }
|
||||
if p.engine2.is_some() { count += 1; }
|
||||
if p.engine3.is_some() { count += 1; }
|
||||
buf[0] = count;
|
||||
|
||||
// LED_MAP register byte
|
||||
buf[1] = pattern_to_led_map_byte(p);
|
||||
|
||||
// Direct PWM [B, G, R, W] — bytes 2-5, leave as 0 for engine patterns
|
||||
|
||||
// Engine programs
|
||||
serialize_engine(&p.engine1, &mut buf[6..40]);
|
||||
serialize_engine(&p.engine2, &mut buf[40..74]);
|
||||
serialize_engine(&p.engine3, &mut buf[74..108]);
|
||||
|
||||
// Chaining
|
||||
buf[108] = p.next_pattern;
|
||||
buf[109] = p.loop_count;
|
||||
}
|
||||
|
||||
/// Serialize an optional engine program into a 34-byte section.
|
||||
fn serialize_engine(eng: &Option<EngineProgram>, buf: &mut [u8]) {
|
||||
match eng {
|
||||
None => {
|
||||
buf[0] = 0;
|
||||
buf[1] = 0;
|
||||
}
|
||||
Some(prog) => {
|
||||
let len = prog.len();
|
||||
buf[0] = (len >> 8) as u8;
|
||||
buf[1] = len as u8;
|
||||
let bytes = prog.as_bytes(); // 32-byte SRAM image, big-endian
|
||||
buf[2..34].copy_from_slice(&bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// CRC-16/CCITT-FALSE (matching Python serializer).
|
||||
/// CRC-16/CCITT-FALSE.
|
||||
pub fn crc16(data: &[u8]) -> u16 {
|
||||
let mut crc: u16 = 0xFFFF;
|
||||
for &b in data {
|
||||
@@ -553,40 +435,3 @@ pub fn crc16(data: &[u8]) -> u16 {
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
/// Serialize the XBLK header into a 16-byte buffer.
|
||||
/// CRC is computed over header bytes 0-13 + pattern data.
|
||||
pub fn serialize_header(
|
||||
pattern_count: u8,
|
||||
active_index: u8,
|
||||
current: u8,
|
||||
led_mode: u8,
|
||||
pattern_data_crc_input: &[u8],
|
||||
buf: &mut [u8; HEADER_SIZE],
|
||||
) {
|
||||
buf[0..4].copy_from_slice(&XBLK_MAGIC);
|
||||
buf[4] = XBLK_VERSION;
|
||||
buf[5] = pattern_count;
|
||||
buf[6] = active_index;
|
||||
buf[7] = current;
|
||||
buf[8] = led_mode;
|
||||
for i in 9..14 {
|
||||
buf[i] = 0;
|
||||
}
|
||||
// CRC over header[0..14] + pattern data
|
||||
let mut crc = crc16(&buf[0..14]);
|
||||
// Continue CRC over pattern data
|
||||
for &b in pattern_data_crc_input {
|
||||
crc ^= (b as u16) << 8;
|
||||
for _ in 0..8 {
|
||||
if crc & 0x8000 != 0 {
|
||||
crc = (crc << 1) ^ 0x1021;
|
||||
} else {
|
||||
crc <<= 1;
|
||||
}
|
||||
crc &= 0xFFFF;
|
||||
}
|
||||
}
|
||||
buf[14] = (crc >> 8) as u8;
|
||||
buf[15] = crc as u8;
|
||||
}
|
||||
|
||||
166
tools/provision_eh.py
Normal file
166
tools/provision_eh.py
Normal 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()
|
||||
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
XBLK pattern library serializer for xblink.
|
||||
XBLK v2 pattern library serializer for xblink.
|
||||
|
||||
Converts pattern definitions to the XBLK binary format and writes them
|
||||
to NTAG5 EEPROM blocks 256+ (upper 1K) via ntag5sensor ISO15693 commands.
|
||||
Converts pattern definitions to the XBLK v2 binary format (GPIO-direct PWM,
|
||||
16-byte pattern entries) and writes them to NTAG5 EEPROM blocks 256+
|
||||
(upper 1K) via ntag5sensor ISO15693 commands.
|
||||
|
||||
Usage:
|
||||
# Serialize built-in patterns to binary file:
|
||||
@@ -22,27 +23,23 @@ import struct
|
||||
import sys
|
||||
import os
|
||||
|
||||
# XBLK format constants (must match src/pattern/mod.rs)
|
||||
# XBLK v2 format constants (must match src/pattern/mod.rs)
|
||||
XBLK_MAGIC = b"XBLK"
|
||||
XBLK_VERSION = 0x01
|
||||
XBLK_VERSION = 0x02
|
||||
HEADER_SIZE = 16
|
||||
PATTERN_ENTRY_SIZE = 112
|
||||
MAX_PATTERNS = 9
|
||||
MAX_COMMANDS_PER_ENGINE = 16
|
||||
PATTERN_ENTRY_SIZE = 16
|
||||
MAX_PATTERNS = 61
|
||||
MAX_PLAYLIST = 32
|
||||
NUM_LEDS = 6
|
||||
|
||||
# EEPROM block offset for pattern library (upper 1K)
|
||||
LIBRARY_BASE_BLOCK = 256
|
||||
|
||||
# LED_MAP encoding helpers
|
||||
LED_MAP_LOOKUP = {
|
||||
"direct": 0b00, "i2c": 0b00,
|
||||
"engine1": 0b01, "e1": 0b01,
|
||||
"engine2": 0b10, "e2": 0b10,
|
||||
"engine3": 0b11, "e3": 0b11,
|
||||
# Waveform IDs
|
||||
WAVEFORM_LOOKUP = {
|
||||
"sine": 0, "triangle": 1, "square": 2, "heartbeat": 3,
|
||||
}
|
||||
|
||||
LED_MODE_LOOKUP = {"rgbw": 0x00, "mono3": 0x01}
|
||||
|
||||
|
||||
def crc16(data: bytes) -> int:
|
||||
"""CRC-16/CCITT-FALSE."""
|
||||
@@ -58,215 +55,131 @@ def crc16(data: bytes) -> int:
|
||||
return crc
|
||||
|
||||
|
||||
def encode_led_map(mapping: dict) -> int:
|
||||
"""Encode {"b": "engine1", "g": "engine1", ...} to LP5562 LED_MAP register byte."""
|
||||
b = LED_MAP_LOOKUP.get(mapping.get("b", "direct"), 0)
|
||||
g = LED_MAP_LOOKUP.get(mapping.get("g", "direct"), 0)
|
||||
r = LED_MAP_LOOKUP.get(mapping.get("r", "direct"), 0)
|
||||
w = LED_MAP_LOOKUP.get(mapping.get("w", "direct"), 0)
|
||||
return b | (g << 2) | (r << 4) | (w << 6)
|
||||
|
||||
|
||||
def encode_pattern(pat: dict) -> bytes:
|
||||
"""Encode a single pattern dict to PATTERN_ENTRY_SIZE bytes."""
|
||||
engines = pat.get("engines", [[], [], []])
|
||||
while len(engines) < 3:
|
||||
engines.append([])
|
||||
waveform = WAVEFORM_LOOKUP.get(pat.get("waveform", "sine"), 0)
|
||||
cycle_len = pat.get("cycle_len", 125)
|
||||
phase = pat.get("phase", [0] * NUM_LEDS)
|
||||
envelope = pat.get("envelope", [255] * NUM_LEDS)
|
||||
repeat_count = pat.get("repeat_count", 0xFF)
|
||||
|
||||
engine_count = sum(1 for e in engines if len(e) > 0)
|
||||
led_map_reg = encode_led_map(pat.get("led_map", {}))
|
||||
direct_pwm = pat.get("direct_pwm", [0, 0, 0, 0])
|
||||
while len(direct_pwm) < 4:
|
||||
direct_pwm.append(0)
|
||||
# Pad/truncate to NUM_LEDS
|
||||
phase = (phase + [0] * NUM_LEDS)[:NUM_LEDS]
|
||||
envelope = (envelope + [255] * NUM_LEDS)[:NUM_LEDS]
|
||||
|
||||
buf = bytearray(PATTERN_ENTRY_SIZE)
|
||||
buf[0] = engine_count
|
||||
buf[1] = led_map_reg
|
||||
buf[2:6] = bytes(direct_pwm[:4])
|
||||
|
||||
for eng_idx, cmds in enumerate(engines[:3]):
|
||||
if len(cmds) > MAX_COMMANDS_PER_ENGINE:
|
||||
raise ValueError(f"Engine {eng_idx+1} has {len(cmds)} commands (max {MAX_COMMANDS_PER_ENGINE})")
|
||||
base = 6 + eng_idx * 34 # 2 bytes count + 32 bytes commands
|
||||
struct.pack_into(">H", buf, base, len(cmds))
|
||||
for i, cmd in enumerate(cmds):
|
||||
struct.pack_into(">H", buf, base + 2 + i * 2, cmd & 0xFFFF)
|
||||
buf[0] = waveform & 0xFF
|
||||
buf[1] = cycle_len & 0xFF
|
||||
buf[2:8] = bytes(phase)
|
||||
buf[8:14] = bytes(envelope)
|
||||
buf[14] = repeat_count & 0xFF
|
||||
buf[15] = 0 # reserved
|
||||
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def encode_library(config: dict) -> bytes:
|
||||
"""Encode a full XBLK library (header + patterns) to bytes."""
|
||||
"""Encode a full XBLK v2 library (header + patterns + playlist) to bytes."""
|
||||
patterns = config.get("patterns", [])
|
||||
if len(patterns) == 0:
|
||||
raise ValueError("No patterns defined")
|
||||
if len(patterns) > MAX_PATTERNS:
|
||||
raise ValueError(f"Too many patterns: {len(patterns)} (max {MAX_PATTERNS})")
|
||||
|
||||
current = config.get("current", 20)
|
||||
mode = LED_MODE_LOOKUP.get(config.get("mode", "rgbw"), 0x00)
|
||||
budget = config.get("budget", 50)
|
||||
active = config.get("active", 0) % len(patterns)
|
||||
playlist = config.get("playlist", [])
|
||||
has_playlist = len(playlist) > 0
|
||||
|
||||
# Build header (without CRC)
|
||||
if len(playlist) > MAX_PLAYLIST:
|
||||
raise ValueError(f"Playlist too long: {len(playlist)} (max {MAX_PLAYLIST})")
|
||||
|
||||
# Build header
|
||||
header = bytearray(HEADER_SIZE)
|
||||
header[0:4] = XBLK_MAGIC
|
||||
header[4] = XBLK_VERSION
|
||||
header[5] = len(patterns)
|
||||
header[6] = active
|
||||
header[7] = current & 0xFF
|
||||
header[8] = mode
|
||||
# bytes 9-13 reserved
|
||||
# bytes 14-15 CRC (filled below)
|
||||
header[5] = 0x01 if has_playlist else 0x00 # flags
|
||||
header[6] = len(patterns)
|
||||
header[7] = active
|
||||
header[8] = budget & 0xFF
|
||||
header[9] = len(playlist) & 0xFF
|
||||
# bytes 10-13 reserved
|
||||
|
||||
# Compute CRC over header bytes 0-13
|
||||
crc = crc16(bytes(header[:14]))
|
||||
struct.pack_into(">H", header, 14, crc)
|
||||
|
||||
# Build pattern data
|
||||
pat_data = b""
|
||||
for pat in patterns:
|
||||
pat_data += encode_pattern(pat)
|
||||
|
||||
# Compute CRC over header (bytes 0-13) + all pattern data
|
||||
crc = crc16(bytes(header[:14]) + pat_data)
|
||||
struct.pack_into(">H", header, 14, crc)
|
||||
# Build playlist data
|
||||
playlist_data = bytes(playlist + [0] * (MAX_PLAYLIST - len(playlist)))
|
||||
|
||||
return bytes(header) + pat_data
|
||||
result = bytes(header) + pat_data
|
||||
if has_playlist:
|
||||
result += playlist_data[:MAX_PLAYLIST]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Built-in patterns (matching src/pattern/mod.rs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# LP5562 EngineCommand helpers (matching lp5562.rs encoding)
|
||||
def ramp_wait(prescale_slow: bool, step_time: int, up: bool, increment: int) -> int:
|
||||
prescale_bit = 0x4000 if prescale_slow else 0
|
||||
sign_bit = 0 if up else 0x0080
|
||||
return prescale_bit | ((step_time & 0x3F) << 8) | sign_bit | (increment & 0x7F)
|
||||
|
||||
def wait(prescale_slow: bool, step_time: int) -> int:
|
||||
prescale_bit = 0x4000 if prescale_slow else 0
|
||||
return prescale_bit | ((step_time & 0x3F) << 8)
|
||||
|
||||
def set_pwm(value: int) -> int:
|
||||
return 0x4000 | value # Actually: 0x40xx format
|
||||
# Wait, let me check the actual encoding...
|
||||
|
||||
def branch(step: int, loop_count: int) -> int:
|
||||
return 0xA000 | ((loop_count & 0x3F) << 7) | (step & 0x7F)
|
||||
|
||||
def trigger(wait_mask: int, send_mask: int) -> int:
|
||||
return 0xE000 | ((wait_mask & 0x07) << 7) | (send_mask & 0x07)
|
||||
|
||||
|
||||
def builtin_patterns() -> dict:
|
||||
"""Return the 5 built-in patterns as a config dict."""
|
||||
# Breathe: 1 engine, all RGB channels
|
||||
breathe_cmds = [
|
||||
ramp_wait(True, 1, True, 127), # 0→128
|
||||
ramp_wait(True, 1, True, 127), # 128→255
|
||||
ramp_wait(True, 1, False, 127), # 255→127
|
||||
ramp_wait(True, 1, False, 127), # 127→0
|
||||
wait(True, 48), # pause
|
||||
branch(0, 0), # loop
|
||||
]
|
||||
|
||||
# Heartbeat: 1 engine, double-pulse
|
||||
heartbeat_cmds = [
|
||||
0x40FF, # set_pwm(255)
|
||||
wait(False, 20), # hold ~10ms
|
||||
0x4000, # set_pwm(0)
|
||||
wait(False, 40), # gap ~20ms
|
||||
0x40FF, # set_pwm(255)
|
||||
wait(False, 20), # hold ~10ms
|
||||
0x4000, # set_pwm(0)
|
||||
wait(True, 63), # rest ~1.0s
|
||||
wait(True, 32), # rest ~0.5s
|
||||
branch(0, 0),
|
||||
]
|
||||
|
||||
# Slow pulse: 1 engine, very gentle
|
||||
slow_pulse_cmds = [
|
||||
ramp_wait(True, 4, True, 127), # 0→128
|
||||
ramp_wait(True, 4, True, 127), # 128→255
|
||||
wait(True, 32), # hold
|
||||
ramp_wait(True, 4, False, 127), # 255→127
|
||||
ramp_wait(True, 4, False, 127), # 127→0
|
||||
wait(True, 63), # pause
|
||||
branch(0, 0),
|
||||
]
|
||||
|
||||
# RGB cycle: 3 engines with trigger sync
|
||||
rgb_e1 = [
|
||||
ramp_wait(True, 1, True, 127),
|
||||
ramp_wait(True, 1, True, 127),
|
||||
trigger(0, 0b010), # send to E2
|
||||
ramp_wait(True, 1, False, 127),
|
||||
ramp_wait(True, 1, False, 127),
|
||||
wait(True, 63),
|
||||
branch(0, 0),
|
||||
]
|
||||
rgb_e2 = [
|
||||
trigger(0b001, 0), # wait for E1
|
||||
ramp_wait(True, 1, True, 127),
|
||||
ramp_wait(True, 1, True, 127),
|
||||
trigger(0, 0b100), # send to E3
|
||||
ramp_wait(True, 1, False, 127),
|
||||
ramp_wait(True, 1, False, 127),
|
||||
wait(True, 32),
|
||||
branch(0, 0),
|
||||
]
|
||||
rgb_e3 = [
|
||||
trigger(0b010, 0), # wait for E2
|
||||
ramp_wait(True, 1, True, 127),
|
||||
ramp_wait(True, 1, True, 127),
|
||||
ramp_wait(True, 1, False, 127),
|
||||
ramp_wait(True, 1, False, 127),
|
||||
wait(True, 32),
|
||||
branch(0, 0),
|
||||
]
|
||||
|
||||
# Color wash: 3 engines, free-running with different periods
|
||||
wash_e1 = [
|
||||
ramp_wait(True, 1, True, 127),
|
||||
ramp_wait(True, 1, True, 127),
|
||||
ramp_wait(True, 1, False, 127),
|
||||
ramp_wait(True, 1, False, 127),
|
||||
branch(0, 0),
|
||||
]
|
||||
wash_e2 = [
|
||||
ramp_wait(True, 1, True, 127),
|
||||
ramp_wait(True, 1, True, 127),
|
||||
ramp_wait(True, 1, False, 127),
|
||||
ramp_wait(True, 1, False, 127),
|
||||
wait(True, 32),
|
||||
branch(0, 0),
|
||||
]
|
||||
wash_e3 = [
|
||||
ramp_wait(True, 1, True, 127),
|
||||
ramp_wait(True, 1, True, 127),
|
||||
ramp_wait(True, 1, False, 127),
|
||||
ramp_wait(True, 1, False, 127),
|
||||
wait(True, 63),
|
||||
branch(0, 0),
|
||||
]
|
||||
|
||||
single_rgb_map = {"b": "engine1", "g": "engine1", "r": "engine1", "w": "direct"}
|
||||
triple_map = {"b": "engine1", "g": "engine2", "r": "engine3", "w": "direct"}
|
||||
|
||||
return {
|
||||
"current": 20,
|
||||
"mode": "rgbw",
|
||||
"budget": 50,
|
||||
"active": 0,
|
||||
"patterns": [
|
||||
{"name": "breathe", "led_map": single_rgb_map, "engines": [breathe_cmds, [], []]},
|
||||
{"name": "heartbeat", "led_map": single_rgb_map, "engines": [heartbeat_cmds, [], []]},
|
||||
{"name": "slow_pulse", "led_map": single_rgb_map, "engines": [slow_pulse_cmds, [], []]},
|
||||
{"name": "rgb_cycle", "led_map": triple_map, "engines": [rgb_e1, rgb_e2, rgb_e3]},
|
||||
{"name": "color_wash", "led_map": triple_map, "engines": [wash_e1, wash_e2, wash_e3]},
|
||||
{
|
||||
"name": "breathe",
|
||||
"waveform": "sine",
|
||||
"cycle_len": 125, # 2.5s
|
||||
"phase": [0, 0, 0, 0, 0, 0],
|
||||
"envelope": [255, 255, 255, 255, 255, 255],
|
||||
"repeat_count": 0xFF,
|
||||
},
|
||||
{
|
||||
"name": "heartbeat",
|
||||
"waveform": "heartbeat",
|
||||
"cycle_len": 80, # 1.6s
|
||||
"phase": [0, 0, 0, 0, 0, 0],
|
||||
"envelope": [255, 255, 255, 255, 255, 255],
|
||||
"repeat_count": 0xFF,
|
||||
},
|
||||
{
|
||||
"name": "wave_chase",
|
||||
"waveform": "sine",
|
||||
"cycle_len": 100, # 2s
|
||||
"phase": [0, 43, 85, 128, 170, 213],
|
||||
"envelope": [255, 255, 255, 255, 255, 255],
|
||||
"repeat_count": 0xFF,
|
||||
},
|
||||
{
|
||||
"name": "slow_pulse",
|
||||
"waveform": "triangle",
|
||||
"cycle_len": 250, # 5s
|
||||
"phase": [0, 0, 0, 0, 0, 0],
|
||||
"envelope": [200, 200, 200, 200, 200, 200],
|
||||
"repeat_count": 0xFF,
|
||||
},
|
||||
{
|
||||
"name": "alternating_blink",
|
||||
"waveform": "square",
|
||||
"cycle_len": 50, # 1s
|
||||
"phase": [0, 128, 0, 128, 0, 128],
|
||||
"envelope": [255, 255, 255, 255, 255, 255],
|
||||
"repeat_count": 0xFF,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def write_to_ntag5(data: bytes):
|
||||
"""Write binary data to NTAG5 EEPROM blocks 256+ via ntag5sensor."""
|
||||
# Add ntag5sensor to path
|
||||
ntag5sensor_path = os.path.join(os.path.dirname(__file__), "..", "..", "ntag5sensor")
|
||||
sys.path.insert(0, ntag5sensor_path)
|
||||
|
||||
@@ -278,20 +191,16 @@ def write_to_ntag5(data: bytes):
|
||||
|
||||
print(f"Writing {len(data)} bytes to EEPROM blocks {LIBRARY_BASE_BLOCK}-{LIBRARY_BASE_BLOCK + len(data)//4 - 1}")
|
||||
|
||||
# Write in 4-byte blocks
|
||||
for i in range(0, len(data), 4):
|
||||
block = LIBRARY_BASE_BLOCK + i // 4
|
||||
chunk = data[i:i+4]
|
||||
if len(chunk) < 4:
|
||||
chunk = chunk + b'\x00' * (4 - len(chunk))
|
||||
|
||||
# Use ISO15693 WRITE SINGLE BLOCK (unaddressed)
|
||||
# Block address needs protocol extension for blocks > 255
|
||||
flags = ISO_FLAG_DATA_RATE | 0x08 # data rate + protocol extension
|
||||
flags = ISO_FLAG_DATA_RATE | 0x08
|
||||
cmd = bytes([flags, 0x21]) + struct.pack("<H", block) + chunk
|
||||
reader.transmit_iso15693(cmd, True)
|
||||
|
||||
# EEPROM write cycle delay
|
||||
import time
|
||||
time.sleep(0.006)
|
||||
|
||||
@@ -302,11 +211,10 @@ def write_to_ntag5(data: bytes):
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="XBLK pattern library serializer")
|
||||
parser = argparse.ArgumentParser(description="XBLK v2 pattern library serializer")
|
||||
parser.add_argument("--json", help="JSON pattern definition file")
|
||||
parser.add_argument("--output", "-o", help="Output binary file")
|
||||
parser.add_argument("--write", action="store_true", help="Write to NTAG5 via PCSC")
|
||||
parser.add_argument("--builtin", action="store_true", help="Use built-in patterns (default if no --json)")
|
||||
parser.add_argument("--dump", action="store_true", help="Hex dump the binary")
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user