- Protocol design: streaming single-hold NFC transfer, 7 commands - Implementation plan: 7 tasks for subagent-driven development - STATUS.md: M6 marked complete, current milestone now M7 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1025 lines
33 KiB
Markdown
1025 lines
33 KiB
Markdown
# M6: SRAM Mailbox Implementation Plan
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||
|
||
**Goal:** Enable NFC↔MCU communication via NTAG5's 256-byte SRAM mailbox so the phone app can read/write LED patterns without power-cycling.
|
||
|
||
**Architecture:** MCU polls FD pin (A1/PA04) in idle loop every ~200ms. When FD goes low (phone wrote to SRAM), MCU reads the 256-byte SRAM, parses a 6-byte command header, executes the command (pattern write, status query, etc.), and writes a response back to SRAM. The phone polls SRAM byte 0 for the 0xFF response marker. Streaming transfers send one pattern per SRAM round-trip during a single NFC hold.
|
||
|
||
**Tech Stack:** Rust `no_std`, `embedded-hal` 1.0 I2C + GPIO traits, XIAO M0 BSP, NTAG5Link I2C at 0x54, LP5562 at 0x30.
|
||
|
||
**Design doc:** `docs/plans/2026-03-05-sram-mailbox-protocol.md`
|
||
|
||
---
|
||
|
||
### Task 1: Add NTAG5 `write_register` and SRAM constants
|
||
|
||
**Files:**
|
||
- Modify: `src/ntag5/mod.rs`
|
||
|
||
**Step 1: Add SRAM and FD pin constants**
|
||
|
||
Add after the existing session register constants (line 19):
|
||
|
||
```rust
|
||
// 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;
|
||
|
||
// Session register for ED/FD pin configuration
|
||
pub const SESSION_ED_FD_PIN_CFG: u16 = 0x10A3;
|
||
|
||
// FD pin modes (register byte values for ED_FD_PIN_CFG, reg_addr 0x01)
|
||
// Mode: FD active on SRAM write by RF, cleared on I2C read
|
||
pub const FD_MODE_SRAM_RF_WRITE: u8 = 0x04;
|
||
```
|
||
|
||
**Step 2: Add `write_register` method**
|
||
|
||
Add after the existing `read_register` method (after line 144):
|
||
|
||
```rust
|
||
/// 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],
|
||
)
|
||
}
|
||
```
|
||
|
||
**Step 3: Add SRAM read/write methods**
|
||
|
||
Add after `write_register`:
|
||
|
||
```rust
|
||
// ---- SRAM access ----
|
||
|
||
/// Read N bytes from SRAM starting at byte offset.
|
||
/// SRAM is at I2C blocks 0x10F8-0x10FF (64 blocks × 4 bytes).
|
||
pub fn read_sram(&mut self, offset: usize, buf: &mut [u8]) -> Result<(), E> {
|
||
let block = SRAM_BASE_BLOCK + (offset as u16 / 4);
|
||
// Read full blocks that cover the requested range
|
||
let block_offset = offset % 4;
|
||
let total_bytes = block_offset + buf.len();
|
||
let blocks_needed = (total_bytes + 3) / 4;
|
||
// For simplicity, read aligned blocks into a temp buffer
|
||
// Max SRAM = 256 bytes, so this fits on stack
|
||
let mut tmp = [0u8; SRAM_SIZE];
|
||
let read_len = blocks_needed * 4;
|
||
self.read_memory(block, &mut tmp[..read_len])?;
|
||
buf.copy_from_slice(&tmp[block_offset..block_offset + buf.len()]);
|
||
Ok(())
|
||
}
|
||
|
||
/// Write N bytes to SRAM starting at byte offset.
|
||
/// Writes full 4-byte blocks. Offset must be 4-byte aligned.
|
||
/// Data length must be a multiple of 4 (pad if needed).
|
||
pub fn write_sram_aligned(&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 FD pin for SRAM-write-by-RF indication.
|
||
/// FD goes low when phone writes to SRAM, returns high when MCU reads SRAM.
|
||
pub fn configure_fd_sram_write(&mut self) -> Result<(), E> {
|
||
// ED_FD_PIN_CFG is register index 1 within SESSION_ED_FD_PIN_CFG block
|
||
// Bits 2:0 control FD output mode
|
||
self.write_register(SESSION_ED_FD_PIN_CFG, 0x01, 0x07, FD_MODE_SRAM_RF_WRITE)
|
||
}
|
||
```
|
||
|
||
**Step 4: Build to verify**
|
||
|
||
Run: `cd /home/work/xblink && cargo build --release 2>&1`
|
||
Expected: Compiles with no errors (new methods are unused, no warnings for pub fns)
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ntag5/mod.rs
|
||
git commit -m "Add NTAG5 SRAM read/write and FD pin config for M6 mailbox"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Add SRAM command protocol types
|
||
|
||
**Files:**
|
||
- Create: `src/ntag5/sram.rs`
|
||
- Modify: `src/ntag5/mod.rs` (add `pub mod sram;`)
|
||
|
||
**Step 1: Create the SRAM protocol module**
|
||
|
||
Create `src/ntag5/sram.rs`:
|
||
|
||
```rust
|
||
//! SRAM mailbox command/response protocol for NFC↔MCU communication.
|
||
//!
|
||
//! Packet format (256 bytes max):
|
||
//! Byte 0: Command ID (0x01-0x7F) or Response marker (0xFF)
|
||
//! Byte 1: Sequence number
|
||
//! Byte 2-3: Payload length (u16 LE)
|
||
//! Byte 4-5: CRC-16 over bytes 0-3 + payload
|
||
//! Byte 6+: Payload
|
||
|
||
use crate::pattern;
|
||
|
||
// Command IDs
|
||
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_CRC: u8 = 0x01;
|
||
pub const STATUS_BAD_CMD: u8 = 0x02;
|
||
pub const STATUS_EEPROM_FAIL: u8 = 0x03;
|
||
pub const STATUS_INVALID_INDEX: u8 = 0x04;
|
||
|
||
// Header size
|
||
pub const CMD_HEADER_SIZE: usize = 6;
|
||
|
||
/// Firmware version reported in GET_STATUS
|
||
pub const FIRMWARE_VERSION: u8 = 0x01;
|
||
|
||
/// Parse command header from SRAM buffer.
|
||
/// Returns (command_id, sequence, payload_length) or None if invalid.
|
||
pub fn parse_header(buf: &[u8; CMD_HEADER_SIZE]) -> Option<(u8, u8, u16)> {
|
||
let cmd = buf[0];
|
||
if cmd == 0 || cmd >= 0x80 {
|
||
return None; // Invalid command or response marker
|
||
}
|
||
let seq = buf[1];
|
||
let payload_len = (buf[2] as u16) | ((buf[3] as u16) << 8);
|
||
Some((cmd, seq, payload_len))
|
||
}
|
||
|
||
/// Verify CRC-16 over header (bytes 0-3) + payload.
|
||
/// CRC is stored in bytes 4-5 of the header (big-endian).
|
||
pub fn verify_crc(header: &[u8; CMD_HEADER_SIZE], payload: &[u8]) -> bool {
|
||
let stored_crc = ((header[4] as u16) << 8) | header[5] as u16;
|
||
let mut data_for_crc = [0u8; 4];
|
||
data_for_crc.copy_from_slice(&header[0..4]);
|
||
// CRC over header[0..4] + payload
|
||
let mut crc = pattern::crc16(&data_for_crc);
|
||
// Continue CRC over payload
|
||
for &b in payload {
|
||
crc ^= (b as u16) << 8;
|
||
for _ in 0..8 {
|
||
if crc & 0x8000 != 0 {
|
||
crc = (crc << 1) ^ 0x1021;
|
||
} else {
|
||
crc <<= 1;
|
||
}
|
||
crc &= 0xFFFF;
|
||
}
|
||
}
|
||
crc == stored_crc
|
||
}
|
||
|
||
/// Build a response packet in the provided buffer.
|
||
/// Returns the total response length (header + payload).
|
||
pub fn build_response(
|
||
buf: &mut [u8],
|
||
seq: u8,
|
||
status: u8,
|
||
payload: &[u8],
|
||
) -> usize {
|
||
buf[0] = RESPONSE_MARKER;
|
||
buf[1] = seq;
|
||
buf[2] = status;
|
||
buf[3] = payload.len() as u8;
|
||
// Copy payload
|
||
for (i, &b) in payload.iter().enumerate() {
|
||
buf[6 + i] = b;
|
||
}
|
||
// CRC over bytes 0-3 + payload
|
||
let mut crc = pattern::crc16(&buf[0..4]);
|
||
for &b in payload {
|
||
crc ^= (b as u16) << 8;
|
||
for _ in 0..8 {
|
||
if crc & 0x8000 != 0 {
|
||
crc = (crc << 1) ^ 0x1021;
|
||
} else {
|
||
crc <<= 1;
|
||
}
|
||
crc &= 0xFFFF;
|
||
}
|
||
}
|
||
buf[4] = (crc >> 8) as u8;
|
||
buf[5] = crc as u8;
|
||
CMD_HEADER_SIZE + payload.len()
|
||
}
|
||
```
|
||
|
||
**Step 2: Add module declaration**
|
||
|
||
In `src/ntag5/mod.rs`, add after `use embedded_hal::i2c::I2c;` (line 12):
|
||
|
||
```rust
|
||
pub mod sram;
|
||
```
|
||
|
||
**Step 3: Build to verify**
|
||
|
||
Run: `cd /home/work/xblink && cargo build --release 2>&1`
|
||
Expected: Compiles. Some unused warnings are OK for now.
|
||
|
||
**Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/ntag5/sram.rs src/ntag5/mod.rs
|
||
git commit -m "Add SRAM mailbox command protocol types and CRC helpers"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Implement SRAM command processor
|
||
|
||
**Files:**
|
||
- Modify: `src/ntag5/sram.rs`
|
||
|
||
This is the core command dispatcher. It reads SRAM, parses the command, and calls the appropriate handler.
|
||
|
||
**Step 1: Add the mailbox state struct and processor**
|
||
|
||
Add at the bottom of `src/ntag5/sram.rs`:
|
||
|
||
```rust
|
||
use crate::ntag5::{self, Ntag5Link, SRAM_SIZE};
|
||
use crate::led::lp5562::{Lp5562, DEFAULT_ADDRESS as LP_ADDR};
|
||
use embedded_hal::i2c::I2c;
|
||
|
||
/// Mailbox state persisted across commands (for streaming operations).
|
||
pub struct MailboxState {
|
||
/// True if we're in the middle of a SYNC_START..SYNC_END sequence
|
||
pub syncing: bool,
|
||
/// Target pattern count for current sync
|
||
pub sync_count: u8,
|
||
/// LED current for sync
|
||
pub sync_current: u8,
|
||
/// LED mode for sync
|
||
pub sync_mode: u8,
|
||
/// Read index for READ_NEXT iteration
|
||
pub read_index: u8,
|
||
/// Total patterns for READ_NEXT
|
||
pub read_count: u8,
|
||
}
|
||
|
||
impl MailboxState {
|
||
pub fn new() -> Self {
|
||
Self {
|
||
syncing: false,
|
||
sync_count: 0,
|
||
sync_current: 0,
|
||
sync_mode: 0,
|
||
read_index: 0,
|
||
read_count: 0,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Process one SRAM mailbox command.
|
||
///
|
||
/// Reads SRAM, parses command, executes, writes response.
|
||
/// Returns Ok(true) if a command was processed, Ok(false) if SRAM was empty/invalid.
|
||
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 command header (first 6 bytes)
|
||
let mut hdr = [0u8; CMD_HEADER_SIZE];
|
||
ntag.read_sram(0, &mut hdr)?;
|
||
|
||
let (cmd, seq, payload_len) = match parse_header(&hdr) {
|
||
Some(v) => v,
|
||
None => return Ok(false), // Not a valid command
|
||
};
|
||
|
||
// Read payload if any
|
||
let plen = payload_len as usize;
|
||
let mut payload = [0u8; 250]; // Max payload
|
||
if plen > 0 {
|
||
let read_len = if plen > 250 { 250 } else { plen };
|
||
ntag.read_sram(CMD_HEADER_SIZE, &mut payload[..read_len])?;
|
||
}
|
||
|
||
// Verify CRC
|
||
if !verify_crc(&hdr, &payload[..plen]) {
|
||
let mut resp = [0u8; CMD_HEADER_SIZE];
|
||
let len = build_response(&mut resp, seq, STATUS_BAD_CRC, &[]);
|
||
ntag.write_sram_aligned(0, &resp[..((len + 3) / 4) * 4])?;
|
||
return Ok(true);
|
||
}
|
||
|
||
// Dispatch command
|
||
let mut resp_buf = [0u8; SRAM_SIZE];
|
||
let resp_len = match cmd {
|
||
CMD_GET_STATUS => handle_get_status(ntag, seq, &mut resp_buf, delay)?,
|
||
CMD_WRITE_PATTERN => handle_write_pattern(ntag, state, seq, &payload[..plen], &mut resp_buf, delay)?,
|
||
CMD_SET_ACTIVE => handle_set_active(ntag, seq, &payload[..plen], &mut resp_buf, delay)?,
|
||
CMD_SYNC_START => handle_sync_start(ntag, state, seq, &payload[..plen], &mut resp_buf, delay)?,
|
||
CMD_SYNC_END => handle_sync_end(ntag, state, seq, &mut resp_buf, delay)?,
|
||
CMD_READ_LIBRARY => handle_read_library(ntag, state, seq, &mut resp_buf, delay)?,
|
||
CMD_READ_NEXT => handle_read_next(ntag, state, seq, &mut resp_buf, delay)?,
|
||
_ => {
|
||
build_response(&mut resp_buf, seq, STATUS_BAD_CMD, &[])
|
||
}
|
||
};
|
||
|
||
// Write response to SRAM (aligned to 4 bytes)
|
||
let aligned_len = ((resp_len + 3) / 4) * 4;
|
||
ntag.write_sram_aligned(0, &resp_buf[..aligned_len])?;
|
||
|
||
Ok(true)
|
||
}
|
||
```
|
||
|
||
**Step 2: Build to verify**
|
||
|
||
Run: `cd /home/work/xblink && cargo build --release 2>&1`
|
||
Expected: Errors about missing handler functions — that's expected, we'll add them in Task 4.
|
||
|
||
**Step 3: Commit (WIP)**
|
||
|
||
```bash
|
||
git add src/ntag5/sram.rs
|
||
git commit -m "WIP: Add SRAM command dispatcher (handlers next)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Implement command handlers
|
||
|
||
**Files:**
|
||
- Modify: `src/ntag5/sram.rs`
|
||
|
||
**Step 1: Add all command handler functions**
|
||
|
||
Add at the bottom of `src/ntag5/sram.rs`:
|
||
|
||
```rust
|
||
// ---------------------------------------------------------------------------
|
||
// Command handlers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// GET_STATUS: return firmware version, pattern count, active index, current, mode.
|
||
fn handle_get_status<I2C, E>(
|
||
ntag: &mut Ntag5Link<I2C>,
|
||
seq: u8,
|
||
resp: &mut [u8],
|
||
_delay: &mut impl embedded_hal::delay::DelayNs,
|
||
) -> Result<usize, ntag5::Error<E>>
|
||
where
|
||
I2C: I2c<Error = E>,
|
||
{
|
||
let mut hdr_buf = [0u8; pattern::HEADER_SIZE];
|
||
match ntag.read_memory(pattern::LIBRARY_BASE_BLOCK, &mut hdr_buf) {
|
||
Ok(()) => {
|
||
if let Some(hdr) = pattern::parse_header(&hdr_buf) {
|
||
let payload = [
|
||
FIRMWARE_VERSION,
|
||
hdr.pattern_count,
|
||
hdr.active_index,
|
||
hdr.current,
|
||
hdr.led_mode,
|
||
];
|
||
Ok(build_response(resp, seq, STATUS_OK, &payload))
|
||
} else {
|
||
// No valid library
|
||
let payload = [FIRMWARE_VERSION, 0, 0, 0, 0];
|
||
Ok(build_response(resp, seq, STATUS_OK, &payload))
|
||
}
|
||
}
|
||
Err(e) => {
|
||
Ok(build_response(resp, seq, STATUS_EEPROM_FAIL, &[]))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// WRITE_PATTERN: write a single 112-byte pattern to EEPROM at given index.
|
||
fn handle_write_pattern<I2C, E>(
|
||
ntag: &mut Ntag5Link<I2C>,
|
||
state: &MailboxState,
|
||
seq: u8,
|
||
payload: &[u8],
|
||
resp: &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(resp, seq, STATUS_BAD_CMD, &[]));
|
||
}
|
||
|
||
let index = payload[0];
|
||
if index as usize >= pattern::MAX_PATTERNS {
|
||
return Ok(build_response(resp, seq, STATUS_INVALID_INDEX, &[]));
|
||
}
|
||
|
||
// Write 112 bytes as 28 × 4-byte blocks
|
||
let pat_base = pattern::LIBRARY_BASE_BLOCK
|
||
+ (pattern::HEADER_SIZE as u16 / 4)
|
||
+ (index as u16 * (pattern::PATTERN_ENTRY_SIZE as u16 / 4));
|
||
|
||
for blk in 0..28u16 {
|
||
let bo = 1 + (blk as usize) * 4; // +1 to skip index byte
|
||
let chunk = [payload[bo], payload[bo + 1], payload[bo + 2], payload[bo + 3]];
|
||
if let Err(_) = ntag.write_verify_block(pat_base + blk, &chunk, delay) {
|
||
return Ok(build_response(resp, seq, STATUS_EEPROM_FAIL, &[]));
|
||
}
|
||
}
|
||
|
||
// If not in a sync sequence, update header CRC now
|
||
if !state.syncing {
|
||
if let Err(_) = update_header_after_write(ntag, index, delay) {
|
||
return Ok(build_response(resp, seq, STATUS_EEPROM_FAIL, &[]));
|
||
}
|
||
}
|
||
|
||
Ok(build_response(resp, seq, STATUS_OK, &[]))
|
||
}
|
||
|
||
/// SET_ACTIVE: switch active pattern index.
|
||
fn handle_set_active<I2C, E>(
|
||
ntag: &mut Ntag5Link<I2C>,
|
||
seq: u8,
|
||
payload: &[u8],
|
||
resp: &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(resp, seq, STATUS_BAD_CMD, &[]));
|
||
}
|
||
|
||
let index = payload[0];
|
||
|
||
// Read current header to validate
|
||
let mut hdr_buf = [0u8; pattern::HEADER_SIZE];
|
||
ntag.read_memory(pattern::LIBRARY_BASE_BLOCK, &mut hdr_buf)?;
|
||
let hdr = match pattern::parse_header(&hdr_buf) {
|
||
Some(h) => h,
|
||
None => return Ok(build_response(resp, seq, STATUS_EEPROM_FAIL, &[])),
|
||
};
|
||
|
||
if index >= hdr.pattern_count {
|
||
return Ok(build_response(resp, seq, STATUS_INVALID_INDEX, &[]));
|
||
}
|
||
|
||
// Update active index in header (byte 6) and rewrite header block
|
||
hdr_buf[6] = index;
|
||
// Recalculate CRC
|
||
recalculate_header_crc(ntag, &mut hdr_buf, hdr.pattern_count, delay)?;
|
||
|
||
// Write header blocks
|
||
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 let Err(_) = ntag.write_verify_block(pattern::LIBRARY_BASE_BLOCK + blk, &chunk, delay) {
|
||
return Ok(build_response(resp, seq, STATUS_EEPROM_FAIL, &[]));
|
||
}
|
||
}
|
||
|
||
// Note: LP5562 reprogramming happens in main.rs after process_command returns,
|
||
// since we don't have access to LP5562 here.
|
||
Ok(build_response(resp, seq, STATUS_OK, &[]))
|
||
}
|
||
|
||
/// SYNC_START: begin full library sync. Clears library.
|
||
fn handle_sync_start<I2C, E>(
|
||
ntag: &mut Ntag5Link<I2C>,
|
||
state: &mut MailboxState,
|
||
seq: u8,
|
||
payload: &[u8],
|
||
resp: &mut [u8],
|
||
_delay: &mut impl embedded_hal::delay::DelayNs,
|
||
) -> Result<usize, ntag5::Error<E>>
|
||
where
|
||
I2C: I2c<Error = E>,
|
||
{
|
||
if payload.len() < 3 {
|
||
return Ok(build_response(resp, seq, STATUS_BAD_CMD, &[]));
|
||
}
|
||
|
||
state.syncing = true;
|
||
state.sync_count = payload[0];
|
||
state.sync_current = payload[1];
|
||
state.sync_mode = payload[2];
|
||
|
||
// Don't erase anything yet — patterns will be overwritten by WRITE_PATTERN commands.
|
||
// Header is written at SYNC_END.
|
||
|
||
Ok(build_response(resp, seq, STATUS_OK, &[]))
|
||
}
|
||
|
||
/// SYNC_END: finalize library sync. Write header with CRC.
|
||
fn handle_sync_end<I2C, E>(
|
||
ntag: &mut Ntag5Link<I2C>,
|
||
state: &mut MailboxState,
|
||
seq: u8,
|
||
resp: &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(resp, seq, STATUS_BAD_CMD, &[]));
|
||
}
|
||
|
||
// Read all pattern data for CRC calculation
|
||
let pat_base = pattern::LIBRARY_BASE_BLOCK + (pattern::HEADER_SIZE as u16 / 4);
|
||
let total_pat_bytes = state.sync_count as usize * pattern::PATTERN_ENTRY_SIZE;
|
||
let mut all_pat_data = [0u8; pattern::MAX_PATTERNS * pattern::PATTERN_ENTRY_SIZE];
|
||
if total_pat_bytes > 0 {
|
||
ntag.read_memory(pat_base, &mut all_pat_data[..total_pat_bytes])?;
|
||
}
|
||
|
||
// Build and write header
|
||
let mut hdr_buf = [0u8; pattern::HEADER_SIZE];
|
||
pattern::serialize_header(
|
||
state.sync_count,
|
||
0, // active = 0
|
||
state.sync_current,
|
||
state.sync_mode,
|
||
&all_pat_data[..total_pat_bytes],
|
||
&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 let Err(_) = ntag.write_verify_block(pattern::LIBRARY_BASE_BLOCK + blk, &chunk, delay) {
|
||
state.syncing = false;
|
||
return Ok(build_response(resp, seq, STATUS_EEPROM_FAIL, &[]));
|
||
}
|
||
}
|
||
|
||
state.syncing = false;
|
||
|
||
// Note: LP5562 reprogramming happens in main.rs after process_command returns.
|
||
Ok(build_response(resp, seq, STATUS_OK, &[]))
|
||
}
|
||
|
||
/// READ_LIBRARY: return library header info, prepare for READ_NEXT iteration.
|
||
fn handle_read_library<I2C, E>(
|
||
ntag: &mut Ntag5Link<I2C>,
|
||
state: &mut MailboxState,
|
||
seq: u8,
|
||
resp: &mut [u8],
|
||
_delay: &mut impl embedded_hal::delay::DelayNs,
|
||
) -> Result<usize, 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)?;
|
||
|
||
match pattern::parse_header(&hdr_buf) {
|
||
Some(hdr) => {
|
||
state.read_index = 0;
|
||
state.read_count = hdr.pattern_count;
|
||
let payload = [hdr.pattern_count, hdr.active_index, hdr.current, hdr.led_mode];
|
||
Ok(build_response(resp, seq, STATUS_OK, &payload))
|
||
}
|
||
None => {
|
||
state.read_index = 0;
|
||
state.read_count = 0;
|
||
let payload = [0u8, 0, 0, 0];
|
||
Ok(build_response(resp, seq, STATUS_OK, &payload))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// READ_NEXT: return next pattern (112 bytes) from EEPROM.
|
||
fn handle_read_next<I2C, E>(
|
||
ntag: &mut Ntag5Link<I2C>,
|
||
state: &mut MailboxState,
|
||
seq: u8,
|
||
resp: &mut [u8],
|
||
_delay: &mut impl embedded_hal::delay::DelayNs,
|
||
) -> Result<usize, ntag5::Error<E>>
|
||
where
|
||
I2C: I2c<Error = E>,
|
||
{
|
||
if state.read_index >= state.read_count {
|
||
return Ok(build_response(resp, seq, STATUS_INVALID_INDEX, &[]));
|
||
}
|
||
|
||
let pat_block = pattern::LIBRARY_BASE_BLOCK
|
||
+ (pattern::HEADER_SIZE as u16 / 4)
|
||
+ (state.read_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)?;
|
||
|
||
state.read_index += 1;
|
||
|
||
Ok(build_response(resp, seq, STATUS_OK, &pat_buf))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Update header after standalone WRITE_PATTERN (not during sync).
|
||
/// Bumps pattern count if index == count (append), recalculates CRC.
|
||
fn update_header_after_write<I2C, E>(
|
||
ntag: &mut Ntag5Link<I2C>,
|
||
written_index: u8,
|
||
delay: &mut impl embedded_hal::delay::DelayNs,
|
||
) -> Result<(), 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)?;
|
||
|
||
let mut count = match pattern::parse_header(&hdr_buf) {
|
||
Some(h) => h.pattern_count,
|
||
None => 0,
|
||
};
|
||
|
||
// If writing at count position, we're appending
|
||
if written_index == count && (count as usize) < pattern::MAX_PATTERNS {
|
||
count += 1;
|
||
hdr_buf[5] = count;
|
||
}
|
||
|
||
recalculate_header_crc(ntag, &mut hdr_buf, count, delay)?;
|
||
|
||
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]];
|
||
ntag.write_verify_block(pattern::LIBRARY_BASE_BLOCK + blk, &chunk, delay)?;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Recalculate CRC-16 in header buffer over header[0..14] + all pattern data.
|
||
fn recalculate_header_crc<I2C, E>(
|
||
ntag: &mut Ntag5Link<I2C>,
|
||
hdr_buf: &mut [u8; pattern::HEADER_SIZE],
|
||
pattern_count: u8,
|
||
_delay: &mut impl embedded_hal::delay::DelayNs,
|
||
) -> Result<(), ntag5::Error<E>>
|
||
where
|
||
I2C: I2c<Error = E>,
|
||
{
|
||
let pat_base = pattern::LIBRARY_BASE_BLOCK + (pattern::HEADER_SIZE as u16 / 4);
|
||
let total_pat_bytes = pattern_count as usize * pattern::PATTERN_ENTRY_SIZE;
|
||
|
||
let mut all_pat_data = [0u8; pattern::MAX_PATTERNS * pattern::PATTERN_ENTRY_SIZE];
|
||
if total_pat_bytes > 0 {
|
||
ntag.read_memory(pat_base, &mut all_pat_data[..total_pat_bytes])?;
|
||
}
|
||
|
||
// Zero CRC bytes before recalculating
|
||
hdr_buf[14] = 0;
|
||
hdr_buf[15] = 0;
|
||
|
||
// CRC over header[0..14] + pattern data
|
||
let mut crc = pattern::crc16(&hdr_buf[0..14]);
|
||
for &b in &all_pat_data[..total_pat_bytes] {
|
||
crc ^= (b as u16) << 8;
|
||
for _ in 0..8 {
|
||
if crc & 0x8000 != 0 {
|
||
crc = (crc << 1) ^ 0x1021;
|
||
} else {
|
||
crc <<= 1;
|
||
}
|
||
crc &= 0xFFFF;
|
||
}
|
||
}
|
||
hdr_buf[14] = (crc >> 8) as u8;
|
||
hdr_buf[15] = crc as u8;
|
||
|
||
Ok(())
|
||
}
|
||
```
|
||
|
||
**Step 2: Build to verify**
|
||
|
||
Run: `cd /home/work/xblink && cargo build --release 2>&1`
|
||
Expected: Compiles successfully. Warnings about unused `handle_*` functions are expected until main.rs integrates them.
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/ntag5/sram.rs
|
||
git commit -m "Add SRAM mailbox command handlers (all 7 commands)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Integrate mailbox into main.rs idle loop
|
||
|
||
**Files:**
|
||
- Modify: `src/main.rs`
|
||
|
||
This is the integration task — wire FD pin, configure NTAG5 FD mode at boot, and replace the idle `loop { delay 60s }` with the mailbox polling loop.
|
||
|
||
**Step 1: Add FD pin setup and mailbox imports**
|
||
|
||
At the top of `main.rs`, add the sram import (after line 22):
|
||
|
||
```rust
|
||
use ntag5::sram::MailboxState;
|
||
```
|
||
|
||
**Step 2: Configure FD pin as GPIO input**
|
||
|
||
After `let mut lp_en = pins.a0.into_push_pull_output();` (line 58), add:
|
||
|
||
```rust
|
||
// FD pin from NTAG5 on A1 (PA04) — input with pull-up (FD is open-drain)
|
||
let fd_pin = pins.a1.into_pull_up_input();
|
||
```
|
||
|
||
**Step 3: Add FD pin configuration after config check**
|
||
|
||
After the config check block (after line 114 `delay.delay_ms(500u32);`), add NTAG5 FD pin configuration:
|
||
|
||
```rust
|
||
// Configure FD pin for SRAM-write-by-RF indication
|
||
let _ = ntag.configure_fd_sram_write(); // Best-effort, non-fatal
|
||
```
|
||
|
||
**Step 4: Replace idle loops with mailbox polling**
|
||
|
||
The key change: both the `Some(pat)` branch (line 146-148) and the successful provisioning branch (line 231-233) currently have `loop { delay_ms(60000); }`. Replace these with the mailbox polling loop.
|
||
|
||
Extract a function to avoid duplicating the loop. Add before `#[entry]`:
|
||
|
||
```rust
|
||
/// Mailbox polling loop — runs after LP5562 is programmed.
|
||
/// Polls FD pin every 200ms, processes SRAM commands when detected.
|
||
/// After commands that change the active pattern, reloads LP5562.
|
||
fn mailbox_loop<I2C, E>(
|
||
ntag: &mut Ntag5Link<I2C>,
|
||
lp: &mut Lp5562<I2C>,
|
||
// ... this won't work because we can't have two &mut to devices sharing I2C
|
||
) { }
|
||
```
|
||
|
||
**Actually**, the I2C bus ownership model means we can't have both `ntag` and `lp` active at once — they share the I2C bus via `release()`/`new()`. The mailbox loop needs to:
|
||
1. Hold NTAG5 ownership for polling/SRAM access
|
||
2. Release to LP5562 only when reprogramming is needed
|
||
|
||
So the loop stays inline in `main.rs`. Replace the idle loops with:
|
||
|
||
In the `Some(pat)` branch (replacing lines 146-148):
|
||
|
||
```rust
|
||
// Mailbox polling loop
|
||
let mut mbox_state = MailboxState::new();
|
||
let mut need_reload = false;
|
||
loop {
|
||
delay.delay_ms(200u32);
|
||
if fd_pin.is_low() {
|
||
// FD asserted = phone wrote to SRAM
|
||
match ntag5::sram::process_command(&mut ntag, &mut mbox_state, &mut delay) {
|
||
Ok(true) => {
|
||
// Check if we need to reload LP5562
|
||
// (SET_ACTIVE, SYNC_END, or standalone WRITE_PATTERN on active index)
|
||
need_reload = true;
|
||
}
|
||
Ok(false) => {} // Invalid/empty command, ignore
|
||
Err(_) => {} // I2C error, ignore and retry next poll
|
||
}
|
||
}
|
||
if need_reload {
|
||
need_reload = false;
|
||
// Re-read active pattern from EEPROM and reprogram LP5562
|
||
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) {
|
||
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) {
|
||
// Temporarily release I2C to LP5562
|
||
// We can't do this with the borrow checker...
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Wait — I2C ownership problem.** The current code uses `release()`/`new()` to move the I2C bus between drivers. In the mailbox loop we need NTAG5 most of the time but occasionally LP5562. We need to restructure to swap ownership when needed.
|
||
|
||
**Better approach**: Keep NTAG5 as the primary owner. When LP5562 reprogramming is needed, release NTAG5, create LP5562, reprogram, release LP5562, recreate NTAG5.
|
||
|
||
Replace the `Some(pat)` branch (lines 132-148) with:
|
||
|
||
```rust
|
||
Some(pat) => {
|
||
// EEPROM pattern loaded — 3 fast blinks
|
||
blink(&mut led, &mut delay, 3, 80);
|
||
|
||
// Load initial pattern (need LP5562)
|
||
{
|
||
let i2c = ntag.release();
|
||
let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS);
|
||
if pattern::load_pattern(&mut lp, &pat, led_current, &mut delay).is_err() {
|
||
blink(&mut led, &mut delay, 10, 50);
|
||
}
|
||
// Return I2C to NTAG5 for mailbox
|
||
let i2c = lp.release();
|
||
ntag = Ntag5Link::new(i2c, ntag5::DEFAULT_ADDRESS);
|
||
}
|
||
|
||
// Mailbox polling loop
|
||
let mut mbox_state = MailboxState::new();
|
||
loop {
|
||
delay.delay_ms(200u32);
|
||
if fd_pin.is_low() {
|
||
match ntag5::sram::process_command(&mut ntag, &mut mbox_state, &mut delay) {
|
||
Ok(true) => {
|
||
// Reload LP5562 from current EEPROM state
|
||
reload_lp5562(&mut ntag, &mut led, &mut delay);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
And add a helper function before `main()`:
|
||
|
||
```rust
|
||
/// Re-read active pattern from EEPROM and reprogram LP5562.
|
||
/// Temporarily swaps I2C ownership from NTAG5 to LP5562 and back.
|
||
fn reload_lp5562<I2C, E>(
|
||
ntag: &mut Ntag5Link<I2C>,
|
||
led: &mut bsp::Led0,
|
||
delay: &mut Delay,
|
||
) where
|
||
I2C: I2c<Error = E>,
|
||
{
|
||
// Can't swap ownership through &mut — need a different approach
|
||
}
|
||
```
|
||
|
||
**The fundamental problem**: Rust's ownership model prevents swapping the I2C bus through `&mut Ntag5Link` because `release()` takes `self` by value. We need to restructure.
|
||
|
||
**Solution**: Use `Option<Ntag5Link>` and `Option<Lp5562>` to allow swapping. OR, make both drivers borrow the I2C bus via `RefCell`. For `no_std` embedded, the simplest approach is to use raw I2C bus access and pass the bus directly:
|
||
|
||
**Revised approach**: Make `Ntag5Link` and `Lp5562` hold `Option<I2C>` internally, with `take()`/`give()` methods. But that's a bigger refactor.
|
||
|
||
**Simplest approach**: Keep the current swap pattern but inline it. The mailbox loop holds the I2C bus directly and creates temporary driver instances:
|
||
|
||
```rust
|
||
// After initial pattern load, keep I2C bus directly
|
||
let mut i2c = ntag.release(); // take bus back
|
||
|
||
let mut mbox_state = MailboxState::new();
|
||
loop {
|
||
delay.delay_ms(200u32);
|
||
if fd_pin.is_low() {
|
||
let mut ntag_tmp = Ntag5Link::new(i2c, ntag5::DEFAULT_ADDRESS);
|
||
let processed = ntag5::sram::process_command(&mut ntag_tmp, &mut mbox_state, &mut delay);
|
||
let should_reload = matches!(processed, Ok(true));
|
||
|
||
if should_reload {
|
||
// Read new active pattern while we still have ntag
|
||
let reload_data = read_active_pattern(&mut ntag_tmp);
|
||
i2c = ntag_tmp.release();
|
||
|
||
if let Some((pat, current)) = reload_data {
|
||
let mut lp = Lp5562::new(i2c, DEFAULT_ADDRESS);
|
||
let _ = pattern::load_pattern(&mut lp, &pat, current, &mut delay);
|
||
i2c = lp.release();
|
||
}
|
||
} else {
|
||
i2c = ntag_tmp.release();
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
This works — create temporary driver instances per iteration. The `new()` and `release()` are zero-cost (just moving the I2C handle).
|
||
|
||
**Step 5: Build and verify**
|
||
|
||
Run: `cd /home/work/xblink && cargo build --release 2>&1`
|
||
Expected: Compiles. Binary size should still fit in flash.
|
||
|
||
**Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/main.rs
|
||
git commit -m "Wire SRAM mailbox polling into main idle loop (M6)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Flash and test with hardware
|
||
|
||
**Step 1: Flash firmware**
|
||
|
||
```bash
|
||
cd /home/work/xblink && cargo build --release 2>&1
|
||
# Double-tap RST on XIAO M0, then:
|
||
cargo hf2 --release
|
||
# Or use: ./flash_when_ready.sh
|
||
```
|
||
|
||
**Step 2: Verify basic boot**
|
||
|
||
Expected LED sequence:
|
||
- 3 slow blinks (firmware alive)
|
||
- Solid on (LP5562 init OK)
|
||
- 2 fast blinks (config OK) or 5 fast (config mismatch)
|
||
- 3 fast blinks (EEPROM pattern loaded)
|
||
- Pattern runs on LP5562
|
||
|
||
**Step 3: Test FD pin wiring**
|
||
|
||
Wire NTAG5 Click FD pin to XIAO A1. Verify the MCU doesn't spuriously detect SRAM writes (FD should stay high with pull-up when no phone is present).
|
||
|
||
**Step 4: Test with PCSC reader (Python script)**
|
||
|
||
Write a Python test script `tools/test_sram_mailbox.py` that:
|
||
1. Sends GET_STATUS via SRAM (using ntag5sensor `read_sram`/`write_sram` equivalents)
|
||
2. Reads response
|
||
3. Verifies firmware version and pattern count
|
||
|
||
This will be a manual test — run it while the MCU is powered and the PCSC reader is on the NTAG5 antenna.
|
||
|
||
**Step 5: Commit test script**
|
||
|
||
```bash
|
||
git add tools/test_sram_mailbox.py
|
||
git commit -m "Add Python SRAM mailbox test script"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Update docs and STATUS
|
||
|
||
**Files:**
|
||
- Modify: `docs/STATUS.md`
|
||
|
||
**Step 1: Mark M6 as complete in STATUS.md**
|
||
|
||
Update the M6 section to show completed tasks and what was implemented.
|
||
|
||
**Step 2: Commit**
|
||
|
||
```bash
|
||
git add docs/STATUS.md
|
||
git commit -m "Mark M6 SRAM mailbox as complete"
|
||
```
|
||
|
||
---
|
||
|
||
## Notes for the implementer
|
||
|
||
### I2C bus swapping pattern
|
||
The critical challenge is I2C bus ownership. Both `Ntag5Link` and `Lp5562` consume the I2C bus via `new()` and return it via `release()`. In the mailbox loop, create temporary driver instances per iteration — `new()` and `release()` are zero-cost moves.
|
||
|
||
### SRAM block addresses
|
||
NTAG5 SRAM via I2C uses block addresses 0x10F8-0x10FF (with the 0x10xx prefix for the session/SRAM memory space). This is different from the NFC-side SRAM addresses (0xF8-0xFF) used by the Python tooling. Verify the correct I2C address prefix from the NTP53x2 datasheet.
|
||
|
||
### FD pin register
|
||
The `ED_FD_PIN_CFG` session register address and bit layout need verification against the NTP53x2 datasheet. The register controls both the ED (energy detect) and FD (field detect) pin behaviors. We want FD mode "SRAM write by RF complete."
|
||
|
||
### Stack usage
|
||
`all_pat_data` in `handle_sync_end` and `recalculate_header_crc` is `9 × 112 = 1008 bytes` on the stack. SAMD21 has 32KB SRAM so this is fine, but be aware of nesting depth.
|
||
|
||
### Binary size
|
||
Current firmware is ~6.4KB. SRAM protocol adds maybe 2-3KB of code. Well within the 256KB flash.
|