From 67797c2008c2e9c7145dca984f55c52870774319 Mon Sep 17 00:00:00 2001 From: Arnaud Taffanel Date: Mon, 24 Aug 2026 17:22:14 +0200 Subject: [PATCH] Add sourced console support --- Cargo.lock | 11 ++-- Cargo.toml | 11 ++-- README.md | 11 +++- docs/console.md | 48 +++++++++++++++-- src/cli.rs | 12 ++++- src/main.rs | 101 +++++++++++++++++++++++++++++------ src/modules/console.rs | 116 +++++++++++++++++++++++++++++++++++++++-- 7 files changed, 275 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 26078b3..5a66b38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -412,7 +412,7 @@ dependencies = [ [[package]] name = "cfcli" -version = "0.11.1" +version = "0.12.0" dependencies = [ "anyhow", "async-stream", @@ -721,9 +721,8 @@ dependencies = [ [[package]] name = "crazyflie-lib" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e1924ed55c8ef67ed9bf2561f2cc7aca148802f9c1ddf6a469bbaadba592ec" +version = "0.8.1" +source = "git+https://github.com/bitcraze/crazyflie-lib-rs?rev=e7e1b594d4d015c06d6ba066a516199450aef69a#e7e1b594d4d015c06d6ba066a516199450aef69a" dependencies = [ "async-broadcast", "async-stream", @@ -744,9 +743,9 @@ dependencies = [ [[package]] name = "crazyflie-link" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899dd3e14b47a1be3ee0c78c34c5b4b6634899ea2f5aab4d033829dd3d3ff3f0" +checksum = "51c777bf6ea26a601a31642c2cab2500e099772e59b57b98a70fdfe1ea5e159e" dependencies = [ "async-trait", "bitflags 2.11.1", diff --git a/Cargo.toml b/Cargo.toml index 8b40a57..97c822e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,8 +57,13 @@ num_enum = "0.7.4" half = "2.6.0" env_logger = "0.11.8" anyhow = "1.0" -crazyflie-link = { version = "0.5.0", default-features = false } -crazyflie-lib = { version = "0.8.0", default-features = false } +crazyflie-link = { version = "0.5.1", default-features = false } +# TODO: Return to a crates.io version before merging this PR. +crazyflie-lib = { + git = "https://github.com/bitcraze/crazyflie-lib-rs", + rev = "e7e1b594d4d015c06d6ba066a516199450aef69a", + default-features = false, +} crazyradio = { version = "0.7.0", features = ["async"] } rusb = "0.9" cfloader = "0.1.0" @@ -87,7 +92,7 @@ hex = "0.4.3" chrono = "0.4.44" [target.'cfg(unix)'.dependencies] -crazyflie-link = { version = "0.5.0", default-features = false, features = ["packet_capture"] } +crazyflie-link = { version = "0.5.1", default-features = false, features = ["packet_capture"] } crazyradio = { version = "0.7.0", features = ["async", "packet_capture"] } # Used by build.rs to generate shell-completion scripts from the same clap diff --git a/README.md b/README.md index aba8c1d..6a06b51 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,13 @@ selected URI for a single command using the `--uri` flag: cfcli --uri radio://0/80/2M/E7E7E7E7E7 console ``` +Discover and stream a sourced console advertised by compatible firmware: + +```text +cfcli console --list-sources +cfcli --timeout 3000 console --source deck:bcCam +``` + You can preserve console output across connections using the `-p` flag. Console data is accumulated during each connection and printed when the `console` command is run: @@ -213,8 +220,8 @@ prompt) a few flags make the output predictable: intended way to stop them and the command exits **0**. For all other commands a timeout means the command got stuck and the command exits **40**. * `--csv` — machine-readable CSV output for the read commands (`scan`, - `param list`/`get`, `log list`/`print`, `mem list`, `platform info`). Other - commands ignore the flag. + `param list`/`get`, `log list`/`print`, `mem list`, `platform info`, + `console --list-sources`). Other commands ignore the flag. Exit codes: diff --git a/docs/console.md b/docs/console.md index 791ab62..cfaa0e3 100644 --- a/docs/console.md +++ b/docs/console.md @@ -2,7 +2,8 @@ This module provides access to the Crazyflie console. -**NOTE:** The console data history clears when you connect to the Crazyflie (i.e is downloaded on connect) +The legacy Crazyflie console and any sourced consoles are selected separately. +Running `cfcli console` without source options keeps the legacy behavior. ## Show console prints @@ -18,6 +19,33 @@ If you do not want any formatting of the text then use the ```--no-format``` par cfcli console --no-format ``` +## Show a sourced console + +Firmware using CRTP protocol 13 or newer may advertise additional console +sources, such as a console retained by a deck. List their paths with: + +```text +cfcli console --list-sources +``` + +The list contains source paths only. Use global `--csv` to emit a `path` +header and one CSV row per source. Older firmware and Crazyflies without +sourced consoles report an empty list successfully. + +Select one source by its exact, case-sensitive path: + +```text +cfcli console --source deck:bcCam +``` + +The command first replays the source history retained for this connection and +then continues with live output. Formatting and `--no-format` behave like the +legacy console. Only one source can be selected at a time in this first +implementation; concurrent multi-source output may be added later. + +If a requested source does not exist, cfcli exits with resource-not-found code +20 and reports the available paths. + ## Preserve console across connections Normally, console data is only available while connected. With the ```--preserve-console``` (```-p```) global flag, console output is saved to a file during every connection. When running multiple commands in a row the console data is accumulated: @@ -35,6 +63,10 @@ cfcli console This is useful for capturing console debug output that was printed during other operations (e.g. parameter changes or log sessions). +Preservation currently applies only to the legacy Crazyflie console. A sourced +console uses its own retained history and `cfcli console --source ...` neither +prints nor clears the locally preserved legacy-console file. + ## Clear preserved console history The `--clear` flag deletes the preserved console history file and exits without connecting to a Crazyflie. Useful when you want to discard accumulated output between runs: @@ -47,10 +79,20 @@ The file path is shown by `cfcli settings show`. ## Stop streaming after a fixed duration -`console` is a streaming command — by default it runs until the link is broken. Combine it with the global `--timeout` flag to stop cleanly after a fixed wall-clock duration: +Legacy and sourced console output are streaming commands — by default they run +until the link is broken. Combine either with the global `--timeout` flag to +stop cleanly after a fixed wall-clock duration: ```text cfcli --timeout 3000 console +cfcli --timeout 3000 console --source deck:bcCam ``` -When `--timeout` fires on a streaming command, the process exits **0** (the timer is the intended way to stop it). This is the recommended pattern when running `cfcli console` from a script or CI step. +When `--timeout` fires on a streaming command, the process exits **0** (the +timer is the intended way to stop it). For a sourced console, cfcli then makes +a clean disable attempt bounded to one additional second before disconnecting. +This is the recommended pattern when running `cfcli console` from a script or +CI step. + +`--list-sources` is bounded rather than streaming. If its global timeout +expires, cfcli exits with timeout code 40. diff --git a/src/cli.rs b/src/cli.rs index 65a5ac7..4e0358f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -295,12 +295,20 @@ enum Commands { /// Print the console text from a Crazyflie Console { /// Output raw console data without processing - #[clap(long)] + #[clap(long, conflicts_with = "list_sources")] no_format: bool, /// Delete the preserved console history file and exit without connecting - #[clap(long)] + #[clap(long, conflicts_with_all = ["source", "list_sources"])] clear: bool, + + /// Print one sourced console selected by its catalog path + #[clap(long, value_name = "PATH", conflicts_with_all = ["list_sources", "clear"])] + source: Option, + + /// List the available sourced-console paths and exit + #[clap(long, conflicts_with_all = ["source", "no_format", "clear"])] + list_sources: bool, }, /// Local CLI settings (scan addresses, timeout, etc.) diff --git a/src/main.rs b/src/main.rs index 0fc7415..2e087c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -126,6 +126,48 @@ mod tests { assert_eq!(unsupported_flash_targets(&selected), vec!["stm32ohnooo-fw".to_string()]); } + + #[test] + fn sourced_console_is_a_streaming_command() { + let args = CliArgs::try_parse_from(["cfcli", "console", "--source", "deck:bcCam"]).unwrap(); + + assert!(is_streaming_command(&args.command)); + } + + #[test] + fn listing_console_sources_is_a_bounded_command() { + let args = CliArgs::try_parse_from(["cfcli", "console", "--list-sources"]).unwrap(); + + assert!(!is_streaming_command(&args.command)); + } + + #[test] + fn console_source_conflicts_with_source_listing() { + let result = CliArgs::try_parse_from([ + "cfcli", + "console", + "--source", + "deck:bcCam", + "--list-sources", + ]); + + assert!(result.is_err()); + } + + #[test] + fn source_listing_conflicts_with_console_formatting() { + let result = CliArgs::try_parse_from(["cfcli", "console", "--list-sources", "--no-format"]); + + assert!(result.is_err()); + } + + #[test] + fn sourced_console_conflicts_with_clearing_legacy_history() { + let result = + CliArgs::try_parse_from(["cfcli", "console", "--source", "deck:bcCam", "--clear"]); + + assert!(result.is_err()); + } } impl MemoryTypeArg { @@ -298,9 +340,15 @@ fn require_arg(non_interactive: bool, missing_arg: &str) -> Result<()> { fn is_streaming_command(cmd: &Commands) -> bool { matches!( cmd, - Commands::Console { .. } - | Commands::Log { command: LogCommands::Print(_) } - | Commands::Cr { command: CrCommands::Sniff(_) } + Commands::Console { + clear: false, + list_sources: false, + .. + } | Commands::Log { + command: LogCommands::Print(_) + } | Commands::Cr { + command: CrCommands::Sniff(_) + } ) } @@ -550,6 +598,7 @@ async fn run() -> Result<()> { let link_context = crazyflie_link::LinkContext::new(); let mut connected_cf: Option = None; + let mut enabled_console_source = None; let preserve_console = args.preserve_console; let timeout_ms = args.timeout; let non_interactive = args.non_interactive || !std::io::stdin().is_terminal(); @@ -677,7 +726,7 @@ async fn run() -> Result<()> { }); } - Commands::Console { no_format, clear } => { + Commands::Console { no_format, clear, source, list_sources } => { if *clear { let path = console_preserve_path(); if path.exists() { @@ -689,21 +738,34 @@ async fn run() -> Result<()> { return Ok(()); } - let saved = read_and_clear_console_file()?; - if !saved.is_empty() { - if *no_format { - print!("{}", saved); - } else { - for line in saved.lines() { - print!("{}", modules::console::format_console_line(line)); - println!(); + if source.is_none() && !list_sources { + let saved = read_and_clear_console_file()?; + if !saved.is_empty() { + if *no_format { + print!("{}", saved); + } else { + for line in saved.lines() { + print!("{}", modules::console::format_console_line(line)); + println!(); + } } } } let cf = connect_cf(&mut connected_cf, &link_context, uri.as_str(), toc_cache, args.debug).await?; - modules::console::print(cf, *no_format).await?; + if *list_sources { + modules::console::list_sources(cf, csv).await?; + } else if let Some(source) = source { + modules::console::print_source( + cf, + source, + *no_format, + &mut enabled_console_source, + ).await?; + } else { + modules::console::print(cf, *no_format).await?; + } // Cleanup at end of run() handles disconnect. } Commands::Log { command } => { @@ -1280,12 +1342,12 @@ async fn run() -> Result<()> { match command { HlCommands::Arm => { println!("Arming Crazyflie..."); - cf.platform.send_arming_request(true).await?; + cf.supervisor.send_arming_request(true).await?; println!("Crazyflie armed!"); } HlCommands::Disarm => { println!("Disarming Crazyflie..."); - cf.platform.send_arming_request(false).await?; + cf.supervisor.send_arming_request(false).await?; println!("Crazyflie disarmed!"); } HlCommands::Takeoff(params) => { @@ -1579,6 +1641,15 @@ async fn run() -> Result<()> { body.await }; + if let (Some(cf), Some(selector)) = (connected_cf.as_ref(), enabled_console_source) { + const SOURCE_DISABLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); + match tokio::time::timeout(SOURCE_DISABLE_TIMEOUT, cf.console.disable(selector)).await { + Ok(Ok(())) => {} + Ok(Err(err)) => eprintln!("Warning: could not disable console source: {}", err), + Err(_) => eprintln!("Warning: timed out while disabling console source"), + } + } + // Save console and disconnect any remaining connection if let Some(ref cf) = connected_cf { save_and_disconnect(cf, preserve_console).await; diff --git a/src/modules/console.rs b/src/modules/console.rs index 0cf9a23..136e3c1 100644 --- a/src/modules/console.rs +++ b/src/modules/console.rs @@ -1,6 +1,10 @@ -use anyhow::Result; -use futures::StreamExt; +use crate::error::CliError; +use crate::utils::display::csv_escape; +use anyhow::{bail, Result}; use colored::*; +use crazyflie_lib::subsystems::console::{ConsoleCatalog, ConsoleHistory, ConsoleSourceSelector}; +use futures::StreamExt; +use std::io::Write; pub fn format_console_line(line: &str) -> String { if let Some((subsystem, message)) = line.split_once(':') { @@ -11,7 +15,7 @@ pub fn format_console_line(line: &str) -> String { } pub async fn print(cf: &crazyflie_lib::Crazyflie, no_format: bool) -> Result<()> { - let mut console_stream = cf.console.stream().await; + let mut console_stream = cf.console.stream().await; while let Some(line) = console_stream.next().await { if no_format { @@ -22,4 +26,108 @@ pub async fn print(cf: &crazyflie_lib::Crazyflie, no_format: bool) -> Result<()> } Ok(()) -} \ No newline at end of file +} + +pub async fn list_sources(cf: &crazyflie_lib::Crazyflie, csv: bool) -> Result<()> { + let catalog = cf.console.catalog().await?; + let paths = catalog + .iter() + .map(|source| source.path()) + .collect::>(); + print!("{}", render_source_paths(&paths, csv)); + + Ok(()) +} + +fn render_source_paths(paths: &[&str], csv: bool) -> String { + if csv { + let mut output = "path\n".to_string(); + for path in paths { + output.push_str(&csv_escape(path)); + output.push('\n'); + } + output + } else if paths.is_empty() { + "No sourced console sources found.\n".to_string() + } else { + format!("Path\n----\n{}\n", paths.join("\n")) + } +} + +fn source_not_found(path: &str, catalog: &ConsoleCatalog) -> CliError { + let available = catalog + .iter() + .map(|source| source.path()) + .collect::>(); + let suffix = if available.is_empty() { + "no sourced console sources are available".to_string() + } else { + format!("available sources: {}", available.join(", ")) + }; + + CliError::NotFound(format!("console source '{}'; {}", path, suffix)) +} + +pub async fn print_source( + cf: &crazyflie_lib::Crazyflie, + path: &str, + no_format: bool, + enabled_source: &mut Option, +) -> Result<()> { + let catalog = cf.console.catalog().await?; + let source = match catalog.find(path) { + Some(source) => source.clone(), + None => bail!(source_not_found(path, &catalog)), + }; + let selector = source.selector(); + + let mut stream = if no_format { + source.text_stream(ConsoleHistory::Replay).await + } else { + source.line_stream(ConsoleHistory::Replay).await + }; + *enabled_source = Some(selector); + cf.console.enable(selector).await?; + + let stdout = std::io::stdout(); + let mut output = stdout.lock(); + while let Some(text) = stream.next().await { + if no_format { + write!(output, "{}", text)?; + output.flush()?; + } else { + writeln!(output, "{}", format_console_line(&text))?; + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_source_paths_for_humans() { + assert_eq!( + render_source_paths(&["deck:bcCam", "deck:other"], false), + "Path\n----\ndeck:bcCam\ndeck:other\n" + ); + } + + #[test] + fn renders_empty_source_catalog_for_humans() { + assert_eq!( + render_source_paths(&[], false), + "No sourced console sources found.\n" + ); + } + + #[test] + fn renders_source_paths_as_csv() { + assert_eq!( + render_source_paths(&["deck:bcCam", "deck,other"], true), + "path\ndeck:bcCam\n\"deck,other\"\n" + ); + } +}