diff --git a/src/args.rs b/src/args.rs index 03d3d0f..cd2ac8b 100644 --- a/src/args.rs +++ b/src/args.rs @@ -170,6 +170,25 @@ pub struct ExportArgs { pub output: Option, } +#[derive(Debug, Parser)] +pub struct FlashArgs { + /// Hardware version. + #[arg(long)] + pub revision: u8, + + /// Path to the firmware source code or firmware binary archive. + #[arg(long)] + pub input: Option, + + /// Path to a file where to write a firmware dump. + #[arg(long)] + pub output: Option, + + /// Serial number to write on the device. + #[arg(long)] + pub serial: Option, +} + #[derive(Debug, Parser)] pub struct BadgesArgs { /// Full app ID. @@ -371,6 +390,9 @@ pub enum RuntimeCommands { /// Fetch and print the ID of the running app. Id, + + /// Write firmware on the device. + Flash(FlashArgs), } #[derive(Debug, Parser)] diff --git a/src/cli.rs b/src/cli.rs index 1be089d..8e23f3f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -35,6 +35,7 @@ pub fn run_command(vfs: PathBuf, command: &Commands) -> anyhow::Result<()> { RuntimeCommands::Cheat(args) => cmd_cheat(root_args, args), RuntimeCommands::Monitor => cmd_monitor(root_args), RuntimeCommands::Logs => cmd_logs(root_args), + RuntimeCommands::Flash(args) => cmd_flash(root_args, args), }, Vfs => cmd_vfs(), } diff --git a/src/commands/flash.rs b/src/commands/flash.rs new file mode 100644 index 0000000..a25915c --- /dev/null +++ b/src/commands/flash.rs @@ -0,0 +1,191 @@ +use crate::{ + args::{FlashArgs, RuntimeArgs}, + fs::path_to_utf8, +}; +use anyhow::{Context, Result, bail}; +use std::{env::temp_dir, fs, path::Path, process::Command}; + +/// `ff flash`: Flash firmware into device or file. +pub fn cmd_flash(root_args: &RuntimeArgs, args: &FlashArgs) -> Result<()> { + if let Some(port) = &root_args.port + && !port.starts_with("/dev/tty") + { + bail!("invalid --port"); + } + if let Some(output) = &args.output + && output.starts_with("/dev/") + { + bail!("invalid --output"); + } + + // Install cargo-espflash. + if !espflash_installed() { + if !cargo_installed() { + bail!("cargo is not installed"); + } + println!("⏳️ installing cargo-espflash..."); + Command::new("cargo") + .args(["install", "cargo-espflash"]) + .output()?; + } + + // If serial number is provided, write it to the device. + if let Some(serial) = args.serial { + println!("⏳️ writing serial number..."); + write_serial(root_args, serial).context("write serial number")?; + } + + if is_source(args)? { + println!("⏳️ flashing firmware from source..."); + flash_from_source(root_args, args)?; + } else { + // TODO: support installing from file. + // TODO: support downloading and installing a release. + bail!("firmware can only be built from source") + } + + println!("✅ flashed"); + Ok(()) +} + +/// Write serial number into flash memory of the device. +fn write_serial(root_args: &RuntimeArgs, serial: u32) -> Result<()> { + let serial_path = temp_dir().join("firefly-serial.bin"); + fs::write(serial_path, serial.to_le_bytes()).context("write serial number into temp file")?; + let mut cmd_args: Vec<&str> = vec![ + "write-bin", + "--skip-update-check", + "--non-interactive", + "--chip", + "esp32s3", + "0x10000", + "/tmp/serial.txt", + ]; + if let Some(port) = &root_args.port { + cmd_args.push("--port"); + cmd_args.push(port); + } + let root = std::env::current_dir()?; + exec_espflash(&root, &cmd_args)?; + Ok(()) +} + +/// Check if the --input (or the current dir) is the firefly-main or firefly-io source code. +fn is_source(args: &FlashArgs) -> Result { + let root = if let Some(path) = &args.input { + path + } else { + &std::env::current_dir().context("detect current dir")? + }; + if root.is_file() { + return Ok(false); + } + let config_path = root.join("Cargo.toml"); + if !config_path.is_file() { + return Ok(false); + } + let config = fs::read_to_string(config_path).context("read Cargo.toml")?; + Ok(config.contains(r#"name = "firefly-main""#) || config.contains(r#"name = "firefly-io""#)) +} + +/// Build firmware from source and flash it to the device. +fn flash_from_source(root_args: &RuntimeArgs, args: &FlashArgs) -> Result<()> { + let root = if let Some(path) = &args.input { + path + } else { + &std::env::current_dir().context("detect current dir")? + }; + let mut shared_args = vec![ + "--skip-update-check", + "--non-interactive", + "--chip", + "esp32s3", + ]; + if let Some(port) = &root_args.port { + shared_args.push("--port"); + shared_args.push(port); + } + + // If output path is provided, save the image into the file. + if let Some(output_path) = &args.output { + // TODO: support saving as gz file + let revision = format!("v{}", args.revision); + let mut cmd_args = vec![ + "save-image", + "--features", + &revision, + "--release", + path_to_utf8(output_path)?, + ]; + cmd_args.extend_from_slice(&shared_args); + exec_espflash(root, &cmd_args).context("save image")?; + return Ok(()); + } + + // Switch OTA to the factory slot. + let partitions_path = root.join("partitions.csv"); + let partitions = path_to_utf8(&partitions_path)?; + let mut cmd_args = vec!["erase-parts", "--partition-table", partitions, "otadata"]; + cmd_args.extend_from_slice(&shared_args); + exec_espflash(root, &cmd_args).context("erase OTA partition")?; + + // Flash the image to the device. + let revision = format!("v{}", args.revision); + let mut cmd_args = vec![ + "flash", + "--features", + &revision, + "--release", + "--partition-table", + partitions, + "--target-app-partition", + "factory", + ]; + cmd_args.extend_from_slice(&shared_args); + exec_espflash(root, &cmd_args).context("flash firmware")?; + + Ok(()) +} + +fn cargo_installed() -> bool { + let output = Command::new("cargo").arg("version").output(); + let Ok(output) = output else { + return false; + }; + output.status.success() +} + +fn espflash_installed() -> bool { + let output = Command::new("cargo-espflash").args(["--version"]).output(); + let Ok(output) = output else { + return false; + }; + output.status.success() +} + +fn exec_espflash(root: &Path, cmd_args: &[&str]) -> Result<()> { + let mut cmd = Command::new("cargo"); + let mut cmd = cmd.arg("espflash").args(cmd_args).current_dir(root); + + // Set env vars from ~/export-esp.sh. + if let Some(home) = std::env::home_dir() { + let dotenv_path = home.join("export-esp.sh"); + if dotenv_path.is_file() { + let dotenv_raw = fs::read_to_string(dotenv_path).context("read ~/export-esp.sh")?; + let parts: Vec<_> = dotenv_raw.split('"').collect(); + if parts.len() == 5 { + let (path, _) = parts[1].split_once(':').unwrap(); + let path = format!("{path}:{}", std::env::var("PATH").unwrap()); + cmd = cmd.env("PATH", path); + cmd = cmd.env("LIBCLANG_PATH", parts[3]); + } + } + } + + let status = cmd.status().context("run espflash")?; + if !status.success() { + let code = status.code().unwrap_or_default(); + bail!("unexpected status code: {code}") + } + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 82f04be..a778c1c 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -6,6 +6,7 @@ mod cheat; mod config; mod emulator; mod export; +mod flash; mod import; mod inspect; mod logs; @@ -27,6 +28,7 @@ pub use cheat::cmd_cheat; pub use config::cmd_config_get; pub use emulator::cmd_emulator; pub use export::cmd_export; +pub use flash::cmd_flash; pub use import::cmd_import; pub use inspect::cmd_inspect; pub use logs::cmd_logs; diff --git a/src/commands/shots.rs b/src/commands/shots.rs index 13e3af5..2f0ed0d 100644 --- a/src/commands/shots.rs +++ b/src/commands/shots.rs @@ -1,4 +1,5 @@ use crate::args::ShotsDownloadArgs; +use crate::fs::path_to_utf8; use anyhow::{Context, Result, bail}; use std::io::Write; use std::path::{Path, PathBuf}; @@ -20,7 +21,7 @@ pub fn cmd_shots_download(vfs: &Path, args: &ShotsDownloadArgs) -> Result<()> { return download_file(&src_path, &dst_dir); } if src_path.is_dir() { - println!("downloading a dir from {}", path_to_utf8(&src_path)); + println!("downloading a dir from {}", path_to_utf8(&src_path)?); return download_dir(&src_path, &dst_dir); } @@ -67,7 +68,7 @@ fn download_dir(src_dir: &Path, dst_dir: &Path) -> Result<()> { } println!( "⏳️ downloading all files from from {}...", - path_to_utf8(src_dir) + path_to_utf8(src_dir)? ); if !dst_dir.exists() { std::fs::create_dir_all(dst_dir).context("create output dir")?; @@ -84,13 +85,7 @@ fn download_dir(src_dir: &Path, dst_dir: &Path) -> Result<()> { } let dst_file_name = get_output_file_name(&src_path)?; let dst_path = dst_dir.join(dst_file_name); - copy_file(&src_path, &dst_path).with_context(|| { - format!( - "copy screenshot from {} into {}", - path_to_utf8(&src_path), - path_to_utf8(&dst_path), - ) - })?; + copy_file(&src_path, &dst_path).context("copy screenshot")?; } Ok(()) } @@ -99,7 +94,7 @@ fn download_dir(src_dir: &Path, dst_dir: &Path) -> Result<()> { fn download_file(src_path: &Path, dst_path: &Path) -> Result<()> { println!( "⏳️ downloading a single file from {}...", - path_to_utf8(src_path) + path_to_utf8(src_path)? ); let is_file = has_ext(dst_path, "png"); if dst_path.is_file() || is_file { @@ -214,8 +209,3 @@ fn write_chunk(mut w: W, name: &[u8; 4], data: &[u8]) -> Result<()> { w.write_all(&crc.finalize().to_be_bytes())?; Ok(()) } - -/// Convert a file system path to UTF-8 if possible. -pub fn path_to_utf8(path: &Path) -> &str { - path.to_str().unwrap_or("???") -} diff --git a/src/fs.rs b/src/fs.rs index 8a0a522..80feeb2 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -1,3 +1,4 @@ +use anyhow::bail; use crossterm::style::Stylize; use std::collections::HashMap; use std::ffi::OsString; @@ -31,6 +32,14 @@ pub fn format_size(size: u64) -> String { } } +/// Convert a file system path to UTF-8 if possible. +pub fn path_to_utf8(path: &Path) -> anyhow::Result<&str> { + match path.to_str() { + Some(path) => Ok(path), + None => bail!("path cannot be converted to UTF-8"), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/langs.rs b/src/langs.rs index f48de50..941a187 100644 --- a/src/langs.rs +++ b/src/langs.rs @@ -1,6 +1,7 @@ use crate::args::BuildArgs; use crate::config::{Config, Lang}; use crate::file_names::BIN; +use crate::fs::path_to_utf8; use crate::wasm::{optimize, strip_custom}; use anyhow::{Context, bail}; use std::env::temp_dir; @@ -621,14 +622,6 @@ fn find_wasm(from_dir: &Path) -> anyhow::Result { } } -/// Convert a file system path to UTF-8 if possible. -pub fn path_to_utf8(path: &Path) -> anyhow::Result<&str> { - match path.to_str() { - Some(path) => Ok(path), - None => bail!("project root path cannot be converted to UTF-8"), - } -} - pub fn run_cmd(cmd: &mut Command) -> anyhow::Result<()> { let status = cmd.status()?; if !status.success() { @@ -642,7 +635,7 @@ pub fn run_cmd(cmd: &mut Command) -> anyhow::Result<()> { pub fn check_installed(lang: &str, bin: &str, arg: &str) -> anyhow::Result<()> { use std::fmt::Write; - let output = Command::new(bin).args([arg]).output(); + let output = Command::new(bin).arg(arg).output(); let Ok(output) = output else { return Ok(()); };