pm-grid: name the drive PM_G-1 + read set lists from it (on-device FAT)

On boot (before USB setup, so the flash write cannot disrupt enumeration) the
device mounts the Mass Storage FAT volume and uses it:
- fatfs 0.4 (git rev c4b88477; 0.3.6 needs core_io for no_std) via a read-only
  FlashIo over the .filesystem region (reads flash through a black_box ptr).
- If the root-dir volume label is not "PM_G-1" (e.g. a leftover CircuitPython
  volume), write an embedded blank PM_G-1 FAT12 template (src/fat_template.bin =
  first 7 sectors of mkfs.fat -F12 -S4096 -n PM_G-1; sets both BPB + root-dir
  VOLUME_ID label) -> the drive now shows as PM_G-1.
- Read programs.json (LFN) and a tolerant scanner (parse_setlists) turns it into
  user set lists appended to the built-ins. Drop programs.json on the drive,
  reboot, your grooves appear (B-hold cycles set lists).

Set lists are now a runtime Vec<SetList>{title,items} (built-ins -> owned +
drive); refactored load/next_track/next_setlist/goto_target/prepare_next/sel.

Validated off-bench: a host probe ran fatfs against a real mkfs 4096-sector image
(label + programs.json read confirmed) before flashing.

WRITE-from-device (practice log / settings) is still deferred (the read path is
in; needs a write-back FlashIo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-06-04 07:29:30 -05:00
parent 394ae65eaf
commit 768ec0021f
5 changed files with 271 additions and 25 deletions

View file

@ -218,10 +218,22 @@ ROM intrinsics) and **`lto = false`** (fat-LTO tripped the same intrinsic). This
persistence** — practice log / `settings.json` / user set-lists can now live on the drive (next: the
device parses the FAT to read/write them).
**Still deferred**: parse the FAT on-device to actually *use* the drive (read `programs.json`, write
practice log / settings), SLSYNC/LOGSYNC (`0x44`/`0x45`), the **on-device 808/909 synth → USB Audio
input** (the standalone-audio alternative the user wants, big), firmware push (intended: UF2 now),
optional piezo. A/B bootloader was **dropped** by the user.
**Drive named "PM_G-1" + reads set lists — ✅ DONE**: on boot (before USB setup, so the flash write
can't disrupt enumeration) the device mounts the FAT (`fatfs` 0.4 git — 0.3.6 needs `core_io` for
no_std; reads via a `FlashIo` over the `.filesystem` region, validated off-bench against a real
`mkfs.fat` image). If the root-dir volume label isn't "PM_G-1" (e.g. a leftover CircuitPython
volume), it writes an embedded blank **PM_G-1 FAT12 template** (`src/fat_template.bin`, the first 7
sectors of `mkfs.fat -F12 -S4096 -n PM_G-1`, sets *both* BPB + root-dir VOLUME_ID label) → the drive
shows as **PM_G-1**. Then it reads `programs.json` (LFN) and a tolerant scanner turns it into **user
set lists appended to the built-ins** — drop your `programs.json` on the drive, reboot, your grooves
appear (B-hold cycles set lists). Set lists are now a runtime `Vec<SetList>` (built-ins → owned +
drive).
**Still deferred**: *write* the drive from the device (practice log / `settings.json` — needs a
write-back `FlashIo`, the read path is done), re-read `programs.json` without a reboot, show the
set-list title, SLSYNC/LOGSYNC (`0x44`/`0x45`), the **on-device 808/909 synth → USB Audio input**
(the standalone-audio alternative, big), firmware push (intended: UF2 now), optional piezo. A/B
bootloader **dropped** by the user.
### Stage 4 — native A/B + secure boot
Replace the `.mpy`-level A/B hack (`code.py` loads `app.mpy`, rolls back to `app.bak`) with the

View file

@ -1,4 +1,4 @@
/target
*.elf
*.bin
*.uf2
/pm-grid.elf
/pm-grid.bin
/pm-grid.uf2

View file

@ -19,6 +19,9 @@ usb-device = "0.3" # USB device stack (rp2040-hal provides the UsbBus)
usbd-midi = "0.5" # USB-MIDI class — the Scroll Pack's only audio path (no speaker)
usbd-storage = { version = "2", features = ["bbb", "scsi", "defmt"] } # USB Mass Storage (the drive)
rp2040-flash = "0.6" # run-from-RAM flash erase/program for the filesystem region
# 0.4.0 (git) — clean no_std IoBase/IoError model (0.3.6 needs core_io for no_std). Reads the FAT
# drive on-device (load programs.json). Pinned for reproducibility.
fatfs = { git = "https://github.com/rafalh/rust-fatfs", rev = "c4b88477", default-features = false, features = ["alloc", "lfn"] }
[profile.release]
opt-level = "s"

