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:
parent
394ae65eaf
commit
768ec0021f
5 changed files with 271 additions and 25 deletions
|
|
@ -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
|
||||
|
|
|
|||
6
rust/pm-grid/.gitignore
vendored
6
rust/pm-grid/.gitignore
vendored
|
|
@ -1,4 +1,4 @@
|
|||
/target
|
||||
*.elf
|
||||
*.bin
|
||||
*.uf2
|
||||
/pm-grid.elf
|
||||
/pm-grid.bin
|
||||
/pm-grid.uf2
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
BIN
rust/pm-grid/src/fat_template.bin
Normal file
BIN
rust/pm-grid/src/fat_template.bin
Normal file
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue