diff --git a/crates/fbuild-daemon/src/handlers/operations/deploy.rs b/crates/fbuild-daemon/src/handlers/operations/deploy.rs index 153134d3..747dcdb9 100644 --- a/crates/fbuild-daemon/src/handlers/operations/deploy.rs +++ b/crates/fbuild-daemon/src/handlers/operations/deploy.rs @@ -776,6 +776,9 @@ pub async fn deploy( ); Box::new(deployer) } + fbuild_core::Platform::RaspberryPi => { + Box::new(fbuild_deploy::rp2040::Rp2040Deployer::for_board(&board_id)) + } fbuild_core::Platform::NxpLpc => fbuild_deploy::lpc::dispatch_box( &board_id, &deploy_board_overrides, @@ -857,7 +860,13 @@ pub async fn deploy( &deploy_result, Ok(r) if r.success && matches!(r.outcome, fbuild_deploy::DeployOutcome::VerifySkip) ); - if let Some(ref p) = deploy_port_str { + let recovery_port = deploy_port_str.clone().or_else(|| { + deploy_result + .as_ref() + .ok() + .and_then(|result| result.port.clone()) + }); + if let Some(ref p) = recovery_port { ctx.serial_manager.clear_preemption(p).await; if !deploy_skipped_bus_work { if let Some(deployer) = deployer_for_recovery { @@ -926,7 +935,11 @@ pub async fn deploy( // monitor-attached and non-monitor-attached response below. Stable // wording — see GitHub issue #76 and the DeployOutcome::describe // test in fbuild-deploy. - let deploy_prefix = format!("deploy succeeded ({})", deploy_outcome.describe()); + let deploy_prefix = format!( + "deploy succeeded ({}); FBUILD_DEPLOY_PORT={}", + deploy_outcome.describe(), + deploy_post_port.as_deref().unwrap_or("") + ); // Post-deploy monitoring: if monitor_after is set, open the serial port // and stream lines checking halt conditions (matching Python behavior). diff --git a/crates/fbuild-deploy/src/lib.rs b/crates/fbuild-deploy/src/lib.rs index 053f3c7e..aca0584b 100644 --- a/crates/fbuild-deploy/src/lib.rs +++ b/crates/fbuild-deploy/src/lib.rs @@ -21,6 +21,7 @@ pub mod lpc_debugger_reflash; pub mod method_validation; pub mod probe_rs; pub mod reset; +pub mod rp2040; pub mod size_check; pub mod teensy; diff --git a/crates/fbuild-deploy/src/rp2040.rs b/crates/fbuild-deploy/src/rp2040.rs new file mode 100644 index 00000000..c87859a7 --- /dev/null +++ b/crates/fbuild-deploy/src/rp2040.rs @@ -0,0 +1,368 @@ +//! RP2040/RP2350 deployment through the stock UF2 BOOTSEL volume. +//! +//! A Raspberry Pi Pico does not require a vendor flashing utility: opening +//! its CDC port at 1200 baud and closing it enters BOOTSEL, where the ROM +//! exposes a mass-storage volume containing `INFO_UF2.TXT`. Copying a UF2 +//! file to that volume is the documented stock-board deployment path. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use fbuild_core::{FbuildError, Result}; + +use crate::{DeployOutcome, Deployer, DeploymentResult}; + +const UF2_MAGIC_START0: u32 = 0x0A32_4555; +const UF2_MAGIC_START1: u32 = 0x9E5D_5157; +const UF2_MAGIC_END: u32 = 0x0AB1_6F30; +const UF2_FLAG_FAMILY_ID_PRESENT: u32 = 0x0000_2000; +const RP2040_FAMILY_ID: u32 = 0xE48B_FF56; +const RP2350_FAMILY_ID: u32 = 0xE48B_FF59; +const UF2_PAYLOAD_SIZE: usize = 256; +const UF2_BLOCK_SIZE: usize = 512; + +/// Build UF2 blocks for a raw RP2040 flash image. +pub fn encode_uf2(binary: &[u8]) -> Vec { + encode_uf2_for_family(binary, RP2040_FAMILY_ID) +} + +/// Build UF2 blocks using an explicit Raspberry Pi family identifier. +pub fn encode_uf2_for_family(binary: &[u8], family_id: u32) -> Vec { + let block_count = binary.len().div_ceil(UF2_PAYLOAD_SIZE).max(1); + let mut output = Vec::with_capacity(block_count * UF2_BLOCK_SIZE); + for block_no in 0..block_count { + let start = block_no * UF2_PAYLOAD_SIZE; + let end = (start + UF2_PAYLOAD_SIZE).min(binary.len()); + let payload_len = end.saturating_sub(start); + let mut block = [0u8; UF2_BLOCK_SIZE]; + put_u32(&mut block, 0, UF2_MAGIC_START0); + put_u32(&mut block, 4, UF2_MAGIC_START1); + put_u32(&mut block, 8, UF2_FLAG_FAMILY_ID_PRESENT); + put_u32(&mut block, 12, start as u32 + 0x1000_0000); + put_u32(&mut block, 16, payload_len as u32); + put_u32(&mut block, 20, block_no as u32); + put_u32(&mut block, 24, block_count as u32); + put_u32(&mut block, 28, family_id); + block[32..32 + payload_len].copy_from_slice(&binary[start..end]); + put_u32(&mut block, 508, UF2_MAGIC_END); + output.extend_from_slice(&block); + } + output +} + +fn put_u32(buffer: &mut [u8], offset: usize, value: u32) { + buffer[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); +} + +/// Return candidate removable roots for this host. Kept separate from the +/// marker check so unit tests can use a temporary directory on every OS. +fn volume_roots() -> Vec { + let mut roots = Vec::new(); + if cfg!(windows) { + for letter in b'A'..=b'Z' { + roots.push(PathBuf::from(format!("{}:\\", letter as char))); + } + } else { + if let Ok(home) = std::env::var("HOME") { + let home = PathBuf::from(home); + if let Some(user) = home.file_name() { + roots.push(PathBuf::from("/media").join(user)); + roots.push(PathBuf::from("/run/media").join(user)); + } + } + roots.push(PathBuf::from("/Volumes")); + roots.push(PathBuf::from("/media")); + roots.push(PathBuf::from("/run/media")); + } + roots +} + +fn has_uf2_marker(path: &Path) -> bool { + let info = path.join("INFO_UF2.TXT"); + if let Ok(contents) = fs::read_to_string(info) { + let upper = contents.to_ascii_uppercase(); + return upper.contains("RP2") || upper.contains("RP2040") || upper.contains("RP2350"); + } + false +} + +/// Find a mounted Pico BOOTSEL volume under the supplied roots. +pub fn find_uf2_volume(roots: &[PathBuf]) -> Option { + find_uf2_volumes(roots).into_iter().next() +} + +fn find_uf2_volumes(roots: &[PathBuf]) -> Vec { + let mut matches = Vec::new(); + for root in roots { + if has_uf2_marker(root) { + matches.push(root.clone()); + } + let Ok(entries) = fs::read_dir(root) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() && has_uf2_marker(&path) { + matches.push(path); + } + } + } + matches +} + +fn find_uf2_volume_until(timeout: Duration) -> Result> { + let deadline = Instant::now() + timeout; + loop { + let matches = find_uf2_volumes(&volume_roots()); + if matches.len() > 1 { + return Err(FbuildError::DeployFailed(format!( + "found multiple RP2040 BOOTSEL volumes: {}", + matches + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", ") + ))); + } + if let Some(path) = matches.into_iter().next() { + return Ok(Some(path)); + } + if Instant::now() >= deadline { + return Ok(None); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +fn touch_1200bps(port: &str) -> Result<()> { + match serialport::new(port, 1200) + .timeout(Duration::from_secs(2)) + .open() + { + Ok(_) => { + // The Pico ROM observes the baud rate on close. The short hold + // avoids racing CDC ACM implementations that apply it lazily. + std::thread::sleep(Duration::from_millis(100)); + Ok(()) + } + Err(error) if error.kind() == serialport::ErrorKind::NoDevice => Ok(()), + Err(error) => Err(FbuildError::SerialError(format!( + "failed to enter RP2040 BOOTSEL on {port}: {error}" + ))), + } +} + +fn write_uf2(firmware_path: &Path, volume: &Path, family_id: u32) -> Result { + let bytes = fs::read(firmware_path).map_err(|error| { + FbuildError::DeployFailed(format!( + "failed to read {}: {error}", + firmware_path.display() + )) + })?; + let uf2 = if firmware_path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("uf2")) + { + bytes + } else { + encode_uf2_for_family(&bytes, family_id) + }; + let destination = volume.join("firmware.uf2"); + fs::write(&destination, uf2).map_err(|error| { + FbuildError::DeployFailed(format!( + "failed to copy UF2 to {}: {error}", + destination.display() + )) + })?; + Ok(destination) +} + +/// Deploys RP2040-family firmware through the stock BOOTSEL mass-storage +/// interface. `bootloader_timeout` is configurable for deterministic tests. +pub struct Rp2040Deployer { + bootloader_timeout: Duration, + post_deploy_timeout: Duration, + family_id: u32, +} + +impl Default for Rp2040Deployer { + fn default() -> Self { + Self { + bootloader_timeout: Duration::from_secs(10), + post_deploy_timeout: Duration::from_secs(5), + family_id: RP2040_FAMILY_ID, + } + } +} + +impl Rp2040Deployer { + pub fn new(bootloader_timeout: Duration, post_deploy_timeout: Duration) -> Self { + Self { + bootloader_timeout, + post_deploy_timeout, + family_id: RP2040_FAMILY_ID, + } + } + + pub fn for_board(board_id: &str) -> Self { + let mut deployer = Self::default(); + if board_id.to_ascii_lowercase().contains("pico2") { + deployer.family_id = RP2350_FAMILY_ID; + } + deployer + } +} + +fn wait_for_cdc_port(previous: Option<&str>, timeout: Duration) -> Option { + let deadline = Instant::now() + timeout; + loop { + if let Ok(ports) = fbuild_serial::ports::available_ports() { + let names: Vec = ports.into_iter().map(|p| p.port_name).collect(); + if let Some(old) = previous { + if names.iter().any(|name| name == old) { + return Some(old.to_string()); + } + } else if let Some(name) = names.into_iter().next() { + return Some(name); + } + } + if Instant::now() >= deadline { + return None; + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +#[async_trait::async_trait] +impl Deployer for Rp2040Deployer { + async fn deploy( + &self, + _project_dir: &Path, + _env_name: &str, + firmware_path: &Path, + port: Option<&str>, + ) -> Result { + let port = port.map(str::trim).filter(|value| !value.is_empty()); + let original_port = port.map(str::to_string); + if let Some(port) = port { + let port = port.to_string(); + tokio::task::spawn_blocking(move || touch_1200bps(&port)) + .await + .map_err(|error| { + FbuildError::DeployFailed(format!("RP2040 reset task failed: {error}")) + })??; + } + let timeout = self.bootloader_timeout; + let volume = tokio::task::spawn_blocking(move || find_uf2_volume_until(timeout)) + .await + .map_err(|error| FbuildError::DeployFailed(format!("RP2040 volume watcher failed: {error}")))? + ? + .ok_or_else(|| FbuildError::DeployFailed( + "RP2040 BOOTSEL volume not found; hold BOOTSEL while reconnecting the stock board".into(), + ))?; + let firmware = firmware_path.to_path_buf(); + let volume_for_copy = volume.clone(); + let family_id = self.family_id; + let destination = + tokio::task::spawn_blocking(move || write_uf2(&firmware, &volume_for_copy, family_id)) + .await + .map_err(|error| { + FbuildError::DeployFailed(format!("RP2040 UF2 writer failed: {error}")) + })??; + let recovery_port = original_port.clone(); + let post_timeout = self.post_deploy_timeout; + let discovered_port = tokio::task::spawn_blocking(move || { + wait_for_cdc_port(recovery_port.as_deref(), post_timeout) + }) + .await + .unwrap_or(None) + .or(original_port); + Ok(DeploymentResult { + success: true, + message: format!( + "firmware copied to RP2040 BOOTSEL volume {}", + volume.display() + ), + port: discovered_port, + stdout: format!("wrote {}", destination.display()), + stderr: String::new(), + outcome: DeployOutcome::FullFlash, + }) + } + + async fn post_deploy_recovery(&self, port: &str) -> Result<()> { + let deadline = Instant::now() + self.post_deploy_timeout; + while Instant::now() < deadline { + let port_name = port.to_string(); + let present = tokio::task::spawn_blocking(move || { + serialport::new(&port_name, 115_200) + .timeout(Duration::from_millis(100)) + .open() + .is_ok() + }) + .await + .unwrap_or(false); + if present { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + tracing::warn!("RP2040 CDC port {port} did not reappear after deploy"); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn uf2_has_rp2040_family_and_expected_blocks() { + let image = vec![0xA5; 300]; + let uf2 = encode_uf2(&image); + assert_eq!(uf2.len(), UF2_BLOCK_SIZE * 2); + assert_eq!( + u32::from_le_bytes(uf2[0..4].try_into().unwrap()), + UF2_MAGIC_START0 + ); + assert_eq!( + u32::from_le_bytes(uf2[28..32].try_into().unwrap()), + RP2040_FAMILY_ID + ); + assert_eq!( + u32::from_le_bytes(uf2[512 + 20..512 + 24].try_into().unwrap()), + 1 + ); + assert_eq!(&uf2[32..32 + 256], &image[..256]); + assert_eq!(&uf2[512 + 32..512 + 32 + 44], &image[256..]); + assert_eq!( + u32::from_le_bytes(uf2[508..512].try_into().unwrap()), + UF2_MAGIC_END + ); + } + + #[test] + fn finds_marker_volume_without_requiring_a_drive_letter() { + let root = tempdir().unwrap(); + let volume = root.path().join("RPI-RP2"); + fs::create_dir(&volume).unwrap(); + fs::write(volume.join("INFO_UF2.TXT"), "Model: Raspberry Pi RP2").unwrap(); + assert_eq!(find_uf2_volume(&[root.path().to_path_buf()]), Some(volume)); + } + + #[test] + fn writes_bin_as_uf2_to_marker_volume() { + let root = tempdir().unwrap(); + fs::write(root.path().join("INFO_UF2.TXT"), "UF2 Bootloader").unwrap(); + let firmware = root.path().join("firmware.bin"); + fs::write(&firmware, [1u8, 2, 3]).unwrap(); + let destination = write_uf2(&firmware, root.path(), RP2040_FAMILY_ID).unwrap(); + assert_eq!(destination.file_name().unwrap(), "firmware.uf2"); + assert_eq!( + fs::metadata(destination).unwrap().len(), + UF2_BLOCK_SIZE as u64 + ); + } +}