From c7a04ed6601cfe9489f3e0559c7a8fbccdf1ec22 Mon Sep 17 00:00:00 2001 From: Marcus Eliasson Date: Mon, 5 Oct 2026 22:16:36 +0200 Subject: [PATCH 1/3] Add swarm bootload info and flash cfcli swarm bootload flash flashes every Crazyflie of a swarm, one after another over unicast, the same way bootload flash flashes one, and takes the same --release, --zip, --bin and --targets. The release, the files and the targets are resolved before any Crazyflie is touched; each Crazyflie's platform then decides which files of a release it gets, so a swarm can mix platforms. A Crazyflie that fails doesn't stop the others, and a summary shows how each one went. cfcli swarm bootload info shows the bootloader versions of each Crazyflie, and whether both bootloaders can take broadcast flashing (protocol 0x11). Unlike bootload info, it restarts each Crazyflie back into its firmware afterwards, and gives each Crazyflie 15 s so that one that hangs doesn't hold up the rest. The release, --bin and --targets handling moves out of main.rs into utils/flash_source.rs, and the four arguments into FirmwareSourceArgs, shared by both commands. bootload flash works as before. --- README.md | 3 +- docs/bootload.md | 3 + docs/swarm.md | 35 +++++++ src/cli.rs | 44 ++++++-- src/main.rs | 169 ++---------------------------- src/modules/bootloader.rs | 14 +++ src/modules/swarm/bootload.rs | 191 ++++++++++++++++++++++++++++++++++ src/modules/swarm/mod.rs | 5 + src/utils/firmware.rs | 1 + src/utils/flash_source.rs | 191 ++++++++++++++++++++++++++++++++++ 10 files changed, 482 insertions(+), 174 deletions(-) create mode 100644 src/modules/swarm/bootload.rs create mode 100644 src/utils/flash_source.rs diff --git a/README.md b/README.md index e929446..a8a1607 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,8 @@ prompt) a few flags make the output predictable: `param list`/`get`, `log list`/`print`, `mem list`, `deck list`, `platform info`, `swarm config list`/`show`, `swarm scan`, `swarm platform info`, `swarm param get`, `swarm log print`, - `swarm deck list`, `swarm debug assert`). Other commands ignore the flag. + `swarm deck list`, `swarm debug assert`, `swarm bootload info`). Other + commands ignore the flag. Exit codes: diff --git a/docs/bootload.md b/docs/bootload.md index 94d67b7..a313b98 100644 --- a/docs/bootload.md +++ b/docs/bootload.md @@ -22,6 +22,9 @@ This command shows you the bootloader version and other related information of t cfcli bootload info ``` +To flash, or show the bootloaders of, every Crazyflie of a swarm, see +[Swarms](swarm.md#flashing). + ## List available releases This command will list the available releases from the [Crazyflie release repository](https://github.com/bitcraze/crazyflie-release). diff --git a/docs/swarm.md b/docs/swarm.md index 90fd541..4bcf4d5 100644 --- a/docs/swarm.md +++ b/docs/swarm.md @@ -201,6 +201,7 @@ These commands work like the normal ones, on every Crazyflie in the swarm: | `cfcli swarm log print` | Log variables from each Crazyflie | | `cfcli swarm deck list` | The decks on each Crazyflie | | `cfcli swarm debug assert` | The assert info of each Crazyflie | +| `cfcli swarm bootload info \| flash` | Bootloaders and firmware, see [Flashing](#flashing) | `--cf` and `--exclude` (comma-separated names) pick some of the Crazyflies, and `--swarm ` runs on another swarm than the selected one: @@ -296,6 +297,40 @@ TOC that isn't in the cache yet is downloaded from one Crazyflie only; the others wait and then use the cache. Run with `-n` (no TOC cache) and every Crazyflie downloads its own. +### Flashing + +`swarm bootload flash` flashes every Crazyflie, one after another, the same +way `cfcli bootload flash` flashes one. It takes the same `--release`, +`--zip`, `--bin` and `--targets`: + +```bash +cfcli swarm bootload flash --release 2026.08 +cfcli swarm bootload flash --bin stm32-fw=cf21bl.bin --cf CF-01,CF-02 +``` + +The release, the files and the targets are checked before any Crazyflie is +touched. Each Crazyflie's platform then decides which files of a release it +gets, so a swarm can mix platforms. A Crazyflie that fails doesn't stop the +others, and a summary at the end shows how each one went. A unicast flash +keeps the radio busy, so flashing several Crazyflies at once wouldn't be +faster; plan for about 20 seconds per Crazyflie for the STM32 and nRF51 +firmware, more with decks. + +`swarm bootload info` shows the bootloader versions of each Crazyflie. It +restarts each one into its bootloader to read them and back into its +firmware afterwards: + +```text +$ cfcli swarm bootload info +CF | nRF51 bootloader | STM32 bootloader | Broadcast +------+------------------+------------------+----------- +CF-01 | 0x11 | 0x11 | yes +CF-02 | 0x10 | 0x10 | no +``` + +`Broadcast` tells whether both bootloaders are new enough to receive one +image for many Crazyflies at the same time (protocol 0x11). + ## Spreading a swarm over channels Crazyflies on the same channel always share a Crazyradio (see diff --git a/src/cli.rs b/src/cli.rs index 49edbb8..e0a79d7 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -513,6 +513,14 @@ enum SwarmCommands { #[clap(subcommand)] command: SwarmLogCommands, }, + /// Bootload the Crazyflies in the swarm + Bootload { + #[clap(flatten)] + target: SwarmTargetArgs, + + #[clap(subcommand)] + command: SwarmBootloadCommands, + }, /// Debugging utilities for the Crazyflies in the swarm Debug { #[clap(flatten)] @@ -581,6 +589,21 @@ enum SwarmLogCommands { Print(VariablesAndPeriod), } +#[derive(Debug, Subcommand)] +enum SwarmBootloadCommands { + /// Show the bootloader versions of each Crazyflie (restarts each one into + /// its bootloader and back) + Info, + /// Flash firmware to each Crazyflie, one after another + Flash(SwarmFlashParameters), +} + +#[derive(Debug, Args)] +struct SwarmFlashParameters { + #[clap(flatten)] + source: FirmwareSourceArgs, +} + #[derive(Debug, Subcommand)] enum SwarmDeckCommands { /// List the decks attached to each Crazyflie @@ -961,22 +984,15 @@ struct InfoParameters { cold: bool, } +/// The firmware to flash, shared by `bootload flash` and `swarm bootload flash`. #[derive(Debug, Args)] -#[command( - // group( - // ArgGroup::new("source_type") - // .args(&["release", "zip"]) - // .required(false) - // .multiple(false) - // ), - group( +#[command(group( ArgGroup::new("firmware_source") .args(&["release", "zip", "bin"]) .required(true) .multiple(true) - ) -)] -struct FlashParameters { +))] +struct FirmwareSourceArgs { /// Release name, interactive selection if left blank (cannot be combined with zip) #[clap(long)] release: Option>, @@ -1000,6 +1016,12 @@ struct FlashParameters { /// Example: stm32-fw,nrf51-fw #[clap(long, verbatim_doc_comment)] targets: Option>, +} + +#[derive(Debug, Args)] +struct FlashParameters { + #[clap(flatten)] + source: FirmwareSourceArgs, /// Use coldboot (i.e rescue mode) to flash the device #[clap(long, default_value_t = false)] cold: bool, diff --git a/src/main.rs b/src/main.rs index 79591ac..5da6895 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::io::IsTerminal; use std::sync::Arc; -use inquire::{Select, MultiSelect}; +use inquire::Select; use anyhow::{bail, Context, Result}; use tabled::settings::{object::Columns, Alignment, Modify}; use tabled::Tabled; @@ -43,6 +43,7 @@ pub mod utils { pub mod deckctrl; pub mod display; pub mod firmware; + pub mod flash_source; pub mod radio; } @@ -51,37 +52,6 @@ use modules::settings; include!("cli.rs"); -fn single_explicit_target_for_bare_bin( - bin: &Option>>, - targets: &Option>, -) -> Option { - let bin_map = bin.as_ref()?; - if bin_map.len() != 1 { - return None; - } - - let (_bin_path, selected_target) = bin_map.iter().next()?; - if selected_target.is_some() { - return None; - } - - let target_arg = match targets { - Some(Some(target_arg)) => target_arg, - _ => return None, - }; - - let mut target_names = target_arg - .split(',') - .map(str::trim) - .filter(|target| !target.is_empty()); - let target = target_names.next()?; - if target_names.next().is_some() { - return None; - } - - Some(target.to_string()) -} - /// One row of `mem list`. #[derive(Tabled)] struct MemoryRow { @@ -95,58 +65,6 @@ struct MemoryRow { serial: String, } -fn unsupported_flash_targets(selected: &[String]) -> Vec { - let supported = bootloader::get_hardcoded_list_of_targets(); - - selected - .iter() - .filter(|target| !supported.contains(&target.as_str())) - .cloned() - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn single_bare_bin_uses_single_explicit_target() { - let mut bin = HashMap::new(); - bin.insert("lighthouse.bin".to_string(), None); - let targets = Some(Some("bcLighthouse4-fw".to_string())); - - assert_eq!( - single_explicit_target_for_bare_bin(&Some(bin), &targets), - Some("bcLighthouse4-fw".to_string()) - ); - } - - #[test] - fn single_bare_bin_does_not_use_multiple_explicit_targets() { - let mut bin = HashMap::new(); - bin.insert("lighthouse.bin".to_string(), None); - let targets = Some(Some("bcLighthouse4-fw,stm32-fw".to_string())); - - assert_eq!(single_explicit_target_for_bare_bin(&Some(bin), &targets), None); - } - - #[test] - fn keyed_bin_does_not_get_rewritten_from_targets() { - let mut bin = HashMap::new(); - bin.insert("bcLighthouse4-fw".to_string(), Some("lighthouse.bin".to_string())); - let targets = Some(Some("bcLighthouse4-fw".to_string())); - - assert_eq!(single_explicit_target_for_bare_bin(&Some(bin), &targets), None); - } - - #[test] - fn unsupported_flash_targets_reports_unknown_targets() { - let selected = vec!["stm32ohnooo-fw".to_string(), "stm32-fw".to_string()]; - - assert_eq!(unsupported_flash_targets(&selected), vec!["stm32ohnooo-fw".to_string()]); - } -} - impl MemoryTypeArg { /// Convert to the `crazyflie_lib` memory type. Defined here (not on the /// CLI type itself) so the CLI module stays free of the lib dependency. @@ -1569,58 +1487,9 @@ async fn run() -> Result<()> { } } BootloadCommands::Flash(params) => { - let release = match ¶ms.release { - Some(Some(r)) => { - let labels = utils::firmware::get_release_labels().await?; - if !labels.contains(r) { - bail!(CliError::NotFound(format!("release '{}'", r))); - } - Some(r.clone()) - }, - Some(None) => { - require_arg(non_interactive, "--release ")?; - let labels = utils::firmware::get_release_labels().await?; - let selected_release = Select::new("Select a firmware release to flash:", labels) - .prompt() - .map_err(|_| anyhow::anyhow!("No release selected"))?; - Some(selected_release) - } - None => None, - }; - - // This case is special since we're not setting the key on the command-line, - // we're actually setting the value and then we'll select they key here - // Note that the list of tarets is hardcoded, this is because we cannot - // query the Crazyflie for it, flashing new firmware might change this - // until we reach the deck flashing stage. - let bin_with_selections = { - let mut result = HashMap::new(); - let target_from_single_bare_bin = - single_explicit_target_for_bare_bin(¶ms.bin, ¶ms.targets); - if let Some(bin_map) = ¶ms.bin { - for (key, value_opt) in bin_map.iter() { - let (k,v) = match (key, value_opt) { - (k, Some(v)) => (k.clone(), v.clone()), - (k, None) => { - if let Some(selected_target) = &target_from_single_bare_bin { - (selected_target.clone(), k.to_string()) - } else { - require_arg(non_interactive, "--bin target=file (or: --targets for a single bare --bin)")?; - let selected_target = Select::new( - &format!("Select target for [{}]:", k), - bootloader::get_hardcoded_list_of_targets() - ) - .prompt() - .map_err(|_| anyhow::anyhow!("No binary selected"))?; - (selected_target.to_string(), k.to_string()) - } - } - }; - result.insert(k, v); - } - } - Some(result) - }; + let source = ¶ms.source; + let release = utils::flash_source::release(&source.release, non_interactive).await?; + let bin_with_selections = utils::flash_source::bins(&source.bin, &source.targets, non_interactive)?; let platform = if params.cold { // In cold-boot/recovery mode the Crazyflie is not running firmware, @@ -1662,32 +1531,8 @@ async fn run() -> Result<()> { }; // First create a list of firmwares and targets before starting the bootloading - let mut upgrade = utils::firmware::FirmwareUpgrade::new(&platform, &release, ¶ms.zip, &bin_with_selections).await?; - - let selected_target_and_types = match ¶ms.targets { - Some(Some(t)) => t.split(',').map(|s| s.trim().to_string()).collect(), - Some(None) => { - require_arg(non_interactive, "--targets ")?; - let available_target_and_types = upgrade.get_target_and_types(); - - let selected_target_and_types = MultiSelect::new("Select targets to flash:", available_target_and_types) - .prompt() - .map_err(|_| anyhow::anyhow!("No targets selected"))?; - selected_target_and_types - } - None => upgrade.get_target_and_types(), - }; - - if matches!(¶ms.targets, Some(Some(_))) { - let unsupported_targets = unsupported_flash_targets(&selected_target_and_types); - if !unsupported_targets.is_empty() { - bail!(CliError::InvalidValue(format!( - "unknown flash target(s): {}. Valid targets: {}", - unsupported_targets.join(", "), - bootloader::get_hardcoded_list_of_targets().join(", ") - ))); - } - } + let mut upgrade = utils::firmware::FirmwareUpgrade::new(&platform, &release, &source.zip, &bin_with_selections).await?; + let selected_target_and_types = utils::flash_source::targets(&upgrade, &source.targets, non_interactive)?; upgrade.filter_targets(&selected_target_and_types); diff --git a/src/modules/bootloader.rs b/src/modules/bootloader.rs index c09498a..a1fa520 100644 --- a/src/modules/bootloader.rs +++ b/src/modules/bootloader.rs @@ -268,6 +268,20 @@ pub async fn print_bootloader_info(link_context: &LinkContext, cold: bool, uri: Ok(()) } +/// The bootloader protocol versions of a Crazyflie that runs its firmware: +/// (STM32, nRF51). The Crazyflie is restarted into its bootloader to read +/// them, and back into its firmware afterwards. +pub async fn bootloader_versions(link_context: &LinkContext, uri: &str) -> Result<(u8, u8)> { + let link = start_bootloader(link_context, false, uri).await?; + let stm32 = get_info(&link, TARGET_STM32).await?; + let nrf51 = get_info(&link, TARGET_NRF51).await?; + send_command(&link, BootloaderCommand::ResetInit, None).await?; + send_command(&link, BootloaderCommand::Reset, Some(&[0x01])).await?; // Reset to firmware + sleep(Duration::from_millis(500)).await; + link.close().await; + Ok((stm32.protocol_version, nrf51.protocol_version)) +} + pub async fn reboot(link_context: &LinkContext, uri: &str,) -> Result<()> { let link = link_context.open_link(uri).await?; diff --git a/src/modules/swarm/bootload.rs b/src/modules/swarm/bootload.rs new file mode 100644 index 0000000..7d2320e --- /dev/null +++ b/src/modules/swarm/bootload.rs @@ -0,0 +1,191 @@ +//! `cfcli swarm bootload`: the bootloaders of a swarm, and flashing it. +//! +//! `flash` flashes the Crazyflies one after another over unicast, the same +//! way `cfcli bootload flash` flashes one. A unicast flash keeps the radio +//! busy, so flashing several at once wouldn't be faster. `info` shows which +//! Crazyflies have bootloaders that could also take broadcast flashing, +//! where every Crazyflie receives each image at the same time. + +use std::time::Duration; + +use anyhow::{anyhow, Result}; +use tabled::Tabled; + +use super::runner::{csv_row_for, split, Runner, SwarmRow}; +use crate::error::CliError; +use crate::modules::bootloader; +use crate::utils::display::{csv_row, print_table, table}; +use crate::utils::firmware::FirmwareUpgrade; +use crate::utils::flash_source; +use crate::{ConfigTocCache, FirmwareSourceArgs, SwarmBootloadCommands}; + +/// How long reading the bootloader versions of one Crazyflie may take. +const INFO_TIMEOUT: Duration = Duration::from_secs(15); +/// The first bootloader protocol version with broadcast flashing, on both +/// the nRF51 and the STM32. +const BROADCAST_PROTOCOL: u8 = 0x11; + +pub async fn bootload( + runner: &Runner<'_>, + command: &SwarmBootloadCommands, + toc_cache: ConfigTocCache, + non_interactive: bool, + csv: bool, +) -> Result<()> { + match command { + SwarmBootloadCommands::Info => info(runner, csv).await, + SwarmBootloadCommands::Flash(params) => flash(runner, ¶ms.source, toc_cache, non_interactive).await, + } +} + +/// One row of `swarm bootload info`. +#[derive(Tabled)] +struct InfoRow { + #[tabled(rename = "nRF51 bootloader")] + nrf51: String, + #[tabled(rename = "STM32 bootloader")] + stm32: String, + #[tabled(rename = "Broadcast")] + broadcast: String, +} + +async fn info(runner: &Runner<'_>, csv: bool) -> Result<()> { + let link_context = runner.link_context(); + let results = runner + .each("Reading", async |t| { + // Restarting a Crazyflie that isn't there fails with an unhelpful + // link error. + t.check_answers(link_context).await?; + tokio::time::timeout(INFO_TIMEOUT, bootloader::bootloader_versions(link_context, &t.link_uri)) + .await + .map_err(|_| { + anyhow!(CliError::Timeout(format!( + "{} didn't finish within {} s", + t.link_uri, + INFO_TIMEOUT.as_secs() + ))) + })? + }) + .await; + let (done, outcome) = split(results); + let broadcast = |stm32: u8, nrf51: u8| if stm32 >= BROADCAST_PROTOCOL && nrf51 >= BROADCAST_PROTOCOL { "yes" } else { "no" }; + if csv { + csv_row(&["cf", "uri", "nrf51_protocol", "stm32_protocol", "broadcast"]); + for (i, (stm32, nrf51)) in &done { + let (nrf51_s, stm32_s) = (format!("0x{:02X}", nrf51), format!("0x{:02X}", stm32)); + csv_row_for(&runner.targets[*i], &[&nrf51_s, &stm32_s, broadcast(*stm32, *nrf51)]); + } + } else if !done.is_empty() { + let rows: Vec<_> = done + .into_iter() + .map(|(i, (stm32, nrf51))| SwarmRow { + cf: runner.targets[i].name.clone(), + row: InfoRow { + nrf51: format!("0x{:02X}", nrf51), + stm32: format!("0x{:02X}", stm32), + broadcast: broadcast(stm32, nrf51).to_string(), + }, + }) + .collect(); + print_table(&table(&rows)); + } + outcome.print_failures(&runner.targets); + outcome.finish() +} + +/// One row of the `swarm bootload flash` summary. +#[derive(Tabled)] +struct FlashRow { + #[tabled(rename = "CF")] + name: String, + #[tabled(rename = "Platform")] + platform: String, + #[tabled(rename = "Result")] + result: String, +} + +async fn flash( + runner: &Runner<'_>, + source: &FirmwareSourceArgs, + toc_cache: ConfigTocCache, + non_interactive: bool, +) -> Result<()> { + // Everything that comes from the command line first, before any + // Crazyflie is touched. + let release = flash_source::release(&source.release, non_interactive).await?; + let bins = flash_source::bins(&source.bin, &source.targets, non_interactive)?; + + // The platform decides which files of a release a Crazyflie gets. + let platforms = runner + .connected("Reading", async |cf, _| Ok(cf.platform.device_type_name().await?)) + .await; + let mut upgrades: Vec<(String, Result)> = Vec::new(); + for platform in platforms.iter().flatten() { + if !upgrades.iter().any(|(p, _)| p == platform) { + let upgrade = FirmwareUpgrade::new(platform, &release, &source.zip, &bins).await; + upgrades.push((platform.clone(), upgrade)); + } + } + + // The targets are picked once, from those of the first platform. + if let Some(first) = upgrades.iter().find_map(|(_, upgrade)| upgrade.as_ref().ok()) { + let selected = flash_source::targets(first, &source.targets, non_interactive)?; + for (_, upgrade) in upgrades.iter_mut() { + if let Ok(upgrade) = upgrade { + upgrade.filter_targets(&selected); + } + } + } + + // One Crazyflie after another. + let link_context = runner.link_context(); + let total = runner.targets.len(); + let mut results: Vec> = Vec::with_capacity(total); + let mut shown_platforms = Vec::with_capacity(total); + for (i, (target, platform)) in runner.targets.iter().zip(platforms).enumerate() { + let platform = match platform { + Ok(platform) => platform, + Err(e) => { + shown_platforms.push(String::new()); + results.push(Err(e)); + continue; + } + }; + shown_platforms.push(platform.clone()); + let upgrade = match upgrades.iter().find(|(p, _)| *p == platform).map(|(_, u)| u) { + Some(Ok(upgrade)) => upgrade, + Some(Err(e)) => { + results.push(Err(anyhow!("{:#}", e))); + continue; + } + None => unreachable!("every platform found has an upgrade"), + }; + if upgrade.get_target_and_types().is_empty() { + results.push(Ok(format!("nothing to flash for {}", platform))); + continue; + } + println!(); + println!("{} ({} of {}, {}): {}", target.name, i + 1, total, platform, target.uri); + let flashed = bootloader::flash(link_context, &target.link_uri, toc_cache.clone(), upgrade.clone(), false).await; + results.push(flashed.map(|()| "flashed".to_string())); + } + + let rows: Vec = runner + .targets + .iter() + .zip(&shown_platforms) + .zip(&results) + .map(|((target, platform), result)| FlashRow { + name: target.name.clone(), + platform: platform.clone(), + result: match result { + Ok(done) => done.clone(), + Err(e) => format!("{:#}", e), + }, + }) + .collect(); + println!(); + print_table(&table(&rows)); + let (_, outcome) = split(results); + outcome.finish() +} diff --git a/src/modules/swarm/mod.rs b/src/modules/swarm/mod.rs index 7b69a27..139405e 100644 --- a/src/modules/swarm/mod.rs +++ b/src/modules/swarm/mod.rs @@ -6,6 +6,7 @@ //! single command. Crazyflies are named by their short `name`, which is also //! what `--cf`/`--exclude` and `select --from-swarm` take. +mod bootload; mod commands; mod rechannel; mod runner; @@ -79,6 +80,10 @@ pub(crate) async fn run( let runner = runner(&store, config, target, link_context, toc_cache).await?; commands::log(&runner, command, non_interactive, csv).await } + SwarmCommands::Bootload { target, command } => { + let runner = runner(&store, config, target, link_context, toc_cache.clone()).await?; + bootload::bootload(&runner, command, toc_cache, non_interactive, csv).await + } SwarmCommands::Debug { target, command } => { let runner = runner(&store, config, target, link_context, toc_cache).await?; commands::debug(&runner, command, csv).await diff --git a/src/utils/firmware.rs b/src/utils/firmware.rs index 768647c..d57a3b1 100644 --- a/src/utils/firmware.rs +++ b/src/utils/firmware.rs @@ -186,6 +186,7 @@ async fn download_release_zip(release: &String, platform_release: &String) -> Re Ok(zip_data) } +#[derive(Clone)] pub struct FirmwareUpgrade { bins: HashMap, } diff --git a/src/utils/flash_source.rs b/src/utils/flash_source.rs new file mode 100644 index 0000000..4ca4e8e --- /dev/null +++ b/src/utils/flash_source.rs @@ -0,0 +1,191 @@ +//! What `bootload flash` and `swarm bootload flash` flash, from their +//! arguments: a release, a zip, binary files and the targets. + +use std::collections::HashMap; + +use anyhow::{anyhow, bail, Result}; +use inquire::{MultiSelect, Select}; + +use crate::error::CliError; +use crate::modules::bootloader; +use crate::utils::firmware::{self, FirmwareUpgrade}; + +/// The release to flash: the one named (checked to exist), one picked from +/// the list, or none. +pub async fn release(release: &Option>, non_interactive: bool) -> Result> { + Ok(match release { + Some(Some(r)) => { + let labels = firmware::get_release_labels().await?; + if !labels.contains(r) { + bail!(CliError::NotFound(format!("release '{}'", r))); + } + Some(r.clone()) + } + Some(None) => { + crate::require_arg(non_interactive, "--release ")?; + let labels = firmware::get_release_labels().await?; + let selected_release = Select::new("Select a firmware release to flash:", labels) + .prompt() + .map_err(|_| anyhow!("No release selected"))?; + Some(selected_release) + } + None => None, + }) +} + +/// The binary files to flash, by target. A `--bin` given without a target +/// gets the single `--targets` one, or one picked from the list. +pub fn bins( + bin: &Option>>, + targets: &Option>, + non_interactive: bool, +) -> Result>> { + // This case is special since we're not setting the key on the command-line, + // we're actually setting the value and then we'll select they key here + // Note that the list of tarets is hardcoded, this is because we cannot + // query the Crazyflie for it, flashing new firmware might change this + // until we reach the deck flashing stage. + let mut result = HashMap::new(); + let target_from_single_bare_bin = single_explicit_target_for_bare_bin(bin, targets); + if let Some(bin_map) = bin { + for (key, value_opt) in bin_map.iter() { + let (k, v) = match (key, value_opt) { + (k, Some(v)) => (k.clone(), v.clone()), + (k, None) => { + if let Some(selected_target) = &target_from_single_bare_bin { + (selected_target.clone(), k.to_string()) + } else { + crate::require_arg( + non_interactive, + "--bin target=file (or: --targets for a single bare --bin)", + )?; + let selected_target = Select::new( + &format!("Select target for [{}]:", k), + bootloader::get_hardcoded_list_of_targets(), + ) + .prompt() + .map_err(|_| anyhow!("No binary selected"))?; + (selected_target.to_string(), k.to_string()) + } + } + }; + result.insert(k, v); + } + } + Ok(Some(result)) +} + +/// The targets to flash: the ones given (checked to be known), ones picked +/// from those in `upgrade`, or all of them. +pub fn targets( + upgrade: &FirmwareUpgrade, + targets: &Option>, + non_interactive: bool, +) -> Result> { + let selected = match targets { + Some(Some(t)) => t.split(',').map(|s| s.trim().to_string()).collect(), + Some(None) => { + crate::require_arg(non_interactive, "--targets ")?; + MultiSelect::new("Select targets to flash:", upgrade.get_target_and_types()) + .prompt() + .map_err(|_| anyhow!("No targets selected"))? + } + None => upgrade.get_target_and_types(), + }; + + if matches!(targets, Some(Some(_))) { + let unsupported_targets = unsupported_flash_targets(&selected); + if !unsupported_targets.is_empty() { + bail!(CliError::InvalidValue(format!( + "unknown flash target(s): {}. Valid targets: {}", + unsupported_targets.join(", "), + bootloader::get_hardcoded_list_of_targets().join(", ") + ))); + } + } + Ok(selected) +} + +fn single_explicit_target_for_bare_bin( + bin: &Option>>, + targets: &Option>, +) -> Option { + let bin_map = bin.as_ref()?; + if bin_map.len() != 1 { + return None; + } + + let (_bin_path, selected_target) = bin_map.iter().next()?; + if selected_target.is_some() { + return None; + } + + let target_arg = match targets { + Some(Some(target_arg)) => target_arg, + _ => return None, + }; + + let mut target_names = target_arg + .split(',') + .map(str::trim) + .filter(|target| !target.is_empty()); + let target = target_names.next()?; + if target_names.next().is_some() { + return None; + } + + Some(target.to_string()) +} + +fn unsupported_flash_targets(selected: &[String]) -> Vec { + let supported = bootloader::get_hardcoded_list_of_targets(); + + selected + .iter() + .filter(|target| !supported.contains(&target.as_str())) + .cloned() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn single_bare_bin_uses_single_explicit_target() { + let mut bin = HashMap::new(); + bin.insert("lighthouse.bin".to_string(), None); + let targets = Some(Some("bcLighthouse4-fw".to_string())); + + assert_eq!( + single_explicit_target_for_bare_bin(&Some(bin), &targets), + Some("bcLighthouse4-fw".to_string()) + ); + } + + #[test] + fn single_bare_bin_does_not_use_multiple_explicit_targets() { + let mut bin = HashMap::new(); + bin.insert("lighthouse.bin".to_string(), None); + let targets = Some(Some("bcLighthouse4-fw,stm32-fw".to_string())); + + assert_eq!(single_explicit_target_for_bare_bin(&Some(bin), &targets), None); + } + + #[test] + fn keyed_bin_does_not_get_rewritten_from_targets() { + let mut bin = HashMap::new(); + bin.insert("bcLighthouse4-fw".to_string(), Some("lighthouse.bin".to_string())); + let targets = Some(Some("bcLighthouse4-fw".to_string())); + + assert_eq!(single_explicit_target_for_bare_bin(&Some(bin), &targets), None); + } + + #[test] + fn unsupported_flash_targets_reports_unknown_targets() { + let selected = vec!["stm32ohnooo-fw".to_string(), "stm32-fw".to_string()]; + + assert_eq!(unsupported_flash_targets(&selected), vec!["stm32ohnooo-fw".to_string()]); + } +} + From df9f8c69a3b4546f574f3691dadb8ccd77e57a65 Mon Sep 17 00:00:00 2001 From: Marcus Eliasson Date: Mon, 5 Oct 2026 22:46:13 +0200 Subject: [PATCH 2/3] Keep platform-specific images to one platform in swarm bootload flash STM32 images differ between platforms (a Crazyflie 2.1 and a 2.1 Brushless can't run each other's), and so do nRF51 images for some of them. A release has the files for every platform and is downloaded per platform, but --bin gave the same file to every Crazyflie of a swarm. swarm bootload flash now refuses --bin STM32 or nRF51 images when the Crazyflies to flash have more than one platform, before flashing anything. Deck firmware works whatever the Crazyflie and still goes to all. --platform cf21|cf21bl|bolt11|flapper|tag flashes only the Crazyflies of one platform and skips the others, so a mixed swarm can be flashed one platform at a time. The firmware for every platform is now prepared before any Crazyflie is flashed: a zip built for another platform stops the command instead of failing those Crazyflies halfway, and each platform is prepared once. The platform names move into utils/flash_source.rs, shared with the --platform of bootload flash --cold, which now gives exit code 30 for an unknown platform. --- docs/swarm.md | 27 ++++++-- src/cli.rs | 7 ++ src/main.rs | 21 +----- src/modules/swarm/bootload.rs | 118 ++++++++++++++++++++++++++++------ src/utils/flash_source.rs | 68 ++++++++++++++++++++ 5 files changed, 197 insertions(+), 44 deletions(-) diff --git a/docs/swarm.md b/docs/swarm.md index 4bcf4d5..3c9b696 100644 --- a/docs/swarm.md +++ b/docs/swarm.md @@ -309,13 +309,30 @@ cfcli swarm bootload flash --bin stm32-fw=cf21bl.bin --cf CF-01,CF-02 ``` The release, the files and the targets are checked before any Crazyflie is -touched. Each Crazyflie's platform then decides which files of a release it -gets, so a swarm can mix platforms. A Crazyflie that fails doesn't stop the -others, and a summary at the end shows how each one went. A unicast flash -keeps the radio busy, so flashing several Crazyflies at once wouldn't be -faster; plan for about 20 seconds per Crazyflie for the STM32 and nRF51 +touched, and the firmware for every platform in the swarm is prepared before +anything is flashed: a `--zip` built for another platform stops the command +instead of failing halfway. A Crazyflie that fails while flashing doesn't +stop the others, and a summary at the end shows how each one went. A unicast +flash keeps the radio busy, so flashing several Crazyflies at once wouldn't +be faster; plan for about 20 seconds per Crazyflie for the STM32 and nRF51 firmware, more with decks. +#### Swarms with several platforms + +A release has the files for every platform, so `--release` flashes each +Crazyflie with the files of its own platform. STM32 and nRF51 images given +with `--bin` are built for one platform, so they are only flashed when all the +Crazyflies to flash have the same platform; otherwise the command stops +before flashing anything. Deck firmware works whatever the Crazyflie. + +`--platform` flashes only the Crazyflies of one platform (`cf21`, `cf21bl`, +`bolt11`, `flapper` or `tag`), the others are skipped: + +```bash +cfcli swarm bootload flash --platform cf21bl --bin stm32-fw=cf21bl.bin +cfcli swarm bootload flash --platform cf21 --bin stm32-fw=cf21.bin +``` + `swarm bootload info` shows the bootloader versions of each Crazyflie. It restarts each one into its bootloader to read them and back into its firmware afterwards: diff --git a/src/cli.rs b/src/cli.rs index e0a79d7..ffa5be7 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -602,6 +602,13 @@ enum SwarmBootloadCommands { struct SwarmFlashParameters { #[clap(flatten)] source: FirmwareSourceArgs, + /// Only flash the Crazyflies of this platform. STM32 and nRF51 images + /// given with --bin are built for one platform, so a swarm with several + /// platforms is flashed one platform at a time. + /// + /// Valid values: cf21, cf21bl, bolt11, flapper, tag + #[clap(long, verbatim_doc_comment)] + platform: Option, } #[derive(Debug, Subcommand)] diff --git a/src/main.rs b/src/main.rs index 5da6895..2476e55 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1495,28 +1495,11 @@ async fn run() -> Result<()> { // In cold-boot/recovery mode the Crazyflie is not running firmware, // so we cannot connect to query the platform. Use the --platform // flag or ask the user interactively. - let resolve_platform = |p: &str| -> Result { - match p.to_lowercase().as_str() { - "cf21" => Ok("Crazyflie 2.1".to_string()), - "cf21bl" => Ok("Crazyflie 2.1 Brushless".to_string()), - "bolt11" => Ok("Crazyflie Bolt 1.1".to_string()), - "flapper" => Ok("Flapper (Bolt 1.1)".to_string()), - "tag" => Ok("Roadrunner 1.0".to_string()), - _ => bail!("Unknown platform '{}'. Valid options: cf21, cf21bl, bolt11, flapper, tag", p), - } - }; match ¶ms.platform { - Some(p) => resolve_platform(p)?, + Some(p) => utils::flash_source::platform_name(p)?.to_string(), None => { require_arg(non_interactive, "--platform")?; - let platforms = vec![ - "Crazyflie 2.1", - "Crazyflie 2.1 Brushless", - "Crazyflie Bolt 1.1", - "Flapper (Bolt 1.1)", - "Roadrunner 1.0", - ]; - Select::new("Select the platform:", platforms) + Select::new("Select the platform:", utils::flash_source::platform_names()) .prompt() .map_err(|_| anyhow::anyhow!("No platform selected"))? .to_string() diff --git a/src/modules/swarm/bootload.rs b/src/modules/swarm/bootload.rs index 7d2320e..17b7a06 100644 --- a/src/modules/swarm/bootload.rs +++ b/src/modules/swarm/bootload.rs @@ -8,7 +8,7 @@ use std::time::Duration; -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, bail, Result}; use tabled::Tabled; use super::runner::{csv_row_for, split, Runner, SwarmRow}; @@ -17,7 +17,7 @@ use crate::modules::bootloader; use crate::utils::display::{csv_row, print_table, table}; use crate::utils::firmware::FirmwareUpgrade; use crate::utils::flash_source; -use crate::{ConfigTocCache, FirmwareSourceArgs, SwarmBootloadCommands}; +use crate::{ConfigTocCache, SwarmBootloadCommands, SwarmFlashParameters}; /// How long reading the bootloader versions of one Crazyflie may take. const INFO_TIMEOUT: Duration = Duration::from_secs(15); @@ -34,7 +34,7 @@ pub async fn bootload( ) -> Result<()> { match command { SwarmBootloadCommands::Info => info(runner, csv).await, - SwarmBootloadCommands::Flash(params) => flash(runner, ¶ms.source, toc_cache, non_interactive).await, + SwarmBootloadCommands::Flash(params) => flash(runner, params, toc_cache, non_interactive).await, } } @@ -106,12 +106,14 @@ struct FlashRow { async fn flash( runner: &Runner<'_>, - source: &FirmwareSourceArgs, + params: &SwarmFlashParameters, toc_cache: ConfigTocCache, non_interactive: bool, ) -> Result<()> { + let source = ¶ms.source; // Everything that comes from the command line first, before any // Crazyflie is touched. + let only = params.platform.as_deref().map(flash_source::platform_name).transpose()?; let release = flash_source::release(&source.release, non_interactive).await?; let bins = flash_source::bins(&source.bin, &source.targets, non_interactive)?; @@ -119,21 +121,51 @@ async fn flash( let platforms = runner .connected("Reading", async |cf, _| Ok(cf.platform.device_type_name().await?)) .await; - let mut upgrades: Vec<(String, Result)> = Vec::new(); - for platform in platforms.iter().flatten() { - if !upgrades.iter().any(|(p, _)| p == platform) { - let upgrade = FirmwareUpgrade::new(platform, &release, &source.zip, &bins).await; - upgrades.push((platform.clone(), upgrade)); + let to_flash: Vec<(usize, &str)> = platforms + .iter() + .enumerate() + .filter_map(|(i, platform)| platform.as_ref().ok().map(|p| (i, p.as_str()))) + .filter(|(_, platform)| only.is_none_or(|only| *platform == only)) + .collect(); + if let Some(only) = only { + if to_flash.is_empty() && platforms.iter().any(Result::is_ok) { + bail!(CliError::NotFound(format!("Crazyflies of platform {} in the swarm", only))); + } + } + + // STM32 and nRF51 images are built for one platform. + let named: Vec<(&str, &str)> = to_flash.iter().map(|(i, p)| (runner.targets[*i].name.as_str(), *p)).collect(); + check_one_platform(&flash_source::platform_bound(&bins), &named)?; + + // The files for every platform, all ready before anything is flashed. + let mut distinct: Vec<&str> = to_flash.iter().map(|(_, platform)| *platform).collect(); + distinct.sort_unstable(); + distinct.dedup(); + let mut upgrades: Vec<(String, FirmwareUpgrade)> = Vec::new(); + let mut unprepared = 0; + for platform in &distinct { + let platform = platform.to_string(); + match FirmwareUpgrade::new(&platform, &release, &source.zip, &bins).await { + Ok(upgrade) => upgrades.push((platform, upgrade)), + Err(e) => { + eprintln!("{}: {:#}", platform, e); + unprepared += 1; + } } } + if unprepared > 0 { + let hint = if distinct.len() > 1 { ". Flash one platform at a time with --platform" } else { "" }; + bail!(CliError::InvalidValue(format!( + "the firmware can't be prepared for {} of the platforms to flash, so nothing was flashed{}", + unprepared, hint + ))); + } // The targets are picked once, from those of the first platform. - if let Some(first) = upgrades.iter().find_map(|(_, upgrade)| upgrade.as_ref().ok()) { + if let Some((_, first)) = upgrades.first() { let selected = flash_source::targets(first, &source.targets, non_interactive)?; for (_, upgrade) in upgrades.iter_mut() { - if let Ok(upgrade) = upgrade { - upgrade.filter_targets(&selected); - } + upgrade.filter_targets(&selected); } } @@ -152,13 +184,10 @@ async fn flash( } }; shown_platforms.push(platform.clone()); - let upgrade = match upgrades.iter().find(|(p, _)| *p == platform).map(|(_, u)| u) { - Some(Ok(upgrade)) => upgrade, - Some(Err(e)) => { - results.push(Err(anyhow!("{:#}", e))); - continue; - } - None => unreachable!("every platform found has an upgrade"), + let Some((_, upgrade)) = upgrades.iter().find(|(p, _)| *p == platform) else { + // Left out by --platform. + results.push(Ok("skipped".to_string())); + continue; }; if upgrade.get_target_and_types().is_empty() { results.push(Ok(format!("nothing to flash for {}", platform))); @@ -189,3 +218,52 @@ async fn flash( let (_, outcome) = split(results); outcome.finish() } + +/// Refuse STM32 and nRF51 images (`bound`, the `--bin` keys) for Crazyflies +/// of more than one platform (`to_flash`: name and platform each). +fn check_one_platform(bound: &[String], to_flash: &[(&str, &str)]) -> Result<()> { + if bound.is_empty() { + return Ok(()); + } + let mut by_platform: Vec<(&str, Vec<&str>)> = Vec::new(); + for (name, platform) in to_flash { + match by_platform.iter_mut().find(|(p, _)| p == platform) { + Some((_, names)) => names.push(name), + None => by_platform.push((platform, vec![name])), + } + } + if by_platform.len() <= 1 { + return Ok(()); + } + let groups: Vec = + by_platform.iter().map(|(platform, names)| format!("{} ({})", platform, names.join(", "))).collect(); + bail!(CliError::InvalidValue(format!( + "--bin {} {} built for one platform, but the Crazyflies to flash are {}. Flash one platform at a \ + time with --platform, or use --release, which has the files for every platform", + bound.join(", "), + if bound.len() == 1 { "is" } else { "are" }, + groups.join(" and ") + ))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn platform_bound_images_need_one_platform() { + let bound = vec!["stm32-fw".to_string()]; + let mixed = [("CF-01", "Crazyflie 2.1"), ("CF-02", "Crazyflie 2.1 Brushless"), ("CF-03", "Crazyflie 2.1")]; + let err = check_one_platform(&bound, &mixed).unwrap_err(); + let message = format!("{:#}", err); + assert!( + message.contains("Crazyflie 2.1 (CF-01, CF-03) and Crazyflie 2.1 Brushless (CF-02)"), + "{}", + message + ); + + // One platform, or no STM32/nRF51 image, is fine. + assert!(check_one_platform(&bound, &mixed[..1]).is_ok()); + assert!(check_one_platform(&[], &mixed).is_ok()); + } +} diff --git a/src/utils/flash_source.rs b/src/utils/flash_source.rs index 4ca4e8e..af7dc29 100644 --- a/src/utils/flash_source.rs +++ b/src/utils/flash_source.rs @@ -10,6 +10,51 @@ use crate::error::CliError; use crate::modules::bootloader; use crate::utils::firmware::{self, FirmwareUpgrade}; +/// The platforms `--platform` takes: the short name, and the name the +/// Crazyflie reports. +const PLATFORMS: [(&str, &str); 5] = [ + ("cf21", "Crazyflie 2.1"), + ("cf21bl", "Crazyflie 2.1 Brushless"), + ("bolt11", "Crazyflie Bolt 1.1"), + ("flapper", "Flapper (Bolt 1.1)"), + ("tag", "Roadrunner 1.0"), +]; + +/// The name a Crazyflie reports for a `--platform` short name. +pub fn platform_name(short: &str) -> Result<&'static str> { + PLATFORMS + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(short)) + .map(|(_, platform)| *platform) + .ok_or_else(|| { + let valid: Vec<&str> = PLATFORMS.iter().map(|(name, _)| *name).collect(); + anyhow!(CliError::InvalidValue(format!( + "unknown platform '{}'. Valid options: {}", + short, + valid.join(", ") + ))) + }) +} + +/// The names Crazyflies report, to pick from. +pub fn platform_names() -> Vec<&'static str> { + PLATFORMS.iter().map(|(_, platform)| *platform).collect() +} + +/// The `--bin` keys for the STM32 or the nRF51, whose images are built for +/// one platform. Deck firmware works whatever the Crazyflie. +pub fn platform_bound(bins: &Option>) -> Vec { + let mut keys: Vec = bins + .iter() + .flatten() + .map(|(key, _)| key) + .filter(|key| matches!(key.split(['-', '@']).next(), Some("stm32") | Some("nrf51"))) + .cloned() + .collect(); + keys.sort(); + keys +} + /// The release to flash: the one named (checked to exist), one picked from /// the list, or none. pub async fn release(release: &Option>, non_interactive: bool) -> Result> { @@ -181,6 +226,29 @@ mod tests { assert_eq!(single_explicit_target_for_bare_bin(&Some(bin), &targets), None); } + #[test] + fn platform_short_names() { + assert_eq!(platform_name("cf21bl").unwrap(), "Crazyflie 2.1 Brushless"); + assert_eq!(platform_name("CF21").unwrap(), "Crazyflie 2.1"); + assert!(platform_name("cf3").is_err()); + } + + #[test] + fn stm32_and_nrf51_images_are_platform_bound() { + let bins: HashMap = [ + ("stm32-fw", "a.bin"), + ("nrf51-fw", "b.bin"), + ("stm32-fw@0x08004000", "c.bin"), + ("bcLighthouse4-fw", "d.bin"), + ("deckctrl-cfg", "e.bin"), + ] + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + assert_eq!(platform_bound(&Some(bins)), vec!["nrf51-fw", "stm32-fw", "stm32-fw@0x08004000"]); + assert!(platform_bound(&None).is_empty()); + } + #[test] fn unsupported_flash_targets_reports_unknown_targets() { let selected = vec!["stm32ohnooo-fw".to_string(), "stm32-fw".to_string()]; From d5b939d8abf3158a82fa7c6cf7dc3505d2dad28f Mon Sep 17 00:00:00 2001 From: Marcus Eliasson Date: Mon, 5 Oct 2026 23:12:59 +0200 Subject: [PATCH 3/3] Read the bootloader protocol version from the right byte The answer to GET_INFO is [target, GET_INFO, page size, buffer pages, flash pages, start page, 12-byte CPU ID, protocol version], as cfloader and cflib read it. get_info took the protocol version from byte 1, which is the GET_INFO command echoed back, so every bootloader reported 0x10: bootload info always printed "Protocol Version: 16", and swarm bootload info said no Crazyflie could take broadcast flashing. It also read the CPU ID as 2 bytes instead of 12. The version now comes from byte 22 (0 for bootloaders from before it was added, which stop after the CPU ID) and the CPU ID is printed in full. --- src/modules/bootloader.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/modules/bootloader.rs b/src/modules/bootloader.rs index a1fa520..b34ded8 100644 --- a/src/modules/bootloader.rs +++ b/src/modules/bootloader.rs @@ -46,7 +46,7 @@ struct BootloaderInfo { buffer_pages: u16, flash_pages: u16, start_page: u16, - cpuid: u16, + cpuid: Vec, } #[repr(u8)] @@ -98,15 +98,18 @@ async fn get_info(link: &Connection, target: u8) -> Result { .unwrap(); let data = packet.get_data(); - if packet.get_header() == 0xFF && data.len() >= 2 && data[0..2] == [target, 0x10] { + // [target, GET_INFO, page size (2), buffer pages (2), flash pages (2), + // start page (2), CPU ID (12), protocol version]. Bootloaders from + // before the protocol version stop after the CPU ID. + if packet.get_header() == 0xFF && data.len() >= 22 && data[0..2] == [target, 0x10] { return Ok(BootloaderInfo { id: data[0], - protocol_version: data[1], + protocol_version: data.get(22).copied().unwrap_or(0), page_size: LittleEndian::read_u16(&data[2..4]), buffer_pages: LittleEndian::read_u16(&data[4..6]), flash_pages: LittleEndian::read_u16(&data[6..8]), start_page: LittleEndian::read_u16(&data[8..10]), - cpuid: LittleEndian::read_u16(&data[10..12]), + cpuid: data[10..22].to_vec(), }); } } @@ -245,7 +248,7 @@ fn print_target_info(name: &str, info: &BootloaderInfo) { println!(" Flash Size: {} bytes ({} KB)", flash_size, flash_size / 1024); println!(" Start Page: {}", info.start_page); println!(" Available Flash: {} bytes ({} KB)", available_flash, available_flash / 1024); - println!(" CPU ID: 0x{:04X}", info.cpuid); + println!(" CPU ID: {}", hex::encode_upper(&info.cpuid)); } pub async fn print_bootloader_info(link_context: &LinkContext, cold: bool, uri: &str) -> Result<()> {