M5 complete: boot-from-EEPROM with self-provisioning

XBLK binary format for pattern library in NTAG5 upper 1K EEPROM:
- 16-byte header (magic, version, pattern count, active index, CRC-16)
- Up to 9 × 112-byte fixed-size pattern entries
- Deserializer + serializer in src/pattern/mod.rs
- MCU self-provisions hardcoded patterns on first boot (no XBLK found)
- Subsequent boots load active pattern directly from EEPROM
- Python serializer tool for future PCSC-based pattern uploads

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
michael
2026-03-05 15:37:42 -08:00
parent acb48902be
commit a32c242859
5 changed files with 829 additions and 58 deletions

View File

@@ -1,6 +1,6 @@
# xblink Project Status
**Current Milestone**: M5Boot-from-EEPROM
**Current Milestone**: M6SRAM Mailbox
**Last Updated**: 2026-03-05
---
@@ -55,13 +55,15 @@
- [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

View File

@@ -0,0 +1,106 @@
# 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 4 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.
## 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.

View File

@@ -71,15 +71,15 @@ fn main() -> ! {
let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS);
// 2 mA per channel (20 × 0.1 mA) — realistic for EH power budget
const LED_CURRENT: u8 = 20;
// Default LED current: 2 mA per channel (20 × 0.1 mA)
let mut led_current: u8 = 20;
// Verify LP5562 is reachable before entering pattern loop
// Verify LP5562 is reachable before continuing
let i2c_ok = (|| -> Result<(), led::lp5562::Error<_>> {
lp.enable()?;
delay.delay_ms(1u32); // >500us startup
lp.init_direct_control(ClockSource::Internal)?;
lp.set_all_current(LED_CURRENT, LED_CURRENT, LED_CURRENT, LED_CURRENT)?;
lp.set_all_current(led_current, led_current, led_current, led_current)?;
Ok(())
})();
@@ -89,11 +89,70 @@ fn main() -> ! {
led.set_low().unwrap();
delay.delay_ms(500u32);
led.set_high().unwrap();
delay.delay_ms(1000u32); // 1s gap between status signals
delay.delay_ms(1000u32);
// --- Try to load pattern library from NTAG5 EEPROM ---
let i2c = lp.release();
let mut ntag = Ntag5Link::new(i2c, ntag5::DEFAULT_ADDRESS);
// Config check (LED blinks only, no NDEF write)
match ntag.check_config() {
Ok(result) => {
if result.all_ok {
blink(&mut led, &mut delay, 2, 100);
} else {
blink(&mut led, &mut delay, 5, 60);
}
}
Err(_) => {
// NTAG5 not reachable — 1 long blink (not fatal)
led.set_low().unwrap();
delay.delay_ms(800u32);
led.set_high().unwrap();
}
}
delay.delay_ms(500u32);
// Try reading XBLK library header from upper 1K
let mut header_buf = [0u8; pattern::HEADER_SIZE];
let eeprom_ok = ntag.read_memory(pattern::LIBRARY_BASE_BLOCK, &mut header_buf).ok()
.and_then(|()| pattern::parse_header(&header_buf));
// Read active pattern if header is valid
let eeprom_pattern = eeprom_ok.as_ref().and_then(|hdr| {
led_current = hdr.current;
let pat_block = pattern::LIBRARY_BASE_BLOCK
+ (pattern::HEADER_SIZE as u16 / 4)
+ (hdr.active_index as u16 * (pattern::PATTERN_ENTRY_SIZE as u16 / 4));
let mut pat_buf = [0u8; pattern::PATTERN_ENTRY_SIZE];
ntag.read_memory(pat_block, &mut pat_buf).ok()?;
pattern::parse_pattern_entry(&pat_buf)
});
match eeprom_pattern {
Some(pat) => {
// Reclaim I2C for LP5562
let i2c = ntag.release();
let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS);
// EEPROM pattern loaded — 3 fast blinks
blink(&mut led, &mut delay, 3, 80);
if pattern::load_pattern(&mut lp, &pat, led_current, &mut delay).is_err() {
blink(&mut led, &mut delay, 10, 50);
}
// LP5562 runs autonomously — MCU idles (future: STANDBY)
loop {
delay.delay_ms(60000u32);
}
}
None => {
// No XBLK in EEPROM — self-provision hardcoded patterns
// 2 long blinks = provisioning
blink(&mut led, &mut delay, 2, 300);
// LED mode: Rgbw for EVM RGB LED, Mono3 for 3 separate LEDs
let mode = LedMode::Rgbw;
let patterns = [
pattern::breathe(mode),
pattern::heartbeat(mode),
@@ -102,58 +161,97 @@ fn main() -> ! {
pattern::color_wash(mode),
];
// Load first pattern — LP5562 engines run autonomously
let _ = pattern::load_pattern(&mut lp, &patterns[0], LED_CURRENT, &mut delay);
// Serialize all pattern entries and write to EEPROM
let pat_base_block = pattern::LIBRARY_BASE_BLOCK
+ (pattern::HEADER_SIZE as u16 / 4);
let mut all_pat_data = [0u8; pattern::PATTERN_ENTRY_SIZE * 5];
let mut provision_ok = true;
// Release I2C from LP5562 (engines keep running) for NTAG5 check
let i2c = lp.release();
let mut ntag = Ntag5Link::new(i2c, ntag5::DEFAULT_ADDRESS);
for (i, pat) in patterns.iter().enumerate() {
let mut entry_buf = [0u8; pattern::PATTERN_ENTRY_SIZE];
pattern::serialize_pattern_entry(pat, &mut entry_buf);
// NTAG5 config check — read session registers, write NDEF result
// If NTAG5 isn't connected, this will fail silently (I2C NAK)
match ntag.check_and_write_ndef(&mut delay) {
Ok(true) => {
// Config OK — 2 short blinks
blink(&mut led, &mut delay, 2, 100);
// Copy into combined buffer for CRC
let offset = i * pattern::PATTERN_ENTRY_SIZE;
all_pat_data[offset..offset + pattern::PATTERN_ENTRY_SIZE]
.copy_from_slice(&entry_buf);
// Write 112 bytes as 28 x 4-byte blocks
let entry_block = pat_base_block
+ (i as u16 * (pattern::PATTERN_ENTRY_SIZE as u16 / 4));
for blk in 0..28u16 {
let bo = (blk as usize) * 4;
let chunk = [entry_buf[bo], entry_buf[bo+1], entry_buf[bo+2], entry_buf[bo+3]];
if ntag.write_verify_block(entry_block + blk, &chunk, &mut delay).is_err() {
provision_ok = false;
break;
}
Ok(false) => {
// Config mismatch — 5 fast blinks (warning)
blink(&mut led, &mut delay, 5, 60);
}
Err(ntag5::Error::VerifyFailed) => {
// EEPROM write-verify failed — SOS pattern (3 rapid, pause, 3 rapid)
blink(&mut led, &mut delay, 3, 40);
delay.delay_ms(300u32);
blink(&mut led, &mut delay, 3, 40);
if !provision_ok { break; }
}
if provision_ok {
// Write header last (so partial writes don't look valid)
let mut hdr_buf = [0u8; pattern::HEADER_SIZE];
pattern::serialize_header(
5, 0, led_current, 0x00,
&all_pat_data,
&mut hdr_buf,
);
for blk in 0..4u16 {
let bo = (blk as usize) * 4;
let chunk = [hdr_buf[bo], hdr_buf[bo+1], hdr_buf[bo+2], hdr_buf[bo+3]];
if ntag.write_verify_block(
pattern::LIBRARY_BASE_BLOCK + blk, &chunk, &mut delay,
).is_err() {
provision_ok = false;
break;
}
Err(ntag5::Error::I2c(_)) => {
// NTAG5 not reachable — 1 long blink (not fatal)
led.set_low().unwrap();
delay.delay_ms(800u32);
led.set_high().unwrap();
}
}
// Reclaim I2C for LP5562
if provision_ok {
// Success — 4 fast blinks, then load pattern 0 from EEPROM
blink(&mut led, &mut delay, 4, 80);
// Re-read active pattern from what we just wrote
let mut pat_buf = [0u8; pattern::PATTERN_ENTRY_SIZE];
let loaded = ntag.read_memory(pat_base_block, &mut pat_buf).ok()
.and_then(|()| pattern::parse_pattern_entry(&pat_buf));
let i2c = ntag.release();
let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS);
// Pattern cycle loop — LP5562 engines run autonomously, no MCU LED indication
let mut idx = 1; // Already loaded pattern 0 above
loop {
if pattern::load_pattern(
&mut lp, &patterns[idx], LED_CURRENT, &mut delay,
).is_err() {
// I2C error loading pattern — fast blink
if let Some(pat) = loaded {
if pattern::load_pattern(&mut lp, &pat, led_current, &mut delay).is_err() {
blink(&mut led, &mut delay, 10, 50);
}
}
// MCU idles while LP5562 runs the pattern
loop {
delay.delay_ms(60000u32);
}
} else {
// Provisioning failed — fall back to hardcoded cycle
blink(&mut led, &mut delay, 8, 50);
let i2c = ntag.release();
let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS);
let mut idx = 0;
loop {
if pattern::load_pattern(
&mut lp, &patterns[idx], led_current, &mut delay,
).is_err() {
blink(&mut led, &mut delay, 10, 50);
}
delay.delay_ms(15000u32);
idx = (idx + 1) % patterns.len();
}
}
}
}
}
Err(_) => {
// === DIAGNOSTIC: fast blink forever = I2C error ===
loop {

View File

@@ -344,3 +344,229 @@ where
Ok(())
}
// ---------------------------------------------------------------------------
// XBLK EEPROM pattern library 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 = 0x01;
/// Header size in bytes.
pub const HEADER_SIZE: usize = 16;
/// Pattern entry size in bytes (fixed for direct seeking).
pub const PATTERN_ENTRY_SIZE: usize = 112;
/// Maximum patterns that fit in 1024 bytes: (1024 - 16) / 112 = 9.
pub const MAX_PATTERNS: usize = 9;
/// Parsed XBLK library header.
pub struct LibraryHeader {
pub pattern_count: u8,
pub active_index: u8,
pub current: u8,
pub led_mode: u8,
}
/// Parse a 16-byte XBLK 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 count = buf[5];
if count == 0 || count as usize > MAX_PATTERNS {
return None;
}
Some(LibraryHeader {
pattern_count: count,
active_index: buf[6] % count, // wrap if out of range
current: buf[7],
led_mode: buf[8],
})
}
/// Parse a 112-byte pattern entry into a Pattern struct.
/// Returns None if the data is malformed.
pub fn parse_pattern_entry(buf: &[u8; PATTERN_ENTRY_SIZE]) -> Option<Pattern> {
let engine_count = buf[0];
if engine_count > 3 {
return None;
}
let led_map_reg = buf[1];
// Decode LED_MAP register: 2 bits per channel [W(7:6), R(5:4), G(3:2), B(1:0)]
let map_b = led_map_byte_to_mapping(led_map_reg & 0x03)?;
let map_g = led_map_byte_to_mapping((led_map_reg >> 2) & 0x03)?;
let map_r = led_map_byte_to_mapping((led_map_reg >> 4) & 0x03)?;
let map_w = led_map_byte_to_mapping((led_map_reg >> 6) & 0x03)?;
// Direct PWM values at bytes 2-5 (unused for now, engines override)
// let _direct_pwm = [buf[2], buf[3], buf[4], buf[5]];
let engine1 = parse_engine_program(&buf[6..40])?;
let engine2 = parse_engine_program(&buf[40..74])?;
let engine3 = parse_engine_program(&buf[74..108])?;
Some(Pattern {
engine1,
engine2,
engine3,
map_b,
map_g,
map_r,
map_w,
})
}
/// Parse a 34-byte engine section: 2 bytes command count (BE) + 32 bytes commands.
/// Returns Some(None) for unused engines, Some(Some(prog)) for valid, None for malformed.
fn parse_engine_program(buf: &[u8]) -> Option<Option<EngineProgram>> {
let cmd_count = ((buf[0] as u16) << 8 | buf[1] as u16) as usize;
if cmd_count == 0 {
return Some(None);
}
if cmd_count > 16 {
return None; // malformed
}
let mut commands = [0u16; 16];
for i in 0..cmd_count {
let offset = 2 + i * 2;
commands[i] = (buf[offset] as u16) << 8 | buf[offset + 1] as u16;
}
let mut prog = EngineProgram::new();
for i in 0..cmd_count {
let _ = prog.push(commands[i]);
}
Some(Some(prog))
}
/// Convert a 2-bit LED_MAP field to a LedMapping enum value.
fn led_map_byte_to_mapping(val: u8) -> Option<LedMapping> {
match val {
0b00 => Some(LedMapping::I2c),
0b01 => Some(LedMapping::Engine1),
0b10 => Some(LedMapping::Engine2),
0b11 => Some(LedMapping::Engine3),
_ => None,
}
}
/// Build the raw LED_MAP register byte from a Pattern's mapping fields.
pub fn pattern_to_led_map_byte(p: &Pattern) -> u8 {
(p.map_b as u8)
| ((p.map_g as u8) << 2)
| ((p.map_r as u8) << 4)
| ((p.map_w as u8) << 6)
}
// ---------------------------------------------------------------------------
// XBLK serializer (MCU-side, for self-provisioning)
// ---------------------------------------------------------------------------
/// Serialize a Pattern into a 112-byte XBLK entry buffer.
pub fn serialize_pattern_entry(p: &Pattern, buf: &mut [u8; PATTERN_ENTRY_SIZE]) {
// Zero the buffer
for b in buf.iter_mut() {
*b = 0;
}
// Engine count
let mut count = 0u8;
if p.engine1.is_some() { count += 1; }
if p.engine2.is_some() { count += 1; }
if p.engine3.is_some() { count += 1; }
buf[0] = count;
// LED_MAP register byte
buf[1] = pattern_to_led_map_byte(p);
// Direct PWM [B, G, R, W] — bytes 2-5, leave as 0 for engine patterns
// Engine programs
serialize_engine(&p.engine1, &mut buf[6..40]);
serialize_engine(&p.engine2, &mut buf[40..74]);
serialize_engine(&p.engine3, &mut buf[74..108]);
}
/// Serialize an optional engine program into a 34-byte section.
fn serialize_engine(eng: &Option<EngineProgram>, buf: &mut [u8]) {
match eng {
None => {
buf[0] = 0;
buf[1] = 0;
}
Some(prog) => {
let len = prog.len();
buf[0] = (len >> 8) as u8;
buf[1] = len as u8;
let bytes = prog.as_bytes(); // 32-byte SRAM image, big-endian
buf[2..34].copy_from_slice(&bytes);
}
}
}
/// CRC-16/CCITT-FALSE (matching Python serializer).
pub fn crc16(data: &[u8]) -> u16 {
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
}
/// Serialize the XBLK header into a 16-byte buffer.
/// CRC is computed over header bytes 0-13 + pattern data.
pub fn serialize_header(
pattern_count: u8,
active_index: u8,
current: u8,
led_mode: u8,
pattern_data_crc_input: &[u8],
buf: &mut [u8; HEADER_SIZE],
) {
buf[0..4].copy_from_slice(&XBLK_MAGIC);
buf[4] = XBLK_VERSION;
buf[5] = pattern_count;
buf[6] = active_index;
buf[7] = current;
buf[8] = led_mode;
for i in 9..14 {
buf[i] = 0;
}
// CRC over header[0..14] + pattern data
let mut crc = crc16(&buf[0..14]);
// Continue CRC over pattern data
for &b in pattern_data_crc_input {
crc ^= (b as u16) << 8;
for _ in 0..8 {
if crc & 0x8000 != 0 {
crc = (crc << 1) ^ 0x1021;
} else {
crc <<= 1;
}
crc &= 0xFFFF;
}
}
buf[14] = (crc >> 8) as u8;
buf[15] = crc as u8;
}

339
tools/xblk_serialize.py Normal file
View File

@@ -0,0 +1,339 @@
#!/usr/bin/env python3
"""
XBLK pattern library serializer for xblink.
Converts pattern definitions to the XBLK binary format and writes them
to NTAG5 EEPROM blocks 256+ (upper 1K) via ntag5sensor ISO15693 commands.
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 format constants (must match src/pattern/mod.rs)
XBLK_MAGIC = b"XBLK"
XBLK_VERSION = 0x01
HEADER_SIZE = 16
PATTERN_ENTRY_SIZE = 112
MAX_PATTERNS = 9
MAX_COMMANDS_PER_ENGINE = 16
# EEPROM block offset for pattern library (upper 1K)
LIBRARY_BASE_BLOCK = 256
# LED_MAP encoding helpers
LED_MAP_LOOKUP = {
"direct": 0b00, "i2c": 0b00,
"engine1": 0b01, "e1": 0b01,
"engine2": 0b10, "e2": 0b10,
"engine3": 0b11, "e3": 0b11,
}
LED_MODE_LOOKUP = {"rgbw": 0x00, "mono3": 0x01}
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_led_map(mapping: dict) -> int:
"""Encode {"b": "engine1", "g": "engine1", ...} to LP5562 LED_MAP register byte."""
b = LED_MAP_LOOKUP.get(mapping.get("b", "direct"), 0)
g = LED_MAP_LOOKUP.get(mapping.get("g", "direct"), 0)
r = LED_MAP_LOOKUP.get(mapping.get("r", "direct"), 0)
w = LED_MAP_LOOKUP.get(mapping.get("w", "direct"), 0)
return b | (g << 2) | (r << 4) | (w << 6)
def encode_pattern(pat: dict) -> bytes:
"""Encode a single pattern dict to PATTERN_ENTRY_SIZE bytes."""
engines = pat.get("engines", [[], [], []])
while len(engines) < 3:
engines.append([])
engine_count = sum(1 for e in engines if len(e) > 0)
led_map_reg = encode_led_map(pat.get("led_map", {}))
direct_pwm = pat.get("direct_pwm", [0, 0, 0, 0])
while len(direct_pwm) < 4:
direct_pwm.append(0)
buf = bytearray(PATTERN_ENTRY_SIZE)
buf[0] = engine_count
buf[1] = led_map_reg
buf[2:6] = bytes(direct_pwm[:4])
for eng_idx, cmds in enumerate(engines[:3]):
if len(cmds) > MAX_COMMANDS_PER_ENGINE:
raise ValueError(f"Engine {eng_idx+1} has {len(cmds)} commands (max {MAX_COMMANDS_PER_ENGINE})")
base = 6 + eng_idx * 34 # 2 bytes count + 32 bytes commands
struct.pack_into(">H", buf, base, len(cmds))
for i, cmd in enumerate(cmds):
struct.pack_into(">H", buf, base + 2 + i * 2, cmd & 0xFFFF)
return bytes(buf)
def encode_library(config: dict) -> bytes:
"""Encode a full XBLK library (header + patterns) to bytes."""
patterns = config.get("patterns", [])
if len(patterns) == 0:
raise ValueError("No patterns defined")
if len(patterns) > MAX_PATTERNS:
raise ValueError(f"Too many patterns: {len(patterns)} (max {MAX_PATTERNS})")
current = config.get("current", 20)
mode = LED_MODE_LOOKUP.get(config.get("mode", "rgbw"), 0x00)
active = config.get("active", 0) % len(patterns)
# Build header (without CRC)
header = bytearray(HEADER_SIZE)
header[0:4] = XBLK_MAGIC
header[4] = XBLK_VERSION
header[5] = len(patterns)
header[6] = active
header[7] = current & 0xFF
header[8] = mode
# bytes 9-13 reserved
# bytes 14-15 CRC (filled below)
# Build pattern data
pat_data = b""
for pat in patterns:
pat_data += encode_pattern(pat)
# Compute CRC over header (bytes 0-13) + all pattern data
crc = crc16(bytes(header[:14]) + pat_data)
struct.pack_into(">H", header, 14, crc)
return bytes(header) + pat_data
# ---------------------------------------------------------------------------
# Built-in patterns (matching src/pattern/mod.rs)
# ---------------------------------------------------------------------------
# LP5562 EngineCommand helpers (matching lp5562.rs encoding)
def ramp_wait(prescale_slow: bool, step_time: int, up: bool, increment: int) -> int:
prescale_bit = 0x4000 if prescale_slow else 0
sign_bit = 0 if up else 0x0080
return prescale_bit | ((step_time & 0x3F) << 8) | sign_bit | (increment & 0x7F)
def wait(prescale_slow: bool, step_time: int) -> int:
prescale_bit = 0x4000 if prescale_slow else 0
return prescale_bit | ((step_time & 0x3F) << 8)
def set_pwm(value: int) -> int:
return 0x4000 | value # Actually: 0x40xx format
# Wait, let me check the actual encoding...
def branch(step: int, loop_count: int) -> int:
return 0xA000 | ((loop_count & 0x3F) << 7) | (step & 0x7F)
def trigger(wait_mask: int, send_mask: int) -> int:
return 0xE000 | ((wait_mask & 0x07) << 7) | (send_mask & 0x07)
def builtin_patterns() -> dict:
"""Return the 5 built-in patterns as a config dict."""
# Breathe: 1 engine, all RGB channels
breathe_cmds = [
ramp_wait(True, 1, True, 127), # 0→128
ramp_wait(True, 1, True, 127), # 128→255
ramp_wait(True, 1, False, 127), # 255→127
ramp_wait(True, 1, False, 127), # 127→0
wait(True, 48), # pause
branch(0, 0), # loop
]
# Heartbeat: 1 engine, double-pulse
heartbeat_cmds = [
0x40FF, # set_pwm(255)
wait(False, 20), # hold ~10ms
0x4000, # set_pwm(0)
wait(False, 40), # gap ~20ms
0x40FF, # set_pwm(255)
wait(False, 20), # hold ~10ms
0x4000, # set_pwm(0)
wait(True, 63), # rest ~1.0s
wait(True, 32), # rest ~0.5s
branch(0, 0),
]
# Slow pulse: 1 engine, very gentle
slow_pulse_cmds = [
ramp_wait(True, 4, True, 127), # 0→128
ramp_wait(True, 4, True, 127), # 128→255
wait(True, 32), # hold
ramp_wait(True, 4, False, 127), # 255→127
ramp_wait(True, 4, False, 127), # 127→0
wait(True, 63), # pause
branch(0, 0),
]
# RGB cycle: 3 engines with trigger sync
rgb_e1 = [
ramp_wait(True, 1, True, 127),
ramp_wait(True, 1, True, 127),
trigger(0, 0b010), # send to E2
ramp_wait(True, 1, False, 127),
ramp_wait(True, 1, False, 127),
wait(True, 63),
branch(0, 0),
]
rgb_e2 = [
trigger(0b001, 0), # wait for E1
ramp_wait(True, 1, True, 127),
ramp_wait(True, 1, True, 127),
trigger(0, 0b100), # send to E3
ramp_wait(True, 1, False, 127),
ramp_wait(True, 1, False, 127),
wait(True, 32),
branch(0, 0),
]
rgb_e3 = [
trigger(0b010, 0), # wait for E2
ramp_wait(True, 1, True, 127),
ramp_wait(True, 1, True, 127),
ramp_wait(True, 1, False, 127),
ramp_wait(True, 1, False, 127),
wait(True, 32),
branch(0, 0),
]
# Color wash: 3 engines, free-running with different periods
wash_e1 = [
ramp_wait(True, 1, True, 127),
ramp_wait(True, 1, True, 127),
ramp_wait(True, 1, False, 127),
ramp_wait(True, 1, False, 127),
branch(0, 0),
]
wash_e2 = [
ramp_wait(True, 1, True, 127),
ramp_wait(True, 1, True, 127),
ramp_wait(True, 1, False, 127),
ramp_wait(True, 1, False, 127),
wait(True, 32),
branch(0, 0),
]
wash_e3 = [
ramp_wait(True, 1, True, 127),
ramp_wait(True, 1, True, 127),
ramp_wait(True, 1, False, 127),
ramp_wait(True, 1, False, 127),
wait(True, 63),
branch(0, 0),
]
single_rgb_map = {"b": "engine1", "g": "engine1", "r": "engine1", "w": "direct"}
triple_map = {"b": "engine1", "g": "engine2", "r": "engine3", "w": "direct"}
return {
"current": 20,
"mode": "rgbw",
"active": 0,
"patterns": [
{"name": "breathe", "led_map": single_rgb_map, "engines": [breathe_cmds, [], []]},
{"name": "heartbeat", "led_map": single_rgb_map, "engines": [heartbeat_cmds, [], []]},
{"name": "slow_pulse", "led_map": single_rgb_map, "engines": [slow_pulse_cmds, [], []]},
{"name": "rgb_cycle", "led_map": triple_map, "engines": [rgb_e1, rgb_e2, rgb_e3]},
{"name": "color_wash", "led_map": triple_map, "engines": [wash_e1, wash_e2, wash_e3]},
],
}
def write_to_ntag5(data: bytes):
"""Write binary data to NTAG5 EEPROM blocks 256+ via ntag5sensor."""
# Add ntag5sensor to path
ntag5sensor_path = os.path.join(os.path.dirname(__file__), "..", "..", "ntag5sensor")
sys.path.insert(0, ntag5sensor_path)
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}")
# Write in 4-byte blocks
for i in range(0, len(data), 4):
block = LIBRARY_BASE_BLOCK + i // 4
chunk = data[i:i+4]
if len(chunk) < 4:
chunk = chunk + b'\x00' * (4 - len(chunk))
# Use ISO15693 WRITE SINGLE BLOCK (unaddressed)
# Block address needs protocol extension for blocks > 255
flags = ISO_FLAG_DATA_RATE | 0x08 # data rate + protocol extension
cmd = bytes([flags, 0x21]) + struct.pack("<H", block) + chunk
reader.transmit_iso15693(cmd, True)
# EEPROM write cycle delay
import time
time.sleep(0.006)
if (i // 4) % 10 == 0:
print(f" Block {block}...")
print("Done!")
def main():
parser = argparse.ArgumentParser(description="XBLK pattern library serializer")
parser.add_argument("--json", help="JSON pattern definition file")
parser.add_argument("--output", "-o", help="Output binary file")
parser.add_argument("--write", action="store_true", help="Write to NTAG5 via PCSC")
parser.add_argument("--builtin", action="store_true", help="Use built-in patterns (default if no --json)")
parser.add_argument("--dump", action="store_true", help="Hex dump the binary")
args = parser.parse_args()
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()