Binary file not shown.

View file

@ -67,6 +67,214 @@ static FILESYSTEM: [u8; FS_LEN] = [0u8; FS_LEN];
static mut MSC_BUF: core::mem::MaybeUninit<[u8; FS_BLOCK_SIZE as usize]> =
core::mem::MaybeUninit::uninit();
/// A freshly-formatted, empty FAT12 volume labelled "PM_G-1" (boot sector + 2 FATs + root dir, the
/// first 7 × 4 KB sectors made by `mkfs.fat -F12 -S4096 -n PM_G-1`). Written to flash when the drive
/// isn't already ours, so the host shows it as "PM_G-1" instead of a leftover CircuitPython volume.
static FAT_TEMPLATE: &[u8] = include_bytes!("fat_template.bin");
// ============================== ON-DEVICE FAT (read the drive) ==============================
// Read-only access to the Mass Storage FAT volume so the device can load programs.json (user set
// lists). Writes go through MSC (the host); here we only read + (re)format to set the label.
#[derive(Debug)]
struct FsErr;
impl fatfs::IoError for FsErr {
fn is_interrupted(&self) -> bool {
false
}
fn new_unexpected_eof_error() -> Self {
FsErr
}
fn new_write_zero_error() -> Self {
FsErr
}
}
/// A byte cursor over the flash `.filesystem` region. Reads hit flash; writes are discarded (we only
/// read on-device — formatting is a separate bulk flash write below).
struct FlashIo {
pos: u64,
}
impl FlashIo {
fn new() -> Self {
FlashIo { pos: 0 }
}
}
impl fatfs::IoBase for FlashIo {
type Error = FsErr;
}
impl fatfs::Read for FlashIo {
fn read(&mut self, buf: &mut [u8]) -> Result<usize, FsErr> {
let p = self.pos as usize;
if p >= FS_LEN {
return Ok(0);
}
let n = buf.len().min(FS_LEN - p);
// black_box the base so the compiler can't const-fold the (zero-init, NOLOAD) static to 0
let base = core::hint::black_box(FILESYSTEM.as_ptr());
unsafe { core::ptr::copy_nonoverlapping(base.add(p), buf.as_mut_ptr(), n) };
self.pos += n as u64;
Ok(n)
}
}
impl fatfs::Write for FlashIo {
fn write(&mut self, buf: &[u8]) -> Result<usize, FsErr> {
self.pos += buf.len() as u64; // discard (on-device is read-only)
Ok(buf.len())
}
fn flush(&mut self) -> Result<(), FsErr> {
Ok(())
}
}
impl fatfs::Seek for FlashIo {
fn seek(&mut self, from: fatfs::SeekFrom) -> Result<u64, FsErr> {
let np: i64 = match from {
fatfs::SeekFrom::Start(o) => o as i64,
fatfs::SeekFrom::End(o) => FS_LEN as i64 + o,
fatfs::SeekFrom::Current(o) => self.pos as i64 + o,
};
if np < 0 {
return Err(FsErr);
}
self.pos = np as u64;
Ok(self.pos)
}
}
/// Write the blank PM_G-1 template over the metadata sectors (erases the leftover volume → empty).
fn format_pmg1() {
let faddr = FILESYSTEM.as_ptr() as u32 & 0x00ff_ffff & !0xfff; // flash offset 0x100000
cortex_m::interrupt::free(|_| unsafe {
rp2040_flash::flash::flash_range_erase_and_program(faddr, FAT_TEMPLATE, false);
});
}
/// Mount the drive; if it isn't a "PM_G-1"-labelled FAT, format it. Then read programs.json (if any)
/// into user set lists. Runs once at boot (before USB), so the flash write can't disrupt enumeration.
fn read_user_setlists() -> Vec<SetList> {
let is_ours = {
let io = FlashIo::new();
match fatfs::FileSystem::new(io, fatfs::FsOptions::new()) {
Ok(fs) => fs
.read_volume_label_from_root_dir()
.ok()
.flatten()
.as_deref()
== Some("PM_G-1"),
Err(_) => false,
}
};
if !is_ours {
info!("fat: (re)formatting drive as PM_G-1");
format_pmg1();
}
let io = FlashIo::new();
let mut out = Vec::new();
if let Ok(fs) = fatfs::FileSystem::new(io, fatfs::FsOptions::new()) {
if let Ok(mut f) = fs.root_dir().open_file("programs.json") {
let mut data = Vec::new();
let mut tmp = [0u8; 256];
loop {
match fatfs::Read::read(&mut f, &mut tmp) {
Ok(0) => break,
Ok(n) => data.extend_from_slice(&tmp[..n]),
_ => break,
}
if data.len() > 64 * 1024 {
break; // sanity cap
}
}
if let Ok(s) = core::str::from_utf8(&data) {
out = parse_setlists(s);
}
info!("fat: programs.json -> {} user set list(s)", out.len());
}
}
out
}
/// Read a JSON string starting at the opening quote `b[start]`; returns (value, index-after-close).
fn json_str(b: &[u8], start: usize) -> Option<(String, usize)> {
let mut s = String::new();
let mut i = start + 1;
while i < b.len() {
match b[i] {
b'\\' => {
i += 1;
let e = *b.get(i)?;
s.push(match e {
b'n' => '\n',
b't' => '\t',
b'r' => '\r',
_ => e as char,
});
i += 1;
}
b'"' => return Some((s, i + 1)),
c => {
s.push(c as char);
i += 1;
}
}
}
None
}
/// Tolerant extractor for the editor's programs.json: walks `"title"`/`"name"`/`"prog"` string
/// fields in document order. Each `title` starts a set list; each `name`+`prog` appends an item.
fn parse_setlists(json: &str) -> Vec<SetList> {
let b = json.as_bytes();
let mut out: Vec<SetList> = Vec::new();
let mut pending_name: Option<String> = None;
let mut i = 0;
while i < b.len() {
if b[i] != b'"' {
i += 1;
continue;
}
let (key, after_key) = match json_str(b, i) {
Some(kv) => kv,
None => break,
};
// is this string a key (followed by ':' then a string value)?
let mut j = after_key;
while j < b.len() && (b[j] as char).is_ascii_whitespace() {
j += 1;
}
if j >= b.len() || b[j] != b':' {
i = after_key;
continue;
}
j += 1;
while j < b.len() && (b[j] as char).is_ascii_whitespace() {
j += 1;
}
if j >= b.len() || b[j] != b'"' {
i = j;
continue;
}
let (val, after_val) = match json_str(b, j) {
Some(kv) => kv,
None => break,
};
match key.as_str() {
"title" => out.push(SetList { title: val, items: Vec::new() }),
"name" => pending_name = Some(val),
"prog" => {
let name = pending_name.take().unwrap_or_else(|| String::from("?"));
if out.is_empty() {
out.push(SetList { title: String::from("My set list"), items: Vec::new() });
}
out.last_mut().unwrap().items.push((name, val));
}
_ => {}
}
i = after_val;
}
out.retain(|s| !s.items.is_empty());
out
}
const XTAL_FREQ_HZ: u32 = 12_000_000;
const MATRIX_ADDR: u8 = 0x74;
const DEVICE_ID: &str = "G"; // reported on the SysEx version query (0x02→0x03)
@ -209,7 +417,14 @@ impl<I: I2c> Matrix<I> {
// ============================== BUILT-IN SET LISTS (same as Kit/Explorer) ==============================
type Item = (&'static str, &'static str);
type SetList = (&'static str, &'static [Item]);
type BuiltinSet = (&'static str, &'static [Item]);
/// A runtime set list (built-ins converted to owned + any loaded from the drive's programs.json).
struct SetList {
#[allow(dead_code)] // parsed from programs.json; not shown yet (Ticker shows the track name)
title: String,
items: Vec<(String, String)>, // (name, program-string)
}
static STYLES: &[Item] = &[
("Four-on-the-floor", "t120;kick:4;snare:4=.x.x;hatClosed:4/2"),
@ -239,7 +454,7 @@ static SONG: &[Item] = &[
("Peak - 16ths", "t132;b4;kick:4=X..x;snare:4=.X.X;hatClosed:4/4"),
("Outro - ramp down", "t132;b4;rmp132/-7/1;kick:4=X..x;hatClosed:4/2=gggggggg"),
];
static SETLISTS: &[SetList] = &[("Styles", STYLES), ("Practice", PRACTICE), ("Song", SONG)];
static SETLISTS: &[BuiltinSet] = &[("Styles", STYLES), ("Practice", PRACTICE), ("Song", SONG)];
// ============================== APP STATE ==============================
const NS_PER_MIN: i64 = 60_000_000_000;
@ -261,6 +476,7 @@ struct Pending {
}
struct App {
setlists: Vec<SetList>, // built-ins (owned) + any loaded from the drive
// current program
track: track_format::Track,
name: String,
@ -337,11 +553,21 @@ fn build_name_cols(name: &str) -> Vec<u8> {
const SCROLL_GAP: i32 = 13; // blank columns between repeats (>= name region width) for a clean loop
impl App {
fn new(now_ns: i64) -> Self {
let prog = SETLISTS[0].1[0].1;
let track = track_format::parse(prog);
fn new(now_ns: i64, user: Vec<SetList>) -> Self {
// built-ins (static) → owned, then append the drive's set lists
let mut setlists: Vec<SetList> = SETLISTS
.iter()
.map(|(title, items)| SetList {
title: String::from(*title),
items: items.iter().map(|(n, p)| (String::from(*n), String::from(*p))).collect(),
})
.collect();
setlists.extend(user);
let prog = setlists[0].items[0].1.clone();
let track = track_format::parse(&prog);
let tempo = track.bpm;
let mut app = App {
setlists,
track,
name: String::new(),
name_cols: Vec::new(),
@ -407,12 +633,12 @@ impl App {
}
fn load(&mut self, sl: usize, item: usize, now_ns: i64) {
self.sl = sl % SETLISTS.len();
let items = SETLISTS[self.sl].1;
self.item = item % items.len();
let (name, prog) = items[self.item];
self.track = track_format::parse(prog);
self.name = String::from(name);
self.sl = sl % self.setlists.len();
let n = self.setlists[self.sl].items.len();
self.item = item % n;
let (name, prog) = self.setlists[self.sl].items[self.item].clone();
self.track = track_format::parse(&prog);
self.name = name;
self.name_cols = build_name_cols(&self.name);
self.scroll_total = self.name_cols.len() as i32 + SCROLL_GAP;
self.scroll_off = 0;
@ -426,14 +652,14 @@ impl App {
}
fn next_track(&mut self, now_ns: i64) {
let n = SETLISTS[self.sl].1.len();
let n = self.setlists[self.sl].items.len();
self.load(self.sl, (self.item + 1) % n, now_ns);
let sel = alloc::format!("sel={}/{}", self.sl, self.item);
self.sync_broadcast(&sel);
}
fn next_setlist(&mut self, now_ns: i64) {
self.load((self.sl + 1) % SETLISTS.len(), 0, now_ns);
self.load((self.sl + 1) % self.setlists.len(), 0, now_ns);
let sel = alloc::format!("sel={}/{}", self.sl, self.item);
self.sync_broadcast(&sel);
}
@ -847,7 +1073,7 @@ impl App {
let mut p = val.split('/');
if let (Some(a), Some(b)) = (p.next(), p.next()) {
if let (Ok(sl), Ok(item)) = (a.parse::<usize>(), b.parse::<usize>()) {
if sl < SETLISTS.len() {
if sl < self.setlists.len() {
self.load(sl, item, now_ns);
}
}
@ -891,7 +1117,7 @@ impl App {
/// Resolve a relative track offset into a set-list item index (clamps below 0, wraps above).
fn goto_target(&self, offset: i64) -> usize {
let n = SETLISTS[self.sl].1.len() as i64;
let n = self.setlists[self.sl].items.len() as i64;
let t = self.item as i64 + offset;
(if t < 0 {
0
@ -907,8 +1133,8 @@ impl App {
if target == self.item {
return;
}
let (name, prog) = SETLISTS[self.sl].1[target];
let track = track_format::parse(prog);
let (name, prog) = self.setlists[self.sl].items[target].clone();
let track = track_format::parse(&prog);
let mbar = master_bar_ns(&track, track.bpm);
let durs = track
.lanes
@ -1352,6 +1578,11 @@ fn main() -> ! {
let mut mtx = Matrix::new(i2c, &mut delay);
// Read the drive's set lists (and (re)format it to "PM_G-1" if it isn't ours) — BEFORE USB
// setup, so the one-time format flash-write can't disrupt enumeration.
let user_setlists = read_user_setlists();
info!("boot: {} total set list(s)", SETLISTS.len() + user_setlists.len());
// --- USB-MIDI: the Scroll Pack has no speaker, so clicks play through the host (the editor's
// "Device audio"). We send a GM note-on per lane hit on channel 10. ---
let usb_bus = UsbBusAllocator::new(hal::usb::UsbBus::new(
@ -1391,7 +1622,7 @@ fn main() -> ! {
let mut btn_y = pins.gpio15.into_pull_up_input();
let now_us = || timer.get_counter().ticks() as i64;
let mut app = App::new(now_us() * 1000);
let mut app = App::new(now_us() * 1000, user_setlists);
info!("groove: bpm={} lanes={}", app.tempo, app.track.lanes.len());
// input edge/hold state