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:
parent
dce51866d2
commit
394ae65eaf
4 changed files with 203 additions and 13 deletions
|
|
@ -207,10 +207,21 @@ derived BPM, 5–300 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
|
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.
|
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
|
**USB Mass Storage — ✅ DONE (drive enumerates; pending on-device test)**: composite **MIDI + MSC**
|
||||||
`CIRCUITPY` drive; would need a flash KV layer, a separate milestone): on-device practice log,
|
(`usbd-storage` 2.0, SCSI over Bulk-Only), adapted from the crate's RP2040 example. The host sees a
|
||||||
`settings.json`, SLSYNC/LOGSYNC (`0x44`/`0x45` set-list + log merge). Also: firmware push (intended:
|
**1 MB removable drive** backed by the upper flash (a `.filesystem` region, `NOLOAD` so it's not in
|
||||||
UF2-flashed now), optional piezo.
|
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
|
### 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
|
Replace the `.mpy`-level A/B hack (`code.py` loads `app.mpy`, rolls back to `app.bak`) with the
|
||||||
|
|
|
||||||
|
|
@ -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."
|
description = "PM_G-1 'Grid' firmware (RP2040 / Pimoroni Pico Scroll Pack PIM545). 17x7 IS31FL3731 LED metronome — Rust sibling of pico-scroll/app.py."
|
||||||
|
|
||||||
[dependencies]
|
[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"
|
rp2040-boot2 = "0.3"
|
||||||
cortex-m = "0.7"
|
cortex-m = "0.7"
|
||||||
cortex-m-rt = "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
|
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)
|
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-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]
|
[profile.release]
|
||||||
opt-level = "s"
|
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
|
debug = 2
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
/* RP2040 (plain Raspberry Pi Pico) memory layout for rp2040-hal + cortex-m-rt.
|
/* 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
|
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 {
|
MEMORY {
|
||||||
BOOT2 : ORIGIN = 0x10000000, LENGTH = 0x100
|
BOOT2 : ORIGIN = 0x10000000, LENGTH = 0x100
|
||||||
FLASH : ORIGIN = 0x10000100, LENGTH = 2048K - 0x100
|
FLASH : ORIGIN = 0x10000100, LENGTH = 0x100000 - 0x100
|
||||||
RAM : ORIGIN = 0x20000000, LENGTH = 264K
|
FILESYSTEM : ORIGIN = 0x10100000, LENGTH = 1M
|
||||||
|
RAM : ORIGIN = 0x20000000, LENGTH = 264K
|
||||||
}
|
}
|
||||||
|
|
||||||
EXTERN(BOOT2_FIRMWARE)
|
EXTERN(BOOT2_FIRMWARE)
|
||||||
|
|
@ -16,3 +20,13 @@ SECTIONS {
|
||||||
KEEP(*(.boot2));
|
KEEP(*(.boot2));
|
||||||
} > BOOT2
|
} > BOOT2
|
||||||
} INSERT BEFORE .text;
|
} 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;
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,10 @@ use track_format::End;
|
||||||
use usb_device::prelude::*;
|
use usb_device::prelude::*;
|
||||||
use usb_device::bus::UsbBusAllocator;
|
use usb_device::bus::UsbBusAllocator;
|
||||||
use usbd_midi::UsbMidiClass;
|
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]
|
#[global_allocator]
|
||||||
static HEAP: Heap = Heap::empty();
|
static HEAP: Heap = Heap::empty();
|
||||||
|
|
@ -46,6 +50,23 @@ static HEAP: Heap = Heap::empty();
|
||||||
#[used]
|
#[used]
|
||||||
pub static BOOT2_FIRMWARE: [u8; 256] = rp2040_boot2::BOOT_LOADER_W25Q080;
|
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 XTAL_FREQ_HZ: u32 = 12_000_000;
|
||||||
const MATRIX_ADDR: u8 = 0x74;
|
const MATRIX_ADDR: u8 = 0x74;
|
||||||
const DEVICE_ID: &str = "G"; // reported on the SysEx version query (0x02→0x03)
|
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 ==============================
|
// ============================== MAIN ==============================
|
||||||
#[rp2040_hal::entry]
|
#[rp2040_hal::entry]
|
||||||
fn main() -> ! {
|
fn main() -> ! {
|
||||||
|
|
@ -1213,6 +1362,13 @@ fn main() -> ! {
|
||||||
&mut pac.RESETS,
|
&mut pac.RESETS,
|
||||||
));
|
));
|
||||||
let mut midi = UsbMidiClass::new(&usb_bus, 1, 1).unwrap();
|
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))
|
let mut usb_dev = UsbDeviceBuilder::new(&usb_bus, UsbVidPid(0x16c0, 0x5e4))
|
||||||
.strings(&[StringDescriptors::default()
|
.strings(&[StringDescriptors::default()
|
||||||
.manufacturer("VARASYS")
|
.manufacturer("VARASYS")
|
||||||
|
|
@ -1225,7 +1381,7 @@ fn main() -> ! {
|
||||||
|
|
||||||
// boot splash (polls USB throughout so the host can enumerate during the animation)
|
// boot splash (polls USB throughout so the host can enumerate during the animation)
|
||||||
splash(&mut mtx, &mut delay, || {
|
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
|
// 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 us = now_us();
|
||||||
let now_ns = us * 1000;
|
let now_ns = us * 1000;
|
||||||
|
|
||||||
// ---- USB: poll, then drain the RX endpoint, feeding SysEx (live-sync) bytes ----
|
// ---- USB: poll the composite device (MIDI + Mass Storage) ----
|
||||||
usb_dev.poll(&mut [&mut midi]);
|
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) {
|
while let Ok(n) = midi.read(&mut rxbuf) {
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue