diff --git a/docs/rust-port.md b/docs/rust-port.md index b3f0e38..26e997f 100644 --- a/docs/rust-port.md +++ b/docs/rust-port.md @@ -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 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 diff --git a/rust/pm-grid/Cargo.toml b/rust/pm-grid/Cargo.toml index 6c47ba9..49113ca 100644 --- a/rust/pm-grid/Cargo.toml +++ b/rust/pm-grid/Cargo.toml @@ -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 diff --git a/rust/pm-grid/memory.x b/rust/pm-grid/memory.x index aedae5d..4c0d126 100644 --- a/rust/pm-grid/memory.x +++ b/rust/pm-grid/memory.x @@ -1,10 +1,14 @@ /* 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 - RAM : ORIGIN = 0x20000000, LENGTH = 264K + BOOT2 : ORIGIN = 0x10000000, LENGTH = 0x100 + FLASH : ORIGIN = 0x10000100, LENGTH = 0x100000 - 0x100 + FILESYSTEM : ORIGIN = 0x10100000, LENGTH = 1M + RAM : ORIGIN = 0x20000000, LENGTH = 264K } EXTERN(BOOT2_FIRMWARE) @@ -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; diff --git a/rust/pm-grid/src/main.rs b/rust/pm-grid/src/main.rs index 606b75c..fb00e56 100644 --- a/rust/pm-grid/src/main.rs +++ b/rust/pm-grid/src/main.rs @@ -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(m: &mut Matrix, 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>>, + st: &mut ScsiState, +) -> Result<(), TransportError> { + 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;