From e4195d25838fef98f83f8fbedc9c861b4e9f6539 Mon Sep 17 00:00:00 2001 From: michael Date: Tue, 3 Mar 2026 10:26:24 -0800 Subject: [PATCH] Add LP5562 driver, M1 smoke test, and milestone-based plan - Integrate LP5562 driver from sibling project (ntag5-samd21-lp562) with full doc comments restored - Update main.rs with complete M1 test: I2C init, GPIO EN, RGBW cycle - Rewrite DEVELOPMENT_PLAN.md from waterfall to milestone-based groups organized by hardware availability (Groups A-E) - Rewrite STATUS.md with milestone checklist tracking - Add LP5562 timing constraints and flash script docs to CLAUDE.md - Add flash_when_ready.sh for auto-flash dev workflow - Add brainstorm design doc for plan redesign rationale Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 29 +- docs/DEVELOPMENT_PLAN.md | 455 +++++------ docs/STATUS.md | 181 +++-- docs/plans/2026-03-03-plan-redesign-design.md | 41 + flash_when_ready.sh | 13 + src/led/lp5562.rs | 756 ++++++++++++++++++ src/led/mod.rs | 1 + src/main.rs | 52 +- 8 files changed, 1171 insertions(+), 357 deletions(-) create mode 100644 docs/plans/2026-03-03-plan-redesign-design.md create mode 100755 flash_when_ready.sh create mode 100644 src/led/lp5562.rs create mode 100644 src/led/mod.rs diff --git a/CLAUDE.md b/CLAUDE.md index 3bb0d44..370572f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,9 @@ cargo build --release # Flash via UF2 bootloader (XIAO M0: double-tap RST to enter bootloader) cargo hf2 --release + +# Auto-flash: watches for bootloader USB device, flashes when detected +./flash_when_ready.sh ``` ## Target @@ -36,29 +39,31 @@ When porting to bare SAMD21E: replace `xiao_m0` with `atsamd-hal = { version = " src/ main.rs # Entry point: boot, pattern load, sleep/wake loop led/ - mod.rs # LedController trait + re-exports + mod.rs # LP5562 re-exports (LedController trait added later, M10) lp5562.rs # LP5562 driver (from ../ntag5-samd21-lp562/) - simple_pwm.rs # Single-color LED driver (GPIO PWM) - ntag5/ - mod.rs # NTAG5Link I2C slave driver (MCU-side) - registers.rs # Register map and config constants - eeprom.rs # EEPROM read/write - sram.rs # SRAM mailbox for NFC pass-through - pattern/ - mod.rs # Pattern format, parser, serializer - engine.rs # LP5562 engine program builder from patterns + ntag5/ # NTAG5Link I2C slave driver (M4+) + pattern/ # Pattern format and engine builder (M5+) ``` ## Architecture Conventions - **`no_std` only**: No heap allocation. Fixed-size arrays and stack-only data structures. - **`embedded-hal` generics**: All hardware drivers must be generic over `embedded_hal::i2c::I2c` (1.0 traits). Never use concrete HAL types in driver code. -- **LedController trait**: LED controllers implement the `LedController` trait defined in `src/led/mod.rs`. This enables modular support for LP5562, single-color PWM, and future LED driver ICs. +- **Hardware-first**: Get hardware working before extracting abstractions. Traits and formats are designed after hands-on experience, not before. - **Error types**: Use `Error` enum wrapping underlying I2C error with `From` impl (see LP5562 driver pattern). - **Register addresses**: Place in a `reg` submodule as `pub const` values. - **`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 + +The LP5562 driver does NOT enforce timing delays internally — the caller is responsible: + +- **>= 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 + ## Key I2C Addresses | Device | Address | Notes | @@ -103,5 +108,5 @@ Key config constants (from `../ntag5sensor/vicinity/ntag5link.py`): ## Sibling Projects -- `../ntag5-samd21-lp562/` — LP5562 driver source (`src/lp5562.rs`), reference `main.rs` boot sequence +- `../ntag5-samd21-lp562/` — Original LP5562 driver + smoke test (xblink's `src/led/lp5562.rs` is synced from here) - `../ntag5sensor/` — NTAG5Link command reference (`vicinity/ntag5link.py`), I2C patterns (`vicinity/i2cbase.py`) diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md index 9bd4b22..8e76b1d 100644 --- a/docs/DEVELOPMENT_PLAN.md +++ b/docs/DEVELOPMENT_PLAN.md @@ -2,333 +2,268 @@ ## Overview -xblink is an NFC-powered LED implant. Development proceeds in phases, starting with dev board prototyping (XIAO M0 + LP5562EVM + NTAG5Link Click) and progressing to a custom SAMD21E PCB suitable for implantation. +xblink is an NFC-powered LED implant. Development is organized into small milestones grouped by hardware requirements. Each milestone is scoped to roughly one session of work. Abstractions (traits, pattern formats) are deferred until we have hands-on experience with the hardware they'll abstract. -LED controller code is modular via a `LedController` trait — the LP5562 is the primary target, but the architecture supports other LED driver ICs and simple GPIO PWM for single-color LEDs. +### Guiding Principles + +- **Hardware-first**: Get real LEDs blinking before designing abstractions +- **YAGNI**: No traits, formats, or protocols until we need them +- **Small milestones**: Each should produce a flashable, testable result +- **Test equipment**: Multimeter for power budget, logic analyzer (optional) for I2C debugging --- -## Phase 1: LP5562 Driver Integration +## Group A — LP5562 Only -**Goal**: Get LP5562EVM running RGBW LED patterns from the XIAO M0. +**Hardware needed**: XIAO M0 + LP5562EVM + mini-USB cable -**Hardware**: XIAO M0 ↔ LP5562EVM via I2C (A4/A5) + EN on D0/A0. +### M1: LED Smoke Test -### Tasks +**Goal**: Verify LP5562 direct PWM control works over I2C. -1. **Copy LP5562 driver** from `../ntag5-samd21-lp562/src/lp5562.rs` into `src/led/lp5562.rs`. This is a complete 757-line driver covering all LP5562 features: direct PWM, current control, engine programming, LED mapping, power-save, and clock config. It is generic over `embedded_hal::i2c::I2c` (1.0). +Copy the proven LP5562 driver from `../ntag5-samd21-lp562/src/lp5562.rs` into the project. Wire up I2C and GPIO EN in `main.rs`. Light up each RGBW channel to confirm wiring and driver work. -2. **Define `LedController` trait** in `src/led/mod.rs`: - ```rust - pub trait LedController { - type Error; - fn set_channel(&mut self, channel: u8, brightness: u8) -> Result<(), Self::Error>; - fn set_all_channels(&mut self, values: &[u8]) -> Result<(), Self::Error>; - fn all_off(&mut self) -> Result<(), Self::Error>; - fn channel_count(&self) -> u8; - fn supports_engines(&self) -> bool; - fn load_pattern(&mut self, pattern: &[u8]) -> Result<(), Self::Error>; - fn stop_pattern(&mut self) -> Result<(), Self::Error>; - } - ``` +**Tasks**: +1. Copy `lp5562.rs` → `src/led/lp5562.rs` +2. Create `src/led/mod.rs` with `pub mod lp5562;` re-export +3. Update `main.rs`: I2C init (A4/A5, 400kHz), GPIO EN on D0/A0, LP5562 enable → 1ms delay → `init_direct_control(Internal)` → `set_all_current(100,100,100,100)` → cycle through RGBW +4. Flash and verify all 4 LED channels light up -3. **Add GPIO EN pin** to LP5562 wrapper. The LP5562EVM EN pin is connected to D0/A0 for hardware power control: - ```rust - pub struct Lp5562Gpio { - lp: Lp5562, - en_pin: EN, - } - ``` - This wrapper manages the hardware enable alongside the I2C driver, allowing complete power-down for energy savings. +**Reference**: Sibling project `main.rs` lines 40-114 — proven working sequence with LP5562EVM. -4. **Test sequences**: - - Direct PWM: set individual channel colors, verify RGBW output - - Current control: set 2-5mA per channel (suitable for EH power budget) - - Engine programs: breathing pattern (ramp up/down with branch loop) - - Engine programs: RGB color cycle (3 engines with synchronized triggers) - - Engine programs: heartbeat pulse (fast ramp, slow decay) +**Done when**: All 4 RGBW LEDs cycle on the LP5562EVM. -### Reference Code +### M2: Engine Patterns -The sibling project's `main.rs` (lines 49-114) provides a complete working example: -- LP5562 init: `enable()` → 1ms delay → `init_direct_control(Internal)` → `set_all_current(100, 100, 100, 100)` -- Color cycling loop with `set_all_pwm()` and delay -- This exact pattern works with the LP5562EVM +**Goal**: Program LP5562 execution engines to run patterns autonomously — the core value proposition. + +After engine programming, the MCU can sleep while LP5562 keeps running patterns on its own internal oscillator. This milestone proves the architecture works. + +**Tasks**: +1. Build engine programs using `EngineCommand` builder (already in driver): + - **Breathing**: `set_pwm(0)` → ramp up → ramp down → `branch(0, 0)` (infinite loop) + - **Heartbeat**: `set_pwm(0)` → fast ramp up → slow ramp down → wait → `branch(0, 0)` + - **Color cycle**: 3 engines with staggered phases, synchronized via `trigger()` +2. Map LED channels to engines via `set_led_mapping()` +3. Load programs, set engines to Run, verify LEDs animate without MCU loop +4. Verify MCU can `stop_engine()` and resume with a different pattern + +**Done when**: LP5562 runs a breathing pattern while `main()` sits in an idle loop (no `delay_ms` driving the LEDs). + +### M3: Hardcoded Pattern Library + +**Goal**: Build a set of const patterns in firmware, selectable at boot. + +This gives us real pattern data to inform the EEPROM storage format later (M5), without designing that format prematurely. + +**Tasks**: +1. Define 4-5 patterns as `const` or `static` data: + - Solid RGBW (direct PWM, no engines) + - Breathing (single channel, 1 engine) + - Heartbeat pulse (1 engine, asymmetric ramp) + - RGB color cycle (3 engines, trigger sync) + - Rainbow fade (3 engines, staggered offset) +2. Simple pattern selector in `main.rs` (e.g., cycle through patterns on each boot, or use a compile-time `const`) +3. Reduce LED current to 20-50 (2-5mA/ch) to simulate EH power budget + +**Done when**: Can flash different patterns by changing a const, each runs autonomously on LP5562. --- -## Phase 2: NTAG5Link Driver (MCU-side I2C) +## Group B — Add NTAG5Link -**Goal**: Read and write NTAG5Link EEPROM and SRAM from the SAMD21 over I2C. +**Hardware needed**: + NTAG5Link Click board + I2C jumper wire -**Hardware**: XIAO M0 ↔ NTAG5Link Click via I2C (shared bus with LP5562). +### M4: NTAG5 EEPROM Read/Write -**Important**: The MCU accesses the NTAG5Link as a standard I2C slave at address 0x54. This is plain register read/write — completely different from the NFC-side ISO15693 custom commands (0xD2 READ_SRAM, 0xD4 WRITE_I2C, etc.) used by the Python `ntag5sensor` tooling. +**Goal**: Communicate with NTAG5Link over I2C as a slave device. -### Driver Design +**Important**: The MCU accesses NTAG5Link as a standard I2C slave at address 0x54 — plain register read/write. This is NOT the NFC-side ISO15693 custom commands used by the Python ntag5sensor tooling. -```rust -pub struct Ntag5Link { - i2c: I2C, - addr: u8, // 0x54 default -} -``` +**Tasks**: +1. Write `src/ntag5/mod.rs` — `Ntag5Link` struct, generic over `embedded_hal::i2c::I2c` +2. Write `src/ntag5/registers.rs` — register map constants from `ntag5link.py` +3. Implement EEPROM block read/write (2-byte block address, 4 bytes per block) +4. Implement session register read (single-byte address) +5. Test: write known data to EEPROM via PCSC reader (ntag5sensor), read back on MCU, display result as LP5562 colors (visual verification) -**EEPROM access** (2048 bytes, 512 blocks of 4 bytes each): -- Read: I2C write 2-byte block address, then I2C read 4 bytes -- Write: I2C write 2-byte block address + 4 bytes data -- Bulk read: iterate blocks into caller-provided buffer +**NTAG5Link config** (one-time setup via PCSC reader): -**SRAM access** (256 bytes, 64 blocks of 4 bytes): -- Same read/write pattern as EEPROM, different address range (0xF8-0xFF) -- Used as NFC ↔ I2C mailbox for phone communication - -**Session registers** (7 bytes at 0x00-0x06): -- Single-byte address, read/write 1 byte -- Include EH status, field detection, arbiter mode control - -### NTAG5Link Configuration - -Must configure NTAG5Link for our use case (via NFC-side config write before first use, or via I2C config registers): - -| Setting | Value | Constant (from ntag5link.py) | -|---------|-------|------------------------------| +| Setting | Value | Constant | +|---------|-------|----------| | Use case | I2C slave | `CONFIG_1_USE_CASE_CONF_I2C_SLAVE = 0 << 4` | | Arbiter mode | SRAM pass-through | `CONFIG_1_ARBITER_MODE_SRAM_PASSTHROUGH = 2 << 2` | | SRAM enable | On | `CONFIG_1_SRAM_ENABLE = 1 << 1` | | EH mode | Low field strength | `CONFIG_0_EH_MODE_LOW_FIELD_STRENGTH = 2 << 2` | -| EH voltage | 3.0V | EH_CONFIG register bits 2:1 = 10 | -| EH current | 9.0mA+ | EH_CONFIG register bits 6:4 | +| EH voltage | 3.0V | EH_CONFIG bits 2:1 = 10 | ---- +**Done when**: MCU reads EEPROM data written by PCSC reader and displays it as LED colors. -## Phase 3: Pattern Storage Format +### M5: Boot-from-EEPROM -**Goal**: Define a compact binary format for LED patterns stored in NTAG5Link EEPROM. +**Goal**: Design pattern binary format (informed by M2-M3 experience) and boot from stored patterns. -### Format Specification +By now we know exactly what engine programs look like in practice, so the format design is grounded in real usage. +**Tasks**: +1. Design binary pattern format — header + engine programs + LED mapping (see format spec below) +2. Write `src/pattern/mod.rs` — deserializer (parse EEPROM bytes → engine programs) +3. Write `src/pattern/engine.rs` — convert parsed pattern → LP5562 engine program load sequence +4. Update `main.rs` boot sequence: read EEPROM → parse pattern → program LP5562 → idle +5. Write a Python script (or extend ntag5sensor) to serialize patterns to EEPROM via PCSC +6. Test: write pattern via PCSC, power-cycle, verify LP5562 runs the pattern + +**Pattern format** (preliminary — will be refined based on M2-M3 learnings): ``` -Pattern Header (16 bytes): - [0-3] Magic: 0x58 0x42 0x4C 0x4B ("XBLK") +Header (16 bytes): + [0-3] Magic: "XBLK" (0x58 0x42 0x4C 0x4B) [4] Version: 0x01 - [5] Flags: - bit 0: has engine 1 program - bit 1: has engine 2 program - bit 2: has engine 3 program - bit 3: has direct color values - bit 4-7: reserved - [6] Number of pattern entries (1-255) - [7] Current setting for all channels (0-255 → 0-25.5mA) - [8-9] Reserved - [10-13] Direct color values (R, G, B, W) — used when flag bit 3 set - [14-15] CRC-16 of bytes 0-13 + all entry data + [5] Flags (which engines used, direct color mode) + [6] Number of pattern entries + [7] LED current setting (0-255 → 0-25.5mA) + [8-13] Reserved / direct RGBW color values + [14-15] CRC-16 -Pattern Entry (2 + N*2 bytes each): - [0] Entry type: - 0x01 = Engine program (LP5562 engine commands) - 0x02 = Direct PWM keyframes - [1] Length in 16-bit words (1-16 for engine programs) - [2..N] Command words (big-endian, LP5562 engine format) +Entry (2 + N*2 bytes): + [0] Type (0x01 = engine program) + [1] Length in 16-bit words (1-16) + [2..] Engine command words (big-endian) -Engine Assignment Block (4 bytes, after all entries): - [0] Engine 1 → entry index (0xFF = unused) - [1] Engine 2 → entry index (0xFF = unused) - [2] Engine 3 → entry index (0xFF = unused) - [3] LED mapping byte (LP5562 LED_MAP register 0x70 format) +Assignment (4 bytes, after entries): + [0-2] Engine 1/2/3 → entry index (0xFF = unused) + [3] LED_MAP register value ``` -**Size**: A typical 3-engine pattern with 16 commands each: 16 + 3×(2 + 32) + 4 = **122 bytes**. Even complex multi-pattern configs fit comfortably in the 2048-byte EEPROM. +Typical 3-engine pattern: 16 + 3×34 + 4 = **122 bytes** (fits easily in 2048B EEPROM). -### Built-in Default Patterns +**Done when**: MCU boots, reads pattern from EEPROM, LP5562 runs it autonomously. -Hardcoded in firmware as fallbacks: -- **Solid color**: Direct PWM, no engines needed -- **Breathing**: 1 engine — ramp up, ramp down, branch to start -- **RGB cycle**: 3 engines — staggered phase with trigger synchronization -- **Heartbeat**: 1 engine — fast ramp up, slow exponential decay -- **Rainbow fade**: 3 engines — slow ramp with offset phases +### M6: SRAM Mailbox + +**Goal**: Phone writes a new pattern via NFC, MCU picks it up and reprograms LP5562. + +**Tasks**: +1. Write `src/ntag5/sram.rs` — SRAM block read/write (64 blocks × 4 bytes at 0xF8-0xFF) +2. Design SRAM mailbox command/response protocol: + ``` + Command (NFC → MCU): [cmd, seq, len_lo, len_hi, payload...] + Response (MCU → NFC): [status, seq, len_lo, len_hi, payload...] + Commands: 0x01=write_pattern, 0x02=get_info, 0x03=set_color, 0x04=get_version + ``` +3. Configure NTAG5Link FD pin for SRAM write indication +4. Implement polling or interrupt-based command detection (EIC wake comes in M7) +5. MCU receives pattern → stores to EEPROM → reprograms LP5562 +6. Test with PCSC reader first, then VivoKey RawNFC app + +**Done when**: Pattern update works via NFC without power-cycling the MCU. --- -## Phase 4: Power Management +## Group C — Power + Recovery -**Goal**: Minimize MCU power draw so LP5562 gets maximum current budget from energy harvesting. +**Hardware needed**: + Hall effect sensor + multimeter -### Sleep Architecture +### M7: Sleep/Wake -1. After boot and LP5562 engine programming, enter SAMD21E **STANDBY** mode (~5µA) -2. LP5562 runs autonomously using its internal 256Hz oscillator — no MCU involvement -3. Wake sources via External Interrupt Controller (EIC): - - **NTAG5Link FD pin**: NFC field detection or SRAM write indication - - **Hall sensor GPIO**: Recovery trigger (magnet proximity) +**Goal**: MCU enters STANDBY after programming LP5562, wakes on NFC activity. -### Power Budget Analysis +**Tasks**: +1. Configure SAMD21 EIC for NTAG5Link FD pin (wake source) +2. After LP5562 engine programming, enter STANDBY (~5µA) +3. On EIC interrupt (FD pin), wake, process SRAM mailbox, return to sleep +4. Verify LP5562 keeps running patterns during MCU sleep +5. Measure current with multimeter: active vs standby vs total system -| Component | Active | Sleep/Idle | Notes | -|-----------|--------|------------|-------| -| SAMD21E | 3-5mA @ 48MHz | ~5µA standby | Sleep ASAP after boot | -| LP5562 | 0.5mA quiescent + LED current | Power-save mode | LEDs dominate budget | -| LP5562 LEDs (4ch) | 2-5mA × 4 = 8-20mA | 0mA (PS_EN) | Must stay within EH budget | -| NTAG5Link | ~100µA | ~1µA standby | Minimal contributor | -| **Total (steady state)** | **9-21mA** | **~5µA** | EH max: ~12.5mA | +**Power budget reference**: -**Key insight**: The system budget is tight. At 12.5mA EH and ~0.6mA quiescent (LP5562 + NTAG5), that leaves ~12mA for LEDs. At 3mA per channel, 4 channels = 12mA — right at the limit. Consider: -- Running fewer channels simultaneously -- Using LP5562 engine PWM duty cycling to reduce average current -- Pulsed patterns (breathing, heartbeat) inherently use less average current than solid-on +| Component | Active | Standby | +|-----------|--------|---------| +| SAMD21E | 3-5mA | ~5µA | +| LP5562 quiescent | ~0.5mA | ~0.5mA | +| LP5562 LEDs (4ch @ 3mA) | ~12mA | ~12mA | +| NTAG5Link | ~100µA | ~1µA | +| **Total** | **~17mA** | **~12.5mA** | -### Energy Harvesting Configuration +Key insight: LED current dominates. MCU sleep saves ~4mA. Total must stay under EH max (~12.5mA), so LED current must be limited. -Set via NTAG5Link config registers (one-time setup via PCSC reader): -- Voltage: 3.0V (EH_CONFIG bits 2:1 = 10) -- Current threshold: start at 9.0mA, increase if patterns are dim -- Mode: Low field strength (CONFIG_0 bits 3:2 = 10) — better for phone NFC -- FD pin: SRAM write indication mode (for NFC communication wake) +**Done when**: MCU sleeps after boot, LP5562 runs patterns, MCU wakes on NFC to accept new pattern. + +### M8: Recovery Mode + +**Goal**: Hall sensor provides safe mode entry for post-implant recovery. + +**Tasks**: +1. Wire hall sensor to EIC-capable GPIO pin +2. Boot-time check: if hall sensor asserted → recovery mode +3. Recovery behavior: load hardcoded solid white (low brightness), skip EEPROM, stay awake +4. Set recovery flag in SRAM so phone app can detect it +5. Add hall sensor as runtime EIC wake source (e.g., cycle patterns) + +**Done when**: Holding magnet near hall sensor at boot triggers recovery mode with default LED pattern. + +### M9: Power Characterization + +**Goal**: Measure real power consumption and tune EH settings. + +**Tasks**: +1. Measure current draw: MCU active, MCU standby, LP5562 at various current settings +2. Test NTAG5Link EH output with phone NFC at different current thresholds +3. Find optimal balance: maximum LED brightness within EH budget +4. Document actual power budget (replaces estimates in this plan) + +**Done when**: We have real numbers and documented EH configuration that works reliably. --- -## Phase 5: NFC Communication Protocol +## Group D — Abstraction + Polish -**Goal**: Enable phone-to-MCU communication through NTAG5Link SRAM mailbox. +**Prerequisite**: Groups A-C working. Now we know the real interface needs. -### SRAM Mailbox Protocol +### M10: LedController Trait -The NTAG5Link's 256-byte SRAM is accessible from both NFC (phone) and I2C (MCU) sides. We use it as a command/response mailbox. +**Goal**: Extract trait from proven LP5562 usage patterns. -``` -Command Frame (NFC → MCU, written to SRAM by phone): - [0] Command byte: - 0x01 = Write pattern (followed by pattern data) - 0x02 = Read current pattern info - 0x03 = Set direct color (4 bytes RGBW follow) - 0x04 = Get firmware version - 0x05 = Enter bootloader (reserved) - [1] Sequence number (for request/response matching) - [2-3] Payload length (little-endian, max 252) - [4-255] Payload data +After M1-M9, we know exactly what `set_channel`, `load_pattern`, and `stop_pattern` actually need to look like. Design the trait from real experience, not speculation. -Response Frame (MCU → NFC, written to SRAM by MCU): - [0] Status: - 0x00 = OK - 0x01 = Error (error code in payload) - 0x02 = Busy - 0x03 = Ready for next chunk - [1] Sequence number (echoed from command) - [2-3] Payload length (little-endian) - [4-255] Payload data -``` +**Tasks**: +1. Define `LedController` trait in `src/led/mod.rs` based on actual usage in `main.rs` +2. Implement for LP5562 wrapper (including GPIO EN pin) +3. Refactor `main.rs` to use trait-based API +4. Verify everything still works -### Chunked Transfer +### M11: SimplePwm Driver -For patterns larger than 252 bytes (unlikely but supported): -- Command 0x01 payload starts with chunk header: `[chunk_index: u8, total_chunks: u8, ...]` -- MCU accumulates chunks in RAM -- After final chunk, MCU writes complete pattern to EEPROM -- MCU responds with 0x03 (ready for next chunk) or 0x00 (complete) +**Goal**: Single-color LED support via GPIO PWM — only if we have hardware to test. -### Communication Flow - -1. Phone writes command frame to NTAG5Link SRAM via NFC -2. NTAG5Link asserts FD pin (SRAM write indication) -3. SAMD21E wakes from STANDBY via EIC interrupt -4. MCU reads SRAM via I2C, processes command -5. MCU pauses LP5562 if needed (set engines to Hold) -6. MCU performs action (update pattern, read info, etc.) -7. MCU writes response frame to SRAM via I2C -8. MCU resumes LP5562 engines if paused -9. MCU returns to STANDBY -10. Phone reads response from SRAM via NFC +Implement `LedController` for direct GPIO PWM. Skip this milestone entirely if we don't have single-color LEDs to validate against. --- -## Phase 6: Recovery and Safe Mode +## Group E — Future -**Goal**: Provide a way to recover from bad patterns or firmware issues post-implantation. +Deferred until Groups A-D are solid. -### Hall Sensor Recovery +### Custom PCB (SAMD21E) -A hall effect sensor connected to a SAMD21E GPIO pin (EIC-capable) provides recovery triggering by holding a magnet near the implant. +- Port from `xiao_m0` BSP to `atsamd-hal` with `samd21e` feature +- Add `cortex-m-rt = "0.7"`, custom `memory.x` linker script +- Remap SERCOM and pin assignments for 32-pin QFN +- PCB design: SAMD21E (5×5mm) + LP5562 (3×3mm) + NTAG5Link (1.45×1mm) + hall sensor + antenna +- Target: disc <15mm diameter or capsule <12×30mm -**Boot-time check**: -1. On every boot, before reading EEPROM, check hall sensor GPIO state -2. If asserted (magnet present): enter recovery mode -3. If not asserted: normal boot, read EEPROM pattern +### Companion App (React Native) -**Recovery mode behavior**: -- Load hardcoded default pattern: solid white at 25% brightness (~1.5mA/ch) -- Do NOT read EEPROM pattern (in case it's corrupted or causes issues) -- Set recovery flag in NTAG5Link SRAM so phone app can detect recovery state -- Remain awake (do not enter STANDBY) to allow firmware update operations -- Optionally: blink onboard LED in recognizable recovery pattern +- NFC via platform APIs (Android NFC, iOS Core NFC) +- ISO15693 access to NTAG5Link SRAM mailbox +- Pattern editor UI + upload +- Status display (firmware version, current pattern, recovery state) -**Runtime trigger**: Hall sensor also serves as EIC wake source during sleep. Possible uses: -- Cycle to next stored pattern -- Enter diagnostic mode -- Force re-read of EEPROM pattern +### Firmware Update (NFC OTA) ---- - -## Phase 7: Firmware Update Strategy - -### Analysis - -| Approach | Pros | Cons | Feasibility | -|----------|------|------|-------------| -| **NFC pass-through bootloader** | True OTA, no physical access | Complex, slow (256B SRAM chunks for 256KB flash = thousands of transactions), custom bootloader needed | Possible but hard | -| **UF2 bootloader (USB)** | Fast, well-established, standard tooling | Requires USB access — impossible once implanted | Dev phase only | -| **Hybrid (recommended)** | Best of both: UF2 for dev, NFC OTA deferred | Two bootloader systems to maintain | Practical | - -### Recommended Approach - -1. **Development phase**: Use UF2 bootloader on XIAO M0 for rapid iteration. Flash with `cargo hf2 --release`. -2. **Pre-implant**: Thoroughly test final firmware via UF2. Ensure pattern system works perfectly since that's the primary update mechanism post-implant. -3. **Post-implant updates**: The pattern storage in NTAG5Link EEPROM provides the most common update path — new patterns without new firmware. This covers the majority of use cases. -4. **Future NFC OTA**: Implement minimal NFC pass-through bootloader in a later phase if needed. Would require: - - ~8KB reserved flash for bootloader code (BOOTPROT fuse) - - Custom chunked transfer protocol over SRAM mailbox - - CRC verification before committing new firmware - - Fallback to known-good firmware on verification failure - ---- - -## Phase 8: Custom PCB (SAMD21E) - -### Porting from XIAO M0 to SAMD21E18A - -| Change | From (XIAO M0) | To (Custom PCB) | -|--------|----------------|------------------| -| BSP crate | `xiao_m0 = "0.13"` | `atsamd-hal = { version = "0.17", features = ["samd21e"] }` | -| Runtime | Included in BSP | Add `cortex-m-rt = "0.7"` | -| Linker script | Included in BSP | Custom `memory.x` for SAMD21E18A | -| Package | SAMD21G18A (48-pin TQFP) | SAMD21E18A (32-pin QFN, 5×5mm) | -| SERCOM | SERCOM0 (BSP-configured) | Remap to available SERCOM on E variant | -| Pins | BSP pin names (a0, a4, a5, led0) | Direct PAC pin references | - -### PCB Design Targets - -| Component | Package | Size | -|-----------|---------|------| -| SAMD21E18A | QFN-32 | 5×5mm | -| LP5562 | QFN-16 | 3×3mm | -| NTAG5Link (NTP53x2) | XSON-8 | 1.45×1mm | -| Hall sensor | SOT-23 | 2.9×1.6mm | -| NFC antenna | PCB trace loop | ~12-15mm diameter | - -Target form factor: disc under 15mm diameter or capsule under 12×30mm. - ---- - -## Phase 9: Companion App - -### React Native App - -- NFC communication via platform-native APIs (Android NFC, iOS Core NFC) -- ISO15693 NTAG5Link access for SRAM mailbox protocol -- Pattern editor: visual LED color/pattern picker -- Pattern upload: serialize to XBLK format, send via SRAM mailbox -- Status display: firmware version, current pattern info, recovery state -- Testing: use VivoKey RawNFC and DT NFC Identifier as reference +- Custom bootloader in protected flash (~8KB, BOOTPROT fuse) +- Chunked firmware transfer via SRAM mailbox (256B per chunk) +- CRC verification, fallback to known-good firmware +- Development: use UF2 via USB; NFC OTA only if needed post-implant --- @@ -337,8 +272,8 @@ Target form factor: disc under 15mm diameter or capsule under 12×30mm. | Risk | Severity | Mitigation | |------|----------|------------| | Power budget too tight for 4ch LED | High | Limit current to 2-3mA/ch, use pulsed patterns, run fewer channels | -| I2C bus contention (LP5562 + NTAG5) | Medium | Always pause LP5562 before NTAG5 I2C access, resume after | -| EEPROM write endurance (~100K cycles) | Low | Use SRAM for transient communication, EEPROM only for pattern persistence | -| Boot time too slow for user experience | Low | Estimated ~15-20ms total — fast enough to appear instant | -| NFC coupling distance too short | Medium | Optimize antenna, use low field strength EH mode, accept 1-3cm range | -| Firmware bug post-implant with no OTA | High | Hall sensor recovery, thorough pre-implant testing, pattern-only updates cover most needs | +| I2C bus contention (LP5562 + NTAG5) | Medium | Pause LP5562 engines before NTAG5 I2C access, resume after | +| EEPROM write endurance (~100K cycles) | Low | SRAM for transient comms, EEPROM only for pattern persistence | +| Boot time too slow | Low | Estimated ~15-20ms — appears instant to user | +| NFC coupling distance too short | Medium | Optimize antenna, low field strength EH mode, accept 1-3cm | +| Firmware bug post-implant | High | Hall sensor recovery, thorough pre-implant testing, pattern-only updates | diff --git a/docs/STATUS.md b/docs/STATUS.md index 13f59d4..e6ff2f5 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,106 +1,124 @@ # xblink Project Status -**Current Phase**: Phase 0 — Project Setup +**Current Milestone**: M1 — LED Smoke Test (code ready, needs USB cable to flash) **Last Updated**: 2026-03-03 --- -## Phase 0 — Project Setup +## Phase 0 — Project Setup (COMPLETE) -- [x] Initialize git repository -- [x] Initialize Rust project with `cargo init` -- [x] Configure `.cargo/config.toml` for thumbv6m-none-eabi -- [x] Set up `Cargo.toml` with XIAO M0 BSP and embedded-hal 1.0 -- [x] Create `.gitignore` -- [x] Create initial `src/main.rs` (blink + LP5562 EN pin on D0/A0) -- [x] Create project directory structure (`src/led/`, `src/ntag5/`, `src/pattern/`, `docs/`) -- [x] Create README.md -- [x] Create CLAUDE.md -- [x] Create docs/STATUS.md (this file) -- [x] Create docs/DEVELOPMENT_PLAN.md +- [x] Initialize git repository and Rust project +- [x] Configure build target (thumbv6m-none-eabi) +- [x] Create project documentation (README, CLAUDE.md, STATUS, DEVELOPMENT_PLAN) - [x] Verify `cargo build --release` compiles - [x] Initial git commit -## Phase 1 — LP5562 Driver Integration +## Group A — LP5562 Only -- [ ] Copy LP5562 driver from `../ntag5-samd21-lp562/src/lp5562.rs` -- [ ] Define `LedController` trait in `src/led/mod.rs` -- [ ] Wrap LP5562 driver to implement `LedController` -- [ ] Add GPIO EN pin control to LP5562 wrapper (D0/A0 hardware enable) -- [ ] Test LP5562 direct PWM on XIAO M0 + LP5562EVM -- [ ] Test LP5562 engine programs (breathing, color cycling) -- [ ] Implement `SimplePwm` driver as second `LedController` implementation +**Hardware needed**: XIAO M0 + LP5562EVM + mini-USB cable -## Phase 2 — NTAG5Link Driver (MCU-side I2C Slave) +### M1: LED Smoke Test -- [ ] Implement NTAG5Link register map (`src/ntag5/registers.rs`) -- [ ] Implement EEPROM block read/write via I2C (`src/ntag5/eeprom.rs`) -- [ ] Implement SRAM mailbox read/write (`src/ntag5/sram.rs`) +- [x] Copy LP5562 driver from `../ntag5-samd21-lp562/src/lp5562.rs` +- [x] Create `src/led/mod.rs` with re-exports +- [x] Update `main.rs` with I2C init + GPIO EN + LP5562 direct PWM test +- [x] Verify `cargo build --release` compiles +- [ ] Flash and verify RGBW LED channels on LP5562EVM + +### M2: Engine Patterns + +- [ ] Build breathing pattern (1 engine, ramp up/down, branch loop) +- [ ] Build heartbeat pattern (1 engine, fast ramp, slow decay) +- [ ] Build RGB color cycle (3 engines, trigger sync) +- [ ] Verify LP5562 runs patterns autonomously (MCU idle loop) +- [ ] Verify MCU can stop and switch patterns + +### M3: Hardcoded Pattern Library + +- [ ] Define 4-5 const patterns in firmware +- [ ] Implement pattern selector (compile-time or boot-cycle) +- [ ] Reduce LED current to 2-5mA/ch (EH-realistic budget) +- [ ] Document actual engine program structures (informs M5 format design) + +## Group B — Add NTAG5Link + +**Hardware needed**: + NTAG5Link Click board + I2C jumper + +### M4: NTAG5 EEPROM Read/Write + +- [ ] Write `Ntag5Link` driver (`src/ntag5/mod.rs`) +- [ ] Implement register map (`src/ntag5/registers.rs`) +- [ ] Implement EEPROM block read/write - [ ] Implement session register access -- [ ] Test EEPROM read/write with XIAO M0 + NTAG5Link Click board -- [ ] Test SRAM mailbox read/write -- [ ] Verify round-trip: write pattern via PCSC reader, read back on MCU +- [ ] Test round-trip: PCSC writes EEPROM, MCU reads back, displays on LEDs -## Phase 3 — Pattern Storage Format +### M5: Boot-from-EEPROM -- [ ] Design binary pattern format (header + engine programs + LED mapping) -- [ ] Document format in `docs/PATTERN_FORMAT.md` +- [ ] Design binary pattern format (informed by M2-M3 experience) - [ ] Implement pattern deserializer (`src/pattern/mod.rs`) -- [ ] Implement LP5562 engine program builder from pattern data (`src/pattern/engine.rs`) -- [ ] Implement pattern serializer (for MCU-side EEPROM writes) -- [ ] Test: write pattern via PCSC, MCU reads and programs LP5562 +- [ ] Implement engine program builder (`src/pattern/engine.rs`) +- [ ] Update main.rs: boot → read EEPROM → parse → program LP5562 → idle +- [ ] Write Python serializer tool for PCSC pattern uploads -## Phase 4 — Power Management +### M6: SRAM Mailbox -- [ ] Characterize energy harvesting power budget with oscilloscope -- [ ] Determine optimal EH voltage/current settings (target: 3.0V, 9.0mA+) -- [ ] Implement SAMD21E STANDBY sleep mode -- [ ] Configure EIC wake on NTAG5Link FD pin -- [ ] Configure EIC wake on hall sensor GPIO -- [ ] Test full boot → program → sleep → wake cycle -- [ ] Measure total system current draw in each state - -## Phase 5 — NFC Communication Protocol - -- [ ] Design SRAM mailbox protocol (command/response frames) +- [ ] Implement SRAM read/write (`src/ntag5/sram.rs`) +- [ ] Design command/response protocol - [ ] Implement MCU-side protocol handler -- [ ] Implement chunked transfer for patterns > 252 bytes -- [ ] Extend ntag5sensor Python tooling with xblink protocol commands -- [ ] Test pattern upload via PCSC reader -- [ ] Test with VivoKey RawNFC app +- [ ] Test pattern update via NFC without power-cycling -## Phase 6 — Recovery and Safe Mode +## Group C — Power + Recovery -- [ ] Implement hall sensor GPIO input with debounce -- [ ] Implement recovery mode detection at boot -- [ ] Recovery behavior: hardcoded default pattern, skip EEPROM, stay awake -- [ ] Set recovery flag in SRAM for phone app detection -- [ ] Test recovery mode end-to-end +**Hardware needed**: + Hall sensor + multimeter -## Phase 7 — Firmware Update Strategy +### M7: Sleep/Wake -- [ ] Evaluate NFC pass-through bootloader feasibility (see DEVELOPMENT_PLAN.md) -- [ ] Evaluate UF2 + NFC hybrid approach -- [ ] Implement chosen update mechanism -- [ ] Test firmware update end-to-end -- [ ] Document update procedure +- [ ] 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 -## Phase 8 — Custom PCB (SAMD21E) +### M8: Recovery Mode -- [ ] Port from `xiao_m0` BSP to bare `atsamd-hal` with `samd21e` feature -- [ ] Remap pin assignments for SAMD21E18A (32-pin QFN) -- [ ] Provide custom `memory.x` linker script -- [ ] Design PCB schematic (SAMD21E + NTAG5Link + LP5562 + hall sensor + antenna) -- [ ] PCB layout for implant form factor -- [ ] Fabricate and test prototype PCB +- [ ] Wire hall sensor to EIC-capable GPIO +- [ ] Implement boot-time hall sensor check +- [ ] Recovery: load default pattern, skip EEPROM, stay awake +- [ ] Test with magnet -## Phase 9 — Companion App +### M9: Power Characterization -- [ ] React Native project setup -- [ ] NFC communication layer (ISO15693 via platform APIs) -- [ ] Pattern editor UI -- [ ] Pattern upload via NFC -- [ ] Firmware version check / status display +- [ ] Measure current at various LED current settings +- [ ] Test NTAG5Link EH output with phone NFC +- [ ] Find optimal brightness vs EH budget balance +- [ ] 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 + +### Custom PCB (SAMD21E) + +- [ ] Port to `atsamd-hal` with `samd21e` feature +- [ ] Custom `memory.x` linker script +- [ ] PCB design and fabrication + +### Companion App (React Native) + +- [ ] NFC communication + pattern editor + upload + +### Firmware Update (NFC OTA) + +- [ ] Custom bootloader if needed post-implant --- @@ -108,12 +126,11 @@ | Question | Status | Notes | |----------|--------|-------| -| Energy harvesting power budget | TBD | Need oscilloscope measurements with LP5562EVM + NTAG5 Click | -| Optimal LED current per channel | TBD | Must fit within EH budget, probably 2-5mA/ch | -| Firmware update strategy | Under evaluation | NFC pass-through vs UF2 hybrid, see DEVELOPMENT_PLAN.md | -| NTAG5Link I2C slave address | Assumed 0x54 | Verify with Click board when jumper available | -| SAMD21E18A package sourcing | Not started | Verify QFN-32 availability for custom PCB phase | -| Antenna design | Not started | Loop antenna for 13.56MHz, PCB-integrated or wire coil | +| Energy harvesting power budget | TBD | Multimeter measurements in M9 | +| Optimal LED current per channel | TBD | Probably 2-5mA/ch, verify in M9 | +| 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 | ## Hardware Inventory @@ -121,6 +138,8 @@ |------|--------|-------| | Seeed XIAO M0 | Available | Dev board MCU (SAMD21G18A) | | LP5562EVM | Available | TI eval module, RGBW LEDs, I2C addr 0x30 | +| Mini-USB cable | Not on hand | Needed to flash XIAO M0 | | NTAG5 Link Click | Available, partially wired | Missing I2C jumper to XIAO | | Hall effect sensor | Not available | Need to source | | ACR1552 PCSC reader | Available | For ntag5sensor Python tooling | +| Multimeter | Available | For power budget measurements | diff --git a/docs/plans/2026-03-03-plan-redesign-design.md b/docs/plans/2026-03-03-plan-redesign-design.md new file mode 100644 index 0000000..4ccc301 --- /dev/null +++ b/docs/plans/2026-03-03-plan-redesign-design.md @@ -0,0 +1,41 @@ +# Plan Redesign: Waterfall → Milestone-Based Development + +**Date**: 2026-03-03 + +## Problem + +The original 9-phase sequential plan had three issues: + +1. **Premature abstraction** — LedController trait designed before hardware validation +2. **Phases too large** — multi-session phases with unclear stopping points +3. **Hardware dependencies unclear** — couldn't tell what to work on with partial hardware + +## Decision + +Restructured into milestone-based groups organized by hardware availability: + +| Group | Hardware Needed | Milestones | +|-------|----------------|------------| +| **A** | XIAO M0 + LP5562EVM + USB cable | M1 smoke test, M2 engine patterns, M3 pattern library | +| **B** | + NTAG5Link Click board + I2C jumper | M4 EEPROM, M5 boot-from-EEPROM, M6 SRAM mailbox | +| **C** | + Hall sensor + multimeter | M7 sleep/wake, M8 recovery, M9 power characterization | +| **D** | All Group A-C hardware | M10 LedController trait, M11 SimplePwm | +| **E** | Future | Custom PCB, companion app, NFC OTA | + +## Key Principles + +- **Hardware-first**: Get real LEDs blinking before designing abstractions +- **YAGNI**: No traits, formats, or protocols until we need them +- **Small milestones**: Each produces a flashable, testable result (~1 session) +- **Deferred abstraction**: LedController trait moves to M10 (after M1-M9 prove the real interface) +- **Pattern format informed by experience**: Binary format designed in M5 after M2-M3 engine programming experience + +## Current Status + +- Phase 0 (scaffold) complete +- M1 code ready to flash (LP5562 driver integrated, main.rs has RGBW cycle test) +- Blocked on mini-USB cable for flashing + +## Reference + +Full plan: [docs/DEVELOPMENT_PLAN.md](../DEVELOPMENT_PLAN.md) diff --git a/flash_when_ready.sh b/flash_when_ready.sh new file mode 100755 index 0000000..e5472f1 --- /dev/null +++ b/flash_when_ready.sh @@ -0,0 +1,13 @@ +#!/bin/bash +echo "Watching for XIAO bootloader... wiggle your RST wire!" +echo "Will flash as soon as it appears." +while true; do + if lsusb | grep -qi "2886"; then + echo "FOUND IT! Flashing now..." + export PATH="$HOME/.cargo/bin:$PATH" + cargo hf2 --release 2>&1 + echo "Done. Exit code: $?" + break + fi + sleep 0.1 +done diff --git a/src/led/lp5562.rs b/src/led/lp5562.rs new file mode 100644 index 0000000..813a69f --- /dev/null +++ b/src/led/lp5562.rs @@ -0,0 +1,756 @@ +//! LP5562 4-channel RGBW LED driver (I2C) — TI SNVS820B +//! +//! Complete `no_std` driver covering all LP5562 features: +//! direct PWM control, current setting, engine programming, +//! LED mapping, power-save, and clock configuration. +//! +//! Generic over `embedded_hal::i2c::I2c` (1.0). + +use embedded_hal::i2c::I2c; + +// --------------------------------------------------------------------------- +// I2C address constants (7-bit) +// --------------------------------------------------------------------------- + +/// I2C address when ADDR_SEL1:0 = 00 (both pins low). +pub const ADDR_SEL_00: u8 = 0x30; +/// I2C address when ADDR_SEL1:0 = 01. +pub const ADDR_SEL_01: u8 = 0x31; +/// I2C address when ADDR_SEL1:0 = 10. +pub const ADDR_SEL_10: u8 = 0x32; +/// I2C address when ADDR_SEL1:0 = 11 (both pins high). +pub const ADDR_SEL_11: u8 = 0x33; +/// Default address (ADDR_SEL pins both low). +pub const DEFAULT_ADDRESS: u8 = ADDR_SEL_00; + +// --------------------------------------------------------------------------- +// Register addresses +// --------------------------------------------------------------------------- + +/// LP5562 register addresses (Table 26, datasheet page 30). +pub mod reg { + pub const ENABLE: u8 = 0x00; + pub const OP_MODE: u8 = 0x01; + pub const B_PWM: u8 = 0x02; + pub const G_PWM: u8 = 0x03; + pub const R_PWM: u8 = 0x04; + pub const B_CURRENT: u8 = 0x05; + pub const G_CURRENT: u8 = 0x06; + pub const R_CURRENT: u8 = 0x07; + pub const CONFIG: u8 = 0x08; + pub const ENG1_PC: u8 = 0x09; + pub const ENG2_PC: u8 = 0x0A; + pub const ENG3_PC: u8 = 0x0B; + pub const STATUS: u8 = 0x0C; + pub const RESET: u8 = 0x0D; + pub const W_PWM: u8 = 0x0E; + pub const W_CURRENT: u8 = 0x0F; + pub const LED_MAP: u8 = 0x70; + pub const ENG1_PROG_START: u8 = 0x10; + pub const ENG2_PROG_START: u8 = 0x30; + pub const ENG3_PROG_START: u8 = 0x50; +} + +// --------------------------------------------------------------------------- +// Bit-field constants +// --------------------------------------------------------------------------- + +// ENABLE register (0x00) +const ENABLE_LOG_EN: u8 = 1 << 7; +const ENABLE_CHIP_EN: u8 = 1 << 6; +const ENABLE_ENG1_EXEC_SHIFT: u8 = 4; +const ENABLE_ENG2_EXEC_SHIFT: u8 = 2; +const ENABLE_ENG3_EXEC_SHIFT: u8 = 0; + +// OP_MODE register (0x01) +const OP_MODE_ENG1_SHIFT: u8 = 4; +const OP_MODE_ENG2_SHIFT: u8 = 2; +const OP_MODE_ENG3_SHIFT: u8 = 0; + +// CONFIG register (0x08) +const CONFIG_PWM_HF: u8 = 1 << 6; +const CONFIG_PS_EN: u8 = 1 << 5; + +// STATUS register (0x0C) +const STATUS_EXT_CLK_USED: u8 = 1 << 3; +const STATUS_ENG1_INT: u8 = 1 << 2; +const STATUS_ENG2_INT: u8 = 1 << 1; +const STATUS_ENG3_INT: u8 = 1 << 0; + +// LED_MAP register (0x70) +const LED_MAP_W_SHIFT: u8 = 6; +const LED_MAP_R_SHIFT: u8 = 4; +const LED_MAP_G_SHIFT: u8 = 2; +const LED_MAP_B_SHIFT: u8 = 0; + +const RESET_MAGIC: u8 = 0xFF; + +// --------------------------------------------------------------------------- +// Enumerations +// --------------------------------------------------------------------------- + +/// LED channel identifier. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Channel { + Blue, + Green, + Red, + White, +} + +impl Channel { + const fn pwm_reg(self) -> u8 { + match self { + Channel::Blue => reg::B_PWM, + Channel::Green => reg::G_PWM, + Channel::Red => reg::R_PWM, + Channel::White => reg::W_PWM, + } + } + + const fn current_reg(self) -> u8 { + match self { + Channel::Blue => reg::B_CURRENT, + Channel::Green => reg::G_CURRENT, + Channel::Red => reg::R_CURRENT, + Channel::White => reg::W_CURRENT, + } + } + + const fn led_map_shift(self) -> u8 { + match self { + Channel::White => LED_MAP_W_SHIFT, + Channel::Red => LED_MAP_R_SHIFT, + Channel::Green => LED_MAP_G_SHIFT, + Channel::Blue => LED_MAP_B_SHIFT, + } + } +} + +/// Execution engine identifier. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EngineId { + Engine1, + Engine2, + Engine3, +} + +impl EngineId { + const fn exec_shift(self) -> u8 { + match self { + EngineId::Engine1 => ENABLE_ENG1_EXEC_SHIFT, + EngineId::Engine2 => ENABLE_ENG2_EXEC_SHIFT, + EngineId::Engine3 => ENABLE_ENG3_EXEC_SHIFT, + } + } + + const fn mode_shift(self) -> u8 { + match self { + EngineId::Engine1 => OP_MODE_ENG1_SHIFT, + EngineId::Engine2 => OP_MODE_ENG2_SHIFT, + EngineId::Engine3 => OP_MODE_ENG3_SHIFT, + } + } + + const fn pc_reg(self) -> u8 { + match self { + EngineId::Engine1 => reg::ENG1_PC, + EngineId::Engine2 => reg::ENG2_PC, + EngineId::Engine3 => reg::ENG3_PC, + } + } + + const fn prog_start(self) -> u8 { + match self { + EngineId::Engine1 => reg::ENG1_PROG_START, + EngineId::Engine2 => reg::ENG2_PROG_START, + EngineId::Engine3 => reg::ENG3_PROG_START, + } + } +} + +/// Engine execution state (ENABLE register, 2-bit field per engine). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum EngineExec { + /// Hold: finish current command then stop. PC is R/W in this state. + Hold = 0b00, + /// Step: execute one instruction, increment PC, return to Hold. + Step = 0b01, + /// Run: execute from current PC continuously. + Run = 0b10, + /// Execute current instruction once, then return to Hold. + ExecuteOnce = 0b11, +} + +/// Engine operation mode (OP_MODE register, 2-bit field per engine). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum EngineMode { + /// Disabled: resets PC, mapped LED output = 0. + Disabled = 0b00, + /// Load: SRAM writable, all engines held. + Load = 0b01, + /// Run: execution controlled by EXEC bits in ENABLE. + Run = 0b10, + /// Direct: engine PWM from corresponding I2C PWM register. + Direct = 0b11, +} + +/// LED-to-engine mapping (LED_MAP register, 2-bit field per channel). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum LedMapping { + /// Controlled via I2C PWM register. + I2c = 0b00, + /// Controlled by Engine 1. + Engine1 = 0b01, + /// Controlled by Engine 2. + Engine2 = 0b10, + /// Controlled by Engine 3. + Engine3 = 0b11, +} + +/// Clock source (CONFIG register bits 1:0). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum ClockSource { + /// External 32 kHz clock on CLK_32K pin. + External = 0b00, + /// Internal oscillator. + Internal = 0b01, + /// Automatic detection. + Auto = 0b10, +} + +/// PWM output frequency. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PwmFrequency { + /// 256 Hz. + Hz256, + /// 558 Hz. + Hz558, +} + +/// Prescaler for ramp/wait engine commands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum Prescale { + /// Divide by 16: 0.49 ms per step. + Fast = 0, + /// Divide by 512: 15.6 ms per step. + Slow = 1, +} + +/// Ramp direction. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RampDirection { + Up, + Down, +} + +// --------------------------------------------------------------------------- +// Status +// --------------------------------------------------------------------------- + +/// Decoded STATUS register (0x0C, read-only). Reading clears interrupt bits. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Status { + pub ext_clk_used: bool, + pub engine1_int: bool, + pub engine2_int: bool, + pub engine3_int: bool, +} + +impl Status { + fn from_reg(val: u8) -> Self { + Self { + ext_clk_used: val & STATUS_EXT_CLK_USED != 0, + engine1_int: val & STATUS_ENG1_INT != 0, + engine2_int: val & STATUS_ENG2_INT != 0, + engine3_int: val & STATUS_ENG3_INT != 0, + } + } +} + +// --------------------------------------------------------------------------- +// Engine command builder +// --------------------------------------------------------------------------- + +/// Builder for 16-bit engine program commands. +pub struct EngineCommand; + +impl EngineCommand { + /// Ramp/Wait command. + /// + /// - `prescale`: Fast (0.49ms/step) or Slow (15.6ms/step) + /// - `step_time`: 1..=63 (number of prescaled periods per step) + /// - `direction`: Up or Down + /// - `increment`: 0 = wait only, 1..=127 = number of PWM steps + pub const fn ramp_wait( + prescale: Prescale, + step_time: u8, + direction: RampDirection, + increment: u8, + ) -> u16 { + let ps = (prescale as u16) << 14; + let st = ((step_time & 0x3F) as u16) << 8; + let sign = match direction { + RampDirection::Down => 1u16 << 7, + RampDirection::Up => 0, + }; + let inc = (increment & 0x7F) as u16; + ps | st | sign | inc + } + + /// Pure wait (ramp with increment = 0). + pub const fn wait(prescale: Prescale, step_time: u8) -> u16 { + Self::ramp_wait(prescale, step_time, RampDirection::Up, 0) + } + + /// Set PWM to an absolute value (0-255). + pub const fn set_pwm(value: u8) -> u16 { + 0x4000 | (value as u16) + } + + /// Go to Start: reset PC to 0. + pub const fn go_to_start() -> u16 { + 0x0000 + } + + /// Branch to `step_number` with `loop_count` (0 = infinite). + pub const fn branch(loop_count: u8, step_number: u8) -> u16 { + let base: u16 = 0b101 << 13; + let lc = ((loop_count & 0x3F) as u16) << 7; + let sn = (step_number & 0x0F) as u16; + base | lc | sn + } + + /// End program. Optionally fire interrupt and/or reset PWM to 0. + pub const fn end(interrupt: bool, reset_pwm: bool) -> u16 { + let base: u16 = 0b110 << 13; + let int_bit = if interrupt { 1u16 << 12 } else { 0 }; + let rst_bit = if reset_pwm { 1u16 << 11 } else { 0 }; + base | int_bit | rst_bit + } + + /// Trigger for engine synchronization. + /// + /// Bitmask: bit0 = Engine1, bit1 = Engine2, bit2 = Engine3. + pub const fn trigger(wait_engines: u8, send_engines: u8) -> u16 { + let base: u16 = 0b111 << 13; + let wait = ((wait_engines & 0x07) as u16) << 8; + let send = ((send_engines & 0x07) as u16) << 1; + base | wait | send + } +} + +// --------------------------------------------------------------------------- +// Engine program container +// --------------------------------------------------------------------------- + +/// Maximum commands per engine. +pub const MAX_PROGRAM_LEN: usize = 16; + +/// A program to load into engine SRAM (up to 16 commands). +#[derive(Clone, Debug)] +pub struct EngineProgram { + commands: [u16; MAX_PROGRAM_LEN], + len: usize, +} + +impl EngineProgram { + pub const fn new() -> Self { + Self { + commands: [0u16; MAX_PROGRAM_LEN], + len: 0, + } + } + + /// Create from a command slice. Panics if > 16 commands. + pub fn from_commands(cmds: &[u16]) -> Self { + assert!(cmds.len() <= MAX_PROGRAM_LEN); + let mut prog = Self::new(); + let mut i = 0; + while i < cmds.len() { + prog.commands[i] = cmds[i]; + i += 1; + } + prog.len = cmds.len(); + prog + } + + pub fn push(&mut self, cmd: u16) -> Result<(), ()> { + if self.len >= MAX_PROGRAM_LEN { + return Err(()); + } + self.commands[self.len] = cmd; + self.len += 1; + Ok(()) + } + + pub const fn len(&self) -> usize { + self.len + } + + pub const fn is_empty(&self) -> bool { + self.len == 0 + } + + /// Full 32-byte SRAM image (big-endian, unused slots = 0x0000). + pub fn as_bytes(&self) -> [u8; 32] { + let mut buf = [0u8; 32]; + let mut i = 0; + while i < MAX_PROGRAM_LEN { + buf[i * 2] = (self.commands[i] >> 8) as u8; + buf[i * 2 + 1] = self.commands[i] as u8; + i += 1; + } + buf + } +} + +// --------------------------------------------------------------------------- +// Error type +// --------------------------------------------------------------------------- + +#[derive(Debug)] +pub enum Error { + I2c(E), + ProgramTooLong, +} + +impl From for Error { + fn from(e: E) -> Self { + Error::I2c(e) + } +} + +// --------------------------------------------------------------------------- +// Driver struct +// --------------------------------------------------------------------------- + +/// LP5562 four-channel RGBW LED driver. +pub struct Lp5562 { + i2c: I2C, + addr: u8, +} + +impl Lp5562 +where + I2C: I2c, +{ + // ---- Construction ---- + + pub fn new(i2c: I2C, addr: u8) -> Self { + Self { i2c, addr } + } + + pub fn new_default(i2c: I2C) -> Self { + Self::new(i2c, DEFAULT_ADDRESS) + } + + /// Consume the driver and return the I2C bus. + pub fn release(self) -> I2C { + self.i2c + } + + // ---- Low-level register access ---- + + pub fn write_register(&mut self, register: u8, value: u8) -> Result<(), E> { + self.i2c.write(self.addr, &[register, value]) + } + + pub fn read_register(&mut self, register: u8) -> Result { + let mut buf = [0u8]; + self.i2c.write_read(self.addr, &[register], &mut buf)?; + Ok(buf[0]) + } + + fn modify_register(&mut self, register: u8, f: F) -> Result<(), E> + where + F: FnOnce(u8) -> u8, + { + let val = self.read_register(register)?; + self.write_register(register, f(val)) + } + + // ---- Chip enable / disable / reset ---- + + /// Enable the chip (CHIP_EN = 1). + /// + /// Caller MUST wait >= 500 us after this before issuing further commands. + pub fn enable(&mut self) -> Result<(), E> { + self.modify_register(reg::ENABLE, |v| v | ENABLE_CHIP_EN) + } + + pub fn disable(&mut self) -> Result<(), E> { + self.modify_register(reg::ENABLE, |v| v & !ENABLE_CHIP_EN) + } + + pub fn is_enabled(&mut self) -> Result { + let val = self.read_register(reg::ENABLE)?; + Ok(val & ENABLE_CHIP_EN != 0) + } + + /// Software reset (write 0xFF to RESET register). + pub fn reset(&mut self) -> Result<(), E> { + self.write_register(reg::RESET, RESET_MAGIC) + } + + // ---- PWM control ---- + + /// Set PWM duty cycle for a channel (0 = off, 255 = full). + pub fn set_pwm(&mut self, channel: Channel, value: u8) -> Result<(), E> { + self.write_register(channel.pwm_reg(), value) + } + + pub fn get_pwm(&mut self, channel: Channel) -> Result { + self.read_register(channel.pwm_reg()) + } + + /// Set PWM for all channels. B/G/R use auto-increment, W separate. + pub fn set_all_pwm(&mut self, blue: u8, green: u8, red: u8, white: u8) -> Result<(), E> { + self.i2c.write(self.addr, &[reg::B_PWM, blue, green, red])?; + self.write_register(reg::W_PWM, white) + } + + pub fn set_rgb(&mut self, red: u8, green: u8, blue: u8) -> Result<(), E> { + self.i2c.write(self.addr, &[reg::B_PWM, blue, green, red]) + } + + pub fn set_rgbw(&mut self, red: u8, green: u8, blue: u8, white: u8) -> Result<(), E> { + self.set_all_pwm(blue, green, red, white) + } + + pub fn all_off(&mut self) -> Result<(), E> { + self.set_all_pwm(0, 0, 0, 0) + } + + // ---- Current control ---- + + /// Set LED driver current (0-255 = 0.0-25.5 mA in 0.1 mA steps). + /// Default after reset: 0xAF = 17.5 mA. + pub fn set_current(&mut self, channel: Channel, value: u8) -> Result<(), E> { + self.write_register(channel.current_reg(), value) + } + + pub fn get_current(&mut self, channel: Channel) -> Result { + self.read_register(channel.current_reg()) + } + + pub fn set_all_current(&mut self, blue: u8, green: u8, red: u8, white: u8) -> Result<(), E> { + self.i2c.write(self.addr, &[reg::B_CURRENT, blue, green, red])?; + self.write_register(reg::W_CURRENT, white) + } + + // ---- Configuration ---- + + pub fn set_clock_source(&mut self, source: ClockSource) -> Result<(), E> { + self.modify_register(reg::CONFIG, |v| (v & !0x03) | (source as u8)) + } + + pub fn set_pwm_frequency(&mut self, freq: PwmFrequency) -> Result<(), E> { + self.modify_register(reg::CONFIG, |v| match freq { + PwmFrequency::Hz256 => v & !CONFIG_PWM_HF, + PwmFrequency::Hz558 => v | CONFIG_PWM_HF, + }) + } + + pub fn set_power_save(&mut self, enabled: bool) -> Result<(), E> { + self.modify_register(reg::CONFIG, |v| { + if enabled { v | CONFIG_PS_EN } else { v & !CONFIG_PS_EN } + }) + } + + pub fn set_log_mode(&mut self, enabled: bool) -> Result<(), E> { + self.modify_register(reg::ENABLE, |v| { + if enabled { v | ENABLE_LOG_EN } else { v & !ENABLE_LOG_EN } + }) + } + + /// Write the entire CONFIG register at once. + pub fn write_config( + &mut self, + clock: ClockSource, + pwm_hf: bool, + power_save: bool, + ) -> Result<(), E> { + let mut val = clock as u8; + if pwm_hf { val |= CONFIG_PWM_HF; } + if power_save { val |= CONFIG_PS_EN; } + self.write_register(reg::CONFIG, val) + } + + // ---- LED mapping ---- + + pub fn set_led_mapping(&mut self, channel: Channel, mapping: LedMapping) -> Result<(), E> { + let shift = channel.led_map_shift(); + self.modify_register(reg::LED_MAP, |v| { + (v & !(0x03 << shift)) | ((mapping as u8) << shift) + }) + } + + pub fn set_all_led_mappings( + &mut self, + blue: LedMapping, + green: LedMapping, + red: LedMapping, + white: LedMapping, + ) -> Result<(), E> { + let val = ((white as u8) << LED_MAP_W_SHIFT) + | ((red as u8) << LED_MAP_R_SHIFT) + | ((green as u8) << LED_MAP_G_SHIFT) + | ((blue as u8) << LED_MAP_B_SHIFT); + self.write_register(reg::LED_MAP, val) + } + + /// Set all channels to direct I2C control. + pub fn set_all_i2c_controlled(&mut self) -> Result<(), E> { + self.write_register(reg::LED_MAP, 0x00) + } + + // ---- Engine execution state ---- + + /// Set execution state for one engine. + /// + /// Wait >= 488 us between consecutive ENABLE register writes. + pub fn set_engine_exec(&mut self, engine: EngineId, exec: EngineExec) -> Result<(), E> { + let shift = engine.exec_shift(); + self.modify_register(reg::ENABLE, |v| { + (v & !(0x03 << shift)) | ((exec as u8) << shift) + }) + } + + pub fn set_all_engine_exec( + &mut self, + eng1: EngineExec, + eng2: EngineExec, + eng3: EngineExec, + ) -> Result<(), E> { + self.modify_register(reg::ENABLE, |v| { + (v & 0xC0) + | ((eng1 as u8) << ENABLE_ENG1_EXEC_SHIFT) + | ((eng2 as u8) << ENABLE_ENG2_EXEC_SHIFT) + | ((eng3 as u8) << ENABLE_ENG3_EXEC_SHIFT) + }) + } + + // ---- Engine operation mode ---- + + /// Set operation mode for one engine. + /// + /// Wait >= 153 us between consecutive OP_MODE register writes. + /// When transitioning from Run, first set exec to Hold. + pub fn set_engine_mode(&mut self, engine: EngineId, mode: EngineMode) -> Result<(), E> { + let shift = engine.mode_shift(); + self.modify_register(reg::OP_MODE, |v| { + (v & !(0x03 << shift)) | ((mode as u8) << shift) + }) + } + + pub fn set_all_engine_modes( + &mut self, + eng1: EngineMode, + eng2: EngineMode, + eng3: EngineMode, + ) -> Result<(), E> { + let val = ((eng1 as u8) << OP_MODE_ENG1_SHIFT) + | ((eng2 as u8) << OP_MODE_ENG2_SHIFT) + | ((eng3 as u8) << OP_MODE_ENG3_SHIFT); + self.write_register(reg::OP_MODE, val) + } + + // ---- Engine program counter ---- + + /// Set PC (0-15). Engine exec must be Hold. + pub fn set_engine_pc(&mut self, engine: EngineId, pc: u8) -> Result<(), E> { + self.write_register(engine.pc_reg(), pc & 0x0F) + } + + pub fn get_engine_pc(&mut self, engine: EngineId) -> Result { + let val = self.read_register(engine.pc_reg())?; + Ok(val & 0x0F) + } + + // ---- Engine program loading ---- + + /// Load a program into engine SRAM. + /// + /// Sets engine to Load mode internally. Caller must have set exec + /// to Hold first and must wait >= 153 us before the next OP_MODE write. + pub fn load_engine_program( + &mut self, + engine: EngineId, + program: &EngineProgram, + ) -> Result<(), Error> { + self.set_engine_mode(engine, EngineMode::Load) + .map_err(Error::I2c)?; + + let prog_bytes = program.as_bytes(); + let start = engine.prog_start(); + let mut buf = [0u8; 33]; + buf[0] = start; + buf[1..33].copy_from_slice(&prog_bytes); + self.i2c.write(self.addr, &buf).map_err(Error::I2c)?; + Ok(()) + } + + /// Load raw u16 commands into engine SRAM. + pub fn load_engine_commands( + &mut self, + engine: EngineId, + commands: &[u16], + ) -> Result<(), Error> { + if commands.len() > MAX_PROGRAM_LEN { + return Err(Error::ProgramTooLong); + } + let prog = EngineProgram::from_commands(commands); + self.load_engine_program(engine, &prog) + } + + // ---- Status ---- + + /// Read and decode STATUS register. Reading clears interrupt flags. + pub fn read_status(&mut self) -> Result { + let val = self.read_register(reg::STATUS)?; + Ok(Status::from_reg(val)) + } + + pub fn read_status_raw(&mut self) -> Result { + self.read_register(reg::STATUS) + } + + // ---- High-level convenience ---- + + /// Initialize for direct I2C PWM control. + /// + /// Call `enable()` first, then wait >= 500 us, then call this. + pub fn init_direct_control(&mut self, clock: ClockSource) -> Result<(), E> { + self.write_config(clock, false, false)?; + self.set_all_i2c_controlled() + } + + /// Load a program, set engine to Run mode and Run exec state. + /// + /// Engine exec must be Hold before calling. Caller is responsible + /// for timing delays between register writes in production code. + pub fn run_engine( + &mut self, + engine: EngineId, + program: &EngineProgram, + ) -> Result<(), Error> { + self.load_engine_program(engine, program)?; + self.set_engine_mode(engine, EngineMode::Run) + .map_err(Error::I2c)?; + self.set_engine_exec(engine, EngineExec::Run) + .map_err(Error::I2c)?; + Ok(()) + } + + /// Stop an engine: set exec to Hold, then mode to Disabled. + pub fn stop_engine(&mut self, engine: EngineId) -> Result<(), E> { + self.set_engine_exec(engine, EngineExec::Hold)?; + self.set_engine_mode(engine, EngineMode::Disabled) + } +} diff --git a/src/led/mod.rs b/src/led/mod.rs new file mode 100644 index 0000000..70b3a72 --- /dev/null +++ b/src/led/mod.rs @@ -0,0 +1 @@ +pub mod lp5562; diff --git a/src/main.rs b/src/main.rs index 613176b..fb43f02 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,9 @@ use hal::delay::Delay; use hal::prelude::*; use pac::{CorePeripherals, Peripherals}; +mod led; +use led::lp5562::{ClockSource, Lp5562, DEFAULT_ADDRESS}; + #[entry] fn main() -> ! { let mut peripherals = Peripherals::take().unwrap(); @@ -31,13 +34,54 @@ fn main() -> ! { // On-board LED for status indication let mut led: bsp::Led0 = pins.led0.into_push_pull_output(); + led.set_high().unwrap(); // LED off (active low) - // LP5562 hardware enable on D0/A0 + // 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(); // Enable LP5562 + lp_en.set_high().unwrap(); + delay.delay_ms(1u16); // Let LP5562 power stabilize + + // I2C on A4 (SDA) / A5 (SCL) at 400 kHz + let i2c = bsp::i2c_master( + &mut clocks, + 400u32.kHz(), + peripherals.SERCOM0, + &mut peripherals.PM, + pins.a4, + pins.a5, + ); + + let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS); + + // Enable chip, wait for startup (>500 us) + lp.enable().unwrap(); + delay.delay_ms(1u16); + + // Direct I2C PWM control, internal oscillator + lp.init_direct_control(ClockSource::Internal).unwrap(); + + // 5 mA per channel (50 × 0.1 mA) — conservative for EH power budget + lp.set_all_current(50, 50, 50, 50).unwrap(); + + // Board LED on = LP5562 initialized OK + led.set_low().unwrap(); + + // M1 smoke test: cycle through RGBW channels + let on: u8 = 255; + let step: u16 = 500; loop { - led.toggle().unwrap(); - delay.delay_ms(500u16); + lp.set_all_pwm(on, 0, 0, 0).unwrap(); // Blue + delay.delay_ms(step); + lp.set_all_pwm(0, on, 0, 0).unwrap(); // Green + delay.delay_ms(step); + lp.set_all_pwm(0, 0, on, 0).unwrap(); // Red + delay.delay_ms(step); + lp.set_all_pwm(0, 0, 0, on).unwrap(); // White + delay.delay_ms(step); + lp.set_all_pwm(on, on, on, on).unwrap(); // All on + delay.delay_ms(step); + lp.all_off().unwrap(); + delay.delay_ms(step); } }