flowrs-engine/crates/flowrs-transport/src/picoboot.rs
flowRS fd958c0547 flowRS engine, cut from flowRS 3451b065
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:04:39 -05:00

777 lines
29 KiB
Rust

//! PICOBOOT — flashing an RP2040/RP2350 from a page, without touching mass storage.
//!
//! # Why this exists
//!
//! A Pico in BOOTSEL mode is famous for mounting as a drive you drag a `.uf2` onto. That mass
//! storage interface is genuinely closed to a browser: WebUSB may not claim protected device
//! classes, and MSC is one. It is easy — and wrong — to stop there and conclude a page can never
//! flash a Pico.
//!
//! The bootrom exposes a **second** interface alongside the drive: PICOBOOT, vendor-defined class
//! `0xFF` with two bulk endpoints. Vendor class is exactly what WebUSB *is* allowed to claim, and
//! PICOBOOT is what `picotool` speaks. So the door is open; it is simply a different door.
//!
//! This module is that protocol, sans-IO like the rest of the crate: it builds the 32-byte command
//! packets and parses the 16-byte status replies, owning no USB handle, no clock and no retries. A
//! driver moves the bytes — `nusb` on a workstation, WebUSB in a tab — and neither can drift from
//! the other, because there is only one implementation.
//!
//! # Verified, not recalled
//!
//! Every constant here was read from primary sources rather than memory: `PICOBOOT_MAGIC`, the
//! command ids and their `0x80` IN-direction bit, the packed `picoboot_cmd`/`picoboot_cmd_status`
//! layouts and all eighteen status codes come from the Pico SDK's `boot/picoboot.h`; the USB ids
//! from picotool's `picoboot_connection.h`; `XIP_BASE` from the SDK address maps for both chips.
//! The UF2 magics and family ids were verified earlier against real forge output.
//!
//! # Scope
//!
//! The command set needed to put an image on flash — exclusive access, EXIT_XIP, erase, write,
//! reboot — plus the UF2 parsing that turns a forge artefact into addressed payloads. `PC_EXEC`,
//! `PC_VECTORIZE_FLASH`, OTP and `PC_GET_INFO` have verified opcodes but no argument builders,
//! because their layouts were not read from a primary source here. Documented, not stubbed.
//!
//! **No RP2040 has been flashed with this.** The packets match the specification; first contact
//! with real silicon is the validation step.
/// Raspberry Pi's USB vendor id.
pub const VENDOR_ID: u16 = 0x2e8a;
/// Product id of an RP2040 in BOOTSEL mode.
pub const PID_RP2040_BOOTSEL: u16 = 0x0003;
/// Product id of an RP2350 in BOOTSEL mode.
pub const PID_RP2350_BOOTSEL: u16 = 0x000f;
/// First word of every command packet.
pub const MAGIC: u32 = 0x431f_d10b;
/// Vendor control request that resets the PICOBOOT interface.
pub const IF_RESET: u8 = 0x41;
/// Vendor control request that reads back a [`Status`].
pub const IF_CMD_STATUS: u8 = 0x42;
/// A command packet is exactly this long.
pub const CMD_LEN: usize = 32;
/// A status reply is exactly this long.
pub const STATUS_LEN: usize = 16;
/// Where execute-in-place flash is mapped, on both RP2040 and RP2350.
pub const XIP_BASE: u32 = 0x1000_0000;
/// The smallest erasable unit of the QSPI flash.
pub const FLASH_SECTOR_SIZE: u32 = 4096;
/// The largest writable page.
pub const FLASH_PAGE_SIZE: u32 = 256;
/// A failure building or reading a PICOBOOT exchange.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PicobootError {
/// A status reply was not [`STATUS_LEN`] bytes.
ShortStatus(usize),
/// The device reported a non-zero status code.
Failed {
/// The raw code.
code: u32,
/// Its documented meaning.
reason: &'static str,
},
/// The status reply's token did not match the command's.
TokenMismatch {
/// The token that was sent.
sent: u32,
/// The token that came back.
got: u32,
},
/// A UF2 image was not a whole number of 512-byte blocks.
Uf2NotBlockAligned(usize),
/// A UF2 block's magics were wrong.
Uf2BadMagic {
/// Index of the offending block.
block: usize,
},
/// A UF2 block claimed a payload longer than a block can hold.
Uf2PayloadTooLong {
/// Index of the offending block.
block: usize,
/// The size it claimed.
claimed: u32,
},
/// The image contained no flashable blocks.
Uf2Empty,
}
impl core::fmt::Display for PicobootError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
PicobootError::ShortStatus(n) => write!(f, "status reply was {n} bytes, want 16"),
PicobootError::Failed { code, reason } => {
write!(f, "device returned {code} ({reason})")
}
PicobootError::TokenMismatch { sent, got } => {
write!(f, "status token {got:#x} does not match command {sent:#x}")
}
PicobootError::Uf2NotBlockAligned(n) => {
write!(f, "UF2 is {n} bytes, not a multiple of 512")
}
PicobootError::Uf2BadMagic { block } => write!(f, "UF2 block {block} has bad magic"),
PicobootError::Uf2PayloadTooLong { block, claimed } => {
write!(f, "UF2 block {block} claims a {claimed}-byte payload")
}
PicobootError::Uf2Empty => write!(f, "UF2 contained no payload blocks"),
}
}
}
impl std::error::Error for PicobootError {}
/// A PICOBOOT command id. The top bit marks a device-to-host transfer, which is why `Read` and
/// `GetInfo` sit above `0x80` — that bit is part of the id, not a flag alongside it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Command {
/// Take (or release) exclusive access, locking out the mass-storage interface.
ExclusiveAccess,
/// Reboot. RP2040 only; RP2350 uses `Reboot2`.
Reboot,
/// Erase a flash range. Must be sector aligned.
FlashErase,
/// Read memory (device to host).
Read,
/// Write memory. Does **not** erase first.
Write,
/// Leave execute-in-place so flash commands are reliable.
ExitXip,
/// Re-enter execute-in-place.
EnterCmdXip,
/// Reboot, RP2350 form.
Reboot2,
/// Query the bootrom (device to host). RP2350 only.
GetInfo,
}
impl Command {
/// The wire id.
#[must_use]
pub const fn id(self) -> u8 {
match self {
Command::ExclusiveAccess => 0x01,
Command::Reboot => 0x02,
Command::FlashErase => 0x03,
Command::Read => 0x84,
Command::Write => 0x05,
Command::ExitXip => 0x06,
Command::EnterCmdXip => 0x07,
Command::Reboot2 => 0x0a,
Command::GetInfo => 0x8b,
}
}
/// Whether the data stage flows device to host — the `0x80` bit of the id.
#[must_use]
pub const fn is_in_transfer(self) -> bool {
self.id() & 0x80 != 0
}
}
/// How much of the mass-storage interface to lock out while flashing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Exclusive {
/// Leave mass storage alone.
No = 0,
/// Disable mass-storage writes; aborts any UF2 download in flight.
Yes = 1,
/// Also mark the media absent, ejecting the drive.
AndEject = 2,
}
/// A built 32-byte command packet.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Packet {
/// The command it carries.
pub command: Command,
/// The token to match against the status reply.
pub token: u32,
/// Bytes of the data stage that follows, or 0.
pub transfer_length: u32,
bytes: [u8; CMD_LEN],
}
impl Packet {
/// The wire bytes.
#[must_use]
pub const fn bytes(&self) -> &[u8; CMD_LEN] {
&self.bytes
}
fn build(command: Command, token: u32, args: &[u8], transfer_length: u32) -> Packet {
debug_assert!(args.len() <= 16, "the args union is 16 bytes");
let mut b = [0u8; CMD_LEN];
b[0..4].copy_from_slice(&MAGIC.to_le_bytes());
b[4..8].copy_from_slice(&token.to_le_bytes());
b[8] = command.id();
b[9] = args.len() as u8;
// b[10..12] is the reserved u16 and must stay zero.
b[12..16].copy_from_slice(&transfer_length.to_le_bytes());
b[16..16 + args.len()].copy_from_slice(args);
Packet {
command,
token,
transfer_length,
bytes: b,
}
}
}
fn range_args(addr: u32, size: u32) -> [u8; 8] {
let mut a = [0u8; 8];
a[0..4].copy_from_slice(&addr.to_le_bytes());
a[4..8].copy_from_slice(&size.to_le_bytes());
a
}
/// `PC_EXCLUSIVE_ACCESS` — take the device away from its own mass-storage interface.
#[must_use]
pub fn exclusive_access(token: u32, mode: Exclusive) -> Packet {
Packet::build(Command::ExclusiveAccess, token, &[mode as u8], 0)
}
/// `PC_EXIT_XIP` — required before flash writes are reliable. Carries no arguments.
#[must_use]
pub fn exit_xip(token: u32) -> Packet {
Packet::build(Command::ExitXip, token, &[], 0)
}
/// `PC_ENTER_CMD_XIP` — put execute-in-place back.
#[must_use]
pub fn enter_cmd_xip(token: u32) -> Packet {
Packet::build(Command::EnterCmdXip, token, &[], 0)
}
/// `PC_FLASH_ERASE` — erase `size` bytes at `addr`. Both must be sector aligned; the device
/// answers `BadAlignment` rather than guessing, so [`erase_plan`] exists to get it right first.
#[must_use]
pub fn flash_erase(token: u32, addr: u32, size: u32) -> Packet {
Packet::build(Command::FlashErase, token, &range_args(addr, size), 0)
}
/// `PC_WRITE` — the packet announcing a write; `size` bytes then follow on the OUT endpoint.
/// Does not erase, so the range must already have been erased.
#[must_use]
pub fn write(token: u32, addr: u32, size: u32) -> Packet {
Packet::build(Command::Write, token, &range_args(addr, size), size)
}
/// `PC_READ` — the packet announcing a read; `size` bytes then arrive on the IN endpoint.
#[must_use]
pub fn read(token: u32, addr: u32, size: u32) -> Packet {
Packet::build(Command::Read, token, &range_args(addr, size), size)
}
/// `PC_REBOOT` (RP2040): jump to `pc` with `sp`, after `delay_ms`. A zeroed pc/sp asks the bootrom
/// to restart the flash image, which is what a flasher wants once it is done.
#[must_use]
pub fn reboot(token: u32, pc: u32, sp: u32, delay_ms: u32) -> Packet {
let mut a = [0u8; 12];
a[0..4].copy_from_slice(&pc.to_le_bytes());
a[4..8].copy_from_slice(&sp.to_le_bytes());
a[8..12].copy_from_slice(&delay_ms.to_le_bytes());
Packet::build(Command::Reboot, token, &a, 0)
}
/// The documented meaning of a status code.
#[must_use]
pub fn status_reason(code: u32) -> &'static str {
match code {
0 => "ok",
1 => "unknown command",
2 => "invalid command length",
3 => "invalid transfer length",
4 => "invalid address",
5 => "bad alignment",
6 => "interleaved write",
7 => "rebooting",
8 => "unknown error",
9 => "invalid state",
10 => "not permitted",
11 => "invalid argument",
12 => "buffer too small",
13 => "precondition not met",
14 => "modified data",
15 => "invalid data",
16 => "not found",
17 => "unsupported modification",
_ => "undocumented status",
}
}
/// A parsed 16-byte status reply.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Status {
/// Echo of the command's token.
pub token: u32,
/// The result code.
pub code: u32,
/// The command this refers to.
pub cmd_id: u8,
/// Whether the device is still working on it.
pub in_progress: bool,
}
impl Status {
/// Parse a status reply.
pub fn parse(bytes: &[u8]) -> Result<Status, PicobootError> {
if bytes.len() < STATUS_LEN {
return Err(PicobootError::ShortStatus(bytes.len()));
}
Ok(Status {
token: u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
code: u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]),
cmd_id: bytes[8],
in_progress: bytes[9] != 0,
})
}
/// Whether the command succeeded.
#[must_use]
pub const fn is_ok(self) -> bool {
self.code == 0
}
/// Check this status against the packet it should answer.
pub fn check(self, sent: &Packet) -> Result<Status, PicobootError> {
if self.token != sent.token {
return Err(PicobootError::TokenMismatch {
sent: sent.token,
got: self.token,
});
}
if !self.is_ok() {
return Err(PicobootError::Failed {
code: self.code,
reason: status_reason(self.code),
});
}
Ok(self)
}
}
/// Which chip a BOOTSEL product id belongs to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Chip {
/// RP2040.
Rp2040,
/// RP2350.
Rp2350,
}
impl Chip {
/// Identify a device from its USB ids, or `None` if it is not a Pico in BOOTSEL mode.
#[must_use]
pub const fn from_usb(vid: u16, pid: u16) -> Option<Chip> {
if vid != VENDOR_ID {
return None;
}
match pid {
PID_RP2040_BOOTSEL => Some(Chip::Rp2040),
PID_RP2350_BOOTSEL => Some(Chip::Rp2350),
_ => None,
}
}
/// Identify a chip from a board registry's part string.
///
/// Gating on the FLASH METHOD alone is not enough: `FlashMethod::Uf2` also covers parts whose
/// UF2 bootloader is not the RP2 bootrom — a SAMD51 mounts a drive too, and has no PICOBOOT
/// interface behind it. Offering a PICOBOOT button for one of those is a click that can only
/// fail, so the caller asks this and refuses when it answers `None`.
#[must_use]
pub fn from_part_name(part: &str) -> Option<Chip> {
let p = part.to_ascii_lowercase();
if p.contains("rp2040") {
Some(Chip::Rp2040)
} else if p.contains("rp2350") {
Some(Chip::Rp2350)
} else {
None
}
}
/// Every chip this module can drive.
pub const ALL: [Chip; 2] = [Chip::Rp2040, Chip::Rp2350];
/// The BOOTSEL product id this chip presents.
#[must_use]
pub const fn bootsel_pid(self) -> u16 {
match self {
Chip::Rp2040 => PID_RP2040_BOOTSEL,
Chip::Rp2350 => PID_RP2350_BOOTSEL,
}
}
/// The name to show a user.
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Chip::Rp2040 => "RP2040",
Chip::Rp2350 => "RP2350",
}
}
}
// --- UF2, the container the forge actually emits ---------------------------------------------
/// A UF2 block is always this long, payload or not.
pub const UF2_BLOCK_LEN: usize = 512;
/// First magic word (`"UF2\n"`).
pub const UF2_MAGIC_START0: u32 = 0x0A32_4655;
/// Second magic word.
pub const UF2_MAGIC_START1: u32 = 0x9E5D_5157;
/// Trailing magic word.
pub const UF2_MAGIC_END: u32 = 0x0AB1_6F30;
/// Family id for RP2040.
pub const UF2_FAMILY_RP2040: u32 = 0xE48B_FF56;
/// Family id for RP2350 in Arm secure mode.
pub const UF2_FAMILY_RP2350_ARM_S: u32 = 0xE48B_FF59;
/// Flag bit meaning the block is not part of the image and must not be written.
const UF2_FLAG_NOT_MAIN_FLASH: u32 = 0x0000_0001;
/// One addressed payload extracted from a UF2 file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Uf2Chunk {
/// Where this payload belongs.
pub addr: u32,
/// The payload itself.
pub data: Vec<u8>,
}
/// Parse a UF2 image into the payloads it wants written.
///
/// Blocks flagged `NOT_MAIN_FLASH` are skipped rather than written — that flag exists precisely to
/// carry metadata a flasher must not put on the device.
pub fn parse_uf2(image: &[u8]) -> Result<Vec<Uf2Chunk>, PicobootError> {
// `is_empty` stays explicit: 0 IS a multiple of 512, so folding it into the
// divisibility check would accept an empty file as a valid zero-block image.
if image.is_empty() || !image.len().is_multiple_of(UF2_BLOCK_LEN) {
return Err(PicobootError::Uf2NotBlockAligned(image.len()));
}
let word = |b: &[u8], at: usize| u32::from_le_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]);
let mut out = Vec::with_capacity(image.len() / UF2_BLOCK_LEN);
for (i, block) in image.as_chunks::<UF2_BLOCK_LEN>().0.iter().enumerate() {
if word(block, 0) != UF2_MAGIC_START0
|| word(block, 4) != UF2_MAGIC_START1
|| word(block, UF2_BLOCK_LEN - 4) != UF2_MAGIC_END
{
return Err(PicobootError::Uf2BadMagic { block: i });
}
let flags = word(block, 8);
let addr = word(block, 12);
let payload_size = word(block, 16);
if flags & UF2_FLAG_NOT_MAIN_FLASH != 0 {
continue;
}
// The payload lives at offset 32 and the trailing magic is the last word, so this is the
// most a block can carry. A larger claim is a malformed file, not a big block.
if payload_size as usize > UF2_BLOCK_LEN - 32 - 4 {
return Err(PicobootError::Uf2PayloadTooLong {
block: i,
claimed: payload_size,
});
}
out.push(Uf2Chunk {
addr,
data: block[32..32 + payload_size as usize].to_vec(),
});
}
if out.is_empty() {
return Err(PicobootError::Uf2Empty);
}
Ok(out)
}
/// Merge consecutive chunks that are physically adjacent into single runs.
///
/// A UF2 carries 256 bytes per block, so a 1 MB image is 4096 separate writes if taken literally.
/// Coalescing them turns that into a handful, which is the difference between a flash that takes
/// seconds and one that takes minutes. Non-adjacent chunks start a new run, so a sparse image is
/// still written correctly rather than being padded across its gaps.
#[must_use]
pub fn coalesce(chunks: &[Uf2Chunk]) -> Vec<Uf2Chunk> {
let mut runs: Vec<Uf2Chunk> = Vec::new();
for c in chunks {
match runs.last_mut() {
Some(run) if run.addr + run.data.len() as u32 == c.addr => {
run.data.extend_from_slice(&c.data);
}
_ => runs.push(c.clone()),
}
}
runs
}
/// The sector-aligned erase each run needs before it can be written.
///
/// `PC_FLASH_ERASE` rejects an unaligned address or size outright, and `PC_WRITE` does not erase,
/// so a flasher that skips this writes into un-erased flash and silently corrupts the image. The
/// range is widened down to the sector containing the start and up to the sector containing the
/// end.
#[must_use]
pub fn erase_plan(runs: &[Uf2Chunk]) -> Vec<(u32, u32)> {
let mut plan: Vec<(u32, u32)> = Vec::new();
for r in runs {
let start = r.addr & !(FLASH_SECTOR_SIZE - 1);
let end_byte = r.addr + r.data.len() as u32;
let end = end_byte.div_ceil(FLASH_SECTOR_SIZE) * FLASH_SECTOR_SIZE;
match plan.last_mut() {
// Two runs inside one sector must not be erased twice — the second erase would wipe
// the first run's freshly written bytes.
Some(last) if last.0 + last.1 >= start => {
let new_end = end.max(last.0 + last.1);
last.1 = new_end - last.0;
}
_ => plan.push((start, end - start)),
}
}
plan
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a syntactically valid UF2 block.
fn uf2_block(addr: u32, payload: &[u8], flags: u32, block_no: u32, total: u32) -> Vec<u8> {
let mut b = vec![0u8; UF2_BLOCK_LEN];
b[0..4].copy_from_slice(&UF2_MAGIC_START0.to_le_bytes());
b[4..8].copy_from_slice(&UF2_MAGIC_START1.to_le_bytes());
b[8..12].copy_from_slice(&flags.to_le_bytes());
b[12..16].copy_from_slice(&addr.to_le_bytes());
b[16..20].copy_from_slice(&(payload.len() as u32).to_le_bytes());
b[20..24].copy_from_slice(&block_no.to_le_bytes());
b[24..28].copy_from_slice(&total.to_le_bytes());
b[28..32].copy_from_slice(&UF2_FAMILY_RP2040.to_le_bytes());
b[32..32 + payload.len()].copy_from_slice(payload);
b[UF2_BLOCK_LEN - 4..].copy_from_slice(&UF2_MAGIC_END.to_le_bytes());
b
}
#[test]
fn the_command_packet_matches_the_sdk_layout() {
let p = exclusive_access(0xdead_beef, Exclusive::AndEject);
let b = p.bytes();
assert_eq!(b.len(), 32, "picoboot_cmd is 32 bytes");
assert_eq!(&b[0..4], &0x431f_d10bu32.to_le_bytes(), "PICOBOOT_MAGIC");
assert_eq!(&b[4..8], &0xdead_beefu32.to_le_bytes(), "dToken");
assert_eq!(b[8], 0x01, "PC_EXCLUSIVE_ACCESS");
assert_eq!(b[9], 1, "bCmdSize is the arg byte count");
assert_eq!(&b[10..12], &[0, 0], "the reserved u16 must be zero");
assert_eq!(&b[12..16], &0u32.to_le_bytes(), "no data stage");
assert_eq!(b[16], 2, "EXCLUSIVE_AND_EJECT");
}
#[test]
fn the_opcodes_are_the_sdk_values_and_the_in_bit_is_part_of_them() {
assert_eq!(Command::ExclusiveAccess.id(), 0x01);
assert_eq!(Command::Reboot.id(), 0x02);
assert_eq!(Command::FlashErase.id(), 0x03);
assert_eq!(Command::Read.id(), 0x84);
assert_eq!(Command::Write.id(), 0x05);
assert_eq!(Command::ExitXip.id(), 0x06);
assert_eq!(Command::EnterCmdXip.id(), 0x07);
assert_eq!(Command::Reboot2.id(), 0x0a);
assert_eq!(Command::GetInfo.id(), 0x8b);
// The direction is encoded IN the id, not beside it.
assert!(Command::Read.is_in_transfer() && Command::GetInfo.is_in_transfer());
assert!(!Command::Write.is_in_transfer() && !Command::FlashErase.is_in_transfer());
}
#[test]
fn a_range_command_carries_addr_then_size_and_write_announces_its_data_stage() {
let p = write(7, XIP_BASE + 0x1000, 256);
let b = p.bytes();
assert_eq!(b[8], 0x05);
assert_eq!(b[9], 8, "range_cmd is two u32s");
assert_eq!(&b[16..20], &(XIP_BASE + 0x1000).to_le_bytes(), "dAddr");
assert_eq!(&b[20..24], &256u32.to_le_bytes(), "dSize");
assert_eq!(
&b[12..16],
&256u32.to_le_bytes(),
"a write announces the bytes that follow"
);
// An argument-less command says so, rather than sending stale bytes.
let x = exit_xip(7);
assert_eq!(x.bytes()[9], 0, "bCmdSize == 0");
assert!(x.bytes()[16..].iter().all(|&v| v == 0));
}
#[test]
fn a_status_reply_is_checked_against_its_command() {
let sent = flash_erase(0x1234, XIP_BASE, FLASH_SECTOR_SIZE);
let mut reply = [0u8; STATUS_LEN];
reply[0..4].copy_from_slice(&0x1234u32.to_le_bytes());
reply[8] = 0x03;
let s = Status::parse(&reply).unwrap().check(&sent).unwrap();
assert!(s.is_ok() && !s.in_progress);
// A failure is reported with its documented meaning, not just a number.
reply[4..8].copy_from_slice(&5u32.to_le_bytes());
let err = Status::parse(&reply).unwrap().check(&sent).unwrap_err();
assert_eq!(
err,
PicobootError::Failed {
code: 5,
reason: "bad alignment"
}
);
// A reply for a DIFFERENT command must not be read as this one's success.
reply[4..8].copy_from_slice(&0u32.to_le_bytes());
reply[0..4].copy_from_slice(&0x9999u32.to_le_bytes());
assert!(matches!(
Status::parse(&reply).unwrap().check(&sent),
Err(PicobootError::TokenMismatch { .. })
));
// A truncated reply is an error, never a silent success.
assert_eq!(Status::parse(&[0u8; 8]), Err(PicobootError::ShortStatus(8)));
}
#[test]
fn bootsel_devices_are_identified_and_nothing_else_is() {
assert_eq!(Chip::from_usb(0x2e8a, 0x0003), Some(Chip::Rp2040));
assert_eq!(Chip::from_usb(0x2e8a, 0x000f), Some(Chip::Rp2350));
// A Pico running user code (stdio USB) is NOT in BOOTSEL and must not be offered.
assert_eq!(Chip::from_usb(0x2e8a, 0x000a), None);
assert_eq!(
Chip::from_usb(0x2e8a, 0x0004),
None,
"picoprobe is not a target"
);
assert_eq!(
Chip::from_usb(0x1234, 0x0003),
None,
"vendor must match too"
);
}
#[test]
fn only_rp2_parts_are_claimed_so_a_samd_uf2_board_is_not_offered() {
assert_eq!(Chip::from_part_name("RP2040"), Some(Chip::Rp2040));
assert_eq!(Chip::from_part_name("rp2350"), Some(Chip::Rp2350));
// A SAMD51 mounts a UF2 drive too and has no PICOBOOT behind it. Claiming it would put
// a button in front of a board this protocol cannot reach.
assert_eq!(Chip::from_part_name("ATSAMD51P19"), None);
assert_eq!(Chip::from_part_name("nRF52833"), None);
// The BOOTSEL ids round-trip with the USB identification.
for c in Chip::ALL {
assert_eq!(Chip::from_usb(VENDOR_ID, c.bootsel_pid()), Some(c));
}
}
#[test]
fn a_uf2_parses_into_addressed_payloads() {
let mut img = uf2_block(XIP_BASE, &[0xAA; 256], 0, 0, 2);
img.extend(uf2_block(XIP_BASE + 256, &[0xBB; 256], 0, 1, 2));
let chunks = parse_uf2(&img).unwrap();
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0].addr, XIP_BASE);
assert_eq!(chunks[0].data, vec![0xAA; 256]);
assert_eq!(chunks[1].addr, XIP_BASE + 256);
}
#[test]
fn a_not_main_flash_block_is_skipped_not_written() {
// That flag exists to carry metadata; writing it would put non-image bytes on the device.
let mut img = uf2_block(XIP_BASE, &[0xAA; 256], UF2_FLAG_NOT_MAIN_FLASH, 0, 2);
img.extend(uf2_block(XIP_BASE + 256, &[0xBB; 256], 0, 1, 2));
let chunks = parse_uf2(&img).unwrap();
assert_eq!(chunks.len(), 1, "the flagged block must not be flashed");
assert_eq!(chunks[0].addr, XIP_BASE + 256);
}
#[test]
fn malformed_uf2_images_are_rejected_rather_than_half_flashed() {
assert_eq!(parse_uf2(&[]), Err(PicobootError::Uf2NotBlockAligned(0)));
assert_eq!(
parse_uf2(&[0u8; 700]),
Err(PicobootError::Uf2NotBlockAligned(700))
);
assert_eq!(
parse_uf2(&[0u8; 512]),
Err(PicobootError::Uf2BadMagic { block: 0 })
);
// A payload larger than a block can hold is a malformed file, and reading it would run
// past the block into the next one's header.
let mut bad = uf2_block(XIP_BASE, &[0xAA; 256], 0, 0, 1);
bad[16..20].copy_from_slice(&500u32.to_le_bytes());
assert_eq!(
parse_uf2(&bad),
Err(PicobootError::Uf2PayloadTooLong {
block: 0,
claimed: 500
})
);
// Every block flagged out leaves nothing to write, which is worth saying explicitly.
let empty = uf2_block(XIP_BASE, &[0xAA; 256], UF2_FLAG_NOT_MAIN_FLASH, 0, 1);
assert_eq!(parse_uf2(&empty), Err(PicobootError::Uf2Empty));
}
#[test]
fn adjacent_chunks_coalesce_and_gaps_do_not() {
let chunks = vec![
Uf2Chunk {
addr: 0x1000_0000,
data: vec![1; 256],
},
Uf2Chunk {
addr: 0x1000_0100,
data: vec![2; 256],
},
// A gap: this must start a new run rather than be padded onto the previous one.
Uf2Chunk {
addr: 0x1001_0000,
data: vec![3; 256],
},
];
let runs = coalesce(&chunks);
assert_eq!(runs.len(), 2, "two contiguous, one detached");
assert_eq!(runs[0].addr, 0x1000_0000);
assert_eq!(runs[0].data.len(), 512, "the adjacent pair merged");
assert_eq!(runs[1].addr, 0x1001_0000);
assert_eq!(runs[1].data.len(), 256);
}
#[test]
fn the_erase_plan_is_sector_aligned_and_never_erases_written_bytes_twice() {
// A run that starts mid-sector must erase from the sector boundary...
let runs = vec![Uf2Chunk {
addr: XIP_BASE + 100,
data: vec![0; 10],
}];
assert_eq!(erase_plan(&runs), vec![(XIP_BASE, FLASH_SECTOR_SIZE)]);
// ...and one byte past a sector must erase two.
let runs = vec![Uf2Chunk {
addr: XIP_BASE,
data: vec![0; FLASH_SECTOR_SIZE as usize + 1],
}];
assert_eq!(erase_plan(&runs), vec![(XIP_BASE, FLASH_SECTOR_SIZE * 2)]);
// Two runs sharing a sector must yield ONE erase. Two would wipe the first run's bytes
// after they had already been written.
let runs = vec![
Uf2Chunk {
addr: XIP_BASE,
data: vec![0; 256],
},
Uf2Chunk {
addr: XIP_BASE + 1024,
data: vec![0; 256],
},
];
let plan = erase_plan(&runs);
assert_eq!(plan.len(), 1, "one shared sector, one erase");
assert_eq!(plan[0], (XIP_BASE, FLASH_SECTOR_SIZE));
// Every plan entry is sector aligned in both address and size.
for (addr, size) in erase_plan(&runs) {
assert_eq!(addr % FLASH_SECTOR_SIZE, 0);
assert_eq!(size % FLASH_SECTOR_SIZE, 0);
}
}
}