diff --git a/Cargo.lock b/Cargo.lock index 080fa271..c1f01d25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1297,6 +1297,7 @@ dependencies = [ "espflash", "fbuild-config", "fbuild-core", + "fbuild-packages", "fbuild-paths", "fbuild-serial", "fbuild-test-support", diff --git a/crates/fbuild-cli/src/cli/args.rs b/crates/fbuild-cli/src/cli/args.rs index 34ac84ac..224b9bd2 100644 --- a/crates/fbuild-cli/src/cli/args.rs +++ b/crates/fbuild-cli/src/cli/args.rs @@ -290,6 +290,9 @@ pub enum Commands { /// Override the board's default upload baud rate #[arg(short = 'b', long = "baud", alias = "baud-rate")] baud_rate: Option, + /// Force the LPC deployer to use lpc21isp instead of probe-rs SWD + #[arg(long = "no-probe-rs")] + no_probe_rs: bool, /// Deploy destination: device (default) or emulator #[arg(long = "to", value_parser = ["device", "emu", "emulator"])] to: Option, diff --git a/crates/fbuild-cli/src/cli/deploy.rs b/crates/fbuild-cli/src/cli/deploy.rs index 48ec7e51..c5981b66 100644 --- a/crates/fbuild-cli/src/cli/deploy.rs +++ b/crates/fbuild-cli/src/cli/deploy.rs @@ -155,6 +155,7 @@ pub async fn run_deploy( qemu: bool, qemu_timeout: u32, baud_rate: Option, + no_probe_rs: bool, to: Option, emulator: Option, target: Option, @@ -195,6 +196,7 @@ pub async fn run_deploy( monitor_expect: expect, monitor_show_timestamp: !no_timestamp, baud_rate, + no_probe_rs, to, emulator, target, diff --git a/crates/fbuild-cli/src/cli/dispatch.rs b/crates/fbuild-cli/src/cli/dispatch.rs index a15b9db1..af8374ef 100644 --- a/crates/fbuild-cli/src/cli/dispatch.rs +++ b/crates/fbuild-cli/src/cli/dispatch.rs @@ -224,6 +224,7 @@ pub async fn async_main() { qemu, qemu_timeout, baud_rate, + no_probe_rs, to, emulator, target, @@ -264,6 +265,7 @@ pub async fn async_main() { qemu, qemu_timeout, baud_rate, + no_probe_rs, to, emulator, target, @@ -563,6 +565,7 @@ pub async fn async_main() { false, 30, None, + false, None, None, None, diff --git a/crates/fbuild-cli/src/daemon_client/types.rs b/crates/fbuild-cli/src/daemon_client/types.rs index 48b0cd8e..ecc30d37 100644 --- a/crates/fbuild-cli/src/daemon_client/types.rs +++ b/crates/fbuild-cli/src/daemon_client/types.rs @@ -76,6 +76,9 @@ pub struct DeployRequest { /// Override the board's default upload baud rate for flashing. #[serde(skip_serializing_if = "Option::is_none")] pub baud_rate: Option, + /// Force LPC deploys through lpc21isp instead of the probe-rs SWD fast path. + #[serde(default)] + pub no_probe_rs: bool, /// Deploy destination: "device", "emu", or "emulator". #[serde(skip_serializing_if = "Option::is_none")] pub to: Option, diff --git a/crates/fbuild-cli/src/mcp/tools.rs b/crates/fbuild-cli/src/mcp/tools.rs index 30567e25..b9b0d10b 100644 --- a/crates/fbuild-cli/src/mcp/tools.rs +++ b/crates/fbuild-cli/src/mcp/tools.rs @@ -168,6 +168,7 @@ pub(super) async fn execute_tool( monitor_expect: None, monitor_show_timestamp: true, baud_rate: None, + no_probe_rs: false, to: None, emulator: None, target: None, diff --git a/crates/fbuild-daemon/src/handlers/operations/deploy.rs b/crates/fbuild-daemon/src/handlers/operations/deploy.rs index 721fbe54..153134d3 100644 --- a/crates/fbuild-daemon/src/handlers/operations/deploy.rs +++ b/crates/fbuild-daemon/src/handlers/operations/deploy.rs @@ -437,6 +437,7 @@ pub async fn deploy( let deploy_port = deploy_port_str.clone(); let deploy_fw = firmware_path.clone(); let baud_override = req.baud_rate; + let no_probe_rs = req.no_probe_rs; let deploy_board_overrides = board_overrides.clone(); // Snapshot the ctx pointer so the spawn_blocking closure can // consult / update the daemon's in-memory trusted-hash cache @@ -780,6 +781,7 @@ pub async fn deploy( &deploy_board_overrides, deploy_project.as_path(), baud_override, + no_probe_rs, ), _ => { return Err(fbuild_core::FbuildError::DeployFailed(format!( diff --git a/crates/fbuild-daemon/src/models.rs b/crates/fbuild-daemon/src/models.rs index aa3d8e02..c7dfdd67 100644 --- a/crates/fbuild-daemon/src/models.rs +++ b/crates/fbuild-daemon/src/models.rs @@ -99,6 +99,9 @@ pub struct DeployRequest { pub monitor_show_timestamp: bool, /// Override the board's default upload baud rate for flashing. pub baud_rate: Option, + /// Force LPC deploys through lpc21isp instead of the probe-rs SWD fast path. + #[serde(default)] + pub no_probe_rs: bool, /// Deploy destination: "device", "emu", or "emulator". pub to: Option, /// Emulator backend when deploying to `emu`/`emulator`. @@ -587,6 +590,7 @@ mod tests { "monitor_halt_on_success": "PASS", "monitor_expect": "ready", "monitor_show_timestamp": false, + "no_probe_rs": true, "request_id": "deploy-1" }"#; let req: DeployRequest = serde_json::from_str(json).unwrap(); @@ -601,6 +605,7 @@ mod tests { assert_eq!(req.monitor_halt_on_success.unwrap(), "PASS"); assert_eq!(req.monitor_expect.unwrap(), "ready"); assert!(!req.monitor_show_timestamp); + assert!(req.no_probe_rs); assert_eq!(req.request_id.unwrap(), "deploy-1"); } @@ -624,6 +629,7 @@ mod tests { assert!(req.monitor_show_timestamp); assert!(req.to.is_none()); assert!(req.emulator.is_none()); + assert!(!req.no_probe_rs); assert!(!req.qemu); assert_eq!(req.qemu_timeout, 30); assert!(req.src_dir.is_none()); diff --git a/crates/fbuild-deploy/Cargo.toml b/crates/fbuild-deploy/Cargo.toml index 7244cc4a..642338e1 100644 --- a/crates/fbuild-deploy/Cargo.toml +++ b/crates/fbuild-deploy/Cargo.toml @@ -16,6 +16,7 @@ espflash-native = ["dep:espflash", "dep:md-5"] [dependencies] fbuild-core = { path = "../fbuild-core" } fbuild-config = { path = "../fbuild-config" } +fbuild-packages = { path = "../fbuild-packages" } fbuild-paths = { path = "../fbuild-paths" } fbuild-serial = { path = "../fbuild-serial" } serialport = { workspace = true } diff --git a/crates/fbuild-deploy/src/lpc.rs b/crates/fbuild-deploy/src/lpc.rs index cd4a838a..ff2c81f8 100644 --- a/crates/fbuild-deploy/src/lpc.rs +++ b/crates/fbuild-deploy/src/lpc.rs @@ -1,33 +1,9 @@ -//! NXP LPC8xx deployer using `lpc21isp`. +//! NXP LPC8xx deployer. //! -//! Flashes firmware.hex to LPC8xx boards via ISP-over-UART. The on-die ROM -//! boot loader handles the protocol; the host just spawns `lpc21isp` with -//! the firmware path, port, baud rate, and crystal frequency. -//! -//! ## Why lpc21isp first (and not pyOCD / CMSIS-DAP) -//! -//! On the LPC845-BRK the on-board debug probe presents a *composite* USB -//! device: CMSIS-DAP (HID) + Mass-Storage + CDC (the application's VCOM). -//! pyOCD's SWD flash path opens the HID interface, and on Windows it -//! leaves the CDC sibling in error 31 (requires a physical USB replug to -//! recover) — see [FastLED/fbuild#565]. That breaks the flash-then-monitor -//! cycle every FastLED test harness depends on (AutoResearch, JSON-RPC -//! bring-up, etc.). -//! -//! lpc21isp uses ISP-over-UART. It never opens the composite-device HID -//! interface, so the CDC sibling stays available across the flash. That's -//! the primary path. SWD remains a future addition for boards without an -//! exposed UART (rare on LPC8xx) — tracked separately. -//! -//! ## Reset / ISP-mode entry -//! -//! lpc21isp's `-control` flag drives DTR/RTS to put the chip into ISP -//! mode before flashing and back into run-mode afterward. The LPC845-BRK -//! wiring matches `-control` semantics out of the box. Boards without -//! auto-reset wiring need the user to hold ISP+RESET manually; the same -//! `-control` argument is harmless on those. -//! -//! [FastLED/fbuild#565]: https://github.com/FastLED/fbuild/issues/565 +//! Supported LPC845-BRK deploys prefer the pinned FastLED probe-rs SWD path +//! for touchless flashing, then fall back to `lpc21isp` ISP-over-UART when +//! probe-rs is disabled, unavailable, or fails. The `--no-probe-rs` CLI flag +//! forces the UART path. use std::path::{Path, PathBuf}; @@ -325,6 +301,8 @@ pub struct LpcDeployer { /// A `None` here disables the SWD path entirely for this /// deployer instance. probe_rs_chip: Option, + /// Explicit opt-out for users who need to force the UART ISP path. + probe_rs_enabled: bool, } impl LpcDeployer { @@ -342,6 +320,7 @@ impl LpcDeployer { timeout_secs, verbose, probe_rs_chip: None, + probe_rs_enabled: true, } } @@ -356,6 +335,13 @@ impl LpcDeployer { self } + /// Enable or disable the probe-rs SWD fast path while preserving + /// the board chip mapping for tests and diagnostics. + pub fn with_probe_rs_enabled(mut self, enabled: bool) -> Self { + self.probe_rs_enabled = enabled; + self + } + /// Build an LPC deployer from board config + lpc21isp params. /// /// Resolves `lpc21isp` through [`find_lpc21isp`] so the deployer runs @@ -414,6 +400,7 @@ pub fn dispatch_box( board_overrides: &std::collections::HashMap, project_path: &Path, baud_override: Option, + no_probe_rs: bool, ) -> Box { let board_config = fbuild_config::BoardConfig::from_board_id_or_default( board_id, @@ -422,7 +409,8 @@ pub fn dispatch_box( Some(project_path), ); let params = Lpc21IspParams::default(); - let deployer = LpcDeployer::from_board_config(&board_config, ¶ms, false); + let deployer = LpcDeployer::from_board_config(&board_config, ¶ms, false) + .with_probe_rs_enabled(!no_probe_rs); let deployer = match baud_override { Some(b) => deployer.with_baud_rate(&b.to_string()), None => deployer, @@ -453,83 +441,102 @@ impl Deployer for LpcDeployer { // just a hands-off flash in ~2 seconds. When probe-rs isn't // available OR the ELF isn't there OR probe-rs errors out, we // fall through to the lpc21isp UART ISP path below. - let probe_rs_candidate = elf_sibling_of(firmware_path) - .and_then(|elf_path| crate::probe_rs::find_probe_rs().map(|binary| (elf_path, binary))) - .filter(|_| crate::probe_rs::lpc_link2_probe_attached()); - if let Some((elf_path, probe_rs_binary)) = probe_rs_candidate { + let mut probe_rs_fallback_note: Option = None; + if self.probe_rs_enabled { if let Some(chip) = self.probe_rs_chip.as_deref() { - let selector = crate::probe_rs::lpc_link2_probe_selector(); - let probe_rs_binary_dbg = probe_rs_binary.clone(); - let elf_path_dbg = elf_path.clone(); - let chip_owned = chip.to_string(); - tracing::info!( - "attempting SWD flash via probe-rs (binary={}, chip={}, probe={}, firmware={})", - probe_rs_binary_dbg.display(), - chip_owned, - selector.as_deref().unwrap_or("(first attached)"), - elf_path_dbg.display(), - ); - let selector_owned = selector.clone(); - let selector_ref = selector.clone(); - let elf_for_task = elf_path.clone(); - let probe_rs_for_task = probe_rs_binary.clone(); - let dl = tokio::task::spawn_blocking(move || { - crate::probe_rs::run_probe_rs_download( - &probe_rs_for_task, - &chip_owned, - selector_owned.as_deref(), - &elf_for_task, - ) - }) - .await - .map_err(|e| { - fbuild_core::FbuildError::DeployFailed(format!( - "probe-rs spawn_blocking join error: {e}" - )) - })??; - - if dl.success() { - // Follow the flash with a reset so the target - // starts executing what we just wrote, matching - // the lpc21isp `-control` post-flash behaviour. - let probe_rs_reset = probe_rs_binary.clone(); - let chip_for_reset = chip.to_string(); - let reset = tokio::task::spawn_blocking(move || { - crate::probe_rs::run_probe_rs_reset( - &probe_rs_reset, - &chip_for_reset, - selector_ref.as_deref(), - ) - }) - .await - .map_err(|e| { - fbuild_core::FbuildError::DeployFailed(format!( - "probe-rs reset spawn_blocking join error: {e}" - )) - })??; - - let mut combined_stdout = dl.stdout; - combined_stdout.push_str(&reset.stdout); - let mut combined_stderr = dl.stderr; - combined_stderr.push_str(&reset.stderr); - - return Ok(DeploymentResult { - success: true, - message: format!( - "firmware flashed via probe-rs SWD (chip={chip}, probe={})", - selector.as_deref().unwrap_or("first-attached") - ), - port: Some(port.to_string()), - stdout: combined_stdout, - stderr: combined_stderr, - outcome: DeployOutcome::FullFlash, - }); + if let Some(elf_path) = elf_sibling_of(firmware_path) { + if let Some(selector) = crate::probe_rs::lpc_link2_probe_selector() { + match crate::probe_rs::ensure_probe_rs_installed().await { + Ok(probe_rs_binary) => { + let probe_rs_binary_dbg = probe_rs_binary.clone(); + let elf_path_dbg = elf_path.clone(); + let chip_owned = chip.to_string(); + tracing::info!( + "attempting SWD flash via probe-rs (binary={}, chip={}, probe={}, firmware={})", + probe_rs_binary_dbg.display(), + chip_owned, + selector, + elf_path_dbg.display(), + ); + let selector_owned = Some(selector.clone()); + let selector_ref = Some(selector.clone()); + let elf_for_task = elf_path.clone(); + let probe_rs_for_task = probe_rs_binary.clone(); + let dl = tokio::task::spawn_blocking(move || { + crate::probe_rs::run_probe_rs_download( + &probe_rs_for_task, + &chip_owned, + selector_owned.as_deref(), + &elf_for_task, + ) + }) + .await + .map_err(|e| { + fbuild_core::FbuildError::DeployFailed(format!( + "probe-rs spawn_blocking join error: {e}" + )) + })??; + + if dl.success() { + // Follow the flash with a reset so the target + // starts executing what we just wrote, matching + // the lpc21isp `-control` post-flash behaviour. + let probe_rs_reset = probe_rs_binary.clone(); + let chip_for_reset = chip.to_string(); + let reset = tokio::task::spawn_blocking(move || { + crate::probe_rs::run_probe_rs_reset( + &probe_rs_reset, + &chip_for_reset, + selector_ref.as_deref(), + ) + }) + .await + .map_err(|e| { + fbuild_core::FbuildError::DeployFailed(format!( + "probe-rs reset spawn_blocking join error: {e}" + )) + })??; + + let mut combined_stdout = dl.stdout; + combined_stdout.push_str(&reset.stdout); + let mut combined_stderr = dl.stderr; + combined_stderr.push_str(&reset.stderr); + + return Ok(DeploymentResult { + success: true, + message: format!( + "firmware flashed via probe-rs SWD (chip={chip}, probe={selector})" + ), + port: Some(port.to_string()), + stdout: combined_stdout, + stderr: combined_stderr, + outcome: DeployOutcome::FullFlash, + }); + } + + let note = format!( + "probe-rs SWD flash exited {}; falling back to lpc21isp UART ISP\n", + dl.exit_code + ); + tracing::warn!("{}", note.trim_end()); + probe_rs_fallback_note = Some(note); + } + Err(e) => { + let note = format!( + "probe-rs unavailable ({e}); falling back to lpc21isp UART ISP\n" + ); + tracing::warn!("{}", note.trim_end()); + probe_rs_fallback_note = Some(note); + } + } + } else { + let note = "probe-rs CMSIS-DAP probe not detected; falling back to lpc21isp UART ISP\n".to_string(); + tracing::info!("{}", note.trim_end()); + probe_rs_fallback_note = Some(note); + } + } else { + tracing::debug!("no ELF sibling for probe-rs; using lpc21isp path"); } - - tracing::warn!( - "probe-rs SWD flash exited {}, falling back to lpc21isp UART ISP", - dl.exit_code - ); } else { tracing::debug!("no probe-rs chip mapping for this board; using lpc21isp path"); } @@ -625,13 +632,21 @@ impl Deployer for LpcDeployer { ) .await?; - if result.success() { + let success = result.success(); + let exit_code = result.exit_code; + let stdout = result.stdout; + let mut stderr = result.stderr; + if let Some(note) = probe_rs_fallback_note { + stderr = format!("{note}{stderr}"); + } + + if success { Ok(DeploymentResult { success: true, message: format!("firmware flashed to {}", port), port: Some(port.to_string()), - stdout: result.stdout, - stderr: result.stderr, + stdout, + stderr, outcome: DeployOutcome::FullFlash, }) } else { @@ -640,10 +655,10 @@ impl Deployer for LpcDeployer { // client without losing the diagnostic surface. Ok(DeploymentResult { success: false, - message: format!("lpc21isp failed (exit code {})", result.exit_code), + message: format!("lpc21isp failed (exit code {})", exit_code), port: Some(port.to_string()), - stdout: result.stdout, - stderr: result.stderr, + stdout, + stderr, outcome: DeployOutcome::FullFlash, }) } @@ -698,6 +713,7 @@ mod tests { assert_eq!(deployer.baud_rate, "115200"); assert_eq!(deployer.xtal_khz, 12_000); assert_eq!(deployer.timeout_secs, 60); + assert!(deployer.probe_rs_enabled); } // ---------- FastLED/fbuild#921 firmware-old detection ---------- @@ -779,6 +795,13 @@ mod tests { assert_eq!(deployer.baud_rate, "57600"); } + #[test] + fn with_probe_rs_enabled_can_force_lpc21isp_path() { + let deployer = + LpcDeployer::new("115200", 12_000, 60, None, false).with_probe_rs_enabled(false); + assert!(!deployer.probe_rs_enabled); + } + #[tokio::test] async fn test_deploy_requires_port() { let deployer = LpcDeployer::new("115200", 12_000, 60, None, false); diff --git a/crates/fbuild-deploy/src/probe_rs.rs b/crates/fbuild-deploy/src/probe_rs.rs index ea90909a..3fe33059 100644 --- a/crates/fbuild-deploy/src/probe_rs.rs +++ b/crates/fbuild-deploy/src/probe_rs.rs @@ -31,8 +31,8 @@ //! can dispatch to it in preference to the UART-ISP path (lpc21isp), //! which requires a `SW3 + SW4` button press to enter ISP mode. -use std::path::Path; -use std::time::Duration; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use fbuild_core::path::NormalizedPath; use fbuild_core::subprocess::run_command_blocking; @@ -43,6 +43,14 @@ use fbuild_core::{FbuildError, Result}; /// probe-rs before the auto-download landing. pub const PROBE_RS_PATH_ENV_VAR: &str = "FBUILD_PROBE_RS_PATH"; +/// Pinned FastLED/probe-rs release consumed by fbuild. The patched +/// fork and cross-compile workflow live in FastLED/framework-arduino-lpc8xx; +/// fbuild only downloads and verifies the host artifact. +pub const PROBE_RS_RELEASE_TAG: &str = "fastled-v0.31.2-nusb-v1-transport"; + +const PROBE_RS_RELEASE_DOWNLOAD_BASE: &str = + "https://github.com/FastLED/framework-arduino-lpc8xx/releases/download"; + /// Hard ceiling on a single probe-rs invocation. A healthy LPC845-BRK /// flash completes in ~2 s; 120 s covers a slow cold HID enumerate plus /// full-chip program with margin. Past that, the probe is wedged and @@ -50,18 +58,60 @@ pub const PROBE_RS_PATH_ENV_VAR: &str = "FBUILD_PROBE_RS_PATH"; /// daemon's spawn_blocking slot indefinitely. const PROBE_RS_TIMEOUT: Duration = Duration::from_secs(120); -/// Locally-built probe-rs binary — the maintainer's dev tree. Kept as -/// a documented convention so ad-hoc smoke tests can just drop the -/// binary in a well-known place instead of remembering the env var. +/// Release asset metadata for the current host. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProbeRsReleaseAsset { + pub name: &'static str, + pub sha256: &'static str, +} + +impl ProbeRsReleaseAsset { + pub fn url(&self) -> String { + format!( + "{}/{}/{}", + PROBE_RS_RELEASE_DOWNLOAD_BASE, PROBE_RS_RELEASE_TAG, self.name + ) + } +} + +/// Return the pinned FastLED/probe-rs release asset for this host. +pub fn probe_rs_release_asset_for_host() -> Result { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("windows", "x86_64") => Ok(ProbeRsReleaseAsset { + name: "probe-rs-fastled-fastled-v0.31.2-nusb-v1-transport-x86_64-pc-windows-msvc.zip", + sha256: "257e294988498218cf350a852bf60e57313b19f492f8019b92905df59095c7a1", + }), + ("windows", "aarch64") => Ok(ProbeRsReleaseAsset { + name: "probe-rs-fastled-fastled-v0.31.2-nusb-v1-transport-aarch64-pc-windows-msvc.zip", + sha256: "f3bd8117b6a1de3e73791aa0ec980ed07a18482fffbc43b70af57750450cd854", + }), + ("linux", "x86_64") => Ok(ProbeRsReleaseAsset { + name: "probe-rs-fastled-fastled-v0.31.2-nusb-v1-transport-x86_64-unknown-linux-gnu.tar.zst", + sha256: "acd37d2a012fd7e12d83e951ea2489dcbc76011b6b9af0a8214ef399a8ec401b", + }), + ("linux", "aarch64") => Ok(ProbeRsReleaseAsset { + name: "probe-rs-fastled-fastled-v0.31.2-nusb-v1-transport-aarch64-unknown-linux-gnu.tar.zst", + sha256: "ea921ea77709640b4947c68646154a0e7e3edcdb6cd9f95270f28599929d0ac6", + }), + ("macos", "x86_64") => Ok(ProbeRsReleaseAsset { + name: "probe-rs-fastled-fastled-v0.31.2-nusb-v1-transport-x86_64-apple-darwin.tar.zst", + sha256: "d106888b816854c29b77af4e67982d6da1c9e2ef7c8203fa478adfb4065699df", + }), + ("macos", "aarch64") => Ok(ProbeRsReleaseAsset { + name: "probe-rs-fastled-fastled-v0.31.2-nusb-v1-transport-aarch64-apple-darwin.tar.zst", + sha256: "36e4dd61804a438eea37dbc68d62bc175c679af38c45f6008cd248169f20d2b5", + }), + (os, arch) => Err(FbuildError::PackageError(format!( + "no FastLED probe-rs artifact is published for {os}/{arch}" + ))), + } +} + +/// fbuild-managed probe-rs install directory. /// /// Honors `FBUILD_DEV_MODE=1` → `~/.fbuild/dev/tools/probe-rs/` to /// match the isolation the rest of `fbuild-paths` applies. -pub fn managed_probe_rs_path() -> Option { - let exe = if cfg!(windows) { - "probe-rs.exe" - } else { - "probe-rs" - }; +pub fn managed_probe_rs_dir() -> Option { let home = home_dir_local()?; let mode = if std::env::var_os("FBUILD_DEV_MODE").is_some() { "dev" @@ -72,11 +122,19 @@ pub fn managed_probe_rs_path() -> Option { home.join(".fbuild") .join(mode) .join("tools") - .join("probe-rs") - .join(exe), + .join("probe-rs"), ) } +pub fn managed_probe_rs_path() -> Option { + let exe = if cfg!(windows) { + "probe-rs.exe" + } else { + "probe-rs" + }; + Some(managed_probe_rs_dir()?.join(exe)) +} + /// Resolve which `probe-rs` binary to invoke, or `None` when neither /// the env override nor the managed path point at an existing file. /// The caller decides whether that's a hard-fail or a fall-through to @@ -86,8 +144,8 @@ pub fn managed_probe_rs_path() -> Option { /// /// 1. `FBUILD_PROBE_RS_PATH` — explicit override. /// 2. `~/.fbuild/{prod|dev}/tools/probe-rs/probe-rs[.exe]` — the -/// canonical fbuild-managed location the auto-download will -/// populate (follow-up). +/// canonical fbuild-managed location populated from the pinned +/// FastLED/probe-rs release. /// /// Deliberately does NOT walk `PATH` — a system `probe-rs` from a /// distro package will lack the FastLED patches and will hang on the @@ -108,6 +166,127 @@ pub fn find_probe_rs() -> Option { None } +/// Resolve and, if needed, install the pinned FastLED/probe-rs binary. +pub async fn ensure_probe_rs_installed() -> Result { + if let Some(existing) = find_probe_rs() { + return Ok(existing); + } + + install_managed_probe_rs().await +} + +/// Download, verify, extract, and install the pinned FastLED/probe-rs +/// release artifact into the fbuild-managed tools directory. +pub async fn install_managed_probe_rs() -> Result { + let asset = probe_rs_release_asset_for_host()?; + let dest = managed_probe_rs_path().ok_or_else(|| { + FbuildError::PackageError("could not determine managed probe-rs path".to_string()) + })?; + + let staging_dir = probe_rs_staging_dir(); + tokio::fs::create_dir_all(&staging_dir).await?; + + let url = asset.url(); + tracing::info!("installing FastLED probe-rs from {}", url); + let archive = fbuild_packages::downloader::download_file(&url, &staging_dir).await?; + + fbuild_packages::downloader::verify_checksum_async(&archive, asset.sha256).await?; + + let dest_path = dest.clone().into_path_buf(); + let installed = tokio::task::spawn_blocking(move || { + extract_and_install_probe_rs(&archive, &staging_dir, &dest_path) + }) + .await + .map_err(|e| FbuildError::PackageError(format!("probe-rs install task failed: {e}")))??; + + Ok(NormalizedPath::new(installed)) +} + +fn probe_rs_staging_dir() -> PathBuf { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + fbuild_paths::temp_subdir("probe-rs-install").join(format!( + "{}-{}-{}", + PROBE_RS_RELEASE_TAG, + std::process::id(), + millis + )) +} + +fn extract_and_install_probe_rs( + archive: &Path, + staging_dir: &Path, + dest_path: &Path, +) -> Result { + let extract_dir = staging_dir.join("extract"); + std::fs::create_dir_all(&extract_dir)?; + fbuild_packages::extractor::extract(archive, &extract_dir)?; + + let found = find_extracted_probe_rs_binary(&extract_dir)?; + let dest_dir = dest_path.parent().ok_or_else(|| { + FbuildError::PackageError(format!( + "managed probe-rs path has no parent: {}", + dest_path.display() + )) + })?; + std::fs::create_dir_all(dest_dir)?; + std::fs::copy(&found, dest_path).map_err(|e| { + FbuildError::PackageError(format!( + "failed to install probe-rs from {} to {}: {}", + found.display(), + dest_path.display(), + e + )) + })?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(dest_path)?.permissions(); + perms.set_mode(perms.mode() | 0o755); + std::fs::set_permissions(dest_path, perms)?; + } + + Ok(dest_path.to_path_buf()) +} + +fn find_extracted_probe_rs_binary(root: &Path) -> Result { + let exe = if cfg!(windows) { + "probe-rs.exe" + } else { + "probe-rs" + }; + find_file_by_name(root, exe).ok_or_else(|| { + FbuildError::PackageError(format!( + "probe-rs binary `{exe}` not found after extracting {}", + root.display() + )) + }) +} + +fn find_file_by_name(root: &Path, file_name: &str) -> Option { + let entries = std::fs::read_dir(root).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == file_name) + { + return Some(path); + } + if path.is_dir() { + if let Some(found) = find_file_by_name(&path, file_name) { + return Some(found); + } + } + } + None +} + /// Map an fbuild `BoardConfig` to the `--chip` name probe-rs expects. /// /// probe-rs uses the exact silicon SKU (e.g. `LPC845M301JBD48`) rather @@ -358,4 +537,18 @@ mod tests { }; assert_eq!(map_board_to_probe_rs_chip(&board), None); } + + #[test] + fn release_asset_for_host_has_pinned_checksum_and_url() { + let asset = probe_rs_release_asset_for_host().unwrap(); + assert!(asset + .name + .starts_with("probe-rs-fastled-fastled-v0.31.2-nusb-v1-transport-")); + assert_eq!(asset.sha256.len(), 64); + assert!(asset.sha256.chars().all(|c| c.is_ascii_hexdigit())); + + let url = asset.url(); + assert!(url.contains(PROBE_RS_RELEASE_TAG)); + assert!(url.ends_with(asset.name)); + } }