From 768ec0021fe31a8baac3189e2e9d25a27ff3c42a Mon Sep 17 00:00:00 2001 From: Me Here Date: Thu, 4 Jun 2026 07:29:30 -0500 Subject: [PATCH] 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{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) --- docs/rust-port.md | 20 ++- rust/pm-grid/.gitignore | 6 +- rust/pm-grid/Cargo.toml | 3 + rust/pm-grid/src/fat_template.bin | Bin 0 -> 28672 bytes rust/pm-grid/src/main.rs | 267 ++++++++++++++++++++++++++++-- 5 files changed, 271 insertions(+), 25 deletions(-) create mode 100644 rust/pm-grid/src/fat_template.bin diff --git a/docs/rust-port.md b/docs/rust-port.md index 26e997f..9c2e7b5 100644 --- a/docs/rust-port.md +++ b/docs/rust-port.md @@ -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` (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 diff --git a/rust/pm-grid/.gitignore b/rust/pm-grid/.gitignore index 8c1f409..931d088 100644 --- a/rust/pm-grid/.gitignore +++ b/rust/pm-grid/.gitignore @@ -1,4 +1,4 @@ /target -*.elf -*.bin -*.uf2 +/pm-grid.elf +/pm-grid.bin +/pm-grid.uf2 diff --git a/rust/pm-grid/Cargo.toml b/rust/pm-grid/Cargo.toml index 49113ca..3bd6978 100644 --- a/rust/pm-grid/Cargo.toml +++ b/rust/pm-grid/Cargo.toml @@ -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" diff --git a/rust/pm-grid/src/fat_template.bin b/rust/pm-grid/src/fat_template.bin new file mode 100644 index 0000000000000000000000000000000000000000..4a1a02859d4569b9edfc85b46b7f108cb3cbd785 GIT binary patch literal 28672 zcmeI&F-yZh6u|L|i&AOHBslqSb1=|uii?X*LWkS##s-2HL z^VU7NINoi`&p0~lwL4X8Z9VsI9=2cd^|Qy;)2b@m^YXLvUThSbht;zo7{U}M(vFLczY^0g7w%U#*G-JsV$+IeRse@QN&y!|j z-Xd`+fz$h?$|ynr0R#|0009ILKmY**5U8ENbT+G9&oqnx0tg_000IagfB*srAb2BE@iz2~00IagfB*sr zAb = 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 { + 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 { + 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 { + 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 { + 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 { + let b = json.as_bytes(); + let mut out: Vec = Vec::new(); + let mut pending_name: Option = 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 Matrix { // ============================== 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, // 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 { 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) -> Self { + // built-ins (static) → owned, then append the drive's set lists + let mut setlists: Vec = 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::(), b.parse::()) { - 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