Compare commits
10 Commits
e45273bc71
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cd728c298 | ||
|
|
f437e4e281 | ||
|
|
43cda8ac3d | ||
|
|
3469944209 | ||
|
|
8dfe9117af | ||
|
|
a6f8c70051 | ||
|
|
2fade7ad8b | ||
|
|
a32c242859 | ||
|
|
acb48902be | ||
|
|
e7bc8834b2 |
72
CLAUDE.md
72
CLAUDE.md
@@ -1,6 +1,6 @@
|
||||
# xblink Development Guide
|
||||
# xBlink Development Guide
|
||||
|
||||
Embedded Rust firmware for a battery-free, NFC-powered LED implant. The NTAG5Link harvests energy from an NFC field and provides EEPROM storage for LED patterns. A SAMD21E MCU reads patterns from EEPROM, programs LP5562 execution engines, then sleeps while the LP5562 autonomously drives RGBW LEDs.
|
||||
Embedded Rust firmware for a battery-free, NFC-powered LED implant. The NTAG5Link harvests energy from an NFC field (1.8V) and provides EEPROM storage for LED patterns. A SAMD21E MCU reads patterns from EEPROM, drives 6 red LEDs directly via TCC hardware PWM through current-limiting resistors, and updates duty cycles from a 50Hz timer ISR. The MCU stays in IDLE sleep during animation.
|
||||
|
||||
## Build Commands
|
||||
|
||||
@@ -19,13 +19,14 @@ 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
|
||||
|
||||
| Crate | Version | Purpose |
|
||||
|-------|---------|---------|
|
||||
| -------------- | ------- | -------------------------------------------------------- |
|
||||
| `xiao_m0` | 0.13 | Board support package (dev board phase) |
|
||||
| `panic-halt` | 0.2 | Minimal panic handler for `no_std` |
|
||||
| `cortex-m` | 0.7 | Cortex-M runtime, `critical-section-single-core` feature |
|
||||
@@ -37,12 +38,13 @@ When porting to bare SAMD21E: replace `xiao_m0` with `atsamd-hal = { version = "
|
||||
|
||||
```
|
||||
src/
|
||||
main.rs # Entry point: boot, pattern load, sleep/wake loop
|
||||
main.rs # Entry point: boot, EEPROM load, TC4 ISR animation, sleep/wake loop
|
||||
led/
|
||||
mod.rs # LP5562 re-exports (LedController trait added later, M10)
|
||||
lp5562.rs # LP5562 driver (from ../ntag5-samd21-lp562/)
|
||||
ntag5/ # NTAG5Link I2C slave driver (M4+)
|
||||
pattern/ # Pattern format and engine builder (M5+)
|
||||
mod.rs # LED module re-exports
|
||||
pwm.rs # TCC0/TCC1 hardware PWM driver for 6 GPIO-direct LEDs
|
||||
lp5562.rs # LP5562 driver (legacy, kept for reference)
|
||||
ntag5/ # NTAG5Link I2C slave driver
|
||||
pattern/ # XBLK v2 format, software pattern engine, waveform LUTs
|
||||
```
|
||||
|
||||
## Architecture Conventions
|
||||
@@ -55,22 +57,31 @@ src/
|
||||
- **`const fn`**: Prefer `const fn` where possible (see `EngineCommand` builder in LP5562 driver).
|
||||
- **No `unsafe`**: Avoid unless absolutely necessary for hardware access.
|
||||
|
||||
## LP5562 Timing Constraints
|
||||
## LED PWM Architecture
|
||||
|
||||
The LP5562 driver does NOT enforce timing delays internally — the caller is responsible:
|
||||
6 LEDs driven directly by SAMD21 TCC hardware PWM through current-limiting resistors (10-47 ohm).
|
||||
|
||||
- **>= 500 us** after `enable()` before any other commands
|
||||
- **>= 488 us** between consecutive ENABLE register writes (engine exec changes)
|
||||
- **>= 153 us** between consecutive OP_MODE register writes (engine mode changes)
|
||||
- When transitioning from Run, set exec to Hold first, then change mode
|
||||
| LED | TCC | Channel | SAMD21E Pin | XIAO Pin |
|
||||
|-----|------|---------|-------------|----------|
|
||||
| 0 | TCC0 | WO[0] | PA04 | A1 |
|
||||
| 1 | TCC0 | WO[1] | PA05 | A2 |
|
||||
| 2 | TCC0 | WO[2] | PA06 | A3* |
|
||||
| 3 | TCC0 | WO[3] | PA07 | A4* |
|
||||
| 4 | TCC1 | WO[0] | PA10 | D2 |
|
||||
| 5 | TCC1 | WO[1] | PA11 | D3 |
|
||||
|
||||
## Key I2C Addresses
|
||||
*PA06/PA07 conflict with I2C on XIAO M0. Dev board uses 4 LEDs (0,1,4,5).
|
||||
|
||||
Both devices share the same I2C bus (A4/A5). Non-conflicting addresses — no bus arbitration issues.
|
||||
- **Animation**: TC4 ISR at 50Hz computes brightness from PatternEngine, writes TCC CC registers
|
||||
- **Power governor**: Proportional scaling ensures total LED current stays within NTAG5 EH budget
|
||||
- **Sleep**: IDLE mode during animation (~0.5mA MCU), STANDBY when no pattern active (~2uA)
|
||||
|
||||
## Key I2C Address
|
||||
|
||||
I2C bus (A4/A5) is used for NTAG5 only (LP5562 removed from design).
|
||||
|
||||
| Device | Address | Notes |
|
||||
|--------|---------|-------|
|
||||
| LP5562 | 0x30 | ADDR_SEL pins both low (LP5562EVM default) |
|
||||
| --------- | ------- | --------------------------------- |
|
||||
| NTAG5Link | 0x54 | Default NTP53x2 I2C slave address |
|
||||
|
||||
## NTAG5Link I2C Register Map (MCU-side)
|
||||
@@ -78,7 +89,7 @@ Both devices share the same I2C bus (A4/A5). Non-conflicting addresses — no bu
|
||||
The MCU accesses NTAG5Link as a standard I2C slave — plain register read/write, NOT the NFC-side ISO15693 custom commands used by the Python ntag5sensor tooling.
|
||||
|
||||
| Region | Address Range | Size | Notes |
|
||||
|--------|--------------|------|-------|
|
||||
| ------------------ | ----------------- | ---------- | --------------------------------- |
|
||||
| Session registers | 0x00-0x06 | 7 bytes | Volatile, runtime config |
|
||||
| Configuration | 0x37-0x3F | Varies | Persistent, EEPROM-backed |
|
||||
| EH config | 0x3D | 1 byte | Energy harvesting voltage/current |
|
||||
@@ -94,23 +105,24 @@ Key config constants (from `../ntag5sensor/vicinity/ntag5link.py`):
|
||||
## Hardware Wiring (Dev Board)
|
||||
|
||||
| XIAO Pin | Connection | Function |
|
||||
|----------|------------|----------|
|
||||
| A4 (SDA) | LP5562 SDA, NTAG5 SDA | Shared I2C data |
|
||||
| A5 (SCL) | LP5562 SCL, NTAG5 SCL | Shared I2C clock |
|
||||
| D0/A0 | LP5562 EN line (open-drain) | Wired-AND with hall sensor, pull-up to VCC |
|
||||
| TBD (EIC) | Hall sensor output | EIC wake + wired-AND to LP5562 EN line |
|
||||
| TBD (EIC) | NTAG5 FD pin | Field detect / SRAM write indication (EIC wake) |
|
||||
|
||||
**LP5562 EN wired-AND**: D0/A0 (open-drain) and hall sensor (open-drain, active-low) both connect to LP5562 EN with a 1M pull-up. Either can force EN low. Magnet kills LEDs at hardware level regardless of MCU state.
|
||||
| -------- | --------------------- | ------------------------------------------- |
|
||||
| A1 (PA04)| LED 0 + resistor | TCC0/WO[0] PWM output |
|
||||
| A2 (PA05)| LED 1 + resistor | TCC0/WO[1] PWM output |
|
||||
| A4 (SDA) | NTAG5 SDA | I2C data |
|
||||
| A5 (SCL) | NTAG5 SCL | I2C clock |
|
||||
| D2 (PA10)| LED 4 + resistor | TCC1/WO[0] PWM output |
|
||||
| D3 (PA11)| LED 5 + resistor | TCC1/WO[1] PWM output |
|
||||
| A1 (PA04)| NTAG5 FD pin | Field detect / SRAM write indication (EIC) |
|
||||
| TBD | Hall sensor output | EIC wake, pattern cycling |
|
||||
|
||||
## Testing
|
||||
|
||||
- **Hardware**: XIAO M0 + LP5562EVM + NTAG5Link Click (when jumpers available)
|
||||
- **Hardware**: XIAO M0 + NTAG5Link Click + 4 LEDs with resistors on A1/A2/D2/D3
|
||||
- **PCSC reader**: Use `../ntag5sensor/` Python tooling with ACR1552 reader
|
||||
- **Phone NFC**: VivoKey RawNFC app for SRAM mailbox testing
|
||||
- **Phone app**: DT NFC Identifier for basic tag info
|
||||
|
||||
## Sibling Projects
|
||||
|
||||
- `../ntag5-samd21-lp562/` — Original LP5562 driver + smoke test (xblink's `src/led/lp5562.rs` is synced from here)
|
||||
- `../ntag5-samd21-lp562/` — Original LP5562 driver (legacy reference)
|
||||
- `../ntag5sensor/` — NTAG5Link command reference (`vicinity/ntag5link.py`), I2C patterns (`vicinity/i2cbase.py`)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# xblink
|
||||
# xBlink
|
||||
|
||||
NFC-powered LED implant using NTAG5Link, SAMD21E, and LP5562.
|
||||
|
||||
|
||||
@@ -179,6 +179,7 @@ Typical 3-engine pattern: 16 + 3×34 + 4 = **122 bytes** (fits easily in 2048B E
|
||||
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
|
||||
6. Verify I2C bus releases cleanly on STANDBY entry (SDA/SCL float high via pull-ups, no bus hold). With SRAM passthrough arbiter, an active I2C bus blocks NFC-side reads — confirmed during M4 testing.
|
||||
|
||||
**Power budget reference**:
|
||||
|
||||
|
||||
675
docs/LP5562_ENGINE_REFERENCE.md
Normal file
675
docs/LP5562_ENGINE_REFERENCE.md
Normal file
@@ -0,0 +1,675 @@
|
||||
# LP5562 Engine Programming Reference
|
||||
|
||||
Reference for the React Native companion app pattern editor. Covers the LP5562's 16-bit command encoding, timing math, and program construction rules. Everything here is verified against the LP5562 datasheet (SNVS820B, Rev Dec 2016) and tested on hardware with the LP5562EVM.
|
||||
|
||||
## Hardware Overview
|
||||
|
||||
The LP5562 has 3 independent execution engines and 4 LED channels (R, G, B, W). Each engine:
|
||||
|
||||
- Has its own SRAM holding up to **16 commands** (16-bit words, 32 bytes)
|
||||
- Runs autonomously on the LP5562's internal 32.768 kHz oscillator
|
||||
- Can be mapped to drive any combination of LED channels
|
||||
- Continues running even when the host MCU is sleeping
|
||||
|
||||
The W channel cannot be engine-driven — it is always I2C-direct.
|
||||
|
||||
### LED Channel Mapping
|
||||
|
||||
Each of the 4 channels (B, G, R, W) can be independently assigned to:
|
||||
|
||||
| Mapping | Value | Meaning |
|
||||
|------------|-------|----------------------------------------------|
|
||||
| `I2c` | 0x00 | Host sets PWM directly via I2C register write |
|
||||
| `Engine1` | 0x01 | Engine 1 controls this channel's PWM |
|
||||
| `Engine2` | 0x02 | Engine 2 controls this channel's PWM |
|
||||
| `Engine3` | 0x03 | Engine 3 controls this channel's PWM |
|
||||
|
||||
Written to the LED_MAP register (0x70). Each channel gets 2 bits.
|
||||
|
||||
**Single-engine patterns** (breathe, heartbeat, pulse): map B, G, R all to the same engine — they breathe in unison. W stays I2C-direct.
|
||||
|
||||
**Multi-engine patterns** (rgb_cycle, color_wash): map each color channel to a different engine — each color animates independently.
|
||||
|
||||
---
|
||||
|
||||
## Command Encoding
|
||||
|
||||
All engine commands are 16-bit words stored in SRAM as big-endian (MSB at lower address). The top 1-3 bits determine the command type.
|
||||
|
||||
### Command Type Identification
|
||||
|
||||
| Bits 15:13 | Type | Description |
|
||||
|------------|------------|----------------------------------|
|
||||
| `0xx` | Ramp/Wait | Bit 15 = 0. Ramp or wait. |
|
||||
| `0xx` | Set PWM | Special case: step_time = 0. |
|
||||
| `000` | Go to Start| All zeros = reset PC to 0. |
|
||||
| `101` | Branch | Jump to step with loop count. |
|
||||
| `110` | End | Stop engine, optional interrupt. |
|
||||
| `111` | Trigger | Inter-engine synchronization. |
|
||||
|
||||
### 1. Ramp/Wait Command
|
||||
|
||||
The most important command. Controls smooth PWM transitions and timed waits.
|
||||
|
||||
```
|
||||
Bit: 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0
|
||||
0 pre- ├──── step_time ────┤ sign ├──── increment ────────────┤
|
||||
scale (6 bits) (7 bits)
|
||||
```
|
||||
|
||||
| Field | Bits | Range | Description |
|
||||
|------------|-------|--------|-----------------------------------------------------------|
|
||||
| (fixed) | 15 | 0 | Always 0 for ramp commands |
|
||||
| prescale | 14 | 0-1 | 0 = Fast (0.49ms), 1 = Slow (15.6ms) |
|
||||
| step_time | 13:8 | 0-63 | Multiplier for prescale period. **0 = Set PWM command.** |
|
||||
| sign | 7 | 0-1 | 0 = ramp up (increase PWM), 1 = ramp down (decrease PWM) |
|
||||
| increment | 6:0 | 0-127 | Number of steps - 1. **0 = pure wait (no PWM change).** |
|
||||
|
||||
**Encoding formula:**
|
||||
|
||||
```
|
||||
word = (prescale << 14) | (step_time << 8) | (sign << 7) | increment
|
||||
```
|
||||
|
||||
#### Timing Math
|
||||
|
||||
Each ramp/wait command executes `(increment + 1)` steps. Each step takes:
|
||||
|
||||
```
|
||||
step_duration = step_time × prescale_period
|
||||
```
|
||||
|
||||
Where:
|
||||
|
||||
| Prescale | Period | Source |
|
||||
|----------|----------|----------------------------------------|
|
||||
| Fast (0) | 0.49 ms | 32768 Hz / 16 = 2048 Hz |
|
||||
| Slow (1) | 15.6 ms | 32768 Hz / 512 = 64 Hz |
|
||||
|
||||
Total command duration:
|
||||
|
||||
```
|
||||
command_duration = (increment + 1) × step_time × prescale_period
|
||||
```
|
||||
|
||||
Maximum single-command duration: 128 steps × 63 × 15.6ms = **125.2 seconds**.
|
||||
|
||||
Minimum step time (fastest visible change): 1 × 0.49ms = **0.49ms**.
|
||||
|
||||
#### Critical: The Increment Field
|
||||
|
||||
**The `increment` field does NOT mean "PWM units per step." It means "total number of steps minus one."**
|
||||
|
||||
Each step always changes PWM by exactly **1 unit**. The increment field controls **how many** of those 1-unit steps occur.
|
||||
|
||||
| increment | Actual steps | PWM change | Notes |
|
||||
|-----------|-------------|------------|--------------------------|
|
||||
| 0 | 0 (wait) | 0 | Pure wait, no PWM change |
|
||||
| 1 | 2 | 2 | Almost no visible change |
|
||||
| 4 | 5 | 5 | Datasheet example |
|
||||
| 63 | 64 | 64 | Quarter of full range |
|
||||
| 127 | 128 | 128 | **Maximum: half of 0-255** |
|
||||
|
||||
**A single ramp command can change PWM by at most 128 units** (half the full range). To ramp from 0 to 255, you need **two consecutive ramp commands**.
|
||||
|
||||
When PWM reaches the boundary (0 or 255), the command continues executing remaining steps but PWM stays clamped at the boundary.
|
||||
|
||||
#### Full-Range Ramp Pattern
|
||||
|
||||
To ramp 0→255 smoothly:
|
||||
|
||||
```
|
||||
ramp_wait(Slow, 1, Up, 127) // 128 steps: PWM 0→128, ~2.0s
|
||||
ramp_wait(Slow, 1, Up, 127) // 128 steps: PWM 128→255, ~2.0s
|
||||
```
|
||||
|
||||
To ramp 255→0 smoothly:
|
||||
|
||||
```
|
||||
ramp_wait(Slow, 1, Down, 127) // 128 steps: PWM 255→127, ~2.0s
|
||||
ramp_wait(Slow, 1, Down, 127) // 128 steps: PWM 127→0, ~2.0s
|
||||
```
|
||||
|
||||
Total: 256 smooth 1-unit steps in ~4.0s. Uses 2 of 16 command slots per direction.
|
||||
|
||||
#### Pure Wait (Timed Delay)
|
||||
|
||||
When increment = 0, the command becomes a wait (no PWM change):
|
||||
|
||||
```
|
||||
word = (prescale << 14) | (step_time << 8) | 0x00
|
||||
```
|
||||
|
||||
Wait duration = `step_time × prescale_period`. Examples:
|
||||
|
||||
| Prescale | step_time | Duration |
|
||||
|----------|-----------|----------|
|
||||
| Fast | 20 | 9.8 ms |
|
||||
| Fast | 40 | 19.6 ms |
|
||||
| Slow | 32 | 499 ms |
|
||||
| Slow | 48 | 749 ms |
|
||||
| Slow | 63 | 983 ms |
|
||||
|
||||
For waits longer than ~1s, chain multiple wait commands.
|
||||
|
||||
### 2. Set PWM Command
|
||||
|
||||
Instantly sets PWM to an absolute value. This is the ramp command with step_time = 0.
|
||||
|
||||
```
|
||||
Bit: 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0
|
||||
0 1 0 0 0 0 0 0 ├──────── PWM value ────────────┤
|
||||
(8 bits, 0-255)
|
||||
```
|
||||
|
||||
**Encoding:** `word = 0x4000 | pwm_value`
|
||||
|
||||
| PWM value | Word | Effect |
|
||||
|-----------|--------|------------------|
|
||||
| 0 | 0x4000 | LED off |
|
||||
| 128 | 0x4080 | Half brightness |
|
||||
| 255 | 0x40FF | Full brightness |
|
||||
|
||||
Executes in 16 clock cycles (~488µs). Use for instant on/off in heartbeat-style patterns.
|
||||
|
||||
### 3. Go to Start
|
||||
|
||||
Resets program counter to 0. All-zero word.
|
||||
|
||||
```
|
||||
word = 0x0000
|
||||
```
|
||||
|
||||
Note: default value for all SRAM slots, so unused slots act as Go-to-Start.
|
||||
|
||||
### 4. Branch Command
|
||||
|
||||
Jump to a step number, with optional loop count.
|
||||
|
||||
```
|
||||
Bit: 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0
|
||||
1 0 1 ├──── loop_count ──────────┤ x x ├─ step_num ─┤
|
||||
(6 bits, 0-63) (4 bits)
|
||||
```
|
||||
|
||||
| Field | Bits | Range | Description |
|
||||
|------------|-------|-------|------------------------------------------|
|
||||
| (fixed) | 15:13 | 101 | Branch command identifier |
|
||||
| loop_count | 12:7 | 0-63 | 0 = infinite loop, 1-63 = finite count |
|
||||
| (don't care)| 6:4 | x | Ignored by hardware |
|
||||
| step_number| 3:0 | 0-15 | Target instruction address |
|
||||
|
||||
**Encoding:** `word = 0xA000 | (loop_count << 7) | step_number`
|
||||
|
||||
**Infinite loop to start:** `branch(0, 0)` → `0xA000`. This is the standard way to make a pattern repeat forever.
|
||||
|
||||
Nested loops are supported. The hardware maintains loop counters independently per nesting level.
|
||||
|
||||
### 5. End Command
|
||||
|
||||
Stops engine execution, optionally fires interrupt and/or resets PWM.
|
||||
|
||||
```
|
||||
Bit: 15 14 13 12 11 10 ... 0
|
||||
1 1 0 int reset x ... x
|
||||
```
|
||||
|
||||
| Field | Bit | Description |
|
||||
|--------|-----|--------------------------------------------------|
|
||||
| int | 12 | 1 = fire interrupt (sets STATUS register bit) |
|
||||
| reset | 11 | 1 = reset PWM to 0, 0 = keep current PWM value |
|
||||
|
||||
**Encoding:** `word = 0xC000 | (int << 12) | (reset << 11)`
|
||||
|
||||
After End, the engine returns to Hold state and PC resets to 0. Not typically used in looping patterns (use Branch instead).
|
||||
|
||||
### 6. Trigger Command
|
||||
|
||||
Synchronize execution between engines. One engine can send a trigger that another engine waits for.
|
||||
|
||||
```
|
||||
Bit: 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0
|
||||
1 1 1 x x x ├─ wait ──┤ x x x ├── send ──────┤ x
|
||||
E3 E2 E1 E3 E2 E1
|
||||
```
|
||||
|
||||
| Field | Bits | Description |
|
||||
|--------------|------|------------------------------------------------------|
|
||||
| wait_engines | 10:8 | Bitmask: which engines to wait for before continuing |
|
||||
| send_engines | 3:1 | Bitmask: which engines to notify |
|
||||
|
||||
Bitmask mapping: bit 0 = Engine1, bit 1 = Engine2, bit 2 = Engine3.
|
||||
|
||||
**Encoding:** `word = 0xE000 | (wait_engines << 8) | (send_engines << 1)`
|
||||
|
||||
A single trigger command can both wait AND send. The engine blocks until all wait conditions are met, then sends its trigger signals.
|
||||
|
||||
---
|
||||
|
||||
## Timing Reference Table
|
||||
|
||||
Pre-computed durations for common configurations.
|
||||
|
||||
### Ramp durations (full 0-255 sweep = two commands)
|
||||
|
||||
| Prescale | step_time | Steps per cmd | Duration per cmd | Full 0→255 |
|
||||
|----------|-----------|---------------|------------------|------------|
|
||||
| Slow | 1 | 128 | 2.0 s | **4.0 s** |
|
||||
| Slow | 2 | 128 | 4.0 s | **8.0 s** |
|
||||
| Slow | 4 | 128 | 8.0 s | **16.0 s** |
|
||||
| Slow | 8 | 128 | 15.9 s | **31.9 s** |
|
||||
| Fast | 1 | 128 | 62.7 ms | **125 ms** |
|
||||
| Fast | 4 | 128 | 251 ms | **502 ms** |
|
||||
| Fast | 10 | 128 | 627 ms | **1.25 s** |
|
||||
|
||||
### Wait durations
|
||||
|
||||
| Prescale | step_time | Duration |
|
||||
|----------|-----------|------------|
|
||||
| Fast | 1 | 0.49 ms |
|
||||
| Fast | 63 | 30.9 ms |
|
||||
| Slow | 1 | 15.6 ms |
|
||||
| Slow | 32 | 499 ms |
|
||||
| Slow | 63 | 983 ms |
|
||||
|
||||
### Maximum durations
|
||||
|
||||
| Type | Value |
|
||||
|-------------------|--------------------------|
|
||||
| Single ramp cmd | 128 × 63 × 15.6ms = 125s |
|
||||
| Single wait cmd | 63 × 15.6ms = 0.98s |
|
||||
| Full 0→255 ramp | 2 × 125s = 250s (max) |
|
||||
|
||||
---
|
||||
|
||||
## Program Construction Rules
|
||||
|
||||
### Hard Limits
|
||||
|
||||
| Constraint | Value | Notes |
|
||||
|-----------------------------------|-------|--------------------------------------------------|
|
||||
| Commands per engine | 16 | 16 × 2 bytes = 32 bytes SRAM per engine |
|
||||
| Engines | 3 | Engine 1, 2, 3 |
|
||||
| Max increment per ramp | 127 | = 128 PWM steps = half of 0-255 range |
|
||||
| Max step_time | 63 | 6-bit field |
|
||||
| Max loop_count (branch) | 63 | 0 = infinite |
|
||||
| Max step_number (branch target) | 15 | 4-bit field |
|
||||
| PWM range | 0-255 | 8-bit, linear or logarithmic scale |
|
||||
|
||||
### Design Guidelines
|
||||
|
||||
1. **Always use `increment=127`** for smooth ramps. Lower values make the ramp shorter (fewer steps), not smoother.
|
||||
|
||||
2. **Two ramp commands per direction** for full-range sweeps. One command only covers 128 of the 255 PWM units.
|
||||
|
||||
3. **End every looping program with `branch(0, 0)`** — infinite loop back to step 0.
|
||||
|
||||
4. **Budget command slots carefully.** A full breathe cycle needs: 2 (ramp up) + 2 (ramp down) + 1 (wait) + 1 (branch) = 6 slots out of 16.
|
||||
|
||||
5. **Use Set PWM for instant transitions.** Ramp commands always take time; `set_pwm` is near-instant (~488µs).
|
||||
|
||||
6. **Chain waits for long pauses.** Max single wait is ~1s (Slow, 63). For longer pauses, use two consecutive waits.
|
||||
|
||||
7. **Triggers are blocking.** A wait-for-trigger command blocks the engine until the trigger is received. Design trigger chains to avoid deadlocks.
|
||||
|
||||
8. **Unused SRAM slots default to Go-to-Start (0x0000).** If a branch jumps past the last command, execution will hit a go-to-start and loop from the beginning.
|
||||
|
||||
### Pattern Switching
|
||||
|
||||
When switching between patterns, the LP5562 must be fully reset to avoid residual PWM state:
|
||||
|
||||
1. Write `0xFF` to Reset register (0x0D) — software reset, all registers return to defaults
|
||||
2. Wait 1ms
|
||||
3. Re-enable the chip (write to Enable register)
|
||||
4. Wait 500µs (startup delay)
|
||||
5. Reconfigure: clock source, LED current, LED mapping
|
||||
6. Load and run new engine programs
|
||||
|
||||
Without reset, engines that previously held a non-zero PWM value will keep that brightness visible during the new pattern's loading phase.
|
||||
|
||||
---
|
||||
|
||||
## Reference Patterns
|
||||
|
||||
Five verified patterns with their engine programs and timing.
|
||||
|
||||
### 1. Breathe
|
||||
|
||||
Single engine, all RGB channels in unison. Smooth triangle wave.
|
||||
|
||||
| Property | Value |
|
||||
|----------------|-----------|
|
||||
| Engines used | 1 |
|
||||
| Commands | 6 / 16 |
|
||||
| Cycle time | ~8.75s |
|
||||
| LED mapping | B,G,R → E1; W → I2C |
|
||||
|
||||
```
|
||||
Step Command Hex Duration PWM range
|
||||
─────────────────────────────────────────────────────────────────────────
|
||||
0 ramp_wait(Slow, 1, Up, 127) 0x417F 2.0s 0 → 128
|
||||
1 ramp_wait(Slow, 1, Up, 127) 0x417F 2.0s 128 → 255
|
||||
2 ramp_wait(Slow, 1, Down, 127) 0x41FF 2.0s 255 → 127
|
||||
3 ramp_wait(Slow, 1, Down, 127) 0x41FF 2.0s 127 → 0
|
||||
4 wait(Slow, 48) 0x7000 0.75s (hold at 0)
|
||||
5 branch(0, 0) 0xA000 instant (loop)
|
||||
```
|
||||
|
||||
### 2. Heartbeat
|
||||
|
||||
Single engine, double-pulse with long rest. Mimics cardiac rhythm.
|
||||
|
||||
| Property | Value |
|
||||
|----------------|-----------|
|
||||
| Engines used | 1 |
|
||||
| Commands | 10 / 16 |
|
||||
| Cycle time | ~1.56s |
|
||||
| LED mapping | B,G,R → E1; W → I2C |
|
||||
|
||||
```
|
||||
Step Command Hex Duration Effect
|
||||
─────────────────────────────────────────────────────────────────────────
|
||||
0 set_pwm(255) 0x40FF instant First beat ON
|
||||
1 wait(Fast, 20) 0x0A00 9.8ms Hold bright
|
||||
2 set_pwm(0) 0x4000 instant First beat OFF
|
||||
3 wait(Fast, 40) 0x1400 19.6ms Inter-beat gap
|
||||
4 set_pwm(255) 0x40FF instant Second beat ON
|
||||
5 wait(Fast, 20) 0x0A00 9.8ms Hold bright
|
||||
6 set_pwm(0) 0x4000 instant Second beat OFF
|
||||
7 wait(Slow, 63) 0x5F00 983ms Long rest pt.1
|
||||
8 wait(Slow, 32) 0x6000 499ms Long rest pt.2
|
||||
9 branch(0, 0) 0xA000 instant Loop forever
|
||||
```
|
||||
|
||||
### 3. Slow Pulse
|
||||
|
||||
Single engine, very gentle ramp. Meditative pace.
|
||||
|
||||
| Property | Value |
|
||||
|----------------|-----------|
|
||||
| Engines used | 1 |
|
||||
| Commands | 7 / 16 |
|
||||
| Cycle time | ~33.5s |
|
||||
| LED mapping | B,G,R → E1; W → I2C |
|
||||
|
||||
```
|
||||
Step Command Hex Duration PWM range
|
||||
─────────────────────────────────────────────────────────────────────────
|
||||
0 ramp_wait(Slow, 4, Up, 127) 0x447F 8.0s 0 → 128
|
||||
1 ramp_wait(Slow, 4, Up, 127) 0x447F 8.0s 128 → 255
|
||||
2 wait(Slow, 32) 0x6000 499ms Hold at peak
|
||||
3 ramp_wait(Slow, 4, Down, 127) 0x44FF 8.0s 255 → 127
|
||||
4 ramp_wait(Slow, 4, Down, 127) 0x44FF 8.0s 127 → 0
|
||||
5 wait(Slow, 63) 0x5F00 983ms Pause at bottom
|
||||
6 branch(0, 0) 0xA000 instant Loop forever
|
||||
```
|
||||
|
||||
### 4. RGB Cycle
|
||||
|
||||
Three engines, trigger-synchronized phase offset. Colors appear sequentially.
|
||||
|
||||
| Property | Value |
|
||||
|----------------|-----------|
|
||||
| Engines used | 3 |
|
||||
| Commands | 7 + 8 + 7 = 22 / 48 |
|
||||
| Cycle time | ~9s per engine |
|
||||
| LED mapping | B → E1; G → E2; R → E3; W → I2C |
|
||||
|
||||
**Trigger chain:** E1 ramps up, sends trigger → E2 starts, ramps up, sends trigger → E3 starts. Creates a staggered color sequence.
|
||||
|
||||
**Engine 1 (Blue):**
|
||||
```
|
||||
Step Command Hex Duration
|
||||
─────────────────────────────────────────────────────────────
|
||||
0 ramp_wait(Slow, 1, Up, 127) 0x417F 2.0s
|
||||
1 ramp_wait(Slow, 1, Up, 127) 0x417F 2.0s
|
||||
2 trigger(send=E2) 0xE004 instant
|
||||
3 ramp_wait(Slow, 1, Down, 127) 0x41FF 2.0s
|
||||
4 ramp_wait(Slow, 1, Down, 127) 0x41FF 2.0s
|
||||
5 wait(Slow, 63) 0x5F00 983ms
|
||||
6 branch(0, 0) 0xA000 instant
|
||||
```
|
||||
|
||||
**Engine 2 (Green):**
|
||||
```
|
||||
Step Command Hex Duration
|
||||
─────────────────────────────────────────────────────────────
|
||||
0 trigger(wait=E1) 0xE100 blocks
|
||||
1 ramp_wait(Slow, 1, Up, 127) 0x417F 2.0s
|
||||
2 ramp_wait(Slow, 1, Up, 127) 0x417F 2.0s
|
||||
3 trigger(send=E3) 0xE008 instant
|
||||
4 ramp_wait(Slow, 1, Down, 127) 0x41FF 2.0s
|
||||
5 ramp_wait(Slow, 1, Down, 127) 0x41FF 2.0s
|
||||
6 wait(Slow, 32) 0x6000 499ms
|
||||
7 branch(0, 0) 0xA000 instant
|
||||
```
|
||||
|
||||
**Engine 3 (Red):**
|
||||
```
|
||||
Step Command Hex Duration
|
||||
─────────────────────────────────────────────────────────────
|
||||
0 trigger(wait=E2) 0xE200 blocks
|
||||
1 ramp_wait(Slow, 1, Up, 127) 0x417F 2.0s
|
||||
2 ramp_wait(Slow, 1, Up, 127) 0x417F 2.0s
|
||||
3 ramp_wait(Slow, 1, Down, 127) 0x41FF 2.0s
|
||||
4 ramp_wait(Slow, 1, Down, 127) 0x41FF 2.0s
|
||||
5 wait(Slow, 32) 0x6000 499ms
|
||||
6 branch(0, 0) 0xA000 instant
|
||||
```
|
||||
|
||||
### 5. Color Wash
|
||||
|
||||
Three engines, free-running at different rates. Colors drift in and out of phase, creating evolving blended hues.
|
||||
|
||||
| Property | Value |
|
||||
|----------------|-----------|
|
||||
| Engines used | 3 |
|
||||
| Commands | 5 + 6 + 6 = 17 / 48 |
|
||||
| Cycle time | E1: ~8.0s, E2: ~8.5s, E3: ~9.0s |
|
||||
| LED mapping | B → E1; G → E2; R → E3; W → I2C |
|
||||
|
||||
**Phase drift mechanism:** Each engine has a slightly different cycle length (due to different pause durations at the bottom of the ramp). Over time, the engines desynchronize, causing the color mix to continuously evolve. No triggers needed.
|
||||
|
||||
**Engine 1 (Blue) — 8.0s cycle, no pause:**
|
||||
```
|
||||
Step Command Hex Duration
|
||||
─────────────────────────────────────────────────────────────
|
||||
0 ramp_wait(Slow, 1, Up, 127) 0x417F 2.0s
|
||||
1 ramp_wait(Slow, 1, Up, 127) 0x417F 2.0s
|
||||
2 ramp_wait(Slow, 1, Down, 127) 0x41FF 2.0s
|
||||
3 ramp_wait(Slow, 1, Down, 127) 0x41FF 2.0s
|
||||
4 branch(0, 0) 0xA000 instant
|
||||
```
|
||||
|
||||
**Engine 2 (Green) — 8.5s cycle, 0.5s pause:**
|
||||
```
|
||||
Step Command Hex Duration
|
||||
─────────────────────────────────────────────────────────────
|
||||
0 ramp_wait(Slow, 1, Up, 127) 0x417F 2.0s
|
||||
1 ramp_wait(Slow, 1, Up, 127) 0x417F 2.0s
|
||||
2 ramp_wait(Slow, 1, Down, 127) 0x41FF 2.0s
|
||||
3 ramp_wait(Slow, 1, Down, 127) 0x41FF 2.0s
|
||||
4 wait(Slow, 32) 0x6000 499ms
|
||||
5 branch(0, 0) 0xA000 instant
|
||||
```
|
||||
|
||||
**Engine 3 (Red) — 9.0s cycle, 1.0s pause:**
|
||||
```
|
||||
Step Command Hex Duration
|
||||
─────────────────────────────────────────────────────────────
|
||||
0 ramp_wait(Slow, 1, Up, 127) 0x417F 2.0s
|
||||
1 ramp_wait(Slow, 1, Up, 127) 0x417F 2.0s
|
||||
2 ramp_wait(Slow, 1, Down, 127) 0x41FF 2.0s
|
||||
3 ramp_wait(Slow, 1, Down, 127) 0x41FF 2.0s
|
||||
4 wait(Slow, 63) 0x5F00 983ms
|
||||
5 branch(0, 0) 0xA000 instant
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## App Pattern Editor: Implementation Notes
|
||||
|
||||
### Binary Representation
|
||||
|
||||
A pattern sent from the app to the implant (via NFC SRAM mailbox) needs:
|
||||
|
||||
1. **Per-engine program**: Array of up to 16 u16 values (big-endian)
|
||||
2. **LED channel mapping**: Which engine drives which channel (4 × 2-bit values)
|
||||
3. **LED current**: Per-channel current setting (0-255 maps to 0-25.5mA)
|
||||
|
||||
Minimum data per pattern: 3 engines × 32 bytes + 1 byte mapping + 4 bytes current = **101 bytes**. Fits easily in the NTAG5Link's 256-byte SRAM mailbox.
|
||||
|
||||
### Encoding Commands in JavaScript/TypeScript
|
||||
|
||||
```typescript
|
||||
// Prescale constants
|
||||
const PRESCALE_FAST = 0; // 0.49ms per step
|
||||
const PRESCALE_SLOW = 1; // 15.6ms per step
|
||||
|
||||
// Ramp/Wait: smooth PWM transition or timed delay
|
||||
function rampWait(
|
||||
prescale: 0 | 1,
|
||||
stepTime: number, // 1-63
|
||||
direction: 'up' | 'down',
|
||||
increment: number // 0-127 (0 = wait only)
|
||||
): number {
|
||||
const ps = (prescale & 1) << 14;
|
||||
const st = (stepTime & 0x3F) << 8;
|
||||
const sign = direction === 'down' ? (1 << 7) : 0;
|
||||
const inc = increment & 0x7F;
|
||||
return ps | st | sign | inc;
|
||||
}
|
||||
|
||||
// Pure wait (no PWM change)
|
||||
function wait(prescale: 0 | 1, stepTime: number): number {
|
||||
return rampWait(prescale, stepTime, 'up', 0);
|
||||
}
|
||||
|
||||
// Set PWM to absolute value (instant)
|
||||
function setPwm(value: number): number {
|
||||
return 0x4000 | (value & 0xFF);
|
||||
}
|
||||
|
||||
// Branch (jump to step, with loop count)
|
||||
function branch(loopCount: number, stepNumber: number): number {
|
||||
return 0xA000 | ((loopCount & 0x3F) << 7) | (stepNumber & 0x0F);
|
||||
}
|
||||
|
||||
// End program
|
||||
function end(interrupt: boolean, resetPwm: boolean): number {
|
||||
return 0xC000 | (interrupt ? (1 << 12) : 0) | (resetPwm ? (1 << 11) : 0);
|
||||
}
|
||||
|
||||
// Trigger (inter-engine sync)
|
||||
// engineBits: bit0=E1, bit1=E2, bit2=E3
|
||||
function trigger(waitEngines: number, sendEngines: number): number {
|
||||
return 0xE000 | ((waitEngines & 0x07) << 8) | ((sendEngines & 0x07) << 1);
|
||||
}
|
||||
```
|
||||
|
||||
### Duration Calculator
|
||||
|
||||
```typescript
|
||||
const PRESCALE_PERIOD = [0.49, 15.6]; // ms per step for Fast, Slow
|
||||
|
||||
function rampDurationMs(
|
||||
prescale: 0 | 1,
|
||||
stepTime: number,
|
||||
increment: number
|
||||
): number {
|
||||
if (increment === 0) {
|
||||
// Wait command: single step
|
||||
return stepTime * PRESCALE_PERIOD[prescale];
|
||||
}
|
||||
const steps = increment + 1;
|
||||
return steps * stepTime * PRESCALE_PERIOD[prescale];
|
||||
}
|
||||
|
||||
// Full 0→255 ramp using two commands
|
||||
function fullRampDurationMs(prescale: 0 | 1, stepTime: number): number {
|
||||
return 2 * rampDurationMs(prescale, stepTime, 127);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern Validation
|
||||
|
||||
Before sending a pattern to the implant, validate:
|
||||
|
||||
```typescript
|
||||
function validateProgram(commands: number[]): string | null {
|
||||
if (commands.length > 16) return 'Max 16 commands per engine';
|
||||
if (commands.length === 0) return 'Program cannot be empty';
|
||||
|
||||
// Check for infinite loop or end command
|
||||
const hasLoop = commands.some((cmd, i) => {
|
||||
const isGoToStart = cmd === 0x0000;
|
||||
const isBranch = (cmd >> 13) === 0b101;
|
||||
const isEnd = (cmd >> 13) === 0b110;
|
||||
return isGoToStart || isBranch || isEnd;
|
||||
});
|
||||
if (!hasLoop) return 'Program must contain a branch, end, or go-to-start';
|
||||
|
||||
return null; // valid
|
||||
}
|
||||
```
|
||||
|
||||
### Preview Animation
|
||||
|
||||
To show a real-time preview in the app, simulate engine execution:
|
||||
|
||||
```typescript
|
||||
function simulateEngine(commands: number[]): Generator<number> {
|
||||
// Returns PWM values over time, one per step
|
||||
// See timing math above for step duration
|
||||
// Key: each ramp step changes PWM by exactly 1 unit
|
||||
// Key: PWM clamps at 0 and 255 boundaries
|
||||
}
|
||||
```
|
||||
|
||||
The simulation should model:
|
||||
- Current PWM value (starts at 0 after reset)
|
||||
- Ramp direction and step count
|
||||
- Boundary clamping (0 and 255)
|
||||
- Branch targets and loop counts
|
||||
- Set PWM instant jumps
|
||||
- Wait pauses (no PWM change)
|
||||
|
||||
For multi-engine patterns with triggers, simulate all three engines and synchronize on trigger send/wait pairs.
|
||||
|
||||
---
|
||||
|
||||
## Pitfalls and Lessons Learned
|
||||
|
||||
### increment=1 does NOT mean "smooth"
|
||||
|
||||
The most common mistake. `increment=1` means **2 total steps** — the ramp finishes almost instantly. For smooth ramps, always use `increment=127` (128 steps, the maximum). This is counterintuitive because "increment of 1" sounds like "change by 1 per step," but that's not what the field means. Every step always changes by 1; the field controls how many steps occur.
|
||||
|
||||
### step_time=0 is Set PWM, not a fast ramp
|
||||
|
||||
The ramp/wait command and set_pwm command share the same encoding space. When `step_time=0`, the hardware interprets bits 7:0 as an absolute PWM value (8 bits) instead of sign + increment (1 + 7 bits). Never pass `step_time=0` to a ramp function — use the dedicated `set_pwm` function instead.
|
||||
|
||||
### Software reset is essential between patterns
|
||||
|
||||
Without a full software reset (0xFF to register 0x0D), residual PWM values from the previous pattern remain visible on LED channels. Toggling the EN pin puts the chip in standby but does NOT clear register state (VDD stays up, no POR). Always use software reset when switching patterns.
|
||||
|
||||
### Engine load order matters
|
||||
|
||||
When loading multiple engines, each call to `run_engine` does a read-modify-write on the shared OP_MODE and ENABLE registers. The LP5562 requires:
|
||||
- >= 153µs between consecutive OP_MODE register writes
|
||||
- >= 488µs between consecutive ENABLE register writes
|
||||
|
||||
In practice, the I2C transaction overhead (~50-100µs per transfer at 400kHz) plus program data write (~660µs for 33 bytes) provides sufficient delay. A 200µs explicit delay between engine loads adds safety margin.
|
||||
|
||||
### Trigger deadlocks
|
||||
|
||||
If Engine A waits for a trigger from Engine B, but Engine B also waits for a trigger from Engine A, both will block forever. Design trigger chains as DAGs — one engine starts freely, triggers the next, which triggers the next. The last engine loops back independently.
|
||||
|
||||
### W channel is always I2C-direct
|
||||
|
||||
The LP5562's White channel cannot be mapped to an execution engine. It can only be controlled via direct I2C PWM register writes. For autonomous operation, treat W as static (set once at load time) or ignore it.
|
||||
|
||||
### LED current and power budget
|
||||
|
||||
For energy-harvested operation, total LED current must stay within the NFC field's power output (~12.5mA max). With 4 channels at full brightness (25.5mA each), you'd draw 102mA — far beyond what NFC can provide.
|
||||
|
||||
Current firmware uses 2mA per channel (register value 20, since each unit = 0.1mA). The app should enforce a maximum total current budget and warn users when pattern brightness exceeds what the implant can sustain.
|
||||
134
docs/STATUS.md
134
docs/STATUS.md
@@ -1,7 +1,7 @@
|
||||
# xblink Project Status
|
||||
|
||||
**Current Milestone**: M2 — Engine Patterns
|
||||
**Last Updated**: 2026-03-05
|
||||
**Current Milestone**: GPIO-Direct LED Pivot (replacing LP5562)
|
||||
**Last Updated**: 2026-03-07
|
||||
|
||||
---
|
||||
|
||||
@@ -28,45 +28,74 @@
|
||||
|
||||
### 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
|
||||
- [x] Build breathing pattern (1 engine, ramp up/down, branch loop)
|
||||
- [x] Build heartbeat pattern (1 engine, fast ramp, slow decay)
|
||||
- [x] Build RGB cycle (3 engines, trigger-synced phase offset)
|
||||
- [x] Verify LP5562 runs patterns autonomously (MCU idle loop)
|
||||
- [x] Verify MCU can stop and switch patterns (8s cycle between all 3)
|
||||
|
||||
### M3: Hardcoded Pattern Library
|
||||
### M3: Hardcoded Pattern Library (COMPLETE)
|
||||
|
||||
- [ ] 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)
|
||||
- [x] Define 5 const patterns in firmware (breathe, heartbeat, slow_pulse, rgb_cycle, color_wash)
|
||||
- [x] Implement pattern selector (cycle through patterns, 15s each)
|
||||
- [x] Reduce LED current to 2mA/ch (EH-realistic budget)
|
||||
- [x] Document engine program structures (`docs/lp5562-engine-reference.md`)
|
||||
|
||||
## Group B — Add NTAG5Link
|
||||
|
||||
**Hardware needed**: + NTAG5Link Click board + I2C jumper
|
||||
|
||||
### M4: NTAG5 EEPROM Read/Write
|
||||
### M4: NTAG5 EEPROM Read/Write (COMPLETE)
|
||||
|
||||
- [ ] Write `Ntag5Link<I2C>` driver (`src/ntag5/mod.rs`)
|
||||
- [ ] Implement register map (`src/ntag5/registers.rs`)
|
||||
- [ ] Implement EEPROM block read/write
|
||||
- [ ] Implement session register access
|
||||
- [ ] Test round-trip: PCSC writes EEPROM, MCU reads back, displays on LEDs
|
||||
- [x] Write `Ntag5Link<I2C>` driver (`src/ntag5/mod.rs`)
|
||||
- [x] Implement session register access (CONFIG_0, CONFIG_1, EH_CONFIG)
|
||||
- [x] Implement EEPROM block read/write with write-verify
|
||||
- [x] Config check: read session regs, compare against expected, write NDEF result
|
||||
- [x] NDEF Type 5 text record writer for config check output
|
||||
- [x] Verified on hardware: config check passes, NDEF readable via NFC
|
||||
- [ ] Register map module (`src/ntag5/registers.rs`) — deferred, constants in mod.rs for now
|
||||
|
||||
### M5: Boot-from-EEPROM
|
||||
### M5: Boot-from-EEPROM (COMPLETE)
|
||||
|
||||
- [ ] Design binary pattern format (informed by M2-M3 experience)
|
||||
- [ ] Implement pattern deserializer (`src/pattern/mod.rs`)
|
||||
- [ ] 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
|
||||
- [x] Design XBLK binary pattern format (`docs/plans/2026-03-05-eeprom-pattern-format.md`)
|
||||
- [x] Implement XBLK deserializer (`src/pattern/mod.rs`: parse_header, parse_pattern_entry)
|
||||
- [x] Implement XBLK serializer (`src/pattern/mod.rs`: serialize_pattern_entry, serialize_header, crc16)
|
||||
- [x] Update main.rs: boot → read EEPROM → parse → program LP5562 → idle, with fallback
|
||||
- [x] MCU self-provisioning: write hardcoded patterns to EEPROM on first boot
|
||||
- [x] Write Python serializer tool (`tools/xblk_serialize.py`) for PCSC pattern uploads
|
||||
- [x] Verified on hardware: self-provisioning writes XBLK, subsequent boots load from EEPROM
|
||||
|
||||
### M6: SRAM Mailbox
|
||||
### M6: SRAM Mailbox (COMPLETE)
|
||||
|
||||
- [ ] Implement SRAM read/write (`src/ntag5/sram.rs`)
|
||||
- [ ] Design command/response protocol
|
||||
- [ ] Implement MCU-side protocol handler
|
||||
- [ ] Test pattern update via NFC without power-cycling
|
||||
- [x] Design SRAM mailbox protocol (`docs/plans/2026-03-05-sram-mailbox-protocol.md`)
|
||||
- [x] Add NTAG5 `write_register`, SRAM read/write, FD pin configuration
|
||||
- [x] Implement command protocol types and CRC helpers (`src/ntag5/sram.rs`)
|
||||
- [x] Implement all 7 command handlers (WRITE_PATTERN, GET_STATUS, SET_ACTIVE, SYNC_START/END, READ_LIBRARY/NEXT)
|
||||
- [x] Streaming transfer: single NFC hold for full library sync (one pattern per SRAM round-trip)
|
||||
- [x] FD pin polling in main idle loop (200ms interval, A1/PA04)
|
||||
- [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
|
||||
|
||||
@@ -74,10 +103,10 @@
|
||||
|
||||
### 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
|
||||
|
||||
@@ -88,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
|
||||
|
||||
@@ -127,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 |
|
||||
|
||||
120
docs/plans/2026-03-05-eeprom-pattern-format.md
Normal file
120
docs/plans/2026-03-05-eeprom-pattern-format.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# EEPROM Pattern Library Format (M5)
|
||||
|
||||
## Memory Layout
|
||||
|
||||
The NTP53x2 has 2048 bytes (512 x 4-byte blocks) of user EEPROM.
|
||||
|
||||
| Region | Blocks | Bytes | Purpose |
|
||||
|--------|--------|-------|---------|
|
||||
| NFC/NDEF | 0-255 | 0-1023 | CC + NDEF data (phone-visible, MCU doesn't touch) |
|
||||
| Pattern library | 256-511 | 1024-2047 | XBLK header + up to 9 patterns |
|
||||
|
||||
I2C base address for the pattern library: `0x0100` (block 256).
|
||||
|
||||
## Header (16 bytes, blocks 256-259)
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
0 4 Magic: "XBLK" (0x58 0x42 0x4C 0x4B)
|
||||
4 1 Version: 0x01
|
||||
5 1 Pattern count (1-9)
|
||||
6 1 Active pattern index (0-based, wraps at count)
|
||||
7 1 LED current (0-255, 0.1mA/step)
|
||||
8 1 LED mode (0x00 = RGBW, 0x01 = Mono3)
|
||||
9 5 Reserved (0x00)
|
||||
14 2 CRC-16 over bytes 0-13 + all pattern data
|
||||
```
|
||||
|
||||
Current and LED mode are global -- all patterns share them.
|
||||
|
||||
## Pattern Entry (112 bytes, 28 blocks each)
|
||||
|
||||
Fixed-size entries for direct seeking: `offset = 16 + (index * 112)`.
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
0 1 Engine count (0-3)
|
||||
1 1 LED_MAP register value (raw LP5562 register byte)
|
||||
2 4 Direct PWM [B, G, R, W] for I2C-mapped channels
|
||||
6 2 Engine 1 command count (big-endian, 0 = unused)
|
||||
8 32 Engine 1 commands (up to 16 x 2 bytes BE, pad with 0x0000)
|
||||
40 2 Engine 2 command count
|
||||
42 32 Engine 2 commands
|
||||
74 2 Engine 3 command count
|
||||
76 32 Engine 3 commands
|
||||
108 1 Next pattern index (0xFF = loop forever, 0-8 = chain to that index)
|
||||
109 1 Loop count (how many full cycles before chaining, 0 = use next_pattern as-is)
|
||||
110 2 Reserved
|
||||
```
|
||||
|
||||
Max capacity: `(1024 - 16) / 112 = 9` patterns.
|
||||
|
||||
The 16-command-per-engine limit is a hardware constraint of the LP5562 (48 bytes engine SRAM). The `branch` command enables infinite looping, so pattern duration is unlimited.
|
||||
|
||||
### Pattern Chaining
|
||||
|
||||
A pattern can specify a successor via `next_pattern_index`. When set to anything other than 0xFF, the pattern's engine programs should use finite-count `branch(0, N)` instead of `branch(0, 0)` (infinite loop). When all engines stop (detected by polling LP5562 STATUS register 0x0C for engine interrupt bits), the MCU loads the next pattern.
|
||||
|
||||
The LP5562 has no interrupt output pin — the MCU must poll STATUS periodically. During the idle loop, a poll every ~500ms is sufficient and costs negligible power vs. the LED current.
|
||||
|
||||
Chain examples:
|
||||
- `next=0xFF`: Loop forever (default, current behavior)
|
||||
- `next=1, loops=0`: Play once, then switch to pattern 1
|
||||
- `next=0, loops=3`: Play 3 full cycles, then switch to pattern 0 (can create A→B→A→B sequences)
|
||||
- Circular chains (A→B→A) create alternating pattern sequences
|
||||
|
||||
## Boot Sequence
|
||||
|
||||
1. Init LP5562 (enable, clock, current)
|
||||
2. Read header at `0x0100` (4 blocks)
|
||||
3. If magic = "XBLK" and CRC valid:
|
||||
- Read pattern at `active_pattern_index`
|
||||
- Deserialize into `Pattern` struct
|
||||
- Load into LP5562 engines
|
||||
4. If invalid (no magic, CRC mismatch, I2C error):
|
||||
- Fall back to hardcoded breathe pattern (compiled into firmware)
|
||||
5. Config check via session registers, report via LED blinks (no NDEF write)
|
||||
6. Sleep (future: STANDBY)
|
||||
|
||||
## Pattern Switching (future, hall sensor)
|
||||
|
||||
1. Wake on hall EIC interrupt
|
||||
2. Read header, increment `active_pattern_index` (wrap at `pattern_count`)
|
||||
3. Write back the single block containing the index (1 EEPROM write, ~5ms)
|
||||
4. Read and load the new pattern
|
||||
5. Sleep
|
||||
|
||||
## Error Handling
|
||||
|
||||
- EEPROM read fails at boot: use hardcoded fallback
|
||||
- CRC mismatch: use hardcoded fallback
|
||||
- Pattern data malformed (engine_count > 3, cmd_count > 16): skip to next, wrap around; if all bad, use fallback
|
||||
- Active index >= pattern count: reset to 0
|
||||
|
||||
## Python Serializer
|
||||
|
||||
`tools/xblk_serialize.py` converts JSON pattern definitions to binary and writes to EEPROM via ntag5sensor/PCSC.
|
||||
|
||||
Input JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"current": 20,
|
||||
"mode": "rgbw",
|
||||
"active": 0,
|
||||
"patterns": [
|
||||
{
|
||||
"name": "breathe",
|
||||
"led_map": {"b": "engine1", "g": "engine1", "r": "engine1", "w": "direct"},
|
||||
"direct_pwm": [0, 0, 0, 0],
|
||||
"engines": [
|
||||
[18176, 18176, 26368, 26368, 15408, 40960],
|
||||
[],
|
||||
[]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Engine commands are raw u16 values. The serializer validates constraints, builds the binary blob, computes CRC-16, and writes to EEPROM blocks 256+ via I2C.
|
||||
284
docs/plans/2026-03-05-led-pattern-editor-app.md
Normal file
284
docs/plans/2026-03-05-led-pattern-editor-app.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# xblink LED Pattern Editor — React Native App Design
|
||||
|
||||
**Date**: 2026-03-05
|
||||
**Status**: Draft — to be refined as firmware milestones (M2–M6) progress
|
||||
|
||||
## Context
|
||||
|
||||
The xblink implant needs a companion phone app for creating and transferring LED animation patterns. The hardware constraints are tight: LP5562 has 3 engines x 16 steps each, NTAG5Link has 256-byte SRAM (transfer buffer) and ~1KB usable EEPROM (after NDEF). This design translates those constraints into an intuitive "hybrid timeline" editor — like a step sequencer for light brightness — with animated preview, preset patterns, and NFC transfer.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **UI model**: Hybrid timeline (per-channel, tap to add set/ramp/wait steps)
|
||||
- **Timing range**: 50ms–14s full LP5562 capability
|
||||
- **Transfer**: NTAG5 SRAM as 256-byte mailbox, EEPROM as persistent storage
|
||||
- **Storage**: ~1KB EEPROM for patterns (~8 slots), MCU SRAM as runtime cache
|
||||
- **Magnet toggle**: Tap-to-cycle through patterns (flashlight-style)
|
||||
- **Preview**: Animated RGBW circles in-app, real-time as you edit
|
||||
- **Stack**: Expo + react-native-nfc-manager + react-native-dt-theme + react-native-reanimated
|
||||
|
||||
## Hardware Constraints Reference
|
||||
|
||||
| Constraint | Value | UI Impact |
|
||||
|-----------|-------|-----------|
|
||||
| Engines | 3 (16 steps each) | R/G/B get timelines, W is static-only |
|
||||
| Max user steps/engine | 14 (2 reserved for branch+end) | Step counter per channel |
|
||||
| Increment max | 127 PWM steps/ramp | Large ramps consume 2 engine slots |
|
||||
| Prescale | Fast 0.49ms, Slow 15.6ms | Per-step selector in editor |
|
||||
| Step time | 1–63 | Per-step, multiplied by prescale |
|
||||
| SRAM | 256 bytes (64x4B blocks) | Single-packet transfer for most patterns |
|
||||
| EEPROM (patterns) | ~1024 bytes (blocks 256–511) | ~8 patterns at 122B each |
|
||||
| Pattern size | 112B fixed-size entry (3x34B engines + 6B header + 4B chain/reserved) | Fits in one SRAM packet |
|
||||
| White channel | Direct I2C PWM only, no engine | Static brightness slider, no timeline |
|
||||
|
||||
## App Screens
|
||||
|
||||
### 1. Pattern Library (Home)
|
||||
|
||||
Ordered list of up to 8 patterns. Order = magnet cycle order.
|
||||
|
||||
```
|
||||
PATTERN LIBRARY [+ Add]
|
||||
"Tap magnet to cycle"
|
||||
|
||||
[drag] 1. Breathing [mini-preview]
|
||||
R:wave G:wave B:-- W:50%
|
||||
Duration: 2.4s | 6/14 steps
|
||||
|
||||
[drag] 2. Heartbeat [mini-preview]
|
||||
R:pulse G:-- B:-- W:0%
|
||||
Duration: 1.0s | 10/14 steps
|
||||
|
||||
[drag] 3. (empty slot)
|
||||
|
||||
Memory: 244/1024 bytes
|
||||
|
||||
[========= Sync to Implant =========]
|
||||
```
|
||||
|
||||
- **Cards**: DTCard per pattern, drag handle for reorder, swipe-left to delete
|
||||
- **Add button**: "Create New" or "From Preset" bottom sheet. Disabled at 8 patterns
|
||||
- **Memory bar**: DTProgressBar, warning at 80%, error at 95%
|
||||
- **Sync button**: Opens NFC transfer flow
|
||||
- **Tap card**: Opens pattern editor
|
||||
|
||||
### 2. Pattern Editor
|
||||
|
||||
The core UI. 3 engine timelines (R, G, B) + static W slider + animated preview.
|
||||
|
||||
```
|
||||
[< Back] Breathing [Save]
|
||||
|
||||
PREVIEW
|
||||
(R) (G) (B) (W) (MIXED)
|
||||
[> Play] [1x] [loop: on]
|
||||
|
||||
COMBINED COLOR STRIP
|
||||
|====gradient showing mixed RGBW over time====|
|
||||
0ms 2400ms
|
||||
|
||||
R 6/14 steps
|
||||
|[SET 0]|===RAMP UP===|===RAMP DN===|[BR->0]|
|
||||
|
||||
G 6/14 steps
|
||||
|[SET 0]|===RAMP UP===|===RAMP DN===|[BR->0]|
|
||||
|
||||
B 0/14 steps
|
||||
(tap to add steps)
|
||||
|
||||
W Static: [=======|===] 128
|
||||
(no engine — direct PWM only)
|
||||
```
|
||||
|
||||
**Timeline blocks**:
|
||||
- **Set PWM**: Narrow bar, shows brightness value (0–255)
|
||||
- **Ramp**: Wide bar proportional to duration, sloped fill (up/down)
|
||||
- **Wait**: Wide bar, flat, hatched fill
|
||||
- **Branch**: Arrow icon pointing to target step, shows loop count or infinity
|
||||
|
||||
**Interactions**:
|
||||
- Tap empty space → Add Step sheet (Set / Ramp / Wait)
|
||||
- Tap existing step → Step Editor modal
|
||||
- Long press → Delete confirmation
|
||||
- Step counter: DTChip, warning at 12+, error at 14
|
||||
|
||||
**Step Editor Modal**:
|
||||
```
|
||||
Edit Step: Ramp Up [Delete]
|
||||
|
||||
Type: [Set PWM] [Ramp] [Wait]
|
||||
|
||||
Target Brightness: [====|===] 200 (0-255)
|
||||
Prescale: [Fast 0.49ms] [Slow 15.6ms]
|
||||
Step Time: [--] 32 [++] (1-63)
|
||||
|
||||
Duration: 502ms
|
||||
Note: This ramp uses 2 engine slots (delta > 127)
|
||||
|
||||
[Cancel] [Apply]
|
||||
```
|
||||
|
||||
- For ramps, user sets absolute target brightness; app calculates increment + direction
|
||||
- If delta > 127, show warning and deduct 2 from step budget
|
||||
- Duration displayed as calculated value, updates live
|
||||
|
||||
**White channel**: Single slider. No timeline. Label explains constraint.
|
||||
|
||||
**Preview area**:
|
||||
- 5 circles: R, G, B, W (individual) + MIXED (additive blend)
|
||||
- Animated via react-native-reanimated (PWM 0–255 → opacity 0–1)
|
||||
- Play/pause, speed selector (0.25x/0.5x/1x/2x), loop toggle
|
||||
- Playback cursor synced across all timelines
|
||||
|
||||
### 3. Preset Browser
|
||||
|
||||
Built-in patterns users can add to their library:
|
||||
|
||||
| Preset | Engines Used | Steps/Engine | Duration | Description |
|
||||
|--------|-------------|-------------|----------|-------------|
|
||||
| Breathing | 1 (all RGB) | 6 | ~4.0s | Slow ramp 0→255→0, all channels |
|
||||
| Heartbeat | 1 (R only) | 10 | ~1.0s | Double pulse, red |
|
||||
| Color Cycle | 3 (R+G+B) | 7 each | ~3.0s | Sequential fade with trigger sync |
|
||||
| Strobe | 1 (all RGB) | 5 | ~100ms | Fast on/off, 10Hz |
|
||||
| Candle | 2 (R+G) | 8 each | ~200ms | Warm flicker, pseudo-random ramps |
|
||||
| Solid Color | 0 (all direct) | 0 | N/A | Static RGBW, color picker UI |
|
||||
|
||||
Each preset card shows animated preview circles.
|
||||
|
||||
### 4. NFC Transfer Flow
|
||||
|
||||
4-phase modal:
|
||||
1. **Pre-flight**: List patterns to sync, total bytes, confirm
|
||||
2. **Hold**: Pulsing NFC icon, "Hold phone against implant"
|
||||
3. **Transferring**: Progress bar, per-pattern status, "DO NOT MOVE"
|
||||
4. **Complete/Error**: Success with magnet-cycle reminder, or retry
|
||||
|
||||
## Data Model
|
||||
|
||||
```typescript
|
||||
interface PatternLibrary {
|
||||
version: 1;
|
||||
patterns: Pattern[]; // Ordered, index = magnet cycle position
|
||||
globalCurrent: number; // LED current 0-255
|
||||
}
|
||||
|
||||
interface Pattern {
|
||||
id: string;
|
||||
name: string; // Max 32 chars
|
||||
presetId: string | null;
|
||||
engines: [EngineProgram, EngineProgram, EngineProgram];
|
||||
channelAssignment: { red: EngineRef; green: EngineRef; blue: EngineRef; white: 'direct' };
|
||||
whiteLevel: number; // 0-255
|
||||
nextPattern: number; // 0xFF = loop forever, 0-8 = chain to index
|
||||
loopCount: number; // Cycles before chaining (0 = chain on stop)
|
||||
}
|
||||
|
||||
interface EngineProgram {
|
||||
steps: EngineStep[]; // Max 14 user steps
|
||||
}
|
||||
|
||||
type EngineStep =
|
||||
| { type: 'set_pwm'; brightness: number }
|
||||
| { type: 'ramp'; targetBrightness: number; prescale: 'fast' | 'slow'; stepTime: number }
|
||||
| { type: 'wait'; prescale: 'fast' | 'slow'; stepTime: number };
|
||||
|
||||
type EngineRef = 'engine1' | 'engine2' | 'engine3' | 'direct';
|
||||
```
|
||||
|
||||
**Step-to-LP5562 mapping**:
|
||||
- `set_pwm` → `EngineCommand::set_pwm(value)` — 1 slot
|
||||
- `ramp` → compute delta from current PWM; if <= 127: 1 `ramp_wait` (1 slot), if > 127: 2 commands (2 slots)
|
||||
- `wait` → `EngineCommand::wait(prescale, stepTime)` — 1 slot
|
||||
- Auto-appended: `branch(0, 0)` for infinite loop, or `branch(0, N)` for chained patterns — 1 reserved slot
|
||||
|
||||
## SRAM Transfer Protocol
|
||||
|
||||
**Packet format** (256 bytes max):
|
||||
|
||||
```
|
||||
Byte Field
|
||||
0 command (0x01=SYNC_START, 0x02=PATTERN_DATA, 0x03=SYNC_END)
|
||||
1 sequence number (0-255)
|
||||
2 flags (bit0=ACK_REQ, bit1=LAST_CHUNK, bit2=ERASE_FIRST)
|
||||
3 total_patterns
|
||||
4 pattern_index
|
||||
5 chunk_index
|
||||
6 chunk_count
|
||||
7 reserved
|
||||
8-9 payload_length (u16 LE)
|
||||
10-11 crc16 (over header + payload)
|
||||
12-255 payload (up to 244 bytes)
|
||||
```
|
||||
|
||||
A single 3-engine pattern (122 bytes) fits in one packet. Transfer flow:
|
||||
1. SYNC_START (global settings)
|
||||
2. PATTERN_DATA x N (one per pattern, usually single-chunk)
|
||||
3. SYNC_END (MCU commits to EEPROM, activates pattern 0)
|
||||
|
||||
**MCU response** (SRAM byte 0 = 0xFF):
|
||||
```
|
||||
0: 0xFF (response marker), 1: echo seq, 2: status (0=OK), 3: active_pattern
|
||||
```
|
||||
|
||||
## Pattern Binary Format
|
||||
|
||||
Matches firmware spec `docs/plans/2026-03-05-eeprom-pattern-format.md`:
|
||||
|
||||
```
|
||||
Header (16 bytes, blocks 256-259):
|
||||
[0-3] Magic "XBLK"
|
||||
[4] Version 0x01
|
||||
[5] Pattern count (1-9)
|
||||
[6] Active pattern index (0-based, wraps at count)
|
||||
[7] LED current (0-255, 0.1mA/step)
|
||||
[8] LED mode (0x00=RGBW, 0x01=Mono3)
|
||||
[9-13] Reserved
|
||||
[14-15] CRC-16 over bytes 0-13 + all pattern data
|
||||
|
||||
Pattern Entry (112 bytes each, fixed-size):
|
||||
[0] Engine count (0-3)
|
||||
[1] LED_MAP register byte
|
||||
[2-5] Direct PWM [B, G, R, W]
|
||||
[6-7] Engine 1 command count (BE)
|
||||
[8-39] Engine 1 commands (16x2 bytes BE)
|
||||
[40-41] Engine 2 command count
|
||||
[42-73] Engine 2 commands
|
||||
[74-75] Engine 3 command count
|
||||
[76-107] Engine 3 commands
|
||||
[108] Next pattern index (0xFF=loop forever, 0-8=chain to that index)
|
||||
[109] Loop count (cycles before chaining, 0=chain on engine stop)
|
||||
[110-111] Reserved
|
||||
```
|
||||
|
||||
Max capacity: 9 patterns in 1024 bytes (upper 1K EEPROM).
|
||||
|
||||
## Reusable Code from Sibling Projects
|
||||
|
||||
| File | What to reuse |
|
||||
|------|--------------|
|
||||
| `dangerous-things-nfc-identifier/src/services/nfc/commands.ts` | `parseApduResponse`, `hexToBytes`, `bytesToHex`, NTAG5 command patterns |
|
||||
| `dangerous-things-nfc-identifier/src/services/nfc/NFCManager.ts` | NFC session management |
|
||||
| `dangerous-things-nfc-identifier/src/services/detection/ntag5sensor.ts` | SRAM read/write, energy harvesting control |
|
||||
| `react-native-dt-theme/src/components/` | DTCard, DTButton, DTChip, DTModal, DTDrawer, DTProgressBar, DTQuantityStepper, DTSwitch, DTTextInput |
|
||||
| `react-native-dt-theme/src/theme/` | Colors, typography, DTThemeProvider |
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
1. **Project scaffold + data model** — Expo init, types, serialization, presets
|
||||
2. **Pattern Library screen** — List, reorder, add/delete, memory bar
|
||||
3. **Timeline Editor** — Channel timelines, step editor modal, W slider, step budget
|
||||
4. **Animated Preview** — Reanimated circles, color strip, playback controls
|
||||
5. **Binary Serialization** — XBLK encoder, CRC-16, SRAM packet framing
|
||||
6. **NFC Transfer** — react-native-nfc-manager, ISO15693, transfer flow UI
|
||||
7. **Polish** — Undo/redo, export/import, settings, onboarding
|
||||
|
||||
## Resolved Questions
|
||||
|
||||
- **EEPROM layout**: Lower 1K (blocks 0-255) = NFC/NDEF, upper 1K (blocks 256-511) = XBLK pattern library. Decided in M5.
|
||||
- **Magnet cycle index**: Persists in EEPROM header byte [6] (active_pattern_index). Single block write on switch (~5ms).
|
||||
- **Non-looping patterns**: Supported via pattern chaining (next_pattern field). Engine programs use finite `branch(0, N)` instead of infinite `branch(0, 0)`. MCU polls LP5562 STATUS register to detect engine completion, then loads the next pattern. No INT pin on LP5562 — polling only.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Trigger command exposure in UI (v1 hides it; presets use it internally)
|
||||
- Pattern chaining UI: how to visualize A→B→C chains in the library screen (arrows? numbered sequence?)
|
||||
1024
docs/plans/2026-03-05-m6-sram-mailbox-impl.md
Normal file
1024
docs/plans/2026-03-05-m6-sram-mailbox-impl.md
Normal file
File diff suppressed because it is too large
Load Diff
220
docs/plans/2026-03-05-sram-mailbox-protocol.md
Normal file
220
docs/plans/2026-03-05-sram-mailbox-protocol.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# M6: SRAM Mailbox Protocol Design
|
||||
|
||||
**Date**: 2026-03-05
|
||||
**Status**: Approved — ready for implementation
|
||||
|
||||
## Overview
|
||||
|
||||
The NTAG5Link's 256-byte SRAM (64 x 4-byte blocks at I2C address 0xF8-0xFF) serves as a bidirectional mailbox between the phone (NFC/RF side) and the MCU (I2C side). The MCU polls the FD pin to detect when the phone has written a command, processes it, and writes a response back to SRAM for the phone to read.
|
||||
|
||||
## Pin Allocation
|
||||
|
||||
| Pin | Function | Milestone |
|
||||
|-----|----------|-----------|
|
||||
| A0 (PA02) | LP5562 EN (open-drain, wired-AND with hall sensor) | M1 (done) |
|
||||
| A1 (PA04) | NTAG5 FD (SRAM write / field detect, EXTINT[4]) | M6 |
|
||||
| TBD | Hall sensor (EIC wake) | M8 |
|
||||
|
||||
## FD Pin Configuration
|
||||
|
||||
NTAG5 session register `FD_PIN_CFG` (0x06) set at boot to "SRAM RF write complete" mode:
|
||||
- FD goes **low** when the RF side (phone) finishes writing to SRAM
|
||||
- FD returns **high** when the I2C side (MCU) reads the SRAM
|
||||
- Natural flow control for command/response cycles
|
||||
|
||||
Pin wiring: NTAG5 Click FD → XIAO A1 (PA04). Configured as GPIO input with pull-up (FD is open-drain on NTAG5).
|
||||
|
||||
M6 uses GPIO polling (~200ms interval in idle loop). M7 upgrades to EIC interrupt (EXTINT[4]) for wake-from-STANDBY.
|
||||
|
||||
## Command Packet Format
|
||||
|
||||
**Phone → MCU (written to SRAM, 256 bytes max):**
|
||||
|
||||
```
|
||||
Byte Field
|
||||
0 Command ID (0x01-0x7F)
|
||||
1 Sequence number (0-255, incremented per packet)
|
||||
2-3 Payload length (u16 LE)
|
||||
4-255 Payload (up to 252 bytes)
|
||||
```
|
||||
|
||||
No application-layer CRC — ISO15693 already provides CRC-16 at the transport layer.
|
||||
|
||||
**MCU → Phone (written back to SRAM):**
|
||||
|
||||
```
|
||||
Byte Field
|
||||
0 0xFF (response marker — distinguishes from commands)
|
||||
1 Echo sequence number
|
||||
2 Status (0x00=OK, 0x02=BAD_CMD, 0x03=EEPROM_FAIL, 0x04=INVALID_INDEX)
|
||||
3 Payload length
|
||||
4-255 Response payload
|
||||
```
|
||||
|
||||
The 0xFF response marker lets the phone distinguish "MCU hasn't responded yet" (byte 0 still holds old command ID) from "response ready" (byte 0 = 0xFF).
|
||||
|
||||
## Command Set
|
||||
|
||||
| ID | Name | Payload (Phone → MCU) | Response Payload (MCU → Phone) |
|
||||
|----|------|----------------------|-------------------------------|
|
||||
| 0x01 | WRITE_PATTERN | index (1B) + pattern (112B) | none |
|
||||
| 0x02 | GET_STATUS | none | version (1B) + pattern_count (1B) + active_index (1B) + led_current (1B) + led_mode (1B) |
|
||||
| 0x03 | SET_ACTIVE | index (1B) | none |
|
||||
| 0x05 | SYNC_START | count (1B) + current (1B) + mode (1B) | none |
|
||||
| 0x06 | SYNC_END | none | none |
|
||||
| 0x07 | READ_LIBRARY | none | pattern_count (1B) + active (1B) + current (1B) + mode (1B) |
|
||||
| 0x08 | READ_NEXT | none | pattern data (112B) |
|
||||
|
||||
## Streaming Transfer Protocol
|
||||
|
||||
### Write (Phone → Implant): Full Library Sync
|
||||
|
||||
Single continuous NFC hold. Phone writes packets back-to-back, polling for MCU response between each.
|
||||
|
||||
```
|
||||
Phone: Write SYNC_START (count=3, current=20, mode=RGBW) → SRAM
|
||||
MCU: Erase library, write new header skeleton, respond OK
|
||||
Phone: Poll byte 0 until 0xFF
|
||||
|
||||
Phone: Write WRITE_PATTERN (index=0, 112B pattern) → SRAM
|
||||
MCU: Write 28 blocks to EEPROM (~140ms), respond OK
|
||||
Phone: Poll byte 0 until 0xFF
|
||||
|
||||
Phone: Write WRITE_PATTERN (index=1, ...) → SRAM
|
||||
MCU: Write 28 blocks to EEPROM, respond OK
|
||||
Phone: Poll byte 0 until 0xFF
|
||||
|
||||
Phone: Write WRITE_PATTERN (index=2, ...) → SRAM
|
||||
MCU: Write 28 blocks to EEPROM, respond OK
|
||||
Phone: Poll byte 0 until 0xFF
|
||||
|
||||
Phone: Write SYNC_END → SRAM
|
||||
MCU: Recalculate header CRC, set active=0, reprogram LP5562, respond OK
|
||||
Phone: Poll byte 0 until 0xFF → done, show success
|
||||
```
|
||||
|
||||
**Timing**: ~150ms per pattern (28 EEPROM blocks × 5ms each + I2C overhead). 9 patterns ≈ 1.4s of EEPROM writes. Total transfer including NFC overhead: under 3 seconds.
|
||||
|
||||
### Read (Implant → Phone): Library Download
|
||||
|
||||
Phone-driven — each READ_NEXT is an explicit request for the next pattern.
|
||||
|
||||
```
|
||||
Phone: Write READ_LIBRARY → SRAM
|
||||
MCU: Read header, respond with [pattern_count, active, current, mode]
|
||||
Phone: Poll byte 0 until 0xFF, read header info
|
||||
|
||||
Phone: Write READ_NEXT (seq=1) → SRAM
|
||||
MCU: Read pattern 0 from EEPROM, write 112B to response payload
|
||||
Phone: Poll byte 0 until 0xFF, read pattern 0
|
||||
|
||||
Phone: Write READ_NEXT (seq=2) → SRAM
|
||||
MCU: Read pattern 1 from EEPROM, write 112B to response payload
|
||||
Phone: Poll byte 0 until 0xFF, read pattern 1
|
||||
|
||||
(repeat until pattern_count exhausted)
|
||||
```
|
||||
|
||||
**Timing**: EEPROM reads are fast (~1ms per block). Full 9-pattern read-back under 1 second.
|
||||
|
||||
### Single-Pattern Update
|
||||
|
||||
For quick edits without replacing the whole library:
|
||||
|
||||
```
|
||||
Phone: Write WRITE_PATTERN (index=2, 112B) → SRAM
|
||||
MCU: Write to EEPROM, update header CRC
|
||||
If index == active pattern: reprogram LP5562 immediately
|
||||
Respond OK
|
||||
```
|
||||
|
||||
## MCU Processing Flow
|
||||
|
||||
### M6 Idle Loop
|
||||
|
||||
```rust
|
||||
loop {
|
||||
delay_ms(200);
|
||||
if fd_pin.is_low() {
|
||||
process_sram_command(&mut ntag, &mut lp5562, &mut delay);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Command Processing
|
||||
|
||||
1. Read SRAM bytes 0-3 (header)
|
||||
2. Validate command ID (0x01-0x08)
|
||||
3. Read remaining payload bytes based on length field
|
||||
4. Execute command (see per-command logic below)
|
||||
5. Write response to SRAM
|
||||
6. Return to polling
|
||||
|
||||
### Per-Command Logic
|
||||
|
||||
**SYNC_START (0x05)**:
|
||||
- Store target `pattern_count`, `led_current`, `led_mode` in local vars
|
||||
- Write new XBLK header with count=0 (partial header, CRC updated at SYNC_END)
|
||||
- Reset internal write index to 0
|
||||
|
||||
**WRITE_PATTERN (0x01)** (during sync or standalone):
|
||||
- Validate index < 9
|
||||
- Write 28 blocks to EEPROM at offset `16 + (index × 112)`, verify each block
|
||||
- If standalone (not in SYNC): update header pattern count and CRC
|
||||
- If `index == active_pattern`: reprogram LP5562
|
||||
|
||||
**SYNC_END (0x06)**:
|
||||
- Update header: set pattern_count, recalculate CRC-16 over all data
|
||||
- Set active_pattern = 0
|
||||
- Read pattern 0, reprogram LP5562
|
||||
- Clear sync state
|
||||
|
||||
**GET_STATUS (0x02)**:
|
||||
- Read XBLK header from EEPROM
|
||||
- Return `[0x01, count, active, current, mode]`
|
||||
|
||||
**SET_ACTIVE (0x03)**:
|
||||
- Validate index < pattern_count
|
||||
- Update header active_pattern byte (single EEPROM block write)
|
||||
- Read new pattern, reprogram LP5562
|
||||
|
||||
**READ_LIBRARY (0x07)**:
|
||||
- Read XBLK header
|
||||
- Store pattern_count for READ_NEXT iteration
|
||||
- Reset read index to 0
|
||||
- Return `[count, active, current, mode]`
|
||||
|
||||
**READ_NEXT (0x08)**:
|
||||
- Read pattern at current read index from EEPROM
|
||||
- Return 112 bytes of pattern data
|
||||
- Increment read index
|
||||
|
||||
## I2C Bus Sharing
|
||||
|
||||
No special handling required. LP5562 engines run autonomously on the chip's internal oscillator after programming — no ongoing I2C traffic. The MCU switches between NTAG5 (0x54) and LP5562 (0x30) by address as needed. No engine pause necessary.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Unknown command**: Respond with `BAD_CMD` (0x02)
|
||||
- **EEPROM write failure**: Respond with `EEPROM_FAIL` (0x03), library may be in partial state
|
||||
- **Invalid pattern index**: Respond with `INVALID_INDEX` (0x04)
|
||||
- **Phone timeout**: If phone stops sending during SYNC, library is partial. Next SYNC_START will erase and restart. No persistent corruption — header CRC won't match partial data, so boot falls back to hardcoded patterns.
|
||||
- **Garbage in SRAM**: Bad command ID → error response, resume polling. Each command is self-contained, no state to corrupt.
|
||||
- **Transport integrity**: ISO15693 provides CRC-16 at the link layer — no application-layer CRC needed.
|
||||
|
||||
## Phone-Side Polling
|
||||
|
||||
After writing a command to SRAM, the phone polls byte 0 via ISO15693 READ SINGLE BLOCK:
|
||||
- If byte 0 != 0xFF → MCU hasn't responded yet, poll again
|
||||
- If byte 0 == 0xFF → response ready, read full response
|
||||
- Sequence number in response must match sent sequence — prevents reading stale responses
|
||||
- Timeout after 2 seconds per command → show error to user
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
1. **NTAG5 SRAM driver** — `src/ntag5/sram.rs`: block read/write at 0xF8-0xFF
|
||||
2. **FD pin setup** — Session register config at boot, GPIO input on A1
|
||||
3. **Command parser** — Read SRAM, validate header, dispatch
|
||||
4. **Command handlers** — WRITE_PATTERN, GET_STATUS, SET_ACTIVE first
|
||||
5. **Streaming sync** — SYNC_START/END, READ_LIBRARY/NEXT
|
||||
6. **Testing** — PCSC reader with Python test script, then VivoKey RawNFC app
|
||||
144
docs/plans/2026-03-06-gpio-led-pivot.md
Normal file
144
docs/plans/2026-03-06-gpio-led-pivot.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# GPIO-Direct LED Pivot
|
||||
|
||||
**Date**: 2026-03-06
|
||||
**Status**: Design complete, implementation pending
|
||||
|
||||
## Why
|
||||
|
||||
The LP5562 LED driver IC requires VDD >= 2.7V. Our NTAG5Link NFC energy harvesting antenna produces only 1.8V. No workaround exists -- a boost converter would exceed the power budget. We're replacing the LP5562 with SAMD21E GPIO-direct PWM driving 6 low-Vf red LEDs through current-limiting resistors.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Remove**: LP5562 from BOM, `src/led/lp5562.rs` driver, LP5562 engine opcode pattern format
|
||||
- **Add**: TCC hardware PWM driver (`src/led/pwm.rs`), software pattern engine, XBLK v2 EEPROM format
|
||||
- **Keep**: NTAG5Link driver, SRAM mailbox protocol, EEPROM storage, EIC wake, hall sensor design
|
||||
|
||||
---
|
||||
|
||||
## Hardware
|
||||
|
||||
**LEDs**: 6x Kingbright APTD1608 (0603, red, Vf ~1.7V at 1mA). 0.1V headroom at 1.8V supply. Arranged in a line.
|
||||
|
||||
**PWM outputs**: TCC0 (4 channels) + TCC1 (2 channels) = 6 independent PWM channels.
|
||||
|
||||
**Pin assignments** (SAMD21E, TCC-capable pins):
|
||||
|
||||
| LED | TCC | Channel | SAMD21E Pin | XIAO Pin |
|
||||
|-----|------|---------|-------------|----------|
|
||||
| 0 | TCC0 | WO[0] | PA04 | A1 |
|
||||
| 1 | TCC0 | WO[1] | PA05 | A2 |
|
||||
| 2 | TCC0 | WO[2] | PA06 | A3 |
|
||||
| 3 | TCC0 | WO[3] | PA07 | A4* |
|
||||
| 4 | TCC1 | WO[0] | PA10 | D2 |
|
||||
| 5 | TCC1 | WO[1] | PA11 | D3 |
|
||||
|
||||
*PA06/PA07 conflict with I2C (A4/A5) on XIAO M0. Dev board testing uses 4 LEDs on non-conflicting pins. Custom PCB (SAMD21E) has no conflict.
|
||||
|
||||
**Resistors**: 10-47 ohm series per LED. At 1.8V supply, Vf=1.7V: I = 0.1V/47ohm = ~2mA (safe). Lower resistance = brighter but more current. Governor handles the budget.
|
||||
|
||||
## Power Governor
|
||||
|
||||
The NTAG5 drops out entirely (hard power loss) when current budget is exceeded. The governor prevents this by running every ISR tick before writing TCC registers:
|
||||
|
||||
```
|
||||
raw[6] = pattern engine computes brightness 0-255 per LED
|
||||
total = sum(raw[0..6])
|
||||
if total > BUDGET:
|
||||
scale = BUDGET * 256 / total // fixed-point
|
||||
for i in 0..6:
|
||||
raw[i] = raw[i] * scale / 256
|
||||
write raw values to TCC CC registers
|
||||
```
|
||||
|
||||
**BUDGET**: Stored in XBLK v2 header (1 byte, units of ~0.1mA steps). Default ~50 (5mA total LED budget). Configurable via companion app or SRAM mailbox command.
|
||||
|
||||
**Phase splitting** is the key optimization: stagger LED phase offsets so peaks don't align. A 6-LED sine wave with 60-degree phase offsets has roughly constant total brightness, maximizing perceived brightness within the budget.
|
||||
|
||||
## Software Pattern Engine
|
||||
|
||||
Replaces LP5562's hardware execution engines. Runs in TC4 ISR at 50Hz.
|
||||
|
||||
**PatternState** (per-pattern, loaded from EEPROM):
|
||||
|
||||
```rust
|
||||
struct PatternState {
|
||||
waveform: Waveform, // sine, triangle, square, heartbeat
|
||||
cycle_len: u8, // ticks per full cycle (1-255 = 20ms-5.1s)
|
||||
phase: [u8; 6], // phase offset per LED (0-255 = 0-360 degrees)
|
||||
envelope: [u8; 6], // max brightness per LED (governor input)
|
||||
tick: u16, // current animation tick (runtime, not stored)
|
||||
}
|
||||
```
|
||||
|
||||
**Waveform lookup tables** (const, in flash):
|
||||
- `SINE_LUT[256]`: 8-bit sine quarter-wave, mirrored at runtime
|
||||
- `TRIANGLE_LUT[256]`: linear ramp up/down
|
||||
- `HEARTBEAT_LUT[256]`: double-pulse cardiac shape
|
||||
|
||||
**ISR flow** (~40us at 8MHz):
|
||||
1. Increment `tick`, wrap at `cycle_len * 256`
|
||||
2. For each LED: `phase_pos = (tick + phase[i] * cycle_len) % (cycle_len * 256)`
|
||||
3. Look up `waveform[phase_pos]`, scale by `envelope[i]`
|
||||
4. Apply governor scaling
|
||||
5. Write 6 TCC CC registers
|
||||
|
||||
**MCU sleep**: IDLE mode (not STANDBY) during animation. CPU halts between interrupts, peripherals (TCC, TC4) keep running. ~0.5mA at 1.8V/8MHz. CPU active only ~40us per 20ms tick = 0.2% duty cycle.
|
||||
|
||||
## XBLK v2 EEPROM Format
|
||||
|
||||
**Storage region**: Last 1KB of NTAG5 EEPROM (blocks 0x100-0x1FF), beyond normal NFC/NDEF access.
|
||||
|
||||
### Header (16 bytes)
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
0x00 4 magic "XBLK"
|
||||
0x04 1 version 0x02
|
||||
0x05 1 flags bit 0: has_playlist
|
||||
0x06 1 pattern_count number of pattern entries
|
||||
0x07 1 active_index pattern to load on boot
|
||||
0x08 1 budget power governor budget
|
||||
0x09 1 playlist_count number of playlist entries (0 if no playlist)
|
||||
0x0A 4 reserved
|
||||
0x0E 2 crc16 over bytes 0x00-0x0D
|
||||
```
|
||||
|
||||
### Pattern Entry (16 bytes each, immediately after header)
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
0x00 1 waveform (0=sine, 1=triangle, 2=square, 3=heartbeat)
|
||||
0x01 1 cycle_len (in 50Hz ticks, 1-255 = 20ms-5.1s)
|
||||
0x02 6 phase[6] (0-255 phase offset per LED, maps to 0-360 degrees)
|
||||
0x08 6 envelope[6] (max brightness per LED, 0-255)
|
||||
0x0E 1 repeat_count (times to play before advancing playlist, 0xFF=forever)
|
||||
0x0F 1 reserved
|
||||
```
|
||||
|
||||
### Playlist Table (after all pattern entries, if flags bit 0 set)
|
||||
|
||||
Each playlist entry is 1 byte = pattern index. Sequence plays in order, loops back to start. Max 32 entries.
|
||||
|
||||
Example: patterns [breathe=0, chase=1, flash=2] with playlist [1, 2, 2, 0] plays: chase, flash, flash, breathe, chase, ...
|
||||
|
||||
**Capacity**: 1024B - 16B header - 32B playlist = 976B for patterns. At 16B each: 61 patterns.
|
||||
|
||||
## Sleep and Power-on Behavior
|
||||
|
||||
Three MCU states:
|
||||
|
||||
1. **STANDBY** (~2uA) -- No animation. Wakes on FD pin (EIC) for SRAM commands or hall sensor tap.
|
||||
2. **ANIMATING** (~0.5mA MCU + LED current) -- IDLE sleep, TC4 ISR at 50Hz updates TCC duty cycles.
|
||||
3. **COMMAND PROCESSING** -- Fully awake, handling SRAM mailbox. Returns to ANIMATING or STANDBY.
|
||||
|
||||
**Power-on sequence**:
|
||||
1. NTAG5 EH powers up, VOUT rises, SAMD21 boots
|
||||
2. Read EEPROM header -- if valid XBLK v2, load pattern at `active_index`
|
||||
3. If playlist exists, start playlist from entry 0
|
||||
4. Configure TC4 (50Hz), TCC0/TCC1 (PWM), start animation
|
||||
5. Enter IDLE sleep (animation runs via ISR)
|
||||
6. On FD interrupt, wake fully, process SRAM command, resume
|
||||
|
||||
**Playlist advancement**: When `repeat_count` cycles complete for current pattern, ISR loads next playlist entry. If playlist wraps, loop from start. Hall sensor tap: advance to next playlist entry (or next pattern if no playlist).
|
||||
|
||||
**Pattern cycling (no playlist)**: Hall sensor increments `active_index`, loads next pattern. Wrapping past last pattern goes to STANDBY (LEDs off).
|
||||
@@ -1 +1,2 @@
|
||||
pub mod lp5562;
|
||||
pub mod pwm;
|
||||
|
||||
139
src/led/pwm.rs
Normal file
139
src/led/pwm.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
//! TCC hardware PWM driver for 6 GPIO-direct LEDs.
|
||||
//!
|
||||
//! Uses TCC0 (4 channels: WO[0]-WO[3]) and TCC1 (2 channels: WO[0]-WO[1])
|
||||
//! to drive 6 LEDs via current-limiting resistors.
|
||||
//!
|
||||
//! Pin assignments (SAMD21E target, XIAO M0 dev board):
|
||||
//! LED 0: TCC0/WO[0] on PA04 (XIAO A1)
|
||||
//! LED 1: TCC0/WO[1] on PA05 (XIAO A2)
|
||||
//! LED 2: TCC0/WO[2] on PA06 (XIAO A3) — conflicts with I2C on XIAO
|
||||
//! LED 3: TCC0/WO[3] on PA07 (XIAO A4) — conflicts with I2C on XIAO
|
||||
//! LED 4: TCC1/WO[0] on PA10 (XIAO D2)
|
||||
//! LED 5: TCC1/WO[1] on PA11 (XIAO D3)
|
||||
//!
|
||||
//! For dev board: only LEDs 0, 1, 4, 5 are usable (4 LEDs).
|
||||
|
||||
use crate::pattern::NUM_LEDS;
|
||||
|
||||
/// Initialize TCC0 and TCC1 for PWM output.
|
||||
///
|
||||
/// # Safety
|
||||
/// Must be called once during init. Caller must ensure PM and GCLK are configured.
|
||||
pub unsafe fn init() {
|
||||
let pm = &*crate::pac::PM::ptr();
|
||||
let gclk = &*crate::pac::GCLK::ptr();
|
||||
let port = &*crate::pac::PORT::ptr();
|
||||
let tcc0 = &*crate::pac::TCC0::ptr();
|
||||
let tcc1 = &*crate::pac::TCC1::ptr();
|
||||
|
||||
// Enable GCLK0 for TCC0/TCC1 (generic clock ID 0x1A = 26)
|
||||
gclk.clkctrl.write(|w| {
|
||||
w.id().bits(0x1A)
|
||||
.gen().gclk0()
|
||||
.clken().set_bit()
|
||||
});
|
||||
while gclk.status.read().syncbusy().bit_is_set() {}
|
||||
|
||||
// Enable TCC0 and TCC1 in Power Manager
|
||||
pm.apbcmask.modify(|_, w| {
|
||||
w.tcc0_().set_bit()
|
||||
.tcc1_().set_bit()
|
||||
});
|
||||
|
||||
// --- Pin muxing: function E (0x04) for TCC ---
|
||||
// PA04 (even in group 2)
|
||||
port.pmux0_[2].modify(|_, w| w.pmuxe().bits(0x04));
|
||||
port.pincfg0_[4].modify(|_, w| w.pmuxen().set_bit());
|
||||
// PA05 (odd in group 2)
|
||||
port.pmux0_[2].modify(|_, w| w.pmuxo().bits(0x04));
|
||||
port.pincfg0_[5].modify(|_, w| w.pmuxen().set_bit());
|
||||
// PA10 (even in group 5)
|
||||
port.pmux0_[5].modify(|_, w| w.pmuxe().bits(0x04));
|
||||
port.pincfg0_[10].modify(|_, w| w.pmuxen().set_bit());
|
||||
// PA11 (odd in group 5)
|
||||
port.pmux0_[5].modify(|_, w| w.pmuxo().bits(0x04));
|
||||
port.pincfg0_[11].modify(|_, w| w.pmuxen().set_bit());
|
||||
|
||||
// --- Configure TCC0 ---
|
||||
tcc0.ctrla.modify(|_, w| w.enable().clear_bit());
|
||||
while tcc0.syncbusy.read().enable().bit_is_set() {}
|
||||
|
||||
tcc0.ctrla.write(|w| {
|
||||
w.prescaler().div1()
|
||||
.prescsync().presc()
|
||||
});
|
||||
|
||||
tcc0.wave.write(|w| w.wavegen().npwm());
|
||||
while tcc0.syncbusy.read().wave().bit_is_set() {}
|
||||
|
||||
// Period = 255 (8-bit resolution, ~31.4 kHz at 8 MHz)
|
||||
tcc0.per().write(|w| w.bits(255));
|
||||
while tcc0.syncbusy.read().per().bit_is_set() {}
|
||||
|
||||
// All channels start at 0
|
||||
tcc0.cc()[0].write(|w| w.bits(0));
|
||||
tcc0.cc()[1].write(|w| w.bits(0));
|
||||
tcc0.cc()[2].write(|w| w.bits(0));
|
||||
tcc0.cc()[3].write(|w| w.bits(0));
|
||||
while tcc0.syncbusy.read().cc0().bit_is_set() {}
|
||||
while tcc0.syncbusy.read().cc1().bit_is_set() {}
|
||||
while tcc0.syncbusy.read().cc2().bit_is_set() {}
|
||||
while tcc0.syncbusy.read().cc3().bit_is_set() {}
|
||||
|
||||
tcc0.ctrla.modify(|_, w| w.enable().set_bit());
|
||||
while tcc0.syncbusy.read().enable().bit_is_set() {}
|
||||
|
||||
// --- Configure TCC1 ---
|
||||
tcc1.ctrla.modify(|_, w| w.enable().clear_bit());
|
||||
while tcc1.syncbusy.read().enable().bit_is_set() {}
|
||||
|
||||
tcc1.ctrla.write(|w| {
|
||||
w.prescaler().div1()
|
||||
.prescsync().presc()
|
||||
});
|
||||
|
||||
tcc1.wave.write(|w| w.wavegen().npwm());
|
||||
while tcc1.syncbusy.read().wave().bit_is_set() {}
|
||||
|
||||
tcc1.per().write(|w| w.bits(255));
|
||||
while tcc1.syncbusy.read().per().bit_is_set() {}
|
||||
|
||||
tcc1.cc()[0].write(|w| w.bits(0));
|
||||
tcc1.cc()[1].write(|w| w.bits(0));
|
||||
while tcc1.syncbusy.read().cc0().bit_is_set() {}
|
||||
while tcc1.syncbusy.read().cc1().bit_is_set() {}
|
||||
|
||||
tcc1.ctrla.modify(|_, w| w.enable().set_bit());
|
||||
while tcc1.syncbusy.read().enable().bit_is_set() {}
|
||||
}
|
||||
|
||||
/// Set duty cycle for a single LED channel (0-255).
|
||||
#[inline]
|
||||
pub fn set_duty(channel: u8, duty: u8) {
|
||||
unsafe {
|
||||
match channel {
|
||||
0..=3 => {
|
||||
let tcc0 = &*crate::pac::TCC0::ptr();
|
||||
tcc0.cc()[channel as usize].write(|w| w.bits(duty as u32));
|
||||
}
|
||||
4..=5 => {
|
||||
let tcc1 = &*crate::pac::TCC1::ptr();
|
||||
tcc1.cc()[(channel - 4) as usize].write(|w| w.bits(duty as u32));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set duty cycles for all 6 LEDs at once.
|
||||
pub fn set_all(duties: &[u8; NUM_LEDS]) {
|
||||
for (i, &d) in duties.iter().enumerate() {
|
||||
set_duty(i as u8, d);
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn all LEDs off.
|
||||
pub fn all_off() {
|
||||
let zeros = [0u8; NUM_LEDS];
|
||||
set_all(&zeros);
|
||||
}
|
||||
412
src/main.rs
412
src/main.rs
@@ -9,13 +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;
|
||||
use led::lp5562::{ClockSource, Lp5562, DEFAULT_ADDRESS};
|
||||
mod ntag5;
|
||||
mod pattern;
|
||||
use ntag5::Ntag5Link;
|
||||
use ntag5::sram::MailboxState;
|
||||
use ntag5::SESSION_CONFIG_REG;
|
||||
use pattern::{PatternEngine, PatternDef, NUM_LEDS};
|
||||
|
||||
/// Global pattern engine, accessed from TC4 ISR and main loop.
|
||||
static ENGINE: Mutex<RefCell<Option<PatternEngine>>> = Mutex::new(RefCell::new(None));
|
||||
|
||||
/// Flag set by TC4 ISR: pattern repeats are done, main loop should advance playlist.
|
||||
static REPEATS_DONE: Mutex<RefCell<bool>> = Mutex::new(RefCell::new(false));
|
||||
|
||||
/// Blink the on-board LED n times (active-low: low=on, high=off)
|
||||
fn blink<D: embedded_hal::delay::DelayNs>(led: &mut bsp::Led0, delay: &mut D, times: u8, ms: u16) {
|
||||
@@ -27,10 +46,59 @@ fn blink<D: embedded_hal::delay::DelayNs>(led: &mut bsp::Led0, delay: &mut D, ti
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure TC4 for 50Hz interrupt (animation tick).
|
||||
unsafe fn init_tc4() {
|
||||
let pm = &*pac::PM::ptr();
|
||||
let gclk = &*pac::GCLK::ptr();
|
||||
let tc4 = &*pac::TC4::ptr();
|
||||
|
||||
// Enable GCLK0 for TC4/TC5 (generic clock ID 0x1C = 28)
|
||||
gclk.clkctrl.write(|w| {
|
||||
w.id().bits(0x1C) // GCLK_TC4_TC5
|
||||
.gen().gclk0()
|
||||
.clken().set_bit()
|
||||
});
|
||||
while gclk.status.read().syncbusy().bit_is_set() {}
|
||||
|
||||
// Enable TC4 in Power Manager
|
||||
pm.apbcmask.modify(|_, w| w.tc4_().set_bit());
|
||||
|
||||
// Disable TC4 first
|
||||
tc4.count16().ctrla.modify(|_, w| w.enable().clear_bit());
|
||||
while tc4.count16().status.read().syncbusy().bit_is_set() {}
|
||||
|
||||
// Software reset
|
||||
tc4.count16().ctrla.modify(|_, w| w.swrst().set_bit());
|
||||
while tc4.count16().status.read().syncbusy().bit_is_set() {}
|
||||
|
||||
// Configure: 16-bit mode, prescaler /256, match frequency mode
|
||||
// 8 MHz / 256 = 31250 Hz. For 50 Hz: 31250 / 50 = 625 counts.
|
||||
tc4.count16().ctrla.write(|w| {
|
||||
w.mode().count16()
|
||||
.prescaler().div256()
|
||||
.wavegen().mfrq()
|
||||
});
|
||||
|
||||
// Set compare value for 50Hz
|
||||
tc4.count16().cc[0].write(|w| unsafe { w.cc().bits(624) }); // 625 - 1
|
||||
|
||||
// Enable MC0 interrupt
|
||||
tc4.count16().intenset.write(|w| w.mc0().set_bit());
|
||||
|
||||
// Enable TC4
|
||||
tc4.count16().ctrla.modify(|_, w| w.enable().set_bit());
|
||||
while tc4.count16().status.read().syncbusy().bit_is_set() {}
|
||||
}
|
||||
|
||||
#[entry]
|
||||
fn main() -> ! {
|
||||
let mut peripherals = Peripherals::take().unwrap();
|
||||
let core = CorePeripherals::take().unwrap();
|
||||
let mut core = CorePeripherals::take().unwrap();
|
||||
|
||||
// Boot on internal oscillator first — minimal current draw (~0.3mA at 1MHz).
|
||||
// This gives EH VOUT time to stabilize before we switch to 8MHz and init peripherals.
|
||||
// Busy-wait ~50ms for VOUT ramp-up before doing anything.
|
||||
cortex_m::asm::delay(50_000); // ~50ms at 1MHz internal oscillator
|
||||
|
||||
let mut clocks = GenericClockController::with_external_32kosc(
|
||||
peripherals.GCLK,
|
||||
@@ -46,16 +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);
|
||||
|
||||
// I2C on A4 (SDA) / A5 (SCL) at 400 kHz
|
||||
// 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);
|
||||
|
||||
// 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(),
|
||||
@@ -65,48 +157,276 @@ fn main() -> ! {
|
||||
pins.a5,
|
||||
);
|
||||
|
||||
let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS);
|
||||
let mut ntag = Ntag5Link::new(i2c, ntag5::DEFAULT_ADDRESS);
|
||||
|
||||
// Try to initialize LP5562 — blink error pattern on failure
|
||||
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(50, 50, 50, 50)?;
|
||||
Ok(())
|
||||
})();
|
||||
// Default power budget
|
||||
let mut budget: u8 = pattern::DEFAULT_BUDGET;
|
||||
|
||||
match i2c_ok {
|
||||
Ok(()) => {
|
||||
// === DIAGNOSTIC: solid LED on = LP5562 init OK ===
|
||||
led.set_low().unwrap();
|
||||
delay.delay_ms(1000u32);
|
||||
// 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();
|
||||
|
||||
// M1 smoke test: cycle through RGBW channels
|
||||
let on: u8 = 255;
|
||||
let step: u16 = 500;
|
||||
// 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();
|
||||
|
||||
loop {
|
||||
lp.set_all_pwm(on, 0, 0, 0).ok(); // Blue
|
||||
delay.delay_ms(step as u32);
|
||||
lp.set_all_pwm(0, on, 0, 0).ok(); // Green
|
||||
delay.delay_ms(step as u32);
|
||||
lp.set_all_pwm(0, 0, on, 0).ok(); // Red
|
||||
delay.delay_ms(step as u32);
|
||||
lp.set_all_pwm(0, 0, 0, on).ok(); // White
|
||||
delay.delay_ms(step as u32);
|
||||
lp.set_all_pwm(on, on, on, on).ok(); // All on
|
||||
delay.delay_ms(step as u32);
|
||||
let _ = lp.all_off();
|
||||
delay.delay_ms(step as u32);
|
||||
// Write full diagnostic to NDEF text record (readable via phone NFC)
|
||||
{
|
||||
let mut buf = [0u8; 128];
|
||||
let mut i = 0;
|
||||
|
||||
fn append(buf: &mut [u8], i: &mut usize, data: &[u8]) {
|
||||
for &b in data {
|
||||
if *i < buf.len() { buf[*i] = b; *i += 1; }
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// === DIAGNOSTIC: fast blink forever = I2C error ===
|
||||
loop {
|
||||
blink(&mut led, &mut delay, 5, 80);
|
||||
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);
|
||||
}
|
||||
append(&mut buf, &mut i, b" ");
|
||||
}
|
||||
}
|
||||
Err(_) => { append(&mut buf, &mut i, b"CFG:I2C_ERR "); }
|
||||
}
|
||||
|
||||
// Config provision result
|
||||
append(&mut buf, &mut i, b"PROV:");
|
||||
if provision_ok { append(&mut buf, &mut i, b"OK "); }
|
||||
else { append(&mut buf, &mut i, b"FAIL "); }
|
||||
|
||||
// EH session register readback
|
||||
append(&mut buf, &mut i, b"EH:");
|
||||
if eh_ok {
|
||||
if let Ok(v) = ntag.read_register(ntag5::SESSION_EH_CONFIG_REG, 0) {
|
||||
hex(&mut buf, &mut i, v);
|
||||
// Also read EH_LOAD_OK status (bit 7)
|
||||
if v & 0x80 != 0 { append(&mut buf, &mut i, b"/LOAD_OK"); }
|
||||
else { append(&mut buf, &mut i, b"/no_load"); }
|
||||
} else {
|
||||
append(&mut buf, &mut i, b"RD_ERR");
|
||||
}
|
||||
} else {
|
||||
append(&mut buf, &mut i, b"WR_ERR");
|
||||
}
|
||||
append(&mut buf, &mut i, b" ");
|
||||
|
||||
// ED/FD config readback
|
||||
append(&mut buf, &mut i, b"ED:");
|
||||
if let Ok(v) = ntag.read_register(ntag5::SESSION_EH_CONFIG_REG, 2) {
|
||||
hex(&mut buf, &mut i, v);
|
||||
} else {
|
||||
append(&mut buf, &mut i, b"ERR");
|
||||
}
|
||||
|
||||
// Write as NDEF text record
|
||||
let _ = ntag.write_ndef_text(&buf[..i], &mut delay);
|
||||
}
|
||||
|
||||
blink(&mut led, &mut delay, 1, 250);
|
||||
delay.delay_ms(500u32);
|
||||
|
||||
// --- 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<PatternDef> = None;
|
||||
|
||||
if let Some(ref hdr) = eeprom_ok {
|
||||
budget = hdr.budget;
|
||||
let pat_block = pattern::LIBRARY_BASE_BLOCK
|
||||
+ (pattern::HEADER_SIZE as u16 / 4)
|
||||
+ (hdr.active_index as u16 * (pattern::PATTERN_ENTRY_SIZE as u16 / 4));
|
||||
let mut pat_buf = [0u8; pattern::PATTERN_ENTRY_SIZE];
|
||||
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;
|
||||
}
|
||||
}
|
||||
if !provision_ok { break; }
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
446
src/ntag5/mod.rs
Normal file
446
src/ntag5/mod.rs
Normal file
@@ -0,0 +1,446 @@
|
||||
/// NTAG5Link I2C slave driver for NTP53x2.
|
||||
///
|
||||
/// Provides session register reads, EEPROM block read/write,
|
||||
/// and NDEF Type 5 text record writing.
|
||||
///
|
||||
/// I2C protocol (Section 8.3.1.4 of NTP53x2 datasheet):
|
||||
/// - READ MEMORY: write [BL_AD1, BL_AD0], read N bytes
|
||||
/// - WRITE MEMORY: write [BL_AD1, BL_AD0, D0, D1, D2, D3]
|
||||
/// - READ REGISTER: write [BL_AD1, BL_AD0, REGA], read 1 byte
|
||||
/// - WRITE REGISTER: write [BL_AD1, BL_AD0, REGA, MASK, REGDATA]
|
||||
|
||||
pub mod sram;
|
||||
|
||||
use embedded_hal::i2c::I2c;
|
||||
|
||||
pub const DEFAULT_ADDRESS: u8 = 0x54;
|
||||
|
||||
// Session register I2C block addresses (16-bit)
|
||||
pub const SESSION_CONFIG_REG: u16 = 0x10A1; // CONFIG_0_REG, CONFIG_1_REG, CONFIG_2_REG, RFU
|
||||
pub const SESSION_EH_CONFIG_REG: u16 = 0x10A7; // EH_CONFIG_REG, RFU
|
||||
pub const SESSION_I2C_SLAVE_REG: u16 = 0x10A9; // I2C_SLAVE_ADDR_REG, I2C_SLAVE_CONFIG_REG
|
||||
|
||||
// SRAM I2C block address range (64 blocks × 4 bytes = 256 bytes)
|
||||
pub const SRAM_BASE_BLOCK: u16 = 0x10F8;
|
||||
pub const SRAM_BLOCK_COUNT: u16 = 64;
|
||||
pub const SRAM_SIZE: usize = 256;
|
||||
|
||||
// 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
|
||||
|
||||
// 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
|
||||
pub const EEPROM_BLOCK_0: u16 = 0x0000;
|
||||
|
||||
// Expected config values for xblink
|
||||
// CONFIG_0: EH_MODE = low field strength (bits 3:2 = 10b)
|
||||
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: 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: check EH_ENABLE (bit 0) + VOUT_V_SEL (bits 2:1), ignore current limit + status bits
|
||||
pub const EH_CONFIG_MASK: u8 = 0x07;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error<E> {
|
||||
I2c(E),
|
||||
/// EEPROM write-verify failed: read-back didn't match written data
|
||||
VerifyFailed,
|
||||
}
|
||||
|
||||
impl<E> From<E> for Error<E> {
|
||||
fn from(e: E) -> Self {
|
||||
Error::I2c(e)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Ntag5Link<I2C> {
|
||||
i2c: I2C,
|
||||
addr: u8,
|
||||
}
|
||||
|
||||
/// Config check result for a single register
|
||||
pub struct RegCheck {
|
||||
/// Register name abbreviation (e.g., "C0", "C1", "EH")
|
||||
pub name: [u8; 2],
|
||||
/// Actual value read
|
||||
pub actual: u8,
|
||||
/// Expected value (after masking)
|
||||
pub expected: u8,
|
||||
/// Whether it matches
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
/// Full config check result
|
||||
pub struct ConfigResult {
|
||||
pub checks: [RegCheck; 3],
|
||||
/// True if all checks passed
|
||||
pub all_ok: bool,
|
||||
}
|
||||
|
||||
impl<I2C, E> Ntag5Link<I2C>
|
||||
where
|
||||
I2C: I2c<Error = E>,
|
||||
{
|
||||
pub fn new(i2c: I2C, addr: u8) -> Self {
|
||||
Self { i2c, addr }
|
||||
}
|
||||
|
||||
/// Consume the driver and return the I2C bus.
|
||||
pub fn release(self) -> I2C {
|
||||
self.i2c
|
||||
}
|
||||
|
||||
// ---- Low-level I2C commands ----
|
||||
|
||||
/// Read N bytes from a 16-bit block address (READ MEMORY command).
|
||||
/// Used for EEPROM and config memory.
|
||||
pub fn read_memory(&mut self, block: u16, buf: &mut [u8]) -> Result<(), E> {
|
||||
let addr_bytes = block.to_be_bytes();
|
||||
self.i2c.write_read(self.addr, &addr_bytes, buf)
|
||||
}
|
||||
|
||||
/// Write 4 bytes to a 16-bit block address (WRITE MEMORY command).
|
||||
/// Used for EEPROM writes. Each EEPROM block is 4 bytes.
|
||||
pub fn write_memory_block(&mut self, block: u16, data: &[u8; 4]) -> Result<(), E> {
|
||||
let addr_bytes = block.to_be_bytes();
|
||||
let mut buf = [0u8; 6];
|
||||
buf[0] = addr_bytes[0];
|
||||
buf[1] = addr_bytes[1];
|
||||
buf[2] = data[0];
|
||||
buf[3] = data[1];
|
||||
buf[4] = data[2];
|
||||
buf[5] = data[3];
|
||||
self.i2c.write(self.addr, &buf)
|
||||
}
|
||||
|
||||
/// Write 4 bytes then read back and verify. Returns Error::VerifyFailed
|
||||
/// if the read-back doesn't match.
|
||||
pub fn write_verify_block(
|
||||
&mut self,
|
||||
block: u16,
|
||||
data: &[u8; 4],
|
||||
delay: &mut impl embedded_hal::delay::DelayNs,
|
||||
) -> Result<(), Error<E>> {
|
||||
self.write_memory_block(block, data)?;
|
||||
delay.delay_ms(5); // EEPROM write cycle ~5ms
|
||||
let mut readback = [0u8; 4];
|
||||
self.read_memory(block, &mut readback)?;
|
||||
if readback != *data {
|
||||
return Err(Error::VerifyFailed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a single session register byte (READ REGISTER command).
|
||||
/// Protocol: write [BL_AD1, BL_AD0, REGA], then read 1 byte.
|
||||
pub fn read_register(&mut self, block: u16, reg_addr: u8) -> Result<u8, E> {
|
||||
let addr_bytes = block.to_be_bytes();
|
||||
let mut buf = [0u8; 1];
|
||||
self.i2c.write_read(
|
||||
self.addr,
|
||||
&[addr_bytes[0], addr_bytes[1], reg_addr],
|
||||
&mut buf,
|
||||
)?;
|
||||
Ok(buf[0])
|
||||
}
|
||||
|
||||
/// Write a single session register byte (WRITE REGISTER command).
|
||||
/// Protocol: write [BL_AD1, BL_AD0, REGA, MASK, REGDATA].
|
||||
/// The MASK selects which bits to modify (1 = modify, 0 = keep).
|
||||
pub fn write_register(&mut self, block: u16, reg_addr: u8, mask: u8, data: u8) -> Result<(), E> {
|
||||
let addr_bytes = block.to_be_bytes();
|
||||
self.i2c.write(
|
||||
self.addr,
|
||||
&[addr_bytes[0], addr_bytes[1], reg_addr, mask, data],
|
||||
)
|
||||
}
|
||||
|
||||
// ---- SRAM access ----
|
||||
|
||||
/// Read N bytes from SRAM starting at byte offset 0.
|
||||
/// SRAM is at I2C blocks 0x10F8-0x10FF (64 blocks × 4 bytes = 256 bytes).
|
||||
/// Always reads from the start of SRAM (block-aligned).
|
||||
pub fn read_sram(&mut self, buf: &mut [u8]) -> Result<(), E> {
|
||||
self.read_memory(SRAM_BASE_BLOCK, buf)
|
||||
}
|
||||
|
||||
/// Write data to SRAM starting at block offset from SRAM base.
|
||||
/// Data length must be a multiple of 4 (NTAG5 block size).
|
||||
pub fn write_sram_blocks(&mut self, block_offset: u16, data: &[u8]) -> Result<(), E> {
|
||||
for (i, chunk) in data.chunks(4).enumerate() {
|
||||
let mut block_data = [0u8; 4];
|
||||
for (j, &b) in chunk.iter().enumerate() {
|
||||
block_data[j] = b;
|
||||
}
|
||||
self.write_memory_block(SRAM_BASE_BLOCK + block_offset + i as u16, &block_data)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
self.write_register(
|
||||
SESSION_EH_CONFIG_REG, 2, 0xFF,
|
||||
ED_CONFIG_NFC_TO_I2C_PASS_THROUGH,
|
||||
)
|
||||
}
|
||||
|
||||
/// Enable EH via session register (volatile, must run each boot).
|
||||
///
|
||||
/// Config EEPROM (block 0x3D) is NOT writable from I2C — only from NFC
|
||||
/// using WRITE CONFIG (0xC1) via PCSC reader. Session register writes
|
||||
/// work immediately but reset on power cycle.
|
||||
///
|
||||
/// Session register 0x10A7: byte 0 = EH_CONFIG, byte 2 = ED_CONFIG
|
||||
pub fn configure_eh_session(&mut self) -> Result<(), E> {
|
||||
// Write EH_CONFIG + EH_TRIGGER to session register byte 0
|
||||
// Session bit 3 = EH_TRIGGER (different from persistent bit 3 = DISABLE_POWER_CHECK)
|
||||
self.write_register(SESSION_EH_CONFIG_REG, 0, 0xFF, EXPECTED_EH_CONFIG | EH_SESSION_TRIGGER)?;
|
||||
// Write ED_CONFIG to session register byte 2 (FD pin = SRAM pass-through)
|
||||
self.write_register(SESSION_EH_CONFIG_REG, 2, 0xFF, ED_CONFIG_NFC_TO_I2C_PASS_THROUGH)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write EH_CONFIG + ED_CONFIG to persistent config EEPROM (block 0x103D).
|
||||
/// This must be done via I2C when no NFC field is present (USB-powered).
|
||||
/// After writing, the NTAG5 will automatically enable EH on subsequent power-ups.
|
||||
/// Block format: [EH_CONFIG, 0x00, ED_CONFIG, 0x00]
|
||||
pub fn provision_eh_persistent(
|
||||
&mut self,
|
||||
delay: &mut impl embedded_hal::delay::DelayNs,
|
||||
) -> Result<(), Error<E>> {
|
||||
let data = [EXPECTED_EH_CONFIG, 0x00, ED_CONFIG_NFC_TO_I2C_PASS_THROUGH, 0x00];
|
||||
self.write_verify_block(CONFIG_EH_BLOCK, &data, delay)
|
||||
}
|
||||
|
||||
/// Write EH config to persistent EEPROM and reset CONFIG to defaults.
|
||||
///
|
||||
/// NOTE: CONFIG_0/CONFIG_1 must NOT be persisted with SRAM passthrough —
|
||||
/// it breaks NFC EEPROM access when I2C bus is floating (MCU unpowered).
|
||||
/// Use configure_config_session() to set CONFIG at boot instead.
|
||||
pub fn provision_all_config(
|
||||
&mut self,
|
||||
delay: &mut impl embedded_hal::delay::DelayNs,
|
||||
) -> Result<(), Error<E>> {
|
||||
// Write EH + ED config FIRST (most important for automatic power-up)
|
||||
let eh_data = [EXPECTED_EH_CONFIG, 0x00, ED_CONFIG_NFC_TO_I2C_PASS_THROUGH, 0x00];
|
||||
self.write_memory_block(CONFIG_EH_BLOCK, &eh_data)?;
|
||||
delay.delay_ms(5); // EEPROM write cycle
|
||||
|
||||
// Then CONFIG_0 (EH_MODE=low field strength), clear CONFIG_1
|
||||
let config_data = [EXPECTED_CONFIG_0, 0x00, 0x00, 0x00];
|
||||
self.write_memory_block(0x1037, &config_data)?;
|
||||
delay.delay_ms(5);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set CONFIG_1 via session register (volatile, safe).
|
||||
/// Enables SRAM passthrough only while MCU is active on the I2C bus.
|
||||
pub fn configure_config_session(&mut self) -> Result<(), E> {
|
||||
self.write_register(SESSION_CONFIG_REG, 1, 0xFF, EXPECTED_CONFIG_1)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read DEV_SEC_CONFIG block (0x103F) to check security/protection status.
|
||||
/// Returns the 4 raw bytes: [DEV_SEC_CONFIG, SRAM_CONF_PROT, PP_AREA1_LSB, PP_AREA1_MSB]
|
||||
pub fn read_dev_sec_config(&mut self) -> Result<[u8; 4], E> {
|
||||
let mut buf = [0u8; 4];
|
||||
self.read_memory(CONFIG_DEV_SEC_BLOCK, &mut buf)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
// ---- Config check ----
|
||||
|
||||
/// Read session registers and compare against expected xblink config.
|
||||
/// Session registers are always readable from I2C, even if config
|
||||
/// memory is password/AES protected.
|
||||
pub fn check_config(&mut self) -> Result<ConfigResult, E> {
|
||||
// Read CONFIG_REG session register: bytes 0,1,2 = CONFIG_0, CONFIG_1, CONFIG_2
|
||||
let c0 = self.read_register(SESSION_CONFIG_REG, 0)?;
|
||||
let c1 = self.read_register(SESSION_CONFIG_REG, 1)?;
|
||||
|
||||
// Read EH_CONFIG_REG session register: byte 0 = EH_CONFIG
|
||||
let eh = self.read_register(SESSION_EH_CONFIG_REG, 0)?;
|
||||
|
||||
let c0_ok = (c0 & CONFIG_0_MASK) == (EXPECTED_CONFIG_0 & CONFIG_0_MASK);
|
||||
let c1_ok = (c1 & CONFIG_1_MASK) == (EXPECTED_CONFIG_1 & CONFIG_1_MASK);
|
||||
let eh_ok = (eh & EH_CONFIG_MASK) == (EXPECTED_EH_CONFIG & EH_CONFIG_MASK);
|
||||
|
||||
let all_ok = c0_ok && c1_ok && eh_ok;
|
||||
|
||||
Ok(ConfigResult {
|
||||
checks: [
|
||||
RegCheck { name: *b"C0", actual: c0, expected: EXPECTED_CONFIG_0, ok: c0_ok },
|
||||
RegCheck { name: *b"C1", actual: c1, expected: EXPECTED_CONFIG_1, ok: c1_ok },
|
||||
RegCheck { name: *b"EH", actual: eh, expected: EXPECTED_EH_CONFIG, ok: eh_ok },
|
||||
],
|
||||
all_ok,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- NDEF Type 5 text record ----
|
||||
|
||||
/// Write an NDEF Type 5 text record to EEPROM user memory.
|
||||
///
|
||||
/// Layout (NFC Forum Type 5 Tag):
|
||||
/// - Block 0: CC (Capability Container) — 4 bytes
|
||||
/// - Block 1+: TLV wrapper + NDEF message
|
||||
///
|
||||
/// The text record uses "en" language code, UTF-8 encoding.
|
||||
pub fn write_ndef_text(&mut self, text: &[u8], delay: &mut impl embedded_hal::delay::DelayNs) -> Result<(), Error<E>> {
|
||||
// CC (block 0) is already present from factory/provisioning — don't overwrite.
|
||||
|
||||
// NDEF message TLV:
|
||||
// [0] 03 = NDEF message TLV type
|
||||
// [1] len = total NDEF message length
|
||||
// NDEF record header:
|
||||
// [2] D1 = MB=1, ME=1, CF=0, SR=1, IL=0, TNF=01 (well-known)
|
||||
// [3] 01 = type length (1 byte: "T")
|
||||
// [4] payload_len = 3 + text.len() (status byte + "en" + text)
|
||||
// [5] 54 = type: "T" (text record)
|
||||
// NDEF payload:
|
||||
// [6] 02 = status byte: UTF-8 (bit 7=0), language code length=2
|
||||
// [7] 65 = 'e'
|
||||
// [8] 6E = 'n'
|
||||
// [9..] = text bytes
|
||||
// After message:
|
||||
// FE = terminator TLV
|
||||
|
||||
let payload_len = 3 + text.len(); // status + "en" + text
|
||||
let ndef_len = 4 + payload_len; // header(3) + type(1) + payload
|
||||
let tlv_len = 2 + ndef_len; // TLV type + TLV len + NDEF message
|
||||
let total_bytes = tlv_len + 1; // + terminator TLV (FE)
|
||||
|
||||
// Build the message into a buffer (max ~80 bytes for our use)
|
||||
let mut msg = [0u8; 80];
|
||||
if total_bytes > msg.len() {
|
||||
// Text too long, truncate silently — shouldn't happen for our short messages
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut i = 0;
|
||||
msg[i] = 0x03; i += 1; // NDEF TLV type
|
||||
msg[i] = ndef_len as u8; i += 1; // NDEF TLV length
|
||||
msg[i] = 0xD1; i += 1; // NDEF record header: MB|ME|SR, TNF=well-known
|
||||
msg[i] = 0x01; i += 1; // Type length = 1
|
||||
msg[i] = payload_len as u8; i += 1; // Payload length
|
||||
msg[i] = b'T'; i += 1; // Type = "T" (text)
|
||||
msg[i] = 0x02; i += 1; // Status: UTF-8, lang len = 2
|
||||
msg[i] = b'e'; i += 1;
|
||||
msg[i] = b'n'; i += 1;
|
||||
for &b in text {
|
||||
msg[i] = b;
|
||||
i += 1;
|
||||
}
|
||||
msg[i] = 0xFE; i += 1; // Terminator TLV
|
||||
|
||||
// Write to EEPROM in 4-byte blocks starting at block 1
|
||||
let mut block = 1u16;
|
||||
let mut offset = 0;
|
||||
while offset < i {
|
||||
let mut data = [0u8; 4];
|
||||
for j in 0..4 {
|
||||
if offset + j < i {
|
||||
data[j] = msg[offset + j];
|
||||
}
|
||||
}
|
||||
self.write_verify_block(block, &data, delay)?;
|
||||
block += 1;
|
||||
offset += 4;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Format the config check result as a human-readable NDEF text record.
|
||||
/// Returns the number of bytes written to `buf`.
|
||||
pub fn format_config_result(result: &ConfigResult, buf: &mut [u8]) -> usize {
|
||||
let mut i = 0;
|
||||
|
||||
// Helper: append a byte slice
|
||||
fn append(buf: &mut [u8], i: &mut usize, data: &[u8]) {
|
||||
for &b in data {
|
||||
if *i < buf.len() {
|
||||
buf[*i] = b;
|
||||
*i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: append hex byte as 2 ASCII chars
|
||||
fn hex(buf: &mut [u8], i: &mut usize, val: u8) {
|
||||
const HEX: &[u8; 16] = b"0123456789ABCDEF";
|
||||
if *i + 1 < buf.len() {
|
||||
buf[*i] = HEX[(val >> 4) as usize];
|
||||
*i += 1;
|
||||
buf[*i] = HEX[(val & 0x0F) as usize];
|
||||
*i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
append(buf, &mut i, b"xblink cfg:");
|
||||
|
||||
if result.all_ok {
|
||||
append(buf, &mut i, b" OK ");
|
||||
} else {
|
||||
append(buf, &mut i, b" BAD ");
|
||||
}
|
||||
|
||||
for check in &result.checks {
|
||||
append(buf, &mut i, &check.name);
|
||||
append(buf, &mut i, b":");
|
||||
hex(buf, &mut i, check.actual);
|
||||
if check.ok {
|
||||
append(buf, &mut i, b"ok ");
|
||||
} else {
|
||||
append(buf, &mut i, b"!=");
|
||||
hex(buf, &mut i, check.expected);
|
||||
append(buf, &mut i, b" ");
|
||||
}
|
||||
}
|
||||
|
||||
i
|
||||
}
|
||||
|
||||
/// Run config check and write result as NDEF text record.
|
||||
/// Returns Ok(true) if config matches, Ok(false) if mismatch,
|
||||
/// Err(VerifyFailed) if EEPROM write-verify failed,
|
||||
/// Err(I2c(e)) if I2C communication failed.
|
||||
pub fn check_and_write_ndef(&mut self, delay: &mut impl embedded_hal::delay::DelayNs) -> Result<bool, Error<E>> {
|
||||
let result = self.check_config()?;
|
||||
let all_ok = result.all_ok;
|
||||
|
||||
let mut buf = [0u8; 64];
|
||||
let len = Self::format_config_result(&result, &mut buf);
|
||||
|
||||
self.write_ndef_text(&buf[..len], delay)?;
|
||||
|
||||
Ok(all_ok)
|
||||
}
|
||||
}
|
||||
466
src/ntag5/sram.rs
Normal file
466
src/ntag5/sram.rs
Normal file
@@ -0,0 +1,466 @@
|
||||
//! SRAM mailbox command/response protocol for NFC<->MCU communication.
|
||||
//!
|
||||
//! The phone writes a command packet to NTAG5Link SRAM via NFC.
|
||||
//! The MCU reads it over I2C, processes the command, and writes
|
||||
//! a response packet back to SRAM for the phone to read.
|
||||
|
||||
use crate::ntag5::{self, Ntag5Link, SRAM_SIZE};
|
||||
use crate::pattern;
|
||||
use embedded_hal::i2c::I2c;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Firmware version reported in GET_STATUS response.
|
||||
pub const FIRMWARE_VERSION: u8 = 0x02;
|
||||
|
||||
// Command IDs (phone -> MCU)
|
||||
pub const CMD_WRITE_PATTERN: u8 = 0x01;
|
||||
pub const CMD_GET_STATUS: u8 = 0x02;
|
||||
pub const CMD_SET_ACTIVE: u8 = 0x03;
|
||||
pub const CMD_SYNC_START: u8 = 0x05;
|
||||
pub const CMD_SYNC_END: u8 = 0x06;
|
||||
pub const CMD_READ_LIBRARY: u8 = 0x07;
|
||||
pub const CMD_READ_NEXT: u8 = 0x08;
|
||||
|
||||
// Response marker
|
||||
pub const RESPONSE_MARKER: u8 = 0xFF;
|
||||
|
||||
// Status codes
|
||||
pub const STATUS_OK: u8 = 0x00;
|
||||
pub const STATUS_BAD_CMD: u8 = 0x02;
|
||||
pub const STATUS_EEPROM_FAIL: u8 = 0x03;
|
||||
pub const STATUS_INVALID_INDEX: u8 = 0x04;
|
||||
|
||||
/// Command header size (cmd + seq + payload_len_u16).
|
||||
pub const CMD_HEADER_SIZE: usize = 4;
|
||||
|
||||
/// Response header size (marker + seq + status + payload_len).
|
||||
pub const RSP_HEADER_SIZE: usize = 4;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mailbox state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Persistent state for the mailbox protocol across commands.
|
||||
pub struct MailboxState {
|
||||
/// True while a SYNC_START..SYNC_END sequence is in progress.
|
||||
pub syncing: bool,
|
||||
/// Pattern count supplied by SYNC_START.
|
||||
pub sync_count: 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.
|
||||
pub read_count: u8,
|
||||
}
|
||||
|
||||
impl MailboxState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
syncing: false,
|
||||
sync_count: 0,
|
||||
sync_budget: pattern::DEFAULT_BUDGET,
|
||||
read_index: 0,
|
||||
read_count: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Protocol parse / build
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse the 4-byte command header. Returns (cmd, seq, payload_len) or None
|
||||
/// if the buffer is too short or the command ID is out of range.
|
||||
fn parse_header(buf: &[u8]) -> Option<(u8, u8, u16)> {
|
||||
if buf.len() < CMD_HEADER_SIZE {
|
||||
return None;
|
||||
}
|
||||
let cmd = buf[0];
|
||||
if cmd == 0 || cmd >= 0x80 {
|
||||
return None;
|
||||
}
|
||||
let seq = buf[1];
|
||||
let payload_len = (buf[2] as u16) | ((buf[3] as u16) << 8); // LE
|
||||
Some((cmd, seq, payload_len))
|
||||
}
|
||||
|
||||
/// Build a response packet into `buf`. Returns the total number of bytes
|
||||
/// written (padded to a multiple of 4 for SRAM block alignment).
|
||||
fn build_response(buf: &mut [u8], seq: u8, status: u8, payload: &[u8]) -> usize {
|
||||
let plen = payload.len();
|
||||
buf[0] = RESPONSE_MARKER;
|
||||
buf[1] = seq;
|
||||
buf[2] = status;
|
||||
buf[3] = plen as u8;
|
||||
buf[RSP_HEADER_SIZE..RSP_HEADER_SIZE + plen].copy_from_slice(payload);
|
||||
// Pad total length to multiple of 4
|
||||
let raw_len = RSP_HEADER_SIZE + plen;
|
||||
let padded = (raw_len + 3) & !3;
|
||||
for b in buf[raw_len..padded].iter_mut() {
|
||||
*b = 0;
|
||||
}
|
||||
padded
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EEPROM helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 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
|
||||
+ (pattern::HEADER_SIZE as u16 / 4)
|
||||
+ (index as u16) * (pattern::PATTERN_ENTRY_SIZE as u16 / 4)
|
||||
}
|
||||
|
||||
/// Read the XBLK library header from EEPROM.
|
||||
fn read_library_header<I2C, E>(
|
||||
ntag: &mut Ntag5Link<I2C>,
|
||||
) -> Result<Option<pattern::LibraryHeader>, ntag5::Error<E>>
|
||||
where
|
||||
I2C: I2c<Error = E>,
|
||||
{
|
||||
let mut hdr_buf = [0u8; pattern::HEADER_SIZE];
|
||||
ntag.read_memory(pattern::LIBRARY_BASE_BLOCK, &mut hdr_buf)?;
|
||||
Ok(pattern::parse_header(&hdr_buf))
|
||||
}
|
||||
|
||||
/// Write the 16-byte header to EEPROM (4 blocks starting at LIBRARY_BASE_BLOCK).
|
||||
fn write_header_to_eeprom<I2C, E>(
|
||||
ntag: &mut Ntag5Link<I2C>,
|
||||
hdr: &[u8; pattern::HEADER_SIZE],
|
||||
delay: &mut impl embedded_hal::delay::DelayNs,
|
||||
) -> Result<(), ntag5::Error<E>>
|
||||
where
|
||||
I2C: I2c<Error = E>,
|
||||
{
|
||||
for i in 0..4u16 {
|
||||
let mut chunk = [0u8; 4];
|
||||
let off = (i as usize) * 4;
|
||||
chunk.copy_from_slice(&hdr[off..off + 4]);
|
||||
ntag.write_verify_block(pattern::LIBRARY_BASE_BLOCK + i, &chunk, delay)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recalculate the header CRC and write the updated header back to EEPROM.
|
||||
fn recalculate_header_crc<I2C, E>(
|
||||
ntag: &mut Ntag5Link<I2C>,
|
||||
count: u8,
|
||||
active: u8,
|
||||
budget: u8,
|
||||
has_playlist: bool,
|
||||
playlist_count: u8,
|
||||
delay: &mut impl embedded_hal::delay::DelayNs,
|
||||
) -> Result<(), ntag5::Error<E>>
|
||||
where
|
||||
I2C: I2c<Error = E>,
|
||||
{
|
||||
let mut hdr = [0u8; pattern::HEADER_SIZE];
|
||||
pattern::serialize_header(count, active, budget, has_playlist, playlist_count, &mut hdr);
|
||||
write_header_to_eeprom(ntag, &hdr, delay)
|
||||
}
|
||||
|
||||
/// After a standalone WRITE_PATTERN (not during sync), re-read the header
|
||||
/// and recalculate its CRC to account for the new pattern data.
|
||||
fn update_header_after_write<I2C, E>(
|
||||
ntag: &mut Ntag5Link<I2C>,
|
||||
delay: &mut impl embedded_hal::delay::DelayNs,
|
||||
) -> Result<(), ntag5::Error<E>>
|
||||
where
|
||||
I2C: I2c<Error = E>,
|
||||
{
|
||||
let hdr = match read_library_header(ntag)? {
|
||||
Some(h) => h,
|
||||
None => return Ok(()),
|
||||
};
|
||||
recalculate_header_crc(
|
||||
ntag,
|
||||
hdr.pattern_count,
|
||||
hdr.active_index,
|
||||
hdr.budget,
|
||||
hdr.has_playlist,
|
||||
hdr.playlist_count,
|
||||
delay,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// GET_STATUS (0x02): Return firmware version + library summary.
|
||||
fn handle_get_status<I2C, E>(
|
||||
ntag: &mut Ntag5Link<I2C>,
|
||||
seq: u8,
|
||||
rsp_buf: &mut [u8],
|
||||
) -> Result<usize, ntag5::Error<E>>
|
||||
where
|
||||
I2C: I2c<Error = E>,
|
||||
{
|
||||
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.budget;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
Ok(build_response(rsp_buf, seq, STATUS_OK, &payload))
|
||||
}
|
||||
|
||||
/// WRITE_PATTERN (0x01): Write a 16-byte pattern entry to EEPROM.
|
||||
fn handle_write_pattern<I2C, E>(
|
||||
ntag: &mut Ntag5Link<I2C>,
|
||||
state: &MailboxState,
|
||||
seq: u8,
|
||||
payload: &[u8],
|
||||
rsp_buf: &mut [u8],
|
||||
delay: &mut impl embedded_hal::delay::DelayNs,
|
||||
) -> Result<usize, ntag5::Error<E>>
|
||||
where
|
||||
I2C: I2c<Error = E>,
|
||||
{
|
||||
if payload.len() < 1 + pattern::PATTERN_ENTRY_SIZE {
|
||||
return Ok(build_response(rsp_buf, seq, STATUS_INVALID_INDEX, &[]));
|
||||
}
|
||||
let index = payload[0];
|
||||
if index as usize >= pattern::MAX_PATTERNS {
|
||||
return Ok(build_response(rsp_buf, seq, STATUS_INVALID_INDEX, &[]));
|
||||
}
|
||||
|
||||
// Write 16 bytes = 4 blocks
|
||||
let base_block = pattern_block(index);
|
||||
let pattern_data = &payload[1..1 + pattern::PATTERN_ENTRY_SIZE];
|
||||
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]);
|
||||
match ntag.write_verify_block(base_block + i, &chunk, delay) {
|
||||
Ok(()) => {}
|
||||
Err(ntag5::Error::VerifyFailed) => {
|
||||
return Ok(build_response(rsp_buf, seq, STATUS_EEPROM_FAIL, &[]));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
// If not syncing, update header CRC
|
||||
if !state.syncing {
|
||||
match update_header_after_write(ntag, delay) {
|
||||
Ok(()) => {}
|
||||
Err(ntag5::Error::VerifyFailed) => {
|
||||
return Ok(build_response(rsp_buf, seq, STATUS_EEPROM_FAIL, &[]));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(build_response(rsp_buf, seq, STATUS_OK, &[]))
|
||||
}
|
||||
|
||||
/// SET_ACTIVE (0x03): Change the active pattern index in the EEPROM header.
|
||||
fn handle_set_active<I2C, E>(
|
||||
ntag: &mut Ntag5Link<I2C>,
|
||||
seq: u8,
|
||||
payload: &[u8],
|
||||
rsp_buf: &mut [u8],
|
||||
delay: &mut impl embedded_hal::delay::DelayNs,
|
||||
) -> Result<usize, ntag5::Error<E>>
|
||||
where
|
||||
I2C: I2c<Error = E>,
|
||||
{
|
||||
if payload.is_empty() {
|
||||
return Ok(build_response(rsp_buf, seq, STATUS_INVALID_INDEX, &[]));
|
||||
}
|
||||
let index = payload[0];
|
||||
|
||||
let hdr = match read_library_header(ntag)? {
|
||||
Some(h) => h,
|
||||
None => return Ok(build_response(rsp_buf, seq, STATUS_EEPROM_FAIL, &[])),
|
||||
};
|
||||
|
||||
if index >= hdr.pattern_count {
|
||||
return Ok(build_response(rsp_buf, seq, STATUS_INVALID_INDEX, &[]));
|
||||
}
|
||||
|
||||
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, &[]));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
|
||||
Ok(build_response(rsp_buf, seq, STATUS_OK, &[]))
|
||||
}
|
||||
|
||||
/// SYNC_START (0x05): Begin a bulk pattern upload session.
|
||||
fn handle_sync_start(
|
||||
state: &mut MailboxState,
|
||||
seq: u8,
|
||||
payload: &[u8],
|
||||
rsp_buf: &mut [u8],
|
||||
) -> usize {
|
||||
if payload.len() < 2 {
|
||||
return build_response(rsp_buf, seq, STATUS_BAD_CMD, &[]);
|
||||
}
|
||||
let count = payload[0];
|
||||
if count == 0 || count as usize > pattern::MAX_PATTERNS {
|
||||
return build_response(rsp_buf, seq, STATUS_INVALID_INDEX, &[]);
|
||||
}
|
||||
state.syncing = true;
|
||||
state.sync_count = count;
|
||||
state.sync_budget = payload[1];
|
||||
build_response(rsp_buf, seq, STATUS_OK, &[])
|
||||
}
|
||||
|
||||
/// SYNC_END (0x06): Finalize bulk upload — write the XBLK header with CRC.
|
||||
fn handle_sync_end<I2C, E>(
|
||||
ntag: &mut Ntag5Link<I2C>,
|
||||
state: &mut MailboxState,
|
||||
seq: u8,
|
||||
rsp_buf: &mut [u8],
|
||||
delay: &mut impl embedded_hal::delay::DelayNs,
|
||||
) -> Result<usize, ntag5::Error<E>>
|
||||
where
|
||||
I2C: I2c<Error = E>,
|
||||
{
|
||||
if !state.syncing {
|
||||
return Ok(build_response(rsp_buf, seq, STATUS_BAD_CMD, &[]));
|
||||
}
|
||||
|
||||
match recalculate_header_crc(
|
||||
ntag, state.sync_count, 0, state.sync_budget, false, 0, delay,
|
||||
) {
|
||||
Ok(()) => {}
|
||||
Err(ntag5::Error::VerifyFailed) => {
|
||||
state.syncing = false;
|
||||
return Ok(build_response(rsp_buf, seq, STATUS_EEPROM_FAIL, &[]));
|
||||
}
|
||||
Err(e) => {
|
||||
state.syncing = false;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
state.syncing = false;
|
||||
Ok(build_response(rsp_buf, seq, STATUS_OK, &[]))
|
||||
}
|
||||
|
||||
/// READ_LIBRARY (0x07): Return library summary and prepare for READ_NEXT.
|
||||
fn handle_read_library<I2C, E>(
|
||||
ntag: &mut Ntag5Link<I2C>,
|
||||
state: &mut MailboxState,
|
||||
seq: u8,
|
||||
rsp_buf: &mut [u8],
|
||||
) -> Result<usize, ntag5::Error<E>>
|
||||
where
|
||||
I2C: I2c<Error = E>,
|
||||
{
|
||||
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.budget;
|
||||
state.read_index = 0;
|
||||
state.read_count = h.pattern_count;
|
||||
}
|
||||
None => {
|
||||
state.read_index = 0;
|
||||
state.read_count = 0;
|
||||
}
|
||||
}
|
||||
Ok(build_response(rsp_buf, seq, STATUS_OK, &payload))
|
||||
}
|
||||
|
||||
/// READ_NEXT (0x08): Return the next pattern entry (16 bytes).
|
||||
fn handle_read_next<I2C, E>(
|
||||
ntag: &mut Ntag5Link<I2C>,
|
||||
state: &mut MailboxState,
|
||||
seq: u8,
|
||||
rsp_buf: &mut [u8],
|
||||
) -> Result<usize, ntag5::Error<E>>
|
||||
where
|
||||
I2C: I2c<Error = E>,
|
||||
{
|
||||
if state.read_index >= state.read_count {
|
||||
return Ok(build_response(rsp_buf, seq, STATUS_INVALID_INDEX, &[]));
|
||||
}
|
||||
|
||||
let mut entry = [0u8; pattern::PATTERN_ENTRY_SIZE];
|
||||
let block = pattern_block(state.read_index);
|
||||
ntag.read_memory(block, &mut entry)?;
|
||||
state.read_index += 1;
|
||||
Ok(build_response(rsp_buf, seq, STATUS_OK, &entry))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Read SRAM, dispatch the command, and write the response back.
|
||||
///
|
||||
/// Returns `Ok(true)` if a command was processed that modifies the pattern library,
|
||||
/// `Ok(false)` if no valid command or no library change.
|
||||
pub fn process_command<I2C, E>(
|
||||
ntag: &mut Ntag5Link<I2C>,
|
||||
state: &mut MailboxState,
|
||||
delay: &mut impl embedded_hal::delay::DelayNs,
|
||||
) -> Result<bool, ntag5::Error<E>>
|
||||
where
|
||||
I2C: I2c<Error = E>,
|
||||
{
|
||||
// Read full SRAM
|
||||
let mut sram = [0u8; SRAM_SIZE];
|
||||
ntag.read_sram(&mut sram)?;
|
||||
|
||||
// Parse header
|
||||
let (cmd, seq, payload_len) = match parse_header(&sram) {
|
||||
Some(h) => h,
|
||||
None => return Ok(false),
|
||||
};
|
||||
|
||||
// Bounds check
|
||||
let total = CMD_HEADER_SIZE + payload_len as usize;
|
||||
if total > SRAM_SIZE {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let payload = &sram[CMD_HEADER_SIZE..total];
|
||||
|
||||
// Dispatch
|
||||
let mut rsp = [0u8; SRAM_SIZE];
|
||||
let rsp_len = match cmd {
|
||||
CMD_GET_STATUS => handle_get_status(ntag, seq, &mut rsp)?,
|
||||
CMD_WRITE_PATTERN => {
|
||||
handle_write_pattern(ntag, state, seq, payload, &mut rsp, delay)?
|
||||
}
|
||||
CMD_SET_ACTIVE => handle_set_active(ntag, seq, payload, &mut rsp, delay)?,
|
||||
CMD_SYNC_START => handle_sync_start(state, seq, payload, &mut rsp),
|
||||
CMD_SYNC_END => handle_sync_end(ntag, state, seq, &mut rsp, delay)?,
|
||||
CMD_READ_LIBRARY => handle_read_library(ntag, state, seq, &mut rsp)?,
|
||||
CMD_READ_NEXT => handle_read_next(ntag, state, seq, &mut rsp)?,
|
||||
_ => build_response(&mut rsp, seq, STATUS_BAD_CMD, &[]),
|
||||
};
|
||||
|
||||
ntag.write_sram_blocks(0, &rsp[..rsp_len])?;
|
||||
|
||||
// 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)
|
||||
}
|
||||
437
src/pattern/mod.rs
Normal file
437
src/pattern/mod.rs
Normal file
@@ -0,0 +1,437 @@
|
||||
//! Software pattern engine for GPIO-direct PWM LED control.
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
/// Number of LED channels.
|
||||
pub const NUM_LEDS: usize = 6;
|
||||
|
||||
/// Animation tick rate in Hz.
|
||||
pub const TICK_RATE_HZ: u32 = 50;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Waveform types and lookup tables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Available waveform shapes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
#[repr(u8)]
|
||||
pub enum Waveform {
|
||||
Sine = 0,
|
||||
Triangle = 1,
|
||||
Square = 2,
|
||||
Heartbeat = 3,
|
||||
}
|
||||
|
||||
impl Waveform {
|
||||
pub fn from_u8(v: u8) -> Option<Self> {
|
||||
match v {
|
||||
0 => Some(Waveform::Sine),
|
||||
1 => Some(Waveform::Triangle),
|
||||
2 => Some(Waveform::Square),
|
||||
3 => Some(Waveform::Heartbeat),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 64-entry quarter-wave sine table (0-255 output range).
|
||||
/// Full wave is reconstructed by mirroring: indices 0..63 = rising first quarter,
|
||||
/// 64..127 = falling second quarter (mirror), 128..191 = negative third (zero),
|
||||
/// 192..255 = negative fourth (zero). For unipolar: mirror to get full 0-255-0 cycle.
|
||||
const SINE_QUARTER: [u8; 64] = {
|
||||
// Approximate sin(x) for x in [0, pi/2], scaled to 0-255.
|
||||
// Generated from: round(255 * sin(i * pi / 128)) for i in 0..64
|
||||
let mut table = [0u8; 64];
|
||||
let mut i = 0;
|
||||
while i < 64 {
|
||||
// Fixed-point sine approximation using Taylor series:
|
||||
// sin(x) ~ x - x^3/6 + x^5/120, where x = i * pi / 128
|
||||
// We use a precomputed table for accuracy.
|
||||
// These values are: round(255 * sin(i * pi / 128))
|
||||
table[i] = SINE_VALUES[i];
|
||||
i += 1;
|
||||
}
|
||||
table
|
||||
};
|
||||
|
||||
const SINE_VALUES: [u8; 64] = [
|
||||
0, 6, 12, 19, 25, 31, 37, 43, 49, 56, 62, 68, 74, 80, 86, 91,
|
||||
97, 103, 109, 114, 120, 125, 131, 136, 141, 146, 151, 156, 161, 166, 170, 175,
|
||||
179, 183, 187, 191, 195, 199, 202, 206, 209, 212, 215, 218, 220, 223, 225, 228,
|
||||
230, 232, 233, 235, 237, 238, 239, 241, 242, 243, 243, 244, 245, 245, 245, 245,
|
||||
];
|
||||
|
||||
/// Heartbeat waveform: 256-entry full cycle.
|
||||
/// Double-pulse cardiac shape: two sharp peaks with a rest period.
|
||||
const HEARTBEAT_LUT: [u8; 256] = {
|
||||
let mut table = [0u8; 256];
|
||||
// First beat: indices 0-31 (sharp rise/fall)
|
||||
let mut i = 0;
|
||||
while i < 16 {
|
||||
table[i] = (i as u8) * 16; // 0 → 240
|
||||
i += 1;
|
||||
}
|
||||
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
|
||||
};
|
||||
|
||||
/// 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]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pattern state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
impl PatternEngine {
|
||||
pub fn new(pattern: PatternDef, budget: u8) -> Self {
|
||||
PatternEngine {
|
||||
pattern,
|
||||
tick: 0,
|
||||
cycle_count: 0,
|
||||
budget: budget as u16,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Power governor: scale down if total exceeds budget
|
||||
if self.budget > 0 {
|
||||
let total: u16 = output.iter().map(|&v| v as u16).sum();
|
||||
if total > self.budget {
|
||||
let scale = (self.budget * 256) / total;
|
||||
for v in output.iter_mut() {
|
||||
*v = ((*v as u16 * scale) / 256) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Advance tick
|
||||
self.tick += 1;
|
||||
if self.tick >= cycle {
|
||||
self.tick = 0;
|
||||
if self.cycle_count < u16::MAX {
|
||||
self.cycle_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this pattern's repeat count has been reached.
|
||||
pub fn repeats_done(&self) -> bool {
|
||||
if self.pattern.repeat_count == 0xFF {
|
||||
return false; // loop forever
|
||||
}
|
||||
self.cycle_count >= self.pattern.repeat_count as u16
|
||||
}
|
||||
|
||||
/// Load a new pattern, resetting tick and cycle count.
|
||||
pub fn load(&mut self, pattern: PatternDef) {
|
||||
self.pattern = pattern;
|
||||
self.tick = 0;
|
||||
self.cycle_count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Predefined patterns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// XBLK v2 EEPROM format
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// EEPROM base block for the pattern library (upper 1K, block 256).
|
||||
pub const LIBRARY_BASE_BLOCK: u16 = 0x0100;
|
||||
|
||||
/// Magic bytes identifying a valid XBLK library.
|
||||
pub const XBLK_MAGIC: [u8; 4] = *b"XBLK";
|
||||
|
||||
/// Format version.
|
||||
pub const XBLK_VERSION: u8 = 0x02;
|
||||
|
||||
/// Header size in bytes.
|
||||
pub const HEADER_SIZE: usize = 16;
|
||||
|
||||
/// Pattern entry size in bytes.
|
||||
pub const PATTERN_ENTRY_SIZE: usize = 16;
|
||||
|
||||
/// Maximum playlist entries.
|
||||
pub const MAX_PLAYLIST: usize = 32;
|
||||
|
||||
/// 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 budget: u8,
|
||||
pub has_playlist: bool,
|
||||
pub playlist_count: u8,
|
||||
}
|
||||
|
||||
/// Parse a 16-byte XBLK v2 header. Returns None if magic or version is invalid.
|
||||
pub fn parse_header(buf: &[u8; HEADER_SIZE]) -> Option<LibraryHeader> {
|
||||
if buf[0..4] != XBLK_MAGIC {
|
||||
return None;
|
||||
}
|
||||
if buf[4] != XBLK_VERSION {
|
||||
return None;
|
||||
}
|
||||
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[7] % count,
|
||||
budget: buf[8],
|
||||
has_playlist: flags & 0x01 != 0,
|
||||
playlist_count: buf[9],
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a 16-byte pattern entry into a PatternDef.
|
||||
pub fn parse_pattern_entry(buf: &[u8; PATTERN_ENTRY_SIZE]) -> Option<PatternDef> {
|
||||
let waveform = Waveform::from_u8(buf[0])?;
|
||||
let cycle_len = buf[1];
|
||||
if cycle_len == 0 {
|
||||
return None;
|
||||
}
|
||||
let 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],
|
||||
})
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// CRC-16/CCITT-FALSE.
|
||||
pub fn crc16(data: &[u8]) -> u16 {
|
||||
let mut crc: u16 = 0xFFFF;
|
||||
for &b in data {
|
||||
crc ^= (b as u16) << 8;
|
||||
for _ in 0..8 {
|
||||
if crc & 0x8000 != 0 {
|
||||
crc = (crc << 1) ^ 0x1021;
|
||||
} else {
|
||||
crc <<= 1;
|
||||
}
|
||||
crc &= 0xFFFF;
|
||||
}
|
||||
}
|
||||
crc
|
||||
}
|
||||
166
tools/provision_eh.py
Normal file
166
tools/provision_eh.py
Normal file
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Configure NTAG5Link energy harvesting for 1.8V automatic VOUT.
|
||||
|
||||
Writes EH_CONFIG to persistent EEPROM (block 0x3D) so the NTAG5
|
||||
automatically outputs 1.8V when an NFC field is present — powering
|
||||
the SAMD21 MCU without any firmware intervention.
|
||||
|
||||
Also sets ED_CONFIG (FD pin) for NFC-to-I2C SRAM pass-through so
|
||||
the MCU can detect SRAM writes from the phone.
|
||||
|
||||
Usage:
|
||||
# Read current EH config:
|
||||
python provision_eh.py --read
|
||||
|
||||
# Write 1.8V / 6.5mA EH config:
|
||||
python provision_eh.py --write
|
||||
|
||||
# Write with custom current limit:
|
||||
python provision_eh.py --write --current 4.0
|
||||
|
||||
# Also configure CONFIG_0 and CONFIG_1 for xblink:
|
||||
python provision_eh.py --write --full
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add ntag5sensor to path
|
||||
ntag5sensor_path = os.path.join(os.path.dirname(__file__), "..", "..", "ntag5sensor")
|
||||
sys.path.insert(0, ntag5sensor_path)
|
||||
|
||||
from reader.acr1552 import ACR1552
|
||||
from vicinity.iso15693 import ISO15693
|
||||
from vicinity.ntag5link import (
|
||||
Ntag5Link,
|
||||
NXP_EH_CONFIG_EH_VOUT_V_SEL_1_8,
|
||||
NXP_EH_CONFIG_EH_VOUT_V_SEL_2_4,
|
||||
NXP_EH_CONFIG_EH_VOUT_V_SEL_3_0,
|
||||
NXP_EH_CONFIG_EH_VOUT_I_SEL_0_4,
|
||||
NXP_EH_CONFIG_EH_VOUT_I_SEL_0_6,
|
||||
NXP_EH_CONFIG_EH_VOUT_I_SEL_1_4,
|
||||
NXP_EH_CONFIG_EH_VOUT_I_SEL_2_7,
|
||||
NXP_EH_CONFIG_EH_VOUT_I_SEL_4_0,
|
||||
NXP_EH_CONFIG_EH_VOUT_I_SEL_6_5,
|
||||
NXP_EH_CONFIG_EH_VOUT_I_SEL_9_0,
|
||||
NXP_EH_CONFIG_EH_VOUT_I_SEL_12_5,
|
||||
NXP_ED_CONFIG_NFC_TO_I2C_PASS_THROUGH,
|
||||
NXP_CONFIG_0_EH_MODE_LOW_FIELD_STRENGTH,
|
||||
NXP_CONFIG_1_ARBITER_MODE_SRAM_PASSTHROUGH,
|
||||
NXP_CONFIG_1_USE_CASE_CONF_I2C_SLAVE,
|
||||
)
|
||||
|
||||
# Current limit lookup: string -> constant
|
||||
CURRENT_MAP = {
|
||||
"0.4": NXP_EH_CONFIG_EH_VOUT_I_SEL_0_4,
|
||||
"0.6": NXP_EH_CONFIG_EH_VOUT_I_SEL_0_6,
|
||||
"1.4": NXP_EH_CONFIG_EH_VOUT_I_SEL_1_4,
|
||||
"2.7": NXP_EH_CONFIG_EH_VOUT_I_SEL_2_7,
|
||||
"4.0": NXP_EH_CONFIG_EH_VOUT_I_SEL_4_0,
|
||||
"6.5": NXP_EH_CONFIG_EH_VOUT_I_SEL_6_5,
|
||||
"9.0": NXP_EH_CONFIG_EH_VOUT_I_SEL_9_0,
|
||||
"12.5": NXP_EH_CONFIG_EH_VOUT_I_SEL_12_5,
|
||||
}
|
||||
|
||||
|
||||
def read_config(chip):
|
||||
"""Read and display current EH and general config."""
|
||||
print("=== NTAG5 Configuration ===\n")
|
||||
|
||||
info = chip.get_system_info()
|
||||
print(f"UID: {info['uid'].hex()}")
|
||||
|
||||
config = chip.get_config_info()
|
||||
print(f"\nCONFIG_0:")
|
||||
print(f" EH mode: {config.get('energy_harvesting_mode', '?')}")
|
||||
print(f" SRAM copy: {config.get('sram_copy_enabled', '?')}")
|
||||
print(f" Auto standby: {config.get('auto_standby_mode', '?')}")
|
||||
|
||||
print(f"\nCONFIG_1:")
|
||||
print(f" SRAM enable: {config.get('sram_enabled', '?')}")
|
||||
print(f" Arbiter mode: {config.get('arbiter_mode', '?')}")
|
||||
print(f" Use case: {config.get('use_case', '?')}")
|
||||
print(f" EH arbiter: {config.get('eh_arbiter_mode_enabled', '?')}")
|
||||
|
||||
eh = chip.get_eh_ed_config_info()
|
||||
print(f"\nEH_CONFIG (block 0x3D):")
|
||||
print(f" EH enable: {eh.get('eh_enable', '?')}")
|
||||
print(f" VOUT voltage: {eh.get('eh_vout_v_sel', '?')}V")
|
||||
print(f" VOUT current: {eh.get('eh_vout_i_sel', '?')}mA")
|
||||
print(f" Power check disabled: {eh.get('disable_power_check', '?')}")
|
||||
print(f" ED/FD config: {eh.get('ed_config', '?')}")
|
||||
|
||||
|
||||
def write_eh(chip, current_sel, full_config=False):
|
||||
"""Write EH config for 1.8V automatic come-up."""
|
||||
|
||||
print("Writing EH config: 1.8V, current limit = "
|
||||
f"{[k for k,v in CURRENT_MAP.items() if v == current_sel][0]}mA")
|
||||
print(f" ED/FD pin: NFC-to-I2C pass-through (SRAM write detect)")
|
||||
|
||||
chip.write_eh_ed_config(
|
||||
enable=True,
|
||||
disable_power_check=False,
|
||||
current=current_sel,
|
||||
voltage=NXP_EH_CONFIG_EH_VOUT_V_SEL_1_8,
|
||||
ed_config=NXP_ED_CONFIG_NFC_TO_I2C_PASS_THROUGH,
|
||||
)
|
||||
print(" EH_CONFIG written.")
|
||||
|
||||
if full_config:
|
||||
print("\nWriting CONFIG_0: EH mode = low field strength")
|
||||
chip.write_config0(
|
||||
eh_mode=NXP_CONFIG_0_EH_MODE_LOW_FIELD_STRENGTH,
|
||||
)
|
||||
print(" CONFIG_0 written.")
|
||||
|
||||
print("Writing CONFIG_1: SRAM enable, arbiter=passthrough, use_case=I2C slave")
|
||||
chip.write_config1(
|
||||
sram_enable=True,
|
||||
arbiter_mode=NXP_CONFIG_1_ARBITER_MODE_SRAM_PASSTHROUGH,
|
||||
use_case=NXP_CONFIG_1_USE_CASE_CONF_I2C_SLAVE,
|
||||
)
|
||||
print(" CONFIG_1 written.")
|
||||
|
||||
# Verify
|
||||
print("\n--- Verify ---")
|
||||
read_config(chip)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Configure NTAG5Link energy harvesting for 1.8V")
|
||||
parser.add_argument("--read", action="store_true",
|
||||
help="Read current config (no writes)")
|
||||
parser.add_argument("--write", action="store_true",
|
||||
help="Write EH config for 1.8V automatic VOUT")
|
||||
parser.add_argument("--current", default="6.5",
|
||||
choices=list(CURRENT_MAP.keys()),
|
||||
help="VOUT current limit in mA (default: 6.5)")
|
||||
parser.add_argument("--full", action="store_true",
|
||||
help="Also write CONFIG_0 and CONFIG_1 for xblink")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.read and not args.write:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
reader = ACR1552()
|
||||
reader.connect()
|
||||
iso = ISO15693(reader)
|
||||
chip = Ntag5Link(iso)
|
||||
|
||||
if args.read:
|
||||
read_config(chip)
|
||||
|
||||
if args.write:
|
||||
current_sel = CURRENT_MAP[args.current]
|
||||
write_eh(chip, current_sel, full_config=args.full)
|
||||
|
||||
reader.disconnect()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
247
tools/xblk_serialize.py
Normal file
247
tools/xblk_serialize.py
Normal file
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
XBLK v2 pattern library serializer for xblink.
|
||||
|
||||
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:
|
||||
python xblk_serialize.py --output patterns.bin
|
||||
|
||||
# Write to NTAG5 via PCSC reader:
|
||||
python xblk_serialize.py --write
|
||||
|
||||
# Load from JSON file:
|
||||
python xblk_serialize.py --json patterns.json --write
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
import os
|
||||
|
||||
# XBLK v2 format constants (must match src/pattern/mod.rs)
|
||||
XBLK_MAGIC = b"XBLK"
|
||||
XBLK_VERSION = 0x02
|
||||
HEADER_SIZE = 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
|
||||
|
||||
# Waveform IDs
|
||||
WAVEFORM_LOOKUP = {
|
||||
"sine": 0, "triangle": 1, "square": 2, "heartbeat": 3,
|
||||
}
|
||||
|
||||
|
||||
def crc16(data: bytes) -> int:
|
||||
"""CRC-16/CCITT-FALSE."""
|
||||
crc = 0xFFFF
|
||||
for b in data:
|
||||
crc ^= b << 8
|
||||
for _ in range(8):
|
||||
if crc & 0x8000:
|
||||
crc = (crc << 1) ^ 0x1021
|
||||
else:
|
||||
crc <<= 1
|
||||
crc &= 0xFFFF
|
||||
return crc
|
||||
|
||||
|
||||
def encode_pattern(pat: dict) -> bytes:
|
||||
"""Encode a single pattern dict to PATTERN_ENTRY_SIZE bytes."""
|
||||
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)
|
||||
|
||||
# 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] = 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 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})")
|
||||
|
||||
budget = config.get("budget", 50)
|
||||
active = config.get("active", 0) % len(patterns)
|
||||
playlist = config.get("playlist", [])
|
||||
has_playlist = len(playlist) > 0
|
||||
|
||||
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] = 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)
|
||||
|
||||
# Build playlist data
|
||||
playlist_data = bytes(playlist + [0] * (MAX_PLAYLIST - len(playlist)))
|
||||
|
||||
result = bytes(header) + pat_data
|
||||
if has_playlist:
|
||||
result += playlist_data[:MAX_PLAYLIST]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Built-in patterns (matching src/pattern/mod.rs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def builtin_patterns() -> dict:
|
||||
"""Return the 5 built-in patterns as a config dict."""
|
||||
return {
|
||||
"budget": 50,
|
||||
"active": 0,
|
||||
"patterns": [
|
||||
{
|
||||
"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."""
|
||||
ntag5sensor_path = os.path.join(os.path.dirname(__file__), "..", "..", "ntag5sensor")
|
||||
sys.path.insert(0, ntag5sensor_path)
|
||||
|
||||
from vicinity.iso15693 import ISO15693, ISO_FLAG_DATA_RATE
|
||||
from vicinity.ntag5link import Ntag5Link
|
||||
|
||||
reader = ISO15693()
|
||||
chip = Ntag5Link(reader)
|
||||
|
||||
print(f"Writing {len(data)} bytes to EEPROM blocks {LIBRARY_BASE_BLOCK}-{LIBRARY_BASE_BLOCK + len(data)//4 - 1}")
|
||||
|
||||
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))
|
||||
|
||||
flags = ISO_FLAG_DATA_RATE | 0x08
|
||||
cmd = bytes([flags, 0x21]) + struct.pack("<H", block) + chunk
|
||||
reader.transmit_iso15693(cmd, True)
|
||||
|
||||
import time
|
||||
time.sleep(0.006)
|
||||
|
||||
if (i // 4) % 10 == 0:
|
||||
print(f" Block {block}...")
|
||||
|
||||
print("Done!")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="XBLK v2 pattern library serializer")
|
||||
parser.add_argument("--json", help="JSON pattern definition file")
|
||||
parser.add_argument("--output", "-o", help="Output binary file")
|
||||
parser.add_argument("--write", action="store_true", help="Write to NTAG5 via PCSC")
|
||||
parser.add_argument("--dump", action="store_true", help="Hex dump the binary")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.json:
|
||||
with open(args.json) as f:
|
||||
config = json.load(f)
|
||||
else:
|
||||
config = builtin_patterns()
|
||||
|
||||
data = encode_library(config)
|
||||
print(f"Library: {len(config['patterns'])} patterns, {len(data)} bytes")
|
||||
print(f"CRC-16: 0x{struct.unpack_from('>H', data, 14)[0]:04X}")
|
||||
|
||||
if args.dump:
|
||||
for i in range(0, len(data), 16):
|
||||
hex_str = " ".join(f"{b:02X}" for b in data[i:i+16])
|
||||
ascii_str = "".join(chr(b) if 32 <= b < 127 else "." for b in data[i:i+16])
|
||||
print(f" {i:04X}: {hex_str:<48s} {ascii_str}")
|
||||
|
||||
if args.output:
|
||||
with open(args.output, "wb") as f:
|
||||
f.write(data)
|
||||
print(f"Written to {args.output}")
|
||||
|
||||
if args.write:
|
||||
write_to_ntag5(data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user