diff --git a/CLAUDE.md b/CLAUDE.md index 0aa4b49..3beeb25 100644 --- a/CLAUDE.md +++ b/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,23 +57,32 @@ 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) -| Device | Address | Notes | -| --------- | ------- | ------------------------------------------ | -| LP5562 | 0x30 | ADDR_SEL pins both low (LP5562EVM default) | -| NTAG5Link | 0x54 | Default NTP53x2 I2C slave address | +## Key I2C Address + +I2C bus (A4/A5) is used for NTAG5 only (LP5562 removed from design). + +| Device | Address | Notes | +| --------- | ------- | --------------------------------- | +| NTAG5Link | 0x54 | Default NTP53x2 I2C slave address | ## NTAG5Link I2C Register Map (MCU-side) @@ -93,24 +104,25 @@ 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. +| XIAO Pin | Connection | Function | +| -------- | --------------------- | ------------------------------------------- | +| 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`) diff --git a/docs/STATUS.md b/docs/STATUS.md index 0fc6d8a..9965489 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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 | diff --git a/docs/plans/2026-03-06-gpio-led-pivot.md b/docs/plans/2026-03-06-gpio-led-pivot.md new file mode 100644 index 0000000..e99a7a3 --- /dev/null +++ b/docs/plans/2026-03-06-gpio-led-pivot.md @@ -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). diff --git a/src/led/mod.rs b/src/led/mod.rs index 70b3a72..e1579c6 100644 --- a/src/led/mod.rs +++ b/src/led/mod.rs @@ -1 +1,2 @@ pub mod lp5562; +pub mod pwm; diff --git a/src/led/pwm.rs b/src/led/pwm.rs new file mode 100644 index 0000000..80b9f46 --- /dev/null +++ b/src/led/pwm.rs @@ -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); +} diff --git a/src/main.rs b/src/main.rs index f3334d9..fceb4e7 100644 --- a/src/main.rs +++ b/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>> = Mutex::new(RefCell::new(None)); + +/// Flag set by TC4 ISR: pattern repeats are done, main loop should advance playlist. +static REPEATS_DONE: Mutex> = Mutex::new(RefCell::new(false)); /// Blink the on-board LED n times (active-low: low=on, high=off) fn blink(led: &mut bsp::Led0, delay: &mut D, times: u8, ms: u16) { @@ -32,10 +46,59 @@ fn blink(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,269 +157,276 @@ fn main() -> ! { pins.a5, ); - let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS); + let mut ntag = Ntag5Link::new(i2c, ntag5::DEFAULT_ADDRESS); - // Default LED current: 2 mA per channel (20 × 0.1 mA) - let mut led_current: u8 = 20; + // Default power budget + let mut budget: u8 = pattern::DEFAULT_BUDGET; - // 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(()) - })(); + // 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(); - 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); + // 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(); - // --- Try to load pattern library from NTAG5 EEPROM --- - let i2c = lp.release(); - let mut ntag = Ntag5Link::new(i2c, ntag5::DEFAULT_ADDRESS); + // Write full diagnostic to NDEF text record (readable via phone NFC) + { + let mut buf = [0u8; 128]; + let mut i = 0; - // Config check (LED blinks only, no NDEF write) - match ntag.check_config() { - Ok(result) => { - if result.all_ok { - blink(&mut led, &mut delay, 2, 100); - } else { - blink(&mut led, &mut delay, 5, 60); + 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) => { + 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); } - } - 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" "); } } - delay.delay_ms(500u32); + Err(_) => { append(&mut buf, &mut i, b"CFG:I2C_ERR "); } + } - // Configure FD pin for SRAM-write-by-RF indication - let _ = ntag.configure_fd_sram_write(); // Best-effort, non-fatal + // 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 "); } - // Try reading XBLK library header from upper 1K - 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)); + // EH session register readback + append(&mut buf, &mut i, b"EH:"); + if eh_ok { + if let Ok(v) = ntag.read_register(ntag5::SESSION_EH_CONFIG_REG, 0) { + hex(&mut buf, &mut i, v); + // Also read EH_LOAD_OK status (bit 7) + if v & 0x80 != 0 { append(&mut buf, &mut i, b"/LOAD_OK"); } + else { append(&mut buf, &mut i, b"/no_load"); } + } else { + append(&mut buf, &mut i, b"RD_ERR"); + } + } else { + append(&mut buf, &mut i, b"WR_ERR"); + } + append(&mut buf, &mut i, b" "); - // Read active pattern if header is valid - let eeprom_pattern = eeprom_ok.as_ref().and_then(|hdr| { - 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]; - ntag.read_memory(pat_block, &mut pat_buf).ok()?; - pattern::parse_pattern_entry(&pat_buf) - }); + // 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"); + } - match eeprom_pattern { - Some(pat) => { - // EEPROM pattern loaded — 3 fast blinks - blink(&mut led, &mut delay, 3, 80); + // Write as NDEF text record + let _ = ntag.write_ndef_text(&buf[..i], &mut delay); + } - // 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); - } + blink(&mut led, &mut delay, 1, 250); + delay.delay_ms(500u32); - // 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); - } - } - } - } - } - } - } + // --- Try loading pattern from EEPROM --- + let mut header_buf = [0u8; pattern::HEADER_SIZE]; + let eeprom_ok = ntag.read_memory(pattern::LIBRARY_BASE_BLOCK, &mut header_buf).ok() + .and_then(|()| pattern::parse_header(&header_buf)); + + let mut active_pattern: Option = None; + + if let Some(ref hdr) = eeprom_ok { + budget = hdr.budget; + let pat_block = pattern::LIBRARY_BASE_BLOCK + + (pattern::HEADER_SIZE as u16 / 4) + + (hdr.active_index as u16 * (pattern::PATTERN_ENTRY_SIZE as u16 / 4)); + let mut pat_buf = [0u8; pattern::PATTERN_ENTRY_SIZE]; + if ntag.read_memory(pat_block, &mut pat_buf).is_ok() { + active_pattern = pattern::parse_pattern_entry(&pat_buf); + } + } + + if active_pattern.is_none() { + // No XBLK in EEPROM — self-provision default patterns + blink(&mut led, &mut delay, 2, 350); + + let patterns = [ + pattern::breathe(), + pattern::heartbeat(), + pattern::wave_chase(), + pattern::slow_pulse(), + pattern::alternating_blink(), + ]; + + let pat_base_block = pattern::LIBRARY_BASE_BLOCK + + (pattern::HEADER_SIZE as u16 / 4); + let mut provision_ok = true; + + for (i, pat) in patterns.iter().enumerate() { + let mut entry_buf = [0u8; pattern::PATTERN_ENTRY_SIZE]; + pattern::serialize_pattern_entry(pat, &mut entry_buf); + + let entry_block = pat_base_block + + (i as u16 * (pattern::PATTERN_ENTRY_SIZE as u16 / 4)); + for blk in 0..(pattern::PATTERN_ENTRY_SIZE as u16 / 4) { + let bo = (blk as usize) * 4; + let chunk = [entry_buf[bo], entry_buf[bo+1], entry_buf[bo+2], entry_buf[bo+3]]; + if ntag.write_verify_block(entry_block + blk, &chunk, &mut delay).is_err() { + provision_ok = false; + break; } - None => { - // No XBLK in EEPROM — self-provision hardcoded patterns - // 2 long blinks = provisioning - blink(&mut led, &mut delay, 2, 300); + } + if !provision_ok { break; } + } - let mode = LedMode::Rgbw; - let patterns = [ - pattern::breathe(mode), - pattern::heartbeat(mode), - pattern::slow_pulse(mode), - pattern::rgb_cycle(mode), - pattern::color_wash(mode), - ]; - - // 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 { - let bo = (blk as usize) * 4; - let chunk = [entry_buf[bo], entry_buf[bo+1], entry_buf[bo+2], entry_buf[bo+3]]; - if ntag.write_verify_block(entry_block + blk, &chunk, &mut delay).is_err() { - provision_ok = false; - break; - } - } - if !provision_ok { break; } - } - - if provision_ok { - // 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, - ); - for blk in 0..4u16 { - let bo = (blk as usize) * 4; - let chunk = [hdr_buf[bo], hdr_buf[bo+1], hdr_buf[bo+2], hdr_buf[bo+3]]; - if ntag.write_verify_block( - pattern::LIBRARY_BASE_BLOCK + blk, &chunk, &mut delay, - ).is_err() { - provision_ok = false; - break; - } - } - } - - if provision_ok { - // 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); - } - } - let i2c = lp.release(); - ntag = Ntag5Link::new(i2c, ntag5::DEFAULT_ADDRESS); - } - - // Mailbox polling loop (same as EEPROM-loaded path) - 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)) { - 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_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); - } - } - } - } - } - } - } - } 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(); - } - } + if provision_ok { + let mut hdr_buf = [0u8; pattern::HEADER_SIZE]; + pattern::serialize_header( + 5, 0, budget, false, 0, &mut hdr_buf, + ); + for blk in 0..4u16 { + let bo = (blk as usize) * 4; + let chunk = [hdr_buf[bo], hdr_buf[bo+1], hdr_buf[bo+2], hdr_buf[bo+3]]; + if ntag.write_verify_block( + pattern::LIBRARY_BASE_BLOCK + blk, &chunk, &mut delay, + ).is_err() { + provision_ok = false; + break; } } } - Err(_) => { - // === DIAGNOSTIC: fast blink forever = I2C error === - loop { - blink(&mut led, &mut delay, 5, 80); - delay.delay_ms(500u32); + + if provision_ok { + blink(&mut led, &mut delay, 4, 250); + active_pattern = Some(pattern::breathe()); + } else { + blink(&mut led, &mut delay, 8, 200); + // Fall back to hardcoded breathe + active_pattern = Some(pattern::breathe()); + } + } else { + blink(&mut led, &mut delay, 3, 250); // EEPROM pattern loaded + } + + // --- Start animation --- + if let Some(pat) = active_pattern { + cortex_m::interrupt::free(|cs| { + ENGINE.borrow(cs).replace(Some(PatternEngine::new(pat, budget))); + }); + } + + // Start TC4 (50Hz animation timer) and enable its interrupt + unsafe { + init_tc4(); + core.NVIC.set_priority(interrupt::TC4, 1); // Higher priority than EIC + NVIC::unmask(interrupt::TC4); + } + + // === DIAGNOSTIC: blink arbiter mode before entering idle loop === + if let Ok(c1) = ntag.read_register(SESSION_CONFIG_REG, 1) { + let arbiter = (c1 >> 2) & 0x03; + blink(&mut led, &mut delay, arbiter + 1, 150); + delay.delay_ms(500u32); + let sram_en = (c1 >> 1) & 0x01; + blink(&mut led, &mut delay, sram_en + 1, 400); + } else { + blink(&mut led, &mut delay, 9, 100); + } + delay.delay_ms(1500u32); + + led.set_high().unwrap(); // LED off + + // ============================================================ + // Sleep/Wake loop — MCU in IDLE, TC4 ISR drives LEDs. + // Wake fully on EIC (FD pin) for SRAM mailbox commands. + // ============================================================ + let mut mbox = MailboxState::new(); + loop { + // IDLE sleep (not STANDBY) — TC4 and TCC keep running + cortex_m::asm::wfi(); + + // Check if woken by EIC (FD pin = SRAM write by RF) + let eic_reg = unsafe { &*pac::EIC::ptr() }; + if eic_reg.intflag.read().extint4().bit_is_set() { + extint4.clear_interrupt(); + + // Process SRAM mailbox command + let cmd_ok = ntag5::sram::process_command( + &mut ntag, &mut mbox, &mut delay, + ); + + if matches!(cmd_ok, Ok(true)) { + // Pattern library was modified — reload from EEPROM + let mut hdr_buf = [0u8; pattern::HEADER_SIZE]; + if ntag.read_memory(pattern::LIBRARY_BASE_BLOCK, &mut hdr_buf).is_ok() { + if let Some(hdr) = pattern::parse_header(&hdr_buf) { + budget = hdr.budget; + let pat_block = pattern::LIBRARY_BASE_BLOCK + + (pattern::HEADER_SIZE as u16 / 4) + + (hdr.active_index as u16 + * (pattern::PATTERN_ENTRY_SIZE as u16 / 4)); + let mut pat_buf = [0u8; pattern::PATTERN_ENTRY_SIZE]; + if ntag.read_memory(pat_block, &mut pat_buf).is_ok() { + if let Some(new_pat) = pattern::parse_pattern_entry(&pat_buf) { + cortex_m::interrupt::free(|cs| { + if let Some(ref mut eng) = *ENGINE.borrow(cs).borrow_mut() { + eng.budget = budget as u16; + eng.load(new_pat); + } + }); + } + } + } + } } } } } + +/// TC4 interrupt handler — 50Hz animation tick. +/// Computes LED brightness from pattern engine and writes TCC duty cycles. +#[interrupt] +fn TC4() { + let tc4 = unsafe { &*pac::TC4::ptr() }; + // Clear MC0 interrupt flag + tc4.count16().intflag.write(|w| w.mc0().set_bit()); + + cortex_m::interrupt::free(|cs| { + if let Some(ref mut engine) = *ENGINE.borrow(cs).borrow_mut() { + let mut output = [0u8; NUM_LEDS]; + engine.tick(&mut output); + led::pwm::set_all(&output); + + if engine.repeats_done() { + *REPEATS_DONE.borrow(cs).borrow_mut() = true; + } + } + }); +} + +/// EIC interrupt handler — clears flag, main loop checks and processes. +#[interrupt] +fn EIC() { + // Don't clear here — let main loop detect it via intflag read + // Just need the handler to exist so WFI returns + let eic = unsafe { &*pac::EIC::ptr() }; + if eic.intflag.read().extint4().bit_is_set() { + eic.intflag.modify(|_, w| w.extint4().set_bit()); + } +} diff --git a/src/ntag5/mod.rs b/src/ntag5/mod.rs index e08c096..72b951e 100644 --- a/src/ntag5/mod.rs +++ b/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 { @@ -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> { + 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> { + // 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 ---- diff --git a/src/ntag5/sram.rs b/src/ntag5/sram.rs index 248398e..8094d12 100644 --- a/src/ntag5/sram.rs +++ b/src/ntag5/sram.rs @@ -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( ntag: &mut Ntag5Link, ) -> Result, ntag5::Error> @@ -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( - ntag: &mut Ntag5Link, - count: u8, - all_data: &mut [u8], -) -> Result> -where - I2C: I2c, -{ - 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( ntag: &mut Ntag5Link, 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> where I2C: I2c, { - // 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( where I2C: I2c, { - 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( ntag: &mut Ntag5Link, 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( where I2C: I2c, { - 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( ntag: &mut Ntag5Link, 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( ntag: &mut Ntag5Link, 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) } diff --git a/src/pattern/mod.rs b/src/pattern/mod.rs index 27afad5..75bf483 100644 --- a/src/pattern/mod.rs +++ b/src/pattern/mod.rs @@ -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 { + 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, - pub engine2: Option, - pub engine3: Option, - /// 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( - lp: &mut Lp5562, - pattern: &Pattern, - current: u8, - delay: &mut impl embedded_hal::delay::DelayNs, -) -> Result<(), crate::led::lp5562::Error> -where - I2C: I2c, -{ - // 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 { if buf[0..4] != XBLK_MAGIC { return None; @@ -397,147 +344,82 @@ pub fn parse_header(buf: &[u8; HEADER_SIZE]) -> Option { 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 { - 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 { + 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> { - 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 { - 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, 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; -} diff --git a/tools/provision_eh.py b/tools/provision_eh.py new file mode 100644 index 0000000..462c2f3 --- /dev/null +++ b/tools/provision_eh.py @@ -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() diff --git a/tools/xblk_serialize.py b/tools/xblk_serialize.py index 3188111..1959130 100644 --- a/tools/xblk_serialize.py +++ b/tools/xblk_serialize.py @@ -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("