diff --git a/Cargo.lock b/Cargo.lock index 3ad78458..bf1bb578 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1408,17 +1408,20 @@ dependencies = [ "fbuild-paths", "fbuild-serial", "fbuild-test-support", + "flate2", "md-5 0.10.6", "object 0.36.7", "serde", "serde_json", "serialport", "sha2", + "tar", "tempfile", "thiserror 2.0.18", "tokio", "tracing", "zip", + "zstd", ] [[package]] diff --git a/crates/fbuild-daemon/src/handlers/operations/deploy.rs b/crates/fbuild-daemon/src/handlers/operations/deploy.rs index 5e956769..2a438d87 100644 --- a/crates/fbuild-daemon/src/handlers/operations/deploy.rs +++ b/crates/fbuild-daemon/src/handlers/operations/deploy.rs @@ -67,16 +67,6 @@ fn find_teensy_loader_cli() -> Option { None } -/// CH32V flashing is tracked in FastLED/fbuild#1105. -fn ch32v_deploy_unimplemented_message(firmware: &std::path::Path) -> String { - format!( - "CH32V flashing is not implemented yet (FastLED/fbuild#1105). Firmware was built at {}. Flash it manually with a WCH-Link probe: `wlink flash {}` (https://github.com/ch32-rs/wlink) or `minichlink -w {} flash -b`.", - firmware.display(), - firmware.display(), - firmware.display() - ) -} - /// POST /api/deploy pub async fn deploy( State(ctx): State>, @@ -813,11 +803,7 @@ pub async fn deploy( baud_override, no_probe_rs, ), - fbuild_core::Platform::Ch32v => { - return Err(fbuild_core::FbuildError::DeployFailed( - ch32v_deploy_unimplemented_message(&deploy_fw), - )); - } + fbuild_core::Platform::Ch32v => Box::new(fbuild_deploy::wlink::WlinkDeployer::new()), _ => { return Err(fbuild_core::FbuildError::DeployFailed(format!( "deployer for {:?} not yet implemented", @@ -1205,12 +1191,4 @@ mod tests { ); assert_eq!(response.stderr.as_deref(), Some(response.message.as_str())); } - - #[test] - fn ch32v_deploy_error_names_supported_manual_flashers() { - let message = ch32v_deploy_unimplemented_message(std::path::Path::new("out/firmware.bin")); - assert!(message.contains("wlink")); - assert!(message.contains("#1105")); - assert!(message.contains("out/firmware.bin")); - } } diff --git a/crates/fbuild-deploy/Cargo.toml b/crates/fbuild-deploy/Cargo.toml index 57f2832d..64250a15 100644 --- a/crates/fbuild-deploy/Cargo.toml +++ b/crates/fbuild-deploy/Cargo.toml @@ -30,6 +30,10 @@ sha2 = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } async-trait = { workspace = true } +flate2 = { workspace = true } +tar = { workspace = true } +zip = { workspace = true } +zstd = { workspace = true } object = { workspace = true } # Native ESP32 flasher protocol (issue #66). The `serialport` feature # gates the `Flasher` type (the real transport entry point), so it is diff --git a/crates/fbuild-deploy/src/lib.rs b/crates/fbuild-deploy/src/lib.rs index f1e1de42..72354544 100644 --- a/crates/fbuild-deploy/src/lib.rs +++ b/crates/fbuild-deploy/src/lib.rs @@ -24,6 +24,7 @@ pub mod reset; pub mod rp2040; pub mod size_check; pub mod teensy; +pub mod wlink; use fbuild_core::Result; use std::path::Path; diff --git a/crates/fbuild-deploy/src/wlink.rs b/crates/fbuild-deploy/src/wlink.rs new file mode 100644 index 00000000..e0c917e0 --- /dev/null +++ b/crates/fbuild-deploy/src/wlink.rs @@ -0,0 +1,218 @@ +//! CH32V deployment through the `wlink` WCH-LinkE flasher. +//! +//! `wlink` is intentionally invoked as an external executable: the tool is +//! released independently and supports the complete CH32V family without +//! linking its implementation into fbuild. `FBUILD_WLINK_PATH` can point at a +//! pinned or locally-built binary; otherwise `wlink` is resolved from PATH. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use fbuild_core::subprocess::run_command; +use fbuild_core::{FbuildError, Result}; + +const WLINK_TIMEOUT: Duration = Duration::from_secs(120); +const WLINK_RELEASE_TAG: &str = "v0.1.2"; +const WLINK_RELEASE_BASE: &str = "https://github.com/ch32-rs/wlink/releases/download"; + +#[derive(Debug, Clone, Copy)] +struct WlinkAsset { + name: &'static str, + sha256: &'static str, +} + +fn release_asset() -> Result { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("windows", "x86_64") => Ok(WlinkAsset { + name: "wlink-v0.1.2-win-x64.zip", + sha256: "59b3989137a9d22c9c1e8c04fd9371af3f54fa43b4cb63c59d6fb4286a34c78a", + }), + ("linux", "x86_64") => Ok(WlinkAsset { + name: "wlink-v0.1.2-linux-x64.tar.gz", + sha256: "f8f1fba2436694116fe2cf16b1572e92d116c4acd921bf12fbc0ca5bf63824bf", + }), + ("macos", "aarch64") => Ok(WlinkAsset { + name: "wlink-v0.1.2-macos-arm64.tar.gz", + sha256: "49164d236346e4c294935412a072040eac8faaeb5f097952846807f7dc0fbf8c", + }), + (os, arch) => Err(FbuildError::PackageError(format!( + "wlink v{WLINK_RELEASE_TAG} has no pinned asset for {os}/{arch}; set FBUILD_WLINK_PATH" + ))), + } +} + +fn managed_wlink_path() -> Result { + let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }) + .map(PathBuf::from) + .ok_or_else(|| { + FbuildError::PackageError("could not determine home directory".to_string()) + })?; + let mode = if std::env::var_os("FBUILD_DEV_MODE").is_some() { + "dev" + } else { + "prod" + }; + Ok(home + .join(".fbuild") + .join(mode) + .join("tools") + .join("wlink") + .join(if cfg!(windows) { "wlink.exe" } else { "wlink" })) +} + +async fn ensure_wlink_installed() -> Result { + if let Some(path) = std::env::var_os("FBUILD_WLINK_PATH").map(PathBuf::from) { + if path.is_file() { + return Ok(path); + } + return Err(FbuildError::PackageError(format!( + "FBUILD_WLINK_PATH does not name a file: {}", + path.display() + ))); + } + let dest = managed_wlink_path()?; + if dest.is_file() { + return Ok(dest); + } + let asset = release_asset()?; + let staging = fbuild_paths::temp_subdir("wlink-install"); + fbuild_core::fs::create_dir_all(&staging).await?; + let url = format!("{WLINK_RELEASE_BASE}/{WLINK_RELEASE_TAG}/{}", asset.name); + let archive = fbuild_packages::downloader::download_file(&url, &staging).await?; + fbuild_packages::downloader::verify_checksum_async(&archive, asset.sha256).await?; + let dest_clone = dest.clone(); + let staging_clone = staging.clone(); + tokio::task::spawn_blocking(move || extract_wlink(&archive, &staging_clone, &dest_clone)) + .await + .map_err(|e| FbuildError::PackageError(format!("wlink install task failed: {e}")))??; + let _ = fbuild_core::fs::remove_dir_all(&staging).await; + Ok(dest) +} + +fn extract_wlink(archive: &Path, staging: &Path, dest: &Path) -> Result<()> { + let extract_dir = staging.join("extract"); + std::fs::create_dir_all(&extract_dir) + .map_err(|e| FbuildError::PackageError(format!("create wlink extract dir: {e}")))?; + if archive.extension().is_some_and(|ext| ext == "zip") { + let file = std::fs::File::open(archive) + .map_err(|e| FbuildError::PackageError(format!("open wlink archive: {e}")))?; + let mut zip = zip::ZipArchive::new(file) + .map_err(|e| FbuildError::PackageError(format!("read wlink archive: {e}")))?; + zip.extract(&extract_dir) + .map_err(|e| FbuildError::PackageError(format!("extract wlink archive: {e}")))?; + } else { + let file = std::fs::File::open(archive) + .map_err(|e| FbuildError::PackageError(format!("open wlink archive: {e}")))?; + let decoder = flate2::read::GzDecoder::new(file); + let mut tar = tar::Archive::new(decoder); + tar.unpack(&extract_dir) + .map_err(|e| FbuildError::PackageError(format!("extract wlink archive: {e}")))?; + } + let binary_name = if cfg!(windows) { "wlink.exe" } else { "wlink" }; + let binary = find_file(&extract_dir, binary_name) + .ok_or_else(|| FbuildError::PackageError(format!("wlink archive lacks {binary_name}")))?; + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| FbuildError::PackageError(format!("create wlink install dir: {e}")))?; + } + std::fs::copy(binary, dest) + .map_err(|e| FbuildError::PackageError(format!("install wlink: {e}")))?; + #[cfg(unix)] + std::fs::set_permissions(dest, std::os::unix::fs::PermissionsExt::from_mode(0o755)) + .map_err(|e| FbuildError::PackageError(format!("make wlink executable: {e}")))?; + Ok(()) +} + +fn find_file(root: &Path, name: &str) -> Option { + for entry in std::fs::read_dir(root).ok()?.flatten() { + let path = entry.path(); + if path.is_file() && path.file_name().and_then(|n| n.to_str()) == Some(name) { + return Some(path); + } + if path.is_dir() { + if let Some(found) = find_file(&path, name) { + return Some(found); + } + } + } + None +} + +#[derive(Debug, Clone)] +pub struct WlinkDeployer { + executable: PathBuf, +} + +impl WlinkDeployer { + pub fn new() -> Self { + let executable = std::env::var_os("FBUILD_WLINK_PATH") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(if cfg!(windows) { "wlink.exe" } else { "wlink" })); + Self { executable } + } + + pub fn flash_argv(&self, firmware_path: &Path) -> Vec { + vec![ + self.executable.to_string_lossy().into_owned(), + "flash".to_string(), + firmware_path.to_string_lossy().into_owned(), + ] + } +} + +impl Default for WlinkDeployer { + fn default() -> Self { + Self::new() + } +} + +#[async_trait::async_trait] +impl crate::Deployer for WlinkDeployer { + async fn deploy( + &self, + _project_dir: &Path, + _env_name: &str, + firmware_path: &Path, + _port: Option<&str>, + ) -> Result { + let executable = ensure_wlink_installed().await?; + let argv = [ + executable.to_string_lossy().into_owned(), + "flash".to_string(), + firmware_path.to_string_lossy().into_owned(), + ]; + let refs = argv.iter().map(String::as_str).collect::>(); + let result = run_command(&refs, None, None, Some(WLINK_TIMEOUT)) + .await + .map_err(|e| FbuildError::DeployFailed(format!("failed to run wlink: {e}")))?; + let success = result.success(); + Ok(crate::DeploymentResult { + success, + message: if success { + "firmware flashed through wlink".to_string() + } else { + format!("wlink failed (exit code {})", result.exit_code) + }, + port: None, + stdout: result.stdout, + stderr: result.stderr, + outcome: crate::DeployOutcome::FullFlash, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flash_argv_uses_wlink_flash_command() { + let deployer = WlinkDeployer { + executable: PathBuf::from("wlink"), + }; + assert_eq!( + deployer.flash_argv(Path::new("build/firmware.bin")), + ["wlink", "flash", "build/firmware.bin"] + ); + } +}