pm-grid: USB Mass Storage (composite MIDI + 1MB flash-backed drive)

Adapt the usbd-storage rp2040 example into pm-grid as a composite MIDI+MSC
device:
- Host sees a 1MB removable drive backed by the upper 1MB of flash (a
  .filesystem region, NOLOAD so it stays out of the UF2 and survives reflashes).
- scsi_command handles the SCSI set (Inquiry / ReadCapacity10/16 /
  ReadFormatCapacities / Read / Write / ModeSense / RequestSense / TestUnitReady).
  Reads come from flash via raw pointer; writes accumulate a 4KB block then
  erase+program the sector with rp2040-flash (wrapped in interrupt::free).
- Host owns the FAT format (formats on first use). Unblocks on-device persistence.
- Composite poll: usb_dev.poll([&mut midi, &mut scsi]); scsi.poll services commands.

Build fixes required by adding rp2040-flash:
- rp2040-hal 0.10 -> 0.11 (0.10 + rp2040-flash 0.6 both export the __aeabi_*/
  __addsf3 ROM intrinsics -> duplicate symbols). No HAL API breakage.
- lto = false + codegen-units = 1 (fat-LTO tripped the same duplicate intrinsic).

UF2 stays ~257KB thanks to NOLOAD. defmt logs on block writes + unknown commands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-06-03 16:25:51 -05:00
parent dce51866d2
commit 394ae65eaf
4 changed files with 203 additions and 13 deletions

View file

@ -207,10 +207,21 @@ derived BPM, 5300 clamp + jitter reject), `0xFA`/`0xFB` start, `0xFC` stop. W
and our own clock-out are suppressed (no feedback); the lock drops after a >1 s gap. Only engages
when a host actually sends clock (the editor doesn't), so it's inert in normal editor use.
**Still deferred** (these need persistent storage the Rust build doesn't have yet — there's no
`CIRCUITPY` drive; would need a flash KV layer, a separate milestone): on-device practice log,
`settings.json`, SLSYNC/LOGSYNC (`0x44`/`0x45` set-list + log merge). Also: firmware push (intended:
UF2-flashed now), optional piezo.
**USB Mass Storage — ✅ DONE (drive enumerates; pending on-device test)**: composite **MIDI + MSC**
(`usbd-storage` 2.0, SCSI over Bulk-Only), adapted from the crate's RP2040 example. The host sees a
**1 MB removable drive** backed by the upper flash (a `.filesystem` region, `NOLOAD` so it's not in
the UF2 and survives reflashes). `scsi_command` serves the SCSI set (Inquiry/ReadCapacity/Read/Write/
ModeSense/RequestSense); reads come from flash via raw pointer, writes erase+program a 4 KB sector
with `rp2040-flash` (wrapped in `interrupt::free`). The host owns the FAT format (formats on first
use). **Required `rp2040-hal` 0.11** (0.10 + `rp2040-flash` 0.6 = duplicate `__aeabi_*`/`__addsf3`
ROM intrinsics) and **`lto = false`** (fat-LTO tripped the same intrinsic). This **unblocks
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.
### 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

@ -5,7 +5,7 @@ edition = "2021"
description = "PM_G-1 'Grid' firmware (RP2040 / Pimoroni Pico Scroll Pack PIM545). 17x7 IS31FL3731 LED metronome — Rust sibling of pico-scroll/app.py."
[dependencies]
rp2040-hal = { version = "0.10", features = ["rt", "critical-section-impl", "defmt"] }
rp2040-hal = { version = "0.11", features = ["rt", "critical-section-impl", "defmt"] }
rp2040-boot2 = "0.3"
cortex-m = "0.7"
cortex-m-rt = "0.7"
@ -17,8 +17,11 @@ track-format = { path = "../track-format" }
embedded-alloc = "0.6" # track-format parses into Vec/String → needs a global allocator
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
[profile.release]
opt-level = "s"
lto = true
lto = false # fat-LTO trips a duplicate soft-float intrinsic (__addsf3) across the USB/flash deps
codegen-units = 1
debug = 2

View file

@ -1,9 +1,13 @@
/* RP2040 (plain Raspberry Pi Pico) memory layout for rp2040-hal + cortex-m-rt.
The RP2040 boots from a 256-byte second-stage bootloader at the start of flash
(BOOT2), which then maps the rest of XIP flash and jumps to .text. */
(BOOT2), which then maps the rest of XIP flash and jumps to .text.
The 2 MB flash is split: 1 MB for the app, 1 MB for the USB Mass Storage
filesystem (a FAT volume the host reads/writes; written on-device via rp2040-flash). */
MEMORY {
BOOT2 : ORIGIN = 0x10000000, LENGTH = 0x100
FLASH : ORIGIN = 0x10000100, LENGTH = 2048K - 0x100
FLASH : ORIGIN = 0x10000100, LENGTH = 0x100000 - 0x100
FILESYSTEM : ORIGIN = 0x10100000, LENGTH = 1M
RAM : ORIGIN = 0x20000000, LENGTH = 264K
}
@ -16,3 +20,13 @@ SECTIONS {
KEEP(*(.boot2));
} > BOOT2
} INSERT BEFORE .text;
SECTIONS {
/* The Mass Storage filesystem region. NOLOAD: not part of the flashed image (so the UF2 stays
small and a reflash doesn't wipe the user's files) — the host formats it on first use, and
the device reads/writes it at runtime via raw-pointer reads + rp2040-flash erase/program. */
.filesystem (NOLOAD) : ALIGN(4096)
{
KEEP(*(.filesystem));
} > FILESYSTEM
} INSERT AFTER .text;

