From a32c242859c05996ec9f60daf1060b6108c9d25f Mon Sep 17 00:00:00 2001 From: michael Date: Thu, 5 Mar 2026 15:37:42 -0800 Subject: [PATCH] M5 complete: boot-from-EEPROM with self-provisioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/STATUS.md | 16 +- .../plans/2026-03-05-eeprom-pattern-format.md | 106 ++++++ src/main.rs | 200 ++++++++--- src/pattern/mod.rs | 226 ++++++++++++ tools/xblk_serialize.py | 339 ++++++++++++++++++ 5 files changed, 829 insertions(+), 58 deletions(-) create mode 100644 docs/plans/2026-03-05-eeprom-pattern-format.md create mode 100644 tools/xblk_serialize.py diff --git a/docs/STATUS.md b/docs/STATUS.md index 744766c..0afb312 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,6 +1,6 @@ # xblink Project Status -**Current Milestone**: M5 — Boot-from-EEPROM +**Current Milestone**: M6 — SRAM 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 diff --git a/docs/plans/2026-03-05-eeprom-pattern-format.md b/docs/plans/2026-03-05-eeprom-pattern-format.md new file mode 100644 index 0000000..068446c --- /dev/null +++ b/docs/plans/2026-03-05-eeprom-pattern-format.md @@ -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. diff --git a/src/main.rs b/src/main.rs index b41994a..04242e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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,69 +89,167 @@ 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); - // LED mode: Rgbw for EVM RGB LED, Mono3 for 3 separate LEDs - let mode = LedMode::Rgbw; - - let patterns = [ - pattern::breathe(mode), - pattern::heartbeat(mode), - pattern::slow_pulse(mode), - pattern::rgb_cycle(mode), - pattern::color_wash(mode), - ]; - - // Load first pattern — LP5562 engines run autonomously - let _ = pattern::load_pattern(&mut lp, &patterns[0], LED_CURRENT, &mut delay); - - // Release I2C from LP5562 (engines keep running) for NTAG5 check + // --- Try to load pattern library from NTAG5 EEPROM --- let i2c = lp.release(); let mut ntag = Ntag5Link::new(i2c, ntag5::DEFAULT_ADDRESS); - // 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); + // 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); + } } - 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); - } - Err(ntag5::Error::I2c(_)) => { + 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); - // Reclaim I2C for LP5562 - let i2c = ntag.release(); - let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS); + // 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)); - // 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 - blink(&mut led, &mut delay, 10, 50); + // 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); - // MCU idles while LP5562 runs the pattern - delay.delay_ms(15000u32); + let mode = LedMode::Rgbw; + let patterns = [ + pattern::breathe(mode), + pattern::heartbeat(mode), + pattern::slow_pulse(mode), + pattern::rgb_cycle(mode), + pattern::color_wash(mode), + ]; - idx = (idx + 1) % patterns.len(); + // Serialize all pattern entries and write to EEPROM + let pat_base_block = pattern::LIBRARY_BASE_BLOCK + + (pattern::HEADER_SIZE as u16 / 4); + let mut all_pat_data = [0u8; pattern::PATTERN_ENTRY_SIZE * 5]; + let mut provision_ok = true; + + for (i, pat) in patterns.iter().enumerate() { + let mut entry_buf = [0u8; pattern::PATTERN_ENTRY_SIZE]; + pattern::serialize_pattern_entry(pat, &mut entry_buf); + + // Copy into combined buffer for CRC + let offset = i * pattern::PATTERN_ENTRY_SIZE; + all_pat_data[offset..offset + pattern::PATTERN_ENTRY_SIZE] + .copy_from_slice(&entry_buf); + + // Write 112 bytes as 28 x 4-byte blocks + let entry_block = pat_base_block + + (i as u16 * (pattern::PATTERN_ENTRY_SIZE as u16 / 4)); + for blk in 0..28u16 { + let bo = (blk as usize) * 4; + let chunk = [entry_buf[bo], entry_buf[bo+1], entry_buf[bo+2], entry_buf[bo+3]]; + if ntag.write_verify_block(entry_block + blk, &chunk, &mut delay).is_err() { + provision_ok = false; + break; + } + } + if !provision_ok { break; } + } + + if provision_ok { + // Write header last (so partial writes don't look valid) + let mut hdr_buf = [0u8; pattern::HEADER_SIZE]; + pattern::serialize_header( + 5, 0, led_current, 0x00, + &all_pat_data, + &mut hdr_buf, + ); + for blk in 0..4u16 { + let bo = (blk as usize) * 4; + let chunk = [hdr_buf[bo], hdr_buf[bo+1], hdr_buf[bo+2], hdr_buf[bo+3]]; + if ntag.write_verify_block( + pattern::LIBRARY_BASE_BLOCK + blk, &chunk, &mut delay, + ).is_err() { + provision_ok = false; + break; + } + } + } + + if provision_ok { + // Success — 4 fast blinks, then load pattern 0 from EEPROM + blink(&mut led, &mut delay, 4, 80); + + // Re-read active pattern from what we just wrote + let mut pat_buf = [0u8; pattern::PATTERN_ENTRY_SIZE]; + let loaded = ntag.read_memory(pat_base_block, &mut pat_buf).ok() + .and_then(|()| pattern::parse_pattern_entry(&pat_buf)); + + let i2c = ntag.release(); + let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS); + + if let Some(pat) = loaded { + if pattern::load_pattern(&mut lp, &pat, led_current, &mut delay).is_err() { + blink(&mut led, &mut delay, 10, 50); + } + } + + 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(_) => { diff --git a/src/pattern/mod.rs b/src/pattern/mod.rs index b4250a1..8cc45af 100644 --- a/src/pattern/mod.rs +++ b/src/pattern/mod.rs @@ -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 { + 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 { + 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> { + 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 { + 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, 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; +} diff --git a/tools/xblk_serialize.py b/tools/xblk_serialize.py new file mode 100644 index 0000000..3188111 --- /dev/null +++ b/tools/xblk_serialize.py @@ -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', 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()