View file

@ -37,6 +37,10 @@ use track_format::End;
use usb_device::prelude::*;
use usb_device::bus::UsbBusAllocator;
use usbd_midi::UsbMidiClass;
use usbd_storage::subclass::scsi::{Scsi, ScsiCommand};
use usbd_storage::subclass::Command;
use usbd_storage::transport::bbb::{BulkOnly, BulkOnlyError};
use usbd_storage::transport::TransportError;
#[global_allocator]
static HEAP: Heap = Heap::empty();
@ -46,6 +50,23 @@ static HEAP: Heap = Heap::empty();
#[used]
pub static BOOT2_FIRMWARE: [u8; 256] = rp2040_boot2::BOOT_LOADER_W25Q080;
// ---- USB Mass Storage: a 1 MB FAT volume in the upper flash (the host formats it on first use) ----
const FS_BLOCK_SIZE: u32 = 4096;
const FS_BLOCKS: u32 = 256; // 256 × 4 KB = 1 MB
const FS_LEN: usize = (FS_BLOCK_SIZE * FS_BLOCKS) as usize;
const MSC_PACKET: u16 = 64;
const MSC_MAX_LUN: u8 = 0;
/// The filesystem flash region (see memory.x `.filesystem`, NOLOAD). We never read it *through* this
/// static (it would const-fold to 0); reads go via raw pointers and writes via `rp2040-flash`.
#[link_section = ".filesystem"]
#[used]
static FILESYSTEM: [u8; FS_LEN] = [0u8; FS_LEN];
/// Bulk-Only-Transport scratch buffer for usbd-storage (one block).
static mut MSC_BUF: core::mem::MaybeUninit<[u8; FS_BLOCK_SIZE as usize]> =
core::mem::MaybeUninit::uninit();
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)
@ -1157,6 +1178,134 @@ fn splash<I: I2c, P: FnMut()>(m: &mut Matrix<I>, delay: &mut Delay, mut poll: P)
}
}
// ============================== USB MASS STORAGE (SCSI over Bulk-Only) ==============================
// Adapted from the usbd-storage rp2040 example (apohrebniak/usbd-storage). The host sees a 1 MB
// removable drive; we serve blocks from the flash `.filesystem` region and write them with
// rp2040-flash (erase+program a 4 KB sector). The host owns the FAT format.
struct ScsiState {
offset: usize, // bytes transferred so far for the in-progress Read/Write
sense_key: u8,
sense_asc: u8,
write_buf: [u8; FS_BLOCK_SIZE as usize],
}
impl ScsiState {
fn new() -> Self {
ScsiState { offset: 0, sense_key: 0, sense_asc: 0, write_buf: [0u8; FS_BLOCK_SIZE as usize] }
}
}
fn scsi_command(
mut command: Command<ScsiCommand, Scsi<BulkOnly<hal::usb::UsbBus, &mut [u8]>>>,
st: &mut ScsiState,
) -> Result<(), TransportError<BulkOnlyError>> {
match command.kind {
ScsiCommand::TestUnitReady { .. } => {
command.pass();
}
ScsiCommand::Inquiry { .. } => {
command.try_write_data_all(&[
0x00, 0x80, 0x04, 0x02, 0x20, 0x00, 0x00, 0x00, // std inquiry, removable
b'V', b'A', b'R', b'A', b'S', b'Y', b'S', b' ', // 8-byte vendor id
b'P', b'M', b'_', b'G', b'-', b'1', b' ', b'G', b'r', b'i', b'd', b' ', b' ', b' ',
b' ', b' ', // 16-byte product id
b'0', b'.', b'1', b'0', // 4-byte revision
])?;
command.pass();
}
ScsiCommand::RequestSense { .. } => {
command.try_write_data_all(&[
0x70, 0x00, st.sense_key, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
st.sense_asc, 0x00, 0x00, 0x00, 0x00, 0x00,
])?;
st.sense_key = 0;
st.sense_asc = 0;
command.pass();
}
ScsiCommand::ReadCapacity10 { .. } => {
let mut data = [0u8; 8];
data[0..4].copy_from_slice(&u32::to_be_bytes(FS_BLOCKS - 1));
data[4..8].copy_from_slice(&u32::to_be_bytes(FS_BLOCK_SIZE));
command.try_write_data_all(&data)?;
command.pass();
}
ScsiCommand::ReadCapacity16 { .. } => {
let mut data = [0u8; 16];
data[0..8].copy_from_slice(&u64::to_be_bytes((FS_BLOCKS - 1) as u64));
data[8..12].copy_from_slice(&u32::to_be_bytes(FS_BLOCK_SIZE));
command.try_write_data_all(&data)?;
command.pass();
}
ScsiCommand::ReadFormatCapacities { .. } => {
let mut data = [0u8; 12];
data[0..4].copy_from_slice(&[0x00, 0x00, 0x00, 0x08]);
data[4..8].copy_from_slice(&u32::to_be_bytes(FS_BLOCKS));
data[8] = 0x01;
let bl = u32::to_be_bytes(FS_BLOCK_SIZE);
data[9..12].copy_from_slice(&bl[1..4]);
command.try_write_data_all(&data)?;
command.pass();
}
ScsiCommand::Read { lba, len } => {
let len = len as u32;
if st.offset != (len * FS_BLOCK_SIZE) as usize {
let start = (FS_BLOCK_SIZE * lba) as usize + st.offset;
let end = ((FS_BLOCK_SIZE * lba) as usize + (FS_BLOCK_SIZE * len) as usize).min(FS_LEN);
let start = start.min(end);
// raw-pointer read of the flash region (the static would const-fold to 0)
let data = unsafe { core::slice::from_raw_parts(FILESYSTEM.as_ptr().add(start), end - start) };
let count = command.write_data(data)?;
st.offset += count;
} else {
command.pass();
st.offset = 0;
}
}
ScsiCommand::Write { lba, len } => {
let len = len as u32;
if st.offset != (len * FS_BLOCK_SIZE) as usize {
loop {
let start = (FS_BLOCK_SIZE * lba) as usize + st.offset;
let block_offset = start % (FS_BLOCK_SIZE as usize);
let count = command.read_data(&mut st.write_buf[block_offset..])?;
st.offset += count;
if count > 0 && (st.offset % (FS_BLOCK_SIZE as usize)) == 0 {
// a full 4 KB block accumulated → erase+program that sector
info!("msc: write block {}", start / FS_BLOCK_SIZE as usize);
let faddr = (FILESYSTEM.as_ptr() as u32 & 0x00ff_ffff) + ((start as u32) & !0xfff);
cortex_m::interrupt::free(|_| unsafe {
rp2040_flash::flash::flash_range_erase_and_program(faddr, &st.write_buf, false);
});
} else {
break;
}
}
if st.offset == (len * FS_BLOCK_SIZE) as usize {
command.pass();
st.offset = 0;
}
} else {
command.pass();
st.offset = 0;
}
}
ScsiCommand::ModeSense6 { .. } => {
command.try_write_data_all(&[0x03, 0x00, 0x00, 0x00])?;
command.pass();
}
ScsiCommand::ModeSense10 { .. } => {
command.try_write_data_all(&[0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])?;
command.pass();
}
ref other => {
info!("msc: unsupported scsi cmd {}", other);
st.sense_key = 0x05; // illegal request
st.sense_asc = 0x20; // invalid command operation code
command.fail();
}
}
Ok(())
}
// ============================== MAIN ==============================
#[rp2040_hal::entry]
fn main() -> ! {
@ -1213,6 +1362,13 @@ fn main() -> ! {
&mut pac.RESETS,
));
let mut midi = UsbMidiClass::new(&usb_bus, 1, 1).unwrap();
let mut scsi = Scsi::new(&usb_bus, MSC_PACKET, MSC_MAX_LUN, unsafe {
#[allow(static_mut_refs)]
MSC_BUF.assume_init_mut()
}
.as_mut_slice())
.unwrap();
let mut scsi_state = ScsiState::new();
let mut usb_dev = UsbDeviceBuilder::new(&usb_bus, UsbVidPid(0x16c0, 0x5e4))
.strings(&[StringDescriptors::default()
.manufacturer("VARASYS")
@ -1225,7 +1381,7 @@ fn main() -> ! {
// boot splash (polls USB throughout so the host can enumerate during the animation)
splash(&mut mtx, &mut delay, || {
usb_dev.poll(&mut [&mut midi]);
usb_dev.poll(&mut [&mut midi, &mut scsi]);
});
// buttons (active-low, internal pull-ups): A=GP12 B=GP13 X=GP14 Y=GP15
@ -1251,8 +1407,14 @@ fn main() -> ! {
let us = now_us();
let now_ns = us * 1000;
// ---- USB: poll, then drain the RX endpoint, feeding SysEx (live-sync) bytes ----
usb_dev.poll(&mut [&mut midi]);
// ---- USB: poll the composite device (MIDI + Mass Storage) ----
if usb_dev.poll(&mut [&mut midi, &mut scsi]) {
// service any pending SCSI command (drive read/write)
let _ = scsi.poll(|cmd| {
let _ = scsi_command(cmd, &mut scsi_state);
});
}
// ---- drain the MIDI RX endpoint, feeding SysEx (live-sync) bytes ----
while let Ok(n) = midi.read(&mut rxbuf) {
if n == 0 {
